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:
@@ -1,7 +1,9 @@
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
import { LinksLanding } from "@/components/landing/links-landing";
|
||||
import { SocialLanding } from "@/components/landing/social-landing";
|
||||
import type { LandingProps } from "@/components/landing/types";
|
||||
import { VideoLanding } from "@/components/landing/video-landing";
|
||||
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>>> =
|
||||
{
|
||||
links: LinksLanding,
|
||||
social: SocialLanding,
|
||||
video: VideoLanding,
|
||||
};
|
||||
|
||||
export type { LandingProps };
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -10,8 +10,10 @@ import {
|
||||
WhatsappForm,
|
||||
WifiForm,
|
||||
} from "@/components/qr-forms/simple-forms";
|
||||
import { SocialForm } from "@/components/qr-forms/social-form";
|
||||
import type { QrFormProps } from "@/components/qr-forms/types";
|
||||
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 type { QrTypeId } from "@/lib/qr/type-module";
|
||||
|
||||
@@ -27,6 +29,8 @@ export const qrForms: Partial<Record<QrTypeId, ComponentType<QrFormProps>>> = {
|
||||
vcard: VCardForm,
|
||||
app: AppForm,
|
||||
links: LinksForm,
|
||||
social: SocialForm,
|
||||
video: VideoForm,
|
||||
whatsapp: WhatsappForm,
|
||||
email: EmailForm,
|
||||
sms: SmsForm,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user