mAi: #2 - admin Share section + env-var docs
Self-contained "Share" section on the admin detail page. When no short URL exists yet: shows an optional custom-slug input + "Create short link" button. When one exists: shows the URL with Copy + Open buttons and a collapsed "Replace" form for picking a new slug. Append-only — does not touch existing buttons, the icon system, or feedback.css; uses inline styles + existing fb-* classes only, so it stays out of dokploy's parallel button-system refactor. .env.example documents SHLINK_URL + SHLINK_API_KEY (must be copied from the flexsiebels.de Dokploy app config to fdbck.msbls.de before this works in prod). Refs m/fdbck#2.
This commit is contained in:
@@ -10,3 +10,8 @@ PUBLIC_SITE_URL=https://fdbck.msbls.de
|
|||||||
|
|
||||||
# Optional cookie scope (leave unset for host-only)
|
# Optional cookie scope (leave unset for host-only)
|
||||||
# COOKIE_DOMAIN=.msbls.de
|
# 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=
|
||||||
|
|||||||
@@ -24,6 +24,11 @@
|
|||||||
let editChatEnabled = $state(inst.chat_enabled);
|
let editChatEnabled = $state(inst.chat_enabled);
|
||||||
let editLiveResults = $state(inst.live_results_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 editMode = $state<'visual' | 'json'>('visual');
|
||||||
let editForm = $state<FeedbackFormDefinition | null>(
|
let editForm = $state<FeedbackFormDefinition | null>(
|
||||||
inst.form_definition ? (inst.form_definition as FeedbackFormDefinition) : null,
|
inst.form_definition ? (inst.form_definition as FeedbackFormDefinition) : null,
|
||||||
@@ -213,6 +218,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 {
|
function fmtDateTime(iso: string): string {
|
||||||
return new Date(iso).toLocaleString();
|
return new Date(iso).toLocaleString();
|
||||||
}
|
}
|
||||||
@@ -288,6 +333,91 @@
|
|||||||
<div class="fb-banner fb-banner--error">{actionError}</div>
|
<div class="fb-banner fb-banner--error">{actionError}</div>
|
||||||
{/if}
|
{/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">
|
<div class="fb-tabs">
|
||||||
{#each [
|
{#each [
|
||||||
{ key: 'chat', label: `Chat (${posts.length})`, show: inst.chat_enabled },
|
{ key: 'chat', label: `Chat (${posts.length})`, show: inst.chat_enabled },
|
||||||
|
|||||||
Reference in New Issue
Block a user