diff --git a/js/web/src/routeTree.gen.ts b/js/web/src/routeTree.gen.ts index 89e2c8a47..4ca75e5a7 100644 --- a/js/web/src/routeTree.gen.ts +++ b/js/web/src/routeTree.gen.ts @@ -21,6 +21,7 @@ import { Route as SettingsRecommendationsRouteImport } from './routes/settings/r import { Route as SettingsPrivacyRouteImport } from './routes/settings/privacy' import { Route as SettingsMultistreamRouteImport } from './routes/settings/multistream' import { Route as SettingsLanguagesRouteImport } from './routes/settings/languages' +import { Route as SettingsBrandingRouteImport } from './routes/settings/branding' import { Route as SettingsAdvancedRouteImport } from './routes/settings/advanced' import { Route as SettingsAccountRouteImport } from './routes/settings/account' import { Route as SettingsAboutRouteImport } from './routes/settings/about' @@ -86,6 +87,11 @@ const SettingsLanguagesRoute = SettingsLanguagesRouteImport.update({ path: '/languages', getParentRoute: () => SettingsRoute, } as any) +const SettingsBrandingRoute = SettingsBrandingRouteImport.update({ + id: '/branding', + path: '/branding', + getParentRoute: () => SettingsRoute, +} as any) const SettingsAdvancedRoute = SettingsAdvancedRouteImport.update({ id: '/advanced', path: '/advanced', @@ -116,6 +122,7 @@ export interface FileRoutesByFullPath { '/settings/about': typeof SettingsAboutRoute '/settings/account': typeof SettingsAccountRoute '/settings/advanced': typeof SettingsAdvancedRoute + '/settings/branding': typeof SettingsBrandingRoute '/settings/languages': typeof SettingsLanguagesRoute '/settings/multistream': typeof SettingsMultistreamRoute '/settings/privacy': typeof SettingsPrivacyRoute @@ -133,6 +140,7 @@ export interface FileRoutesByTo { '/settings/about': typeof SettingsAboutRoute '/settings/account': typeof SettingsAccountRoute '/settings/advanced': typeof SettingsAdvancedRoute + '/settings/branding': typeof SettingsBrandingRoute '/settings/languages': typeof SettingsLanguagesRoute '/settings/multistream': typeof SettingsMultistreamRoute '/settings/privacy': typeof SettingsPrivacyRoute @@ -152,6 +160,7 @@ export interface FileRoutesById { '/settings/about': typeof SettingsAboutRoute '/settings/account': typeof SettingsAccountRoute '/settings/advanced': typeof SettingsAdvancedRoute + '/settings/branding': typeof SettingsBrandingRoute '/settings/languages': typeof SettingsLanguagesRoute '/settings/multistream': typeof SettingsMultistreamRoute '/settings/privacy': typeof SettingsPrivacyRoute @@ -172,6 +181,7 @@ export interface FileRouteTypes { | '/settings/about' | '/settings/account' | '/settings/advanced' + | '/settings/branding' | '/settings/languages' | '/settings/multistream' | '/settings/privacy' @@ -189,6 +199,7 @@ export interface FileRouteTypes { | '/settings/about' | '/settings/account' | '/settings/advanced' + | '/settings/branding' | '/settings/languages' | '/settings/multistream' | '/settings/privacy' @@ -207,6 +218,7 @@ export interface FileRouteTypes { | '/settings/about' | '/settings/account' | '/settings/advanced' + | '/settings/branding' | '/settings/languages' | '/settings/multistream' | '/settings/privacy' @@ -311,6 +323,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsLanguagesRouteImport parentRoute: typeof SettingsRoute } + '/settings/branding': { + id: '/settings/branding' + path: '/branding' + fullPath: '/settings/branding' + preLoaderRoute: typeof SettingsBrandingRouteImport + parentRoute: typeof SettingsRoute + } '/settings/advanced': { id: '/settings/advanced' path: '/advanced' @@ -356,6 +375,7 @@ interface SettingsRouteChildren { SettingsAboutRoute: typeof SettingsAboutRoute SettingsAccountRoute: typeof SettingsAccountRoute SettingsAdvancedRoute: typeof SettingsAdvancedRoute + SettingsBrandingRoute: typeof SettingsBrandingRoute SettingsLanguagesRoute: typeof SettingsLanguagesRoute SettingsMultistreamRoute: typeof SettingsMultistreamRoute SettingsPrivacyRoute: typeof SettingsPrivacyRoute @@ -369,6 +389,7 @@ const SettingsRouteChildren: SettingsRouteChildren = { SettingsAboutRoute: SettingsAboutRoute, SettingsAccountRoute: SettingsAccountRoute, SettingsAdvancedRoute: SettingsAdvancedRoute, + SettingsBrandingRoute: SettingsBrandingRoute, SettingsLanguagesRoute: SettingsLanguagesRoute, SettingsMultistreamRoute: SettingsMultistreamRoute, SettingsPrivacyRoute: SettingsPrivacyRoute, diff --git a/js/web/src/routes/settings/branding.tsx b/js/web/src/routes/settings/branding.tsx new file mode 100644 index 000000000..92034ef21 --- /dev/null +++ b/js/web/src/routes/settings/branding.tsx @@ -0,0 +1,478 @@ +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { usePDSAgent } from "../../lib/store/hooks"; + +export const Route = createFileRoute("/settings/branding")({ + component: BrandingAdmin, +}); + +function BrandingAdmin() { + const { t } = useTranslation("settings"); + const navigate = useNavigate(); + const agent = usePDSAgent(); + + const [uploading, setUploading] = useState(false); + const [broadcasterDID, setBroadcasterDID] = useState(""); + + // Text fields + const [siteTitle, setSiteTitle] = useState(""); + const [siteDescription, setSiteDescription] = useState(""); + const [primaryColor, setPrimaryColor] = useState(""); + const [accentColor, setAccentColor] = useState(""); + const [defaultStreamer, setDefaultStreamer] = useState(""); + + // Legal links + const [legalLinkText, setLegalLinkText] = useState(""); + const [legalLinkUrl, setLegalLinkUrl] = useState(""); + const [editingLinkIndex, setEditingLinkIndex] = useState(null); + const [legalLinks, setLegalLinks] = useState<{ text: string; url: string }[]>( + [], + ); + + const uploadText = async (key: string, value: string) => { + if (!agent) return; + if (!value.trim()) { + toast.error("Value cannot be empty"); + return; + } + try { + setUploading(true); + const textBytes = new TextEncoder().encode(value.trim()); + const base64Data = btoa(String.fromCharCode(...textBytes)); + await agent.place.stream.branding.updateBlob({ + key, + broadcaster: broadcasterDID || undefined, + data: base64Data, + mimeType: "text/plain", + }); + toast.success(`${key} updated`); + } catch (error: any) { + toast.error(error.message || `Failed to update ${key}`); + } finally { + setUploading(false); + } + }; + + const uploadFile = async (key: string, file: File) => { + if (!agent) return; + try { + setUploading(true); + const arrayBuffer = await file.arrayBuffer(); + const uint8Array = new Uint8Array(arrayBuffer); + const base64Data = btoa(String.fromCharCode(...uint8Array)); + + let width: number | undefined; + let height: number | undefined; + if (file.type.startsWith("image/")) { + const img = new Image(); + const imageUrl = URL.createObjectURL(file); + await new Promise((resolve, reject) => { + img.onload = () => { + width = img.naturalWidth; + height = img.naturalHeight; + URL.revokeObjectURL(imageUrl); + resolve(); + }; + img.onerror = () => { + URL.revokeObjectURL(imageUrl); + reject(new Error("Failed to load image")); + }; + img.src = imageUrl; + }); + } + + await agent.place.stream.branding.updateBlob({ + key, + broadcaster: broadcasterDID || undefined, + data: base64Data, + mimeType: file.type, + width, + height, + }); + toast.success(`${key} uploaded`); + } catch (error: any) { + toast.error(error.message || `Failed to upload ${key}`); + } finally { + setUploading(false); + } + }; + + const deleteBlob = async (key: string) => { + if (!agent) return; + try { + setUploading(true); + await agent.place.stream.branding.deleteBlob({ + key, + broadcaster: broadcasterDID || undefined, + }); + toast.success(`${key} deleted`); + } catch (error: any) { + toast.error(error.message || `Failed to delete ${key}`); + } finally { + setUploading(false); + } + }; + + const saveLegalLink = async () => { + if (!legalLinkText.trim() || !legalLinkUrl.trim()) return; + const updated = [...legalLinks]; + const newLink = { text: legalLinkText.trim(), url: legalLinkUrl.trim() }; + if (editingLinkIndex !== null) { + updated[editingLinkIndex] = newLink; + } else { + updated.push(newLink); + } + setLegalLinks(updated); + await uploadText("legalLinks", JSON.stringify(updated)); + setLegalLinkText(""); + setLegalLinkUrl(""); + setEditingLinkIndex(null); + }; + + const deleteLegalLink = async (index: number) => { + const updated = legalLinks.filter((_, i) => i !== index); + setLegalLinks(updated); + if (updated.length === 0) { + await deleteBlob("legalLinks"); + } else { + await uploadText("legalLinks", JSON.stringify(updated)); + } + }; + + if (!agent) { + return ( +
+ {t("branding-login-required")} +
+ ); + } + + return ( +
+ + +
+

{t("branding-admin")}

+

+ {t("branding-admin-description")} +

+
+ + {uploading && ( +
Uploading…
+ )} + + {/* Broadcaster DID */} +
+

+ {t("branding-configuration")} +

+
+ + setBroadcasterDID(e.target.value)} + placeholder={t("branding-default-streamer-placeholder")} + /> +

