Merge mai/hermes/fdbck-shlink-short-link: shlink integration + admin Share section
This commit is contained in:
@@ -10,3 +10,8 @@ PUBLIC_SITE_URL=https://fdbck.msbls.de
|
||||
|
||||
# Optional cookie scope (leave unset for host-only)
|
||||
# COOKIE_DOMAIN=.msbls.de
|
||||
|
||||
# Shlink short-URL service (admin "Share" feature)
|
||||
# SHLINK_URL defaults to https://msbls.de when unset.
|
||||
SHLINK_URL=https://msbls.de
|
||||
SHLINK_API_KEY=
|
||||
|
||||
@@ -111,5 +111,13 @@ export const SignInSchema = z.object({
|
||||
password: z.string().min(6).max(200),
|
||||
});
|
||||
|
||||
/** Body schema for the admin "create short URL" endpoint. */
|
||||
export const ShareCreateSchema = z.object({
|
||||
customSlug: z.string().min(1).max(64).regex(/^[a-zA-Z0-9_-]+$/, {
|
||||
message: 'customSlug may only contain letters, digits, "-" and "_"',
|
||||
}).optional(),
|
||||
maxVisits: z.number().int().positive().max(1_000_000).optional(),
|
||||
});
|
||||
|
||||
export type FeedbackQuestion = z.infer<typeof FeedbackQuestionSchema>;
|
||||
export type FeedbackFormDefinition = z.infer<typeof FeedbackFormDefinitionSchema>;
|
||||
|
||||
@@ -35,6 +35,8 @@ export interface FeedbackInstance {
|
||||
live_results_enabled: boolean;
|
||||
status: 'open' | 'closed';
|
||||
closed_at: string | null;
|
||||
short_url: string | null;
|
||||
short_code: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
49
src/lib/server/shlink.ts
Normal file
49
src/lib/server/shlink.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Shlink REST v3 client — wraps `POST /rest/v3/short-urls`.
|
||||
*
|
||||
* Env:
|
||||
* SHLINK_URL base URL of the shlink instance (default: https://msbls.de)
|
||||
* SHLINK_API_KEY REST API key (required — throws if unset)
|
||||
*
|
||||
* Ported from flexsiebels/website/src/routes/api/share/+server.ts.
|
||||
*/
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
export interface ShlinkShortUrl {
|
||||
shortUrl: string;
|
||||
shortCode: string;
|
||||
longUrl: string;
|
||||
domain: string | null;
|
||||
dateCreated: string;
|
||||
visitsCount?: number;
|
||||
tags?: string[];
|
||||
customSlug?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CreateShortUrlOpts {
|
||||
longUrl: string;
|
||||
customSlug?: string;
|
||||
maxVisits?: number;
|
||||
domain?: string;
|
||||
}
|
||||
|
||||
export async function createShortUrl(opts: CreateShortUrlOpts): Promise<ShlinkShortUrl> {
|
||||
const shlinkUrl = env.SHLINK_URL || 'https://msbls.de';
|
||||
const shlinkKey = env.SHLINK_API_KEY;
|
||||
if (!shlinkKey) throw new Error('Shlink API key is not configured');
|
||||
|
||||
const body: Record<string, unknown> = { longUrl: opts.longUrl };
|
||||
if (opts.customSlug) body.customSlug = opts.customSlug;
|
||||
if (opts.maxVisits) body.maxVisits = opts.maxVisits;
|
||||
if (opts.domain) body.domain = opts.domain;
|
||||
|
||||
const res = await fetch(`${shlinkUrl}/rest/v3/short-urls`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Api-Key': shlinkKey },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`Shlink returned ${res.status}: ${await res.text()}`);
|
||||
return res.json() as Promise<ShlinkShortUrl>;
|
||||
}
|
||||
@@ -25,6 +25,11 @@
|
||||
let editChatEnabled = $state(inst.chat_enabled);
|
||||
let editLiveResults = $state(inst.live_results_enabled);
|
||||
|
||||
let shareSlugInput = $state('');
|
||||
let shareInFlight = $state(false);
|
||||
let shareError = $state<string | null>(null);
|
||||
let shareCopied = $state(false);
|
||||
|
||||
let editMode = $state<'visual' | 'json'>('visual');
|
||||
let editForm = $state<FeedbackFormDefinition | null>(
|
||||
inst.form_definition ? (inst.form_definition as FeedbackFormDefinition) : null,
|
||||
@@ -214,6 +219,46 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function copyShortUrl(): Promise<void> {
|
||||
if (!inst.short_url) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(inst.short_url);
|
||||
shareCopied = true;
|
||||
setTimeout(() => (shareCopied = false), 1500);
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
}
|
||||
|
||||
async function createShareLink(): Promise<void> {
|
||||
shareError = null;
|
||||
shareInFlight = true;
|
||||
try {
|
||||
const slug = shareSlugInput.trim();
|
||||
const body: { customSlug?: string } = {};
|
||||
if (slug) body.customSlug = slug;
|
||||
|
||||
const res = await fetch(`/api/admin/feedback/${inst.id}/share`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const j = (await res.json().catch(() => ({}))) as { error?: string; details?: unknown };
|
||||
shareError = j.error ?? `Error ${res.status}`;
|
||||
if (j.details) shareError += ' — ' + JSON.stringify(j.details);
|
||||
return;
|
||||
}
|
||||
const j = (await res.json()) as { instance: typeof inst };
|
||||
inst = j.instance;
|
||||
shareSlugInput = '';
|
||||
} catch (e) {
|
||||
shareError = e instanceof Error ? e.message : 'Network error';
|
||||
} finally {
|
||||
shareInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
function fmtDateTime(iso: string): string {
|
||||
return new Date(iso).toLocaleString();
|
||||
}
|
||||
@@ -289,6 +334,91 @@
|
||||
<div class="fb-banner fb-banner--error">{actionError}</div>
|
||||
{/if}
|
||||
|
||||
<section class="fb-section" data-fb-share>
|
||||
<h2>Share</h2>
|
||||
{#if inst.short_url}
|
||||
<p style="margin: 0 0 0.5rem 0; color: var(--color-text-muted); font-size: 0.85rem;">
|
||||
Memorable short link — resolves to <code>/f/{inst.slug}</code>.
|
||||
</p>
|
||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center;">
|
||||
<a
|
||||
href={inst.short_url}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
style="font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 0.95rem; word-break: break-all;"
|
||||
>
|
||||
{inst.short_url}
|
||||
</a>
|
||||
<button type="button" class="fb-btn fb-btn--ghost" onclick={copyShortUrl}>
|
||||
{shareCopied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
<a class="fb-btn fb-btn--ghost" href={inst.short_url} target="_blank" rel="noopener">
|
||||
Open ↗
|
||||
</a>
|
||||
</div>
|
||||
<details style="margin-top: 0.75rem;">
|
||||
<summary style="cursor: pointer; color: var(--color-text-muted); font-size: 0.85rem;">
|
||||
Replace with a different short link
|
||||
</summary>
|
||||
<div style="margin-top: 0.5rem;">
|
||||
<label class="fb-question__label" for="fb-share-slug-replace">Custom slug (optional)</label>
|
||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center;">
|
||||
<input
|
||||
id="fb-share-slug-replace"
|
||||
class="fb-input"
|
||||
maxlength="64"
|
||||
placeholder="e.g. vote, session-feedback"
|
||||
bind:value={shareSlugInput}
|
||||
style="max-width: 320px;"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="fb-btn"
|
||||
disabled={shareInFlight}
|
||||
onclick={createShareLink}
|
||||
>
|
||||
{shareInFlight ? 'Creating…' : 'Create new'}
|
||||
</button>
|
||||
</div>
|
||||
<p style="margin: 0.4rem 0 0 0; color: var(--color-text-muted); font-size: 0.8rem;">
|
||||
Leave blank for a random short code, or pick a memorable slug like <code>vote</code> or <code>session-feedback</code>.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
{:else}
|
||||
<p style="margin: 0 0 0.5rem 0; color: var(--color-text-muted); font-size: 0.85rem;">
|
||||
Create a memorable short link (e.g. <code>https://msbls.de/vote</code>) that redirects to this form.
|
||||
</p>
|
||||
<div style="margin-top: 0.5rem;">
|
||||
<label class="fb-question__label" for="fb-share-slug">Custom slug (optional)</label>
|
||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center;">
|
||||
<input
|
||||
id="fb-share-slug"
|
||||
class="fb-input"
|
||||
maxlength="64"
|
||||
placeholder="e.g. vote, session-feedback"
|
||||
bind:value={shareSlugInput}
|
||||
style="max-width: 320px;"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="fb-btn"
|
||||
disabled={shareInFlight}
|
||||
onclick={createShareLink}
|
||||
>
|
||||
{shareInFlight ? 'Creating…' : 'Create short link'}
|
||||
</button>
|
||||
</div>
|
||||
<p style="margin: 0.4rem 0 0 0; color: var(--color-text-muted); font-size: 0.8rem;">
|
||||
Leave blank for a random short code, or pick a memorable slug like <code>vote</code> or <code>session-feedback</code>.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if shareError}
|
||||
<div class="fb-banner fb-banner--error" style="margin-top: 0.5rem;">{shareError}</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<div class="fb-tabs">
|
||||
{#each [
|
||||
{ key: 'chat', label: `Chat (${posts.length})`, show: inst.chat_enabled },
|
||||
|
||||
54
src/routes/api/admin/feedback/[id]/share/+server.ts
Normal file
54
src/routes/api/admin/feedback/[id]/share/+server.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* POST /api/admin/feedback/<id>/share — create or refresh the shlink short URL
|
||||
* that points to /f/<instance.slug>.
|
||||
*
|
||||
* Body: { customSlug?: string, maxVisits?: number }
|
||||
* Returns: { shortUrl, shortCode, customSlug, instance }
|
||||
*/
|
||||
import type { RequestHandler } from './$types';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { json, requireAuth } from '$lib/server/response';
|
||||
import { parseBody, handleApiError, notFound, unauthorized } from '$lib/server/errors';
|
||||
import { ShareCreateSchema } from '$lib/schemas';
|
||||
import { fdb } from '$lib/server/fdb';
|
||||
import { getInstanceById } from '$lib/server/feedback';
|
||||
import { createShortUrl } from '$lib/server/shlink';
|
||||
|
||||
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
const err = requireAuth(locals.userId);
|
||||
if (err) return err;
|
||||
|
||||
try {
|
||||
const inst = await getInstanceById(params.id);
|
||||
if (!inst) return notFound('Instance not found');
|
||||
if (inst.owner_user_id !== locals.userId) return unauthorized('Not your instance');
|
||||
|
||||
const body = await parseBody(request, ShareCreateSchema);
|
||||
|
||||
const siteUrl = (env.PUBLIC_SITE_URL || 'https://fdbck.msbls.de').replace(/\/+$/, '');
|
||||
const longUrl = `${siteUrl}/f/${inst.slug}`;
|
||||
|
||||
const result = await createShortUrl({
|
||||
longUrl,
|
||||
customSlug: body.customSlug,
|
||||
maxVisits: body.maxVisits,
|
||||
});
|
||||
|
||||
const { data: updated, error } = await fdb()
|
||||
.from('feedback_instances')
|
||||
.update({ short_url: result.shortUrl, short_code: result.shortCode })
|
||||
.eq('id', inst.id)
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
|
||||
return json({
|
||||
shortUrl: result.shortUrl,
|
||||
shortCode: result.shortCode,
|
||||
customSlug: result.customSlug ?? body.customSlug ?? null,
|
||||
instance: updated,
|
||||
});
|
||||
} catch (e) {
|
||||
return handleApiError(e, 'admin feedback share POST');
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user