Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@
"iron-session": "^8.0.4",
"next": "^16.0.0",
"next-themes": "^0.4.4",
"qrcode": "^1.5.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^2.6.0",
"zod": "^3.24.1",
"zustand": "^5.0.2"
},
"devDependencies": {
"@types/qrcode": "^1.5.6",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"autoprefixer": "^10.4.20",
Expand Down
88 changes: 54 additions & 34 deletions apps/web/src/app/dashboard/[guildId]/forms/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Card, StatusBadge } from "@msk-forms/ui";
import type { Route } from "next";
import Link from "next/link";
import QRCode from "qrcode";

import { ShareButton } from "@/components/dashboard/share-button";
import { appBaseUrl } from "@/lib/url";
import { getGuildForms } from "@/lib/guild";
import { getDict } from "@/i18n";
Expand Down Expand Up @@ -33,6 +35,16 @@ export default async function GuildFormsPage({
archived: dict.builder.statusArchived,
};

// Pre-render a QR code (server-side, no client dependency) for each live form.
const qrByForm: Record<string, string> = {};
await Promise.all(
forms
.filter((f) => f.status === "live")
.map(async (f) => {
qrByForm[f.id] = await QRCode.toDataURL(`${base}/f/${f.slug}`, { width: 220, margin: 1 });
}),
);

return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
Expand All @@ -53,41 +65,49 @@ export default async function GuildFormsPage({
</Card>
) : (
<div className="flex flex-col gap-2">
{forms.map((form) => (
<Card key={form.id} className="flex items-center justify-between gap-4 p-4">
<div className="flex min-w-0 flex-col gap-1">
<div className="flex items-center gap-3">
<span className="truncate font-medium text-foreground">{form.title}</span>
<StatusBadge
label={statusLabel[form.status] ?? form.status}
color={FORM_STATUS_COLORS[form.status] ?? "#6b6b72"}
/>
{forms.map((form) => {
const qr = qrByForm[form.id];
return (
<Card key={form.id} className="flex flex-col gap-3 p-4">
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 flex-col gap-1">
<div className="flex items-center gap-3">
<span className="truncate font-medium text-foreground">{form.title}</span>
<StatusBadge
label={statusLabel[form.status] ?? form.status}
color={FORM_STATUS_COLORS[form.status] ?? "#6b6b72"}
/>
</div>
<span className="text-xs text-muted-foreground">
{form._count.submissions} {t.countSubmissions}
{form.status === "live" && (
<>
{" · "}
<a
href={`${base}/f/${form.slug}`}
target="_blank"
rel="noreferrer"
className="text-primary hover:underline"
>
/f/{form.slug}
</a>
</>
)}
</span>
</div>
<Link
href={`/dashboard/${guildId}/forms/${form.id}/edit` as Route}
className="shrink-0 rounded-md border border-border px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:border-primary/40 hover:text-foreground"
>
{t.edit}
</Link>
</div>
<span className="text-xs text-muted-foreground">
{form._count.submissions} {t.countSubmissions}
{form.status === "live" && (
<>
{" · "}
<a
href={`${base}/f/${form.slug}`}
target="_blank"
rel="noreferrer"
className="text-primary hover:underline"
>
/f/{form.slug}
</a>
</>
)}
</span>
</div>
<Link
href={`/dashboard/${guildId}/forms/${form.id}/edit` as Route}
className="shrink-0 rounded-md border border-border px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:border-primary/40 hover:text-foreground"
>
{t.edit}
</Link>
</Card>
))}
{form.status === "live" && qr && (
<ShareButton url={`${base}/f/${form.slug}`} qrDataUrl={qr} t={dict.share} />
)}
</Card>
);
})}
</div>
)}
</div>
Expand Down
102 changes: 102 additions & 0 deletions apps/web/src/components/dashboard/share-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"use client";

import { Button } from "@msk-forms/ui";
import { useState } from "react";

export interface ShareLabels {
share: string;
link: string;
copy: string;
copied: string;
qrCode: string;
embedCode: string;
}

/**
* Per-form distribution panel (concept §4/§9): public link, a (server-rendered)
* QR code, and an embed snippet — each with a copy button. Toggled open inline.
*/
export function ShareButton({
url,
qrDataUrl,
t,
}: {
url: string;
qrDataUrl: string;
t: ShareLabels;
}) {
const [open, setOpen] = useState(false);
const [copied, setCopied] = useState<string | null>(null);

const embed = `<iframe src="${url}" width="100%" height="600" style="border:0" title="MSK Forms"></iframe>`;

async function copy(text: string, key: string) {
try {
await navigator.clipboard.writeText(text);
setCopied(key);
setTimeout(() => setCopied((c) => (c === key ? null : c)), 1500);
} catch {
/* clipboard unavailable — ignore */
}
}

const CopyBtn = ({ text, k }: { text: string; k: string }) => (
<Button variant="ghost" className="shrink-0" onClick={() => copy(text, k)}>
{copied === k ? t.copied : t.copy}
</Button>
);

return (
<div className="flex flex-col gap-3">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="self-start text-sm font-medium text-primary transition-colors hover:underline"
>
{t.share}
</button>

{open && (
<div className="flex flex-col gap-4 rounded-md border border-border bg-background p-4">
<label className="flex flex-col gap-1">
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t.link}
</span>
<div className="flex gap-2">
<input
readOnly
value={url}
onFocus={(e) => e.currentTarget.select()}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground"
/>
<CopyBtn text={url} k="link" />
</div>
</label>

<div className="flex flex-col gap-1">
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t.qrCode}
</span>
<img src={qrDataUrl} alt={t.qrCode} width={176} height={176} className="rounded-md" />
</div>

<label className="flex flex-col gap-1">
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t.embedCode}
</span>
<div className="flex gap-2">
<textarea
readOnly
value={embed}
rows={2}
onFocus={(e) => e.currentTarget.select()}
className="w-full resize-none rounded-md border border-input bg-background px-3 py-2 font-mono text-xs text-foreground"
/>
<CopyBtn text={embed} k="embed" />
</div>
</label>
</div>
)}
</div>
);
}
14 changes: 14 additions & 0 deletions apps/web/src/i18n/dictionaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,13 @@ const en = {
consent: "Consent", heading: "Heading", paragraph: "Paragraph", divider: "Divider",
},
},
share: {
share: "Share",
link: "Public link",
copy: "Copy", copied: "Copied!",
qrCode: "QR code",
embedCode: "Embed code",
},
authError: "Login failed. Please try again.",
};

Expand Down Expand Up @@ -279,6 +286,13 @@ const de: Dictionary = {
consent: "Zustimmung", heading: "Überschrift", paragraph: "Absatz", divider: "Trenner",
},
},
share: {
share: "Teilen",
link: "Öffentlicher Link",
copy: "Kopieren", copied: "Kopiert!",
qrCode: "QR-Code",
embedCode: "Embed-Code",
},
authError: "Anmeldung fehlgeschlagen. Bitte versuche es erneut.",
};

Expand Down
Loading