+ {t("branding-broadcaster-did-description")} +

+
+
+ + {/* Text settings */} +
+

+ {t("branding-text-settings")} +

+
+ uploadText("siteTitle", siteTitle)} + disabled={uploading} + /> + uploadText("siteDescription", siteDescription)} + disabled={uploading} + /> + uploadText("defaultStreamer", defaultStreamer)} + onDelete={() => deleteBlob("defaultStreamer")} + disabled={uploading} + /> +
+
+ + {/* Colors */} +
+

+ {t("branding-colors")} +

+
+ uploadText("primaryColor", primaryColor)} + disabled={uploading} + /> + uploadText("accentColor", accentColor)} + disabled={uploading} + /> +
+
+ + {/* Images */} +
+

+ {t("branding-images")} +

+
+ uploadFile("mainLogo", file)} + onDelete={() => deleteBlob("mainLogo")} + disabled={uploading} + /> + uploadFile("favicon", file)} + onDelete={() => deleteBlob("favicon")} + disabled={uploading} + /> + uploadFile("sidebarBackgroundImage", file)} + onDelete={() => deleteBlob("sidebarBackgroundImage")} + disabled={uploading} + /> +
+
+ + {/* Legal links */} +
+

+ {t("branding-legal-links")} +

+
+
+ setLegalLinkText(e.target.value)} + placeholder={t("branding-legal-link-text-placeholder")} + /> + setLegalLinkUrl(e.target.value)} + placeholder={t("branding-legal-link-url-placeholder")} + /> +
+
+ + {editingLinkIndex !== null && ( + + )} +
+
+ + {legalLinks.length > 0 && ( +
+ {legalLinks.map((link, index) => ( +
+
+
{link.text}
+
+ {link.url} +
+
+
+ + +
+
+ ))} +
+ )} +
+
+ ); +} + +function BrandingTextField({ + label, + value, + onChange, + placeholder, + onSave, + onDelete, + disabled, +}: { + label: string; + value: string; + onChange: (v: string) => void; + placeholder: string; + onSave: () => void; + onDelete?: () => void; + disabled: boolean; +}) { + const { t } = useTranslation("settings"); + return ( +
+
{label}
+
+ onChange(e.target.value)} + placeholder={placeholder} + className="flex-1" + /> + +
+ {onDelete && ( + + )} +
+ ); +} + +function BrandingImageField({ + label, + description, + accept, + onUpload, + onDelete, + disabled, +}: { + label: string; + description: string; + accept: string; + onUpload: (file: File) => void; + onDelete: () => void; + disabled: boolean; +}) { + const { t } = useTranslation("settings"); + + const handleSelect = () => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = accept; + input.onchange = (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (file) onUpload(file); + }; + input.click(); + }; + + return ( +
+
{label}
+
{description}
+
+ + +
+
+ ); +}