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
+4
View File
@@ -1,7 +1,9 @@
import type { ComponentType } from "react"; import type { ComponentType } from "react";
import { LinksLanding } from "@/components/landing/links-landing"; import { LinksLanding } from "@/components/landing/links-landing";
import { SocialLanding } from "@/components/landing/social-landing";
import type { LandingProps } from "@/components/landing/types"; import type { LandingProps } from "@/components/landing/types";
import { VideoLanding } from "@/components/landing/video-landing";
import type { QrTypeId } from "@/lib/qr/type-module"; import type { QrTypeId } from "@/lib/qr/type-module";
/** /**
@@ -14,6 +16,8 @@ import type { QrTypeId } from "@/lib/qr/type-module";
export const qrLandings: Partial<Record<QrTypeId, ComponentType<LandingProps>>> = export const qrLandings: Partial<Record<QrTypeId, ComponentType<LandingProps>>> =
{ {
links: LinksLanding, links: LinksLanding,
social: SocialLanding,
video: VideoLanding,
}; };
export type { LandingProps }; export type { LandingProps };
+52
View File
@@ -0,0 +1,52 @@
import { Icon } from "@/components/icons";
import { LandingShell } from "@/components/landing/shell";
import type { LandingProps } from "@/components/landing/types";
import {
getSocialNetwork,
socialHandleLabel,
} from "@/lib/qr/social-networks";
import { socialDataSchema } from "@/lib/qr/types/social";
export function SocialLanding({ data }: LandingProps) {
const { title, description, profiles } = socialDataSchema.parse(data);
return (
<LandingShell title={title} subtitle={description || null}>
<ul className="space-y-3">
{profiles.map((profile) => {
const network = getSocialNetwork(profile.network);
return (
<li key={profile.network}>
<a
href={profile.url}
rel="noopener noreferrer"
className="flex items-center gap-3.5 rounded-2xl border border-slate-200 bg-white px-4 py-3.5 text-slate-900 shadow-sm transition-colors hover:border-slate-300 hover:bg-slate-50"
>
<span
aria-hidden="true"
className="flex size-10 shrink-0 items-center justify-center rounded-full text-sm font-semibold text-white"
style={{ backgroundColor: network?.color ?? "#475569" }}
>
{network?.monogram ?? "?"}
</span>
<span className="min-w-0 flex-1">
<span className="block font-medium">
{network?.label ?? profile.network}
</span>
<span className="block truncate text-sm text-slate-500">
{socialHandleLabel(profile.url)}
</span>
</span>
<Icon
name="external"
className="size-4 shrink-0 text-slate-400"
aria-hidden="true"
/>
</a>
</li>
);
})}
</ul>
</LandingShell>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { Icon } from "@/components/icons";
import { LandingShell } from "@/components/landing/shell";
import type { LandingProps } from "@/components/landing/types";
import { EmbeddedVideo } from "@/components/landing/video-player";
import { resolveVideoSource, videoDataSchema } from "@/lib/qr/types/video";
export function VideoLanding({ data }: LandingProps) {
const { url, title, description } = videoDataSchema.parse(data);
const quelle = resolveVideoSource(url);
return (
<LandingShell
title={title || "Video"}
subtitle={description || null}
footer={
quelle.kind === "youtube" || quelle.kind === "vimeo" ? (
<a
href={url}
rel="noopener noreferrer"
className="underline underline-offset-2 hover:text-slate-600"
>
Bei {quelle.label} öffnen
</a>
) : null
}
>
{quelle.kind === "youtube" || quelle.kind === "vimeo" ? (
<EmbeddedVideo
embedUrl={quelle.embedUrl}
provider={quelle.label}
title={title || "Video"}
/>
) : quelle.kind === "file" ? (
<video
controls
// Nicht von allein laden: Wer unterwegs scannt, zahlt das Volumen.
preload="none"
className="aspect-video w-full rounded-2xl bg-black shadow-sm"
>
<source src={url} />
Dein Browser kann dieses Video nicht abspielen.
</video>
) : (
<a
href={url}
rel="noopener noreferrer"
className="flex items-center gap-3 rounded-2xl border border-slate-200 bg-white px-5 py-4 text-slate-900 shadow-sm transition-colors hover:border-indigo-300 hover:bg-indigo-50/60"
>
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-slate-100 text-slate-600">
<Icon name="video" className="size-5" />
</span>
<span className="min-w-0 flex-1">
<span className="block font-medium">Video ansehen</span>
<span className="block truncate text-sm text-slate-500">
{quelle.label}
</span>
</span>
<Icon
name="external"
className="size-4 shrink-0 text-slate-400"
aria-hidden="true"
/>
</a>
)}
</LandingShell>
);
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
import { useState } from "react";
import { Icon } from "@/components/icons";
/**
* Eingebettetes Video mit Zwei-Klick-Lösung.
*
* Ein Rahmen von YouTube oder Vimeo baut beim Laden eine Verbindung zum
* Anbieter auf — auf einer Seite, die Fremde nach einem Scan zu sehen
* bekommen, ist das ohne Zustimmung nicht in Ordnung. Deshalb erscheint der
* Rahmen erst nach einem Klick, und es steht vorher da, was dabei passiert.
*/
export function EmbeddedVideo({
embedUrl,
provider,
title,
}: {
embedUrl: string;
provider: string;
title: string;
}) {
const [gestartet, setGestartet] = useState(false);
if (gestartet) {
return (
<div className="aspect-video overflow-hidden rounded-2xl bg-black shadow-sm">
<iframe
src={embedUrl}
title={title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className="h-full w-full border-0"
/>
</div>
);
}
return (
<button
type="button"
onClick={() => setGestartet(true)}
className="group flex aspect-video w-full flex-col items-center justify-center gap-3 rounded-2xl border border-slate-300 bg-slate-900 px-6 text-center shadow-sm transition-colors hover:bg-slate-800"
>
<span className="flex size-16 items-center justify-center rounded-full bg-white/10 text-white transition-colors group-hover:bg-white/20">
<Icon name="video" className="size-8" />
</span>
<span className="font-medium text-white">Video abspielen</span>
<span className="max-w-xs text-xs leading-relaxed text-slate-300">
Beim Abspielen wird eine Verbindung zu {provider} hergestellt. Dabei
werden Daten an den Anbieter übertragen.
</span>
</button>
);
}
+4
View File
@@ -10,8 +10,10 @@ import {
WhatsappForm, WhatsappForm,
WifiForm, WifiForm,
} from "@/components/qr-forms/simple-forms"; } from "@/components/qr-forms/simple-forms";
import { SocialForm } from "@/components/qr-forms/social-form";
import type { QrFormProps } from "@/components/qr-forms/types"; import type { QrFormProps } from "@/components/qr-forms/types";
import { VCardForm } from "@/components/qr-forms/vcard-form"; import { VCardForm } from "@/components/qr-forms/vcard-form";
import { VideoForm } from "@/components/qr-forms/video-form";
import { WebsiteForm } from "@/components/qr-forms/website-form"; import { WebsiteForm } from "@/components/qr-forms/website-form";
import type { QrTypeId } from "@/lib/qr/type-module"; import type { QrTypeId } from "@/lib/qr/type-module";
@@ -27,6 +29,8 @@ export const qrForms: Partial<Record<QrTypeId, ComponentType<QrFormProps>>> = {
vcard: VCardForm, vcard: VCardForm,
app: AppForm, app: AppForm,
links: LinksForm, links: LinksForm,
social: SocialForm,
video: VideoForm,
whatsapp: WhatsappForm, whatsapp: WhatsappForm,
email: EmailForm, email: EmailForm,
sms: SmsForm, sms: SmsForm,
+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>
);
}
+80
View File
@@ -0,0 +1,80 @@
"use client";
import type { QrFormProps } from "@/components/qr-forms/types";
import { Field, TextArea, TextInput } from "@/components/ui";
import { resolveVideoSource } from "@/lib/qr/types/video";
const HINWEIS: Record<string, string> = {
youtube: "Erkannt als YouTube-Video — es wird eingebettet abgespielt.",
vimeo: "Erkannt als Vimeo-Video — es wird eingebettet abgespielt.",
file: "Erkannt als Videodatei — sie wird direkt auf der Seite abgespielt.",
link:
"Diese Adresse lässt sich nicht einbetten. Auf der Seite erscheint stattdessen ein Link dorthin.",
};
export function VideoForm({ value, onChange, error, disabled }: QrFormProps) {
const url = typeof value.url === "string" ? value.url : "";
const title = typeof value.title === "string" ? value.title : "";
const description =
typeof value.description === "string" ? value.description : "";
const erkannt = url.trim().length > 0 ? resolveVideoSource(url.trim()) : null;
return (
<div className="space-y-5">
<Field
label="Adresse des Videos"
htmlFor="video-url"
error={error}
hint={
erkannt
? HINWEIS[erkannt.kind]
: "YouTube, Vimeo oder eine direkte Videodatei (.mp4, .webm)."
}
>
<TextInput
id="video-url"
inputMode="url"
spellCheck={false}
value={url}
invalid={Boolean(error)}
disabled={disabled}
placeholder="https://www.youtube.com/watch?v=…"
onChange={(event) => onChange({ ...value, url: event.target.value })}
/>
</Field>
<Field
label="Titel (optional)"
htmlFor="video-title"
hint="Steht über dem Video."
>
<TextInput
id="video-title"
value={title}
disabled={disabled}
maxLength={80}
placeholder="Rundgang durch die Werkstatt"
onChange={(event) => onChange({ ...value, title: event.target.value })}
/>
</Field>
<Field
label="Kurzer Text (optional)"
htmlFor="video-description"
hint="Eine Zeile zur Einordnung."
>
<TextArea
id="video-description"
rows={2}
value={description}
disabled={disabled}
maxLength={300}
onChange={(event) =>
onChange({ ...value, description: event.target.value })
}
/>
</Field>
</div>
);
}
+4 -12
View File
@@ -11,7 +11,9 @@ import { linksType } from "@/lib/qr/types/links";
import { locationType } from "@/lib/qr/types/location"; import { locationType } from "@/lib/qr/types/location";
import { paymentType } from "@/lib/qr/types/payment"; import { paymentType } from "@/lib/qr/types/payment";
import { smsType } from "@/lib/qr/types/sms"; import { smsType } from "@/lib/qr/types/sms";
import { socialType } from "@/lib/qr/types/social";
import { vcardType } from "@/lib/qr/types/vcard"; import { vcardType } from "@/lib/qr/types/vcard";
import { videoType } from "@/lib/qr/types/video";
import { websiteType } from "@/lib/qr/types/website"; import { websiteType } from "@/lib/qr/types/website";
import { whatsappType } from "@/lib/qr/types/whatsapp"; import { whatsappType } from "@/lib/qr/types/whatsapp";
import { wifiType } from "@/lib/qr/types/wifi"; import { wifiType } from "@/lib/qr/types/wifi";
@@ -30,12 +32,7 @@ const MODULES: readonly QrTypeModule[] = [
vcardType, vcardType,
appType, appType,
linksType, linksType,
defineComingSoonType({ socialType,
id: "social",
title: "Social Media",
description: "Bündle deine Profile in einem Code.",
icon: "share",
}),
whatsappType, whatsappType,
defineComingSoonType({ defineComingSoonType({
id: "pdf", id: "pdf",
@@ -49,12 +46,7 @@ const MODULES: readonly QrTypeModule[] = [
description: "Zeige ein Bild oder eine Galerie.", description: "Zeige ein Bild oder eine Galerie.",
icon: "image", icon: "image",
}), }),
defineComingSoonType({ videoType,
id: "video",
title: "Video",
description: "Verlinke ein Video oder einen Stream.",
icon: "video",
}),
emailType, emailType,
smsType, smsType,
locationType, locationType,
+192
View File
@@ -0,0 +1,192 @@
/**
* Katalog der Netzwerke, die der Typ „Social Media“ kennt.
*
* Gemeinsame Grundlage von Formular, Validierung und Landingpage. Ein
* weiteres Netzwerk ist genau ein Eintrag mehr.
*
* Zu den Zeichen: Statt nachgebauter Markenlogos steht ein Kreis in der
* Hausfarbe mit ein bis zwei Buchstaben. Ein schlecht nachgezeichnetes Logo
* sieht schlechter aus als ein sauberes Monogramm — und die Netzwerknamen
* stehen ohnehin daneben.
*/
export type SocialNetwork = {
id: string;
label: string;
/** Hausfarbe für das Monogramm. */
color: string;
/** Ein bis zwei Buchstaben. */
monogram: string;
/**
* Wird einem eingegebenen Kürzel vorangestellt, wenn jemand statt der
* vollen Adresse nur „@name“ eintippt. Fehlt er, ist nur eine vollständige
* Adresse zulässig.
*/
prefix?: string;
/** Beispiel für das Eingabefeld. */
placeholder: string;
};
export const SOCIAL_NETWORKS: readonly SocialNetwork[] = [
{
id: "instagram",
label: "Instagram",
color: "#C13584",
monogram: "Ig",
prefix: "https://instagram.com/",
placeholder: "@firma oder https://instagram.com/firma",
},
{
id: "facebook",
label: "Facebook",
color: "#1877F2",
monogram: "f",
prefix: "https://facebook.com/",
placeholder: "firma oder https://facebook.com/firma",
},
{
id: "linkedin",
label: "LinkedIn",
color: "#0A66C2",
monogram: "in",
prefix: "https://linkedin.com/company/",
placeholder: "firma oder https://linkedin.com/company/firma",
},
{
id: "xing",
label: "Xing",
color: "#0698A0",
monogram: "Xi",
prefix: "https://xing.com/pages/",
placeholder: "firma oder https://xing.com/pages/firma",
},
{
id: "youtube",
label: "YouTube",
color: "#FF0000",
monogram: "Yt",
prefix: "https://youtube.com/@",
placeholder: "@firma oder https://youtube.com/@firma",
},
{
id: "tiktok",
label: "TikTok",
color: "#111827",
monogram: "Tt",
prefix: "https://tiktok.com/@",
placeholder: "@firma oder https://tiktok.com/@firma",
},
{
id: "x",
label: "X (Twitter)",
color: "#0F172A",
monogram: "X",
prefix: "https://x.com/",
placeholder: "@firma oder https://x.com/firma",
},
{
id: "threads",
label: "Threads",
color: "#0F172A",
monogram: "Th",
prefix: "https://threads.net/@",
placeholder: "@firma oder https://threads.net/@firma",
},
{
id: "mastodon",
label: "Mastodon",
color: "#6364FF",
monogram: "Ma",
placeholder: "https://mastodon.social/@firma",
},
{
id: "whatsapp",
label: "WhatsApp",
color: "#25D366",
monogram: "Wa",
prefix: "https://wa.me/",
placeholder: "4915112345678 oder https://wa.me/4915112345678",
},
{
id: "telegram",
label: "Telegram",
color: "#229ED9",
monogram: "Tg",
prefix: "https://t.me/",
placeholder: "@firma oder https://t.me/firma",
},
{
id: "pinterest",
label: "Pinterest",
color: "#BD081C",
monogram: "Pi",
prefix: "https://pinterest.com/",
placeholder: "firma oder https://pinterest.com/firma",
},
{
id: "snapchat",
label: "Snapchat",
color: "#B45309",
monogram: "Sc",
prefix: "https://snapchat.com/add/",
placeholder: "firma oder https://snapchat.com/add/firma",
},
{
id: "github",
label: "GitHub",
color: "#24292F",
monogram: "Gh",
prefix: "https://github.com/",
placeholder: "firma oder https://github.com/firma",
},
{
id: "website",
label: "Website",
color: "#4F46E5",
monogram: "Web",
placeholder: "https://example.com",
},
];
const BY_ID = new Map(SOCIAL_NETWORKS.map((network) => [network.id, network]));
export function getSocialNetwork(id: string): SocialNetwork | undefined {
return BY_ID.get(id);
}
export const SOCIAL_NETWORK_IDS = SOCIAL_NETWORKS.map((network) => network.id);
/**
* Macht aus einer Eingabe eine vollständige Adresse.
*
* Wer „@firma“ eintippt, meint das Profil bei dem gewählten Netzwerk — das
* ist die naheliegende Eingabe und soll nicht als Fehler enden. Eine bereits
* vollständige Adresse bleibt unangetastet, damit auch abweichende
* Profil-Adressen (eigene Domain, Untergruppe) möglich bleiben.
*/
export function expandSocialHandle(networkId: string, input: string): string {
const value = input.trim();
if (value.length === 0) return value;
if (/^https?:\/\//i.test(value)) return value;
const network = getSocialNetwork(networkId);
if (!network?.prefix) return value;
const handle = value.replace(/^[@/]+/, "");
if (handle.length === 0) return value;
return network.prefix + handle;
}
/**
* Kurzform der Adresse für die zweite Zeile — ohne Schema und ohne „www.“,
* denn dort steht das Wesentliche: bei wem man landet.
*/
export function socialHandleLabel(url: string): string {
try {
const parsed = new URL(url);
const path = parsed.pathname.replace(/\/+$/, "");
const host = parsed.host.replace(/^www\./, "");
return path && path !== "/" ? `${host}${path}` : host;
} catch {
return url;
}
}
+97
View File
@@ -0,0 +1,97 @@
import { z } from "zod";
import { isHttpUrl } from "@/lib/qr/encoding";
import {
expandSocialHandle,
getSocialNetwork,
SOCIAL_NETWORK_IDS,
} from "@/lib/qr/social-networks";
import { defineQrType, QrTypeError } from "@/lib/qr/type-module";
export const MAX_PROFILES = 12;
export const socialProfileSchema = z
.object({
network: z.string().trim().refine((id) => SOCIAL_NETWORK_IDS.includes(id), {
error: "Dieses Netzwerk kennen wir nicht.",
}),
url: z
.string()
.trim()
.min(1, { error: "Bitte gib zu jedem Netzwerk ein Profil an." })
.max(2048, { error: "Die Adresse ist zu lang." }),
})
// Aus „@firma“ wird die volle Profiladresse — erst danach wird geprüft.
.transform((profile) => ({
...profile,
url: expandSocialHandle(profile.network, profile.url),
}))
.refine((profile) => isHttpUrl(profile.url), {
error:
"Bitte gib das Profil als vollständige Adresse (https://…) oder als Kürzel wie @firma an.",
path: ["url"],
});
export const socialDataSchema = z
.object({
title: z
.string()
.trim()
.min(1, { error: "Bitte gib der Seite eine Überschrift." })
.max(80, { error: "Die Überschrift ist zu lang (höchstens 80 Zeichen)." }),
description: z
.string()
.trim()
.max(300, { error: "Der Text ist zu lang (höchstens 300 Zeichen)." })
.default(""),
profiles: z
.array(socialProfileSchema)
.min(1, { error: "Bitte gib mindestens ein Profil an." })
.max(MAX_PROFILES, {
error: `Mehr als ${MAX_PROFILES} Profile sind nicht vorgesehen.`,
}),
})
.refine(
(data) =>
new Set(data.profiles.map((profile) => profile.network)).size ===
data.profiles.length,
{
error: "Jedes Netzwerk kann nur einmal vorkommen.",
path: ["profiles"],
},
);
export type SocialData = z.infer<typeof socialDataSchema>;
export type SocialProfile = SocialData["profiles"][number];
export const socialType = defineQrType({
id: "social",
title: "Social Media",
description: "Bündle deine Profile in einem Code.",
icon: "share",
implemented: true,
// Mehrere Profile passen nicht in einen statischen Code — der kann immer
// nur eine einzige Adresse enthalten.
supportsStatic: false,
supportsDynamic: true,
hasLandingPage: true,
schema: socialDataSchema,
defaultData: {
title: "",
description: "",
profiles: [{ network: "instagram", url: "" }],
},
encode: () => {
throw new QrTypeError(
"Eine Profilsammlung funktioniert nur als dynamischer Code — sie braucht eine Seite, die die Profile anzeigt.",
);
},
summary: (data) => {
const names = data.profiles
.map((profile) => getSocialNetwork(profile.network)?.label)
.filter((label): label is string => Boolean(label));
const gezeigt = names.slice(0, 3).join(", ");
const rest = names.length - 3;
return rest > 0 ? `${gezeigt} +${rest} weitere` : gezeigt || data.title;
},
});
+133
View File
@@ -0,0 +1,133 @@
import { z } from "zod";
import { isHttpUrl } from "@/lib/qr/encoding";
import { defineQrType } from "@/lib/qr/type-module";
/** Wie das Video auf der Landingpage gezeigt wird. */
export type VideoSource =
| { kind: "youtube"; id: string; embedUrl: string; label: string }
| { kind: "vimeo"; id: string; embedUrl: string; label: string }
| { kind: "file"; label: string }
| { kind: "link"; label: string };
const YOUTUBE_HOSTS = new Set([
"youtube.com",
"www.youtube.com",
"m.youtube.com",
"youtu.be",
"www.youtu.be",
"youtube-nocookie.com",
"www.youtube-nocookie.com",
]);
const FILE_ENDINGS = /\.(mp4|webm|ogv|ogg|mov|m4v)$/i;
function youtubeId(parsed: URL): string | null {
if (parsed.host.endsWith("youtu.be")) {
const id = parsed.pathname.split("/").filter(Boolean)[0];
return id ?? null;
}
const v = parsed.searchParams.get("v");
if (v) return v;
// /embed/<id>, /shorts/<id>, /live/<id>
const segments = parsed.pathname.split("/").filter(Boolean);
if (
segments.length >= 2 &&
["embed", "shorts", "live", "v"].includes(segments[0])
) {
return segments[1];
}
return null;
}
/**
* Erkennt, was für eine Adresse das ist — und damit, wie sie gezeigt wird.
*
* Bewusst nur die beiden verbreiteten Plattformen plus direkte Dateien.
* Alles andere endet als schlichter Link; das ist ehrlicher als ein
* eingebetteter Rahmen, der beim Anbieter nicht erlaubt ist.
*/
export function resolveVideoSource(url: string): VideoSource {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return { kind: "link", label: "Video öffnen" };
}
if (YOUTUBE_HOSTS.has(parsed.host)) {
const id = youtubeId(parsed);
if (id && /^[\w-]{6,20}$/.test(id)) {
return {
kind: "youtube",
id,
// Ohne Cookies: Die Datenschutz-Variante von YouTube setzt erst beim
// Abspielen etwas — und abgespielt wird hier nur auf Klick.
embedUrl: `https://www.youtube-nocookie.com/embed/${id}?autoplay=1&rel=0`,
label: "YouTube",
};
}
}
if (parsed.host === "vimeo.com" || parsed.host === "www.vimeo.com") {
const id = parsed.pathname.split("/").filter(Boolean)[0];
if (id && /^\d+$/.test(id)) {
return {
kind: "vimeo",
id,
embedUrl: `https://player.vimeo.com/video/${id}?autoplay=1`,
label: "Vimeo",
};
}
}
if (FILE_ENDINGS.test(parsed.pathname)) {
return { kind: "file", label: "Videodatei" };
}
return { kind: "link", label: parsed.host.replace(/^www\./, "") };
}
export const videoDataSchema = z.object({
url: z
.string()
.trim()
.min(1, { error: "Bitte gib die Adresse des Videos ein." })
.max(2048, { error: "Die Adresse ist zu lang." })
.refine(isHttpUrl, {
error:
"Die Adresse muss mit http:// oder https:// beginnen, z. B. https://youtube.com/watch?v=…",
}),
title: z
.string()
.trim()
.max(80, { error: "Der Titel ist zu lang (höchstens 80 Zeichen)." })
.default(""),
description: z
.string()
.trim()
.max(300, { error: "Der Text ist zu lang (höchstens 300 Zeichen)." })
.default(""),
});
export type VideoData = z.infer<typeof videoDataSchema>;
export const videoType = defineQrType({
id: "video",
title: "Video",
description: "Verlinke ein Video oder einen Stream.",
icon: "video",
implemented: true,
// Statisch ist die Adresse des Videos selbst kodiert — dann führt der Code
// direkt zum Anbieter, ohne Zwischenseite und ohne Zähler.
supportsStatic: true,
supportsDynamic: true,
hasLandingPage: true,
schema: videoDataSchema,
defaultData: { url: "", title: "", description: "" },
encode: (data) => data.url,
summary: (data) => {
const quelle = resolveVideoSource(data.url);
return data.title ? `${data.title} (${quelle.label})` : `Video bei ${quelle.label}`;
},
});