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:
+4
-12
@@ -11,7 +11,9 @@ import { linksType } from "@/lib/qr/types/links";
|
||||
import { locationType } from "@/lib/qr/types/location";
|
||||
import { paymentType } from "@/lib/qr/types/payment";
|
||||
import { smsType } from "@/lib/qr/types/sms";
|
||||
import { socialType } from "@/lib/qr/types/social";
|
||||
import { vcardType } from "@/lib/qr/types/vcard";
|
||||
import { videoType } from "@/lib/qr/types/video";
|
||||
import { websiteType } from "@/lib/qr/types/website";
|
||||
import { whatsappType } from "@/lib/qr/types/whatsapp";
|
||||
import { wifiType } from "@/lib/qr/types/wifi";
|
||||
@@ -30,12 +32,7 @@ const MODULES: readonly QrTypeModule[] = [
|
||||
vcardType,
|
||||
appType,
|
||||
linksType,
|
||||
defineComingSoonType({
|
||||
id: "social",
|
||||
title: "Social Media",
|
||||
description: "Bündle deine Profile in einem Code.",
|
||||
icon: "share",
|
||||
}),
|
||||
socialType,
|
||||
whatsappType,
|
||||
defineComingSoonType({
|
||||
id: "pdf",
|
||||
@@ -49,12 +46,7 @@ const MODULES: readonly QrTypeModule[] = [
|
||||
description: "Zeige ein Bild oder eine Galerie.",
|
||||
icon: "image",
|
||||
}),
|
||||
defineComingSoonType({
|
||||
id: "video",
|
||||
title: "Video",
|
||||
description: "Verlinke ein Video oder einen Stream.",
|
||||
icon: "video",
|
||||
}),
|
||||
videoType,
|
||||
emailType,
|
||||
smsType,
|
||||
locationType,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
@@ -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}`;
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user