Typen Social Media und Video

Social Media buendelt Profile auf einer Seite. Wer „@firma" eintippt, meint
das Profil beim gewaehlten Netzwerk — daraus wird die volle Adresse. Statt
nachgezeichneter Markenlogos steht ein Kreis in der Hausfarbe mit Monogramm;
ein schlecht nachgebautes Logo sieht schlechter aus als ein sauberes Zeichen.

Video erkennt YouTube, Vimeo und direkte Dateien. Der Rahmen zum Anbieter
laedt erst auf Klick, und davor steht, dass dabei Daten uebertragen werden —
diese Seite bekommen Fremde nach einem Scan zu sehen, ungefragt einen
Drittanbieter einzubinden waere nicht in Ordnung. Videodateien laden mit
preload="none": wer unterwegs scannt, zahlt das Volumen.
This commit is contained in:
Florian Wilde
2026-08-14 22:44:23 +02:00
parent e627f7c372
commit fdf6452b35
11 changed files with 859 additions and 12 deletions
+170
View File
@@ -0,0 +1,170 @@
"use client";
import { Icon } from "@/components/icons";
import type { QrFormProps } from "@/components/qr-forms/types";
import { Button, Field, Select, TextArea, TextInput } from "@/components/ui";
import { SOCIAL_NETWORKS, getSocialNetwork } from "@/lib/qr/social-networks";
import { MAX_PROFILES } from "@/lib/qr/types/social";
type Profile = { network: string; url: string };
function readProfiles(value: unknown): Profile[] {
if (!Array.isArray(value)) return [{ network: "instagram", url: "" }];
const entries = value.map((entry) => {
const record = (entry ?? {}) as Record<string, unknown>;
return {
network: typeof record.network === "string" ? record.network : "website",
url: typeof record.url === "string" ? record.url : "",
};
});
return entries.length > 0 ? entries : [{ network: "instagram", url: "" }];
}
export function SocialForm({ value, onChange, error, disabled }: QrFormProps) {
const title = typeof value.title === "string" ? value.title : "";
const description =
typeof value.description === "string" ? value.description : "";
const profiles = readProfiles(value.profiles);
const belegt = new Set(profiles.map((profile) => profile.network));
const naechstesFreies =
SOCIAL_NETWORKS.find((network) => !belegt.has(network.id))?.id ?? "website";
const setProfiles = (next: Profile[]) =>
onChange({ ...value, profiles: next });
const updateProfile = (index: number, patch: Partial<Profile>) =>
setProfiles(
profiles.map((profile, i) => (i === index ? { ...profile, ...patch } : profile)),
);
return (
<div className="space-y-5">
<Field
label="Überschrift der Seite"
htmlFor="social-title"
hint="Meist der Name, unter dem man euch kennt."
>
<TextInput
id="social-title"
value={title}
disabled={disabled}
maxLength={80}
placeholder="Bäckerei Sonnenschein"
onChange={(event) => onChange({ ...value, title: event.target.value })}
/>
</Field>
<Field
label="Kurzer Text (optional)"
htmlFor="social-description"
hint="Eine Zeile darunter, z. B. „Folge uns für Neues aus der Backstube“."
>
<TextArea
id="social-description"
rows={2}
value={description}
disabled={disabled}
maxLength={300}
onChange={(event) =>
onChange({ ...value, description: event.target.value })
}
/>
</Field>
<div>
<div className="mb-2 flex items-baseline justify-between gap-3">
<span className="text-sm font-medium text-slate-700">Profile</span>
<span className="text-xs text-slate-500">
{profiles.length} von {MAX_PROFILES}
</span>
</div>
<ul className="space-y-3">
{profiles.map((profile, index) => {
const network = getSocialNetwork(profile.network);
return (
<li
key={index}
className="rounded-xl border border-slate-200 bg-slate-50/70 p-3"
>
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1 space-y-2">
<Select
aria-label={`Netzwerk von Profil ${index + 1}`}
value={profile.network}
disabled={disabled}
onChange={(event) =>
updateProfile(index, { network: event.target.value })
}
>
{SOCIAL_NETWORKS.map((entry) => (
<option
key={entry.id}
value={entry.id}
// Doppelte Netzwerke lehnt die Prüfung ohnehin ab —
// besser, sie stehen gar nicht erst zur Wahl.
disabled={
entry.id !== profile.network && belegt.has(entry.id)
}
>
{entry.label}
</option>
))}
</Select>
<TextInput
aria-label={`Profil ${index + 1}`}
inputMode="url"
spellCheck={false}
value={profile.url}
disabled={disabled}
maxLength={2048}
placeholder={network?.placeholder ?? "https://example.com"}
onChange={(event) =>
updateProfile(index, { url: event.target.value })
}
/>
</div>
<button
type="button"
disabled={disabled || profiles.length === 1}
onClick={() =>
setProfiles(profiles.filter((_, i) => i !== index))
}
aria-label={`Profil ${index + 1} entfernen`}
className="mt-1 rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
>
<Icon name="trash" className="size-4" />
</button>
</div>
</li>
);
})}
</ul>
{profiles.length < MAX_PROFILES &&
profiles.length < SOCIAL_NETWORKS.length ? (
<Button
type="button"
variant="secondary"
size="sm"
disabled={disabled}
onClick={() =>
setProfiles([...profiles, { network: naechstesFreies, url: "" }])
}
className="mt-3"
>
Profil hinzufügen
</Button>
) : null}
{error ? (
<p role="alert" className="mt-2 text-sm text-red-600">
{error}
</p>
) : null}
</div>
</div>
);
}