Live video on the AT Protocol

restyle and hook up branding admin to settings

+555 -326
-1
js/app/components/index.tsx
··· 1 1 export { Countdown } from "./countdown"; 2 2 export { default as Provider } from "./provider/provider"; 3 - export { BrandingAdmin } from "./settings/branding-admin"; 4 3 export { Settings } from "./settings/settings";
+459 -311
js/app/components/settings/branding-admin.tsx
··· 1 1 import { 2 2 Button, 3 3 Input, 4 + MenuContainer, 5 + MenuGroup, 6 + MenuInfo, 7 + MenuItem, 8 + MenuLabel, 9 + MenuSeparator, 4 10 Text, 5 11 useStreamplaceStore, 6 12 useToast, 13 + useTranslation, 7 14 View, 8 15 zero, 9 16 } from "@streamplace/components"; ··· 15 22 import { usePDSAgent } from "@streamplace/components/src/streamplace-store/xrpc"; 16 23 import { useEffect, useState } from "react"; 17 24 import { ActivityIndicator, Image, Platform, ScrollView } from "react-native"; 25 + import { SettingsRowItem } from "./components/settings-navigation-item"; 18 26 19 27 export function BrandingAdmin() { 28 + const { t } = useTranslation("settings"); 20 29 const agent = usePDSAgent(); 21 30 const fetchBranding = useFetchBranding(); 22 31 const toast = useToast(); ··· 52 61 53 62 const uploadText = async (key: string, value: string) => { 54 63 if (!agent) { 55 - toast.show("Not authenticated", "Please log in first", { 56 - variant: "error", 57 - }); 64 + toast.show( 65 + t("branding-not-authenticated"), 66 + t("branding-not-authenticated"), 67 + { 68 + variant: "error", 69 + }, 70 + ); 58 71 return; 59 72 } 60 73 61 74 if (!value.trim()) { 62 - toast.show("Empty value", "Please enter a value", { variant: "error" }); 75 + toast.show(t("branding-empty-value"), t("branding-empty-value"), { 76 + variant: "error", 77 + }); 63 78 return; 64 79 } 65 80 ··· 75 90 mimeType: "text/plain", 76 91 }); 77 92 78 - toast.show("Success", `${key} updated successfully`, { 79 - variant: "success", 80 - }); 93 + toast.show( 94 + t("branding-update-success", { key }), 95 + t("branding-update-success", { key }), 96 + { 97 + variant: "success", 98 + }, 99 + ); 81 100 82 101 // clear input based on key 83 102 switch (key) { ··· 101 120 // reload branding 102 121 setTimeout(() => fetchBranding(), 500); 103 122 } catch (err: any) { 104 - toast.show("Upload failed", err.message || "Failed to upload", { 105 - variant: "error", 106 - }); 123 + toast.show( 124 + t("branding-upload-failed"), 125 + err.message || t("branding-upload-failed"), 126 + { 127 + variant: "error", 128 + }, 129 + ); 107 130 } finally { 108 131 setUploading(false); 109 132 } ··· 111 134 112 135 const uploadFile = async (key: string, file: File) => { 113 136 if (!agent) { 114 - toast.show("Not authenticated", "Please log in first", { 115 - variant: "error", 116 - }); 137 + toast.show( 138 + t("branding-not-authenticated"), 139 + t("branding-not-authenticated"), 140 + { 141 + variant: "error", 142 + }, 143 + ); 117 144 return; 118 145 } 119 146 ··· 155 182 height, 156 183 }); 157 184 158 - toast.show("Success", `${key} uploaded successfully`, { 159 - variant: "success", 160 - }); 185 + toast.show( 186 + t("branding-update-success", { key }), 187 + t("branding-upload-success", { key }), 188 + { 189 + variant: "success", 190 + }, 191 + ); 161 192 162 193 // reload branding 163 194 setTimeout(() => fetchBranding(), 500); 164 195 } catch (err: any) { 165 - toast.show("Upload failed", err.message || "Failed to upload", { 166 - variant: "error", 167 - }); 196 + toast.show( 197 + t("branding-upload-failed"), 198 + err.message || t("branding-upload-failed"), 199 + { 200 + variant: "error", 201 + }, 202 + ); 168 203 } finally { 169 204 setUploading(false); 170 205 } ··· 172 207 173 208 const handleFileSelect = (key: string, accept: string) => { 174 209 if (Platform.OS !== "web") { 175 - toast.show("Not available", "File uploads are only available on web", { 210 + toast.show(t("branding-not-available"), t("branding-not-available"), { 176 211 variant: "error", 177 212 }); 178 213 return; ··· 194 229 195 230 const deleteBlob = async (key: string) => { 196 231 if (!agent) { 197 - toast.show("Not authenticated", "Please log in first", { 198 - variant: "error", 199 - }); 232 + toast.show( 233 + t("branding-not-authenticated"), 234 + t("branding-not-authenticated"), 235 + { 236 + variant: "error", 237 + }, 238 + ); 200 239 return; 201 240 } 202 241 ··· 207 246 broadcaster: broadcasterDID || undefined, 208 247 }); 209 248 210 - toast.show("Success", `${key} deleted successfully`, { 211 - variant: "success", 212 - }); 249 + toast.show( 250 + t("branding-update-success", { key }), 251 + t("branding-delete-success", { key }), 252 + { 253 + variant: "success", 254 + }, 255 + ); 213 256 214 257 // reload branding 215 258 setTimeout(() => fetchBranding(), 500); 216 259 } catch (err: any) { 217 - toast.show("Delete failed", err.message || "Failed to delete", { 218 - variant: "error", 219 - }); 260 + toast.show( 261 + t("branding-delete-failed"), 262 + err.message || t("branding-delete-failed"), 263 + { 264 + variant: "error", 265 + }, 266 + ); 220 267 } finally { 221 268 setUploading(false); 222 269 } ··· 225 272 if (!agent) { 226 273 return ( 227 274 <View style={[zero.layout.flex.align.center, zero.px[16], zero.py[24]]}> 228 - <Text>Please log in to manage branding</Text> 275 + <Text>{t("branding-login-required")}</Text> 229 276 </View> 230 277 ); 231 278 } 232 279 233 280 return ( 234 281 <ScrollView> 235 - <View style={[zero.layout.flex.align.center, zero.px[16], zero.py[24]]}> 236 - <View 237 - style={[ 238 - zero.gap.all[12], 239 - { paddingVertical: 24, maxWidth: 600, width: "100%" }, 240 - ]} 241 - > 242 - <View> 243 - <Text size="2xl" weight="bold"> 244 - Branding Administration 245 - </Text> 246 - <Text color="muted">Customize your Streamplace instance</Text> 247 - </View> 248 - 249 - {uploading && ( 250 - <View style={[zero.layout.flex.align.center, zero.py[16]]}> 251 - <ActivityIndicator /> 252 - </View> 253 - )} 254 - 255 - {/* Broadcaster DID */} 256 - <View style={[zero.gap.all[2]]}> 257 - <Text size="lg" weight="semibold"> 258 - Broadcaster DID 259 - </Text> 260 - <Text size="sm" color="muted"> 261 - Leave empty to use server default 262 - </Text> 263 - <Input 264 - placeholder="did:plc:..." 265 - value={broadcasterDID} 266 - onChangeText={setBroadcasterDID} 267 - /> 268 - </View> 269 - 270 - {/* Site Title */} 271 - <View style={[zero.gap.all[2]]}> 272 - <Text size="lg" weight="semibold"> 273 - Site Title 274 - </Text> 275 - <Text size="sm" color="muted"> 276 - Current: {currentTitle?.data || "Streamplace"} 277 - </Text> 278 - <View style={[zero.layout.flex.direction.row, zero.gap.all[2]]}> 279 - <View style={{ flex: 1 }}> 280 - <Input 281 - placeholder="Enter new site title" 282 - value={siteTitle} 283 - onChangeText={setSiteTitle} 284 - /> 285 - </View> 286 - <Button 287 - onPress={() => uploadText("siteTitle", siteTitle)} 288 - disabled={uploading || !siteTitle.trim()} 289 - > 290 - Update 291 - </Button> 282 + <View style={[zero.layout.flex.align.center, zero.px[2], zero.py[2]]}> 283 + <View style={{ maxWidth: 500, width: "100%" }}> 284 + <MenuContainer> 285 + <View style={[zero.gap.all[2]]}> 286 + <Text size="2xl" weight="bold"> 287 + {t("branding-admin")} 288 + </Text> 289 + <Text color="muted">{t("branding-admin-description")}</Text> 292 290 </View> 293 - </View> 294 291 295 - {/* Site Description */} 296 - <View style={[zero.gap.all[2]]}> 297 - <Text size="lg" weight="semibold"> 298 - Site Description 299 - </Text> 300 - <Text size="sm" color="muted"> 301 - Current: {currentDescription?.data || "Live streaming platform"} 302 - </Text> 303 - <View style={[zero.layout.flex.direction.row, zero.gap.all[2]]}> 304 - <View style={{ flex: 1 }}> 305 - <Input 306 - placeholder="Enter site description" 307 - value={siteDescription} 308 - onChangeText={setSiteDescription} 309 - /> 292 + {uploading && ( 293 + <View style={[zero.layout.flex.align.center, zero.py[16]]}> 294 + <ActivityIndicator /> 310 295 </View> 311 - <Button 312 - onPress={() => uploadText("siteDescription", siteDescription)} 313 - disabled={uploading || !siteDescription.trim()} 314 - > 315 - Update 316 - </Button> 317 - </View> 318 - </View> 319 - 320 - {/* Primary Color */} 321 - <View style={[zero.gap.all[2]]}> 322 - <Text size="lg" weight="semibold"> 323 - Primary Color 324 - </Text> 325 - <Text size="sm" color="muted"> 326 - Current: {currentPrimaryColor?.data || "#6366f1"} 327 - </Text> 328 - <View style={[zero.layout.flex.direction.row, zero.gap.all[2]]}> 329 - <View style={{ flex: 1 }}> 330 - <Input 331 - placeholder="#6366f1" 332 - value={primaryColor} 333 - onChangeText={setPrimaryColor} 334 - /> 335 - </View> 336 - <Button 337 - onPress={() => uploadText("primaryColor", primaryColor)} 338 - disabled={uploading || !primaryColor.trim()} 339 - > 340 - Update 341 - </Button> 342 - </View> 343 - </View> 344 - 345 - {/* Accent Color */} 346 - <View style={[zero.gap.all[2]]}> 347 - <Text size="lg" weight="semibold"> 348 - Accent Color 349 - </Text> 350 - <Text size="sm" color="muted"> 351 - Current: {currentAccentColor?.data || "#8b5cf6"} 352 - </Text> 353 - <View style={[zero.layout.flex.direction.row, zero.gap.all[2]]}> 354 - <View style={{ flex: 1 }}> 355 - <Input 356 - placeholder="#8b5cf6" 357 - value={accentColor} 358 - onChangeText={setAccentColor} 359 - /> 360 - </View> 361 - <Button 362 - onPress={() => uploadText("accentColor", accentColor)} 363 - disabled={uploading || !accentColor.trim()} 364 - > 365 - Update 366 - </Button> 367 - </View> 368 - </View> 369 - 370 - {/* Default Streamer */} 371 - <View style={[zero.gap.all[2]]}> 372 - <Text size="lg" weight="semibold"> 373 - Default Streamer 374 - </Text> 375 - <Text size="sm" color="muted"> 376 - Current: {currentDefaultStreamer?.data || "None"} 377 - </Text> 378 - <View style={[zero.layout.flex.direction.row, zero.gap.all[2]]}> 379 - <View style={{ flex: 1 }}> 380 - <Input 381 - placeholder="did:plc:..." 382 - value={defaultStreamer} 383 - onChangeText={setDefaultStreamer} 384 - /> 385 - </View> 386 - <Button 387 - onPress={() => uploadText("defaultStreamer", defaultStreamer)} 388 - disabled={uploading || !defaultStreamer.trim()} 389 - > 390 - Update 391 - </Button> 392 - </View> 393 - <Button 394 - variant="destructive" 395 - onPress={() => deleteBlob("defaultStreamer")} 396 - disabled={uploading} 397 - > 398 - Clear Default Streamer 399 - </Button> 400 - </View> 401 - 402 - {/* Main Logo */} 403 - <View style={[zero.gap.all[2]]}> 404 - <Text size="lg" weight="semibold"> 405 - Main Logo 406 - </Text> 407 - <Text size="sm" color="muted"> 408 - SVG, PNG, or JPEG (max 500KB) 409 - </Text> 410 - {currentLogo?.data && ( 411 - <Image 412 - source={{ uri: currentLogo.data }} 413 - style={{ width: 200, height: 100, resizeMode: "contain" }} 414 - /> 415 296 )} 416 - <View style={[zero.layout.flex.direction.row, zero.gap.all[2]]}> 417 - <Button 418 - onPress={() => 419 - handleFileSelect( 420 - "mainLogo", 421 - "image/svg+xml,image/png,image/jpeg", 422 - ) 423 - } 424 - disabled={uploading || Platform.OS !== "web"} 425 - > 426 - Upload Logo 427 - </Button> 428 - <Button 429 - variant="destructive" 430 - onPress={() => deleteBlob("mainLogo")} 431 - disabled={uploading} 432 - > 433 - Delete Logo 434 - </Button> 435 - </View> 436 - </View> 437 297 438 - {/* Favicon */} 439 - <View style={[zero.gap.all[2]]}> 440 - <Text size="lg" weight="semibold"> 441 - Favicon 442 - </Text> 443 - <Text size="sm" color="muted"> 444 - SVG, PNG, or ICO (max 100KB) 445 - </Text> 446 - {currentFavicon?.data && ( 447 - <Image 448 - source={{ uri: currentFavicon.data }} 449 - style={{ width: 64, height: 64, resizeMode: "contain" }} 450 - /> 451 - )} 452 - <View style={[zero.layout.flex.direction.row, zero.gap.all[2]]}> 453 - <Button 454 - onPress={() => 455 - handleFileSelect( 456 - "favicon", 457 - "image/svg+xml,image/png,image/x-icon", 458 - ) 459 - } 460 - disabled={uploading || Platform.OS !== "web"} 461 - > 462 - Upload Favicon 463 - </Button> 464 - <Button 465 - variant="destructive" 466 - onPress={() => deleteBlob("favicon")} 467 - disabled={uploading} 468 - > 469 - Delete Favicon 470 - </Button> 471 - </View> 472 - </View> 298 + <MenuLabel>{t("branding-configuration")}</MenuLabel> 299 + <MenuGroup> 300 + <MenuItem> 301 + <SettingsRowItem> 302 + <View style={[zero.gap.all[2], { flex: 1 }]}> 303 + <Text size="sm" weight="semibold"> 304 + {t("branding-broadcaster-did")} 305 + </Text> 306 + <Input 307 + placeholder={t("branding-default-streamer-placeholder")} 308 + value={broadcasterDID} 309 + onChangeText={setBroadcasterDID} 310 + /> 311 + <MenuInfo 312 + description={t("branding-broadcaster-did-description")} 313 + /> 314 + </View> 315 + </SettingsRowItem> 316 + </MenuItem> 317 + </MenuGroup> 473 318 474 - {/* Sidebar Background Image */} 475 - <View style={[zero.gap.all[1]]}> 476 - <Text size="lg" weight="semibold"> 477 - Sidebar Background Image 478 - </Text> 479 - <Text size="sm" color="muted"> 480 - SVG, PNG, or JPEG (max 500kb) - appears aligned to bottom of 481 - sidebar, full width. 482 - </Text> 483 - <Text size="sm" color="muted"> 484 - Upload an image with opacity for best results, as there is not 485 - currently a separate opacity option. 486 - </Text> 487 - {currentSidebarBg?.data && ( 488 - <Image 489 - source={{ uri: currentSidebarBg.data }} 490 - style={{ width: 200, height: 200, resizeMode: "contain" }} 491 - /> 492 - )} 493 - <Text> 494 - {currentSidebarBg?.height || "unknown"} x{" "} 495 - {currentSidebarBg?.width || "unknown"} 496 - </Text> 497 - <View style={[zero.layout.flex.direction.row, zero.gap.all[2]]}> 498 - <Button 499 - onPress={() => 500 - handleFileSelect( 501 - "sidebarBackgroundImage", 502 - "image/svg+xml,image/png,image/jpeg", 503 - ) 504 - } 505 - disabled={uploading || Platform.OS !== "web"} 506 - > 507 - Upload Background 508 - </Button> 509 - <Button 510 - variant="destructive" 511 - onPress={() => deleteBlob("sidebarBackgroundImage")} 512 - disabled={uploading} 513 - > 514 - Delete Background 515 - </Button> 516 - </View> 517 - </View> 319 + <MenuLabel>{t("branding-text-settings")}</MenuLabel> 320 + <MenuGroup> 321 + <MenuItem> 322 + <SettingsRowItem> 323 + <View style={[zero.gap.all[2], { flex: 1 }]}> 324 + <Text size="sm" weight="semibold"> 325 + {t("branding-site-title")} 326 + </Text> 327 + <Text size="xs" color="muted"> 328 + {t("branding-current", { 329 + value: currentTitle?.data || "Streamplace", 330 + })} 331 + </Text> 332 + <View 333 + style={[zero.layout.flex.direction.row, zero.gap.all[2]]} 334 + > 335 + <View style={{ flex: 1 }}> 336 + <Input 337 + placeholder={t("branding-site-title-placeholder")} 338 + value={siteTitle} 339 + onChangeText={setSiteTitle} 340 + /> 341 + </View> 342 + <Button 343 + onPress={() => uploadText("siteTitle", siteTitle)} 344 + disabled={uploading || !siteTitle.trim()} 345 + width="min" 346 + style={{ height: 42 }} 347 + > 348 + {t("update")} 349 + </Button> 350 + </View> 351 + </View> 352 + </SettingsRowItem> 353 + </MenuItem> 354 + <MenuSeparator /> 355 + <MenuItem> 356 + <SettingsRowItem> 357 + <View style={[zero.gap.all[2], { flex: 1 }]}> 358 + <Text size="sm" weight="semibold"> 359 + {t("branding-site-description")} 360 + </Text> 361 + <Text size="xs" color="muted"> 362 + {t("branding-current", { 363 + value: 364 + currentDescription?.data || "Live streaming platform", 365 + })} 366 + </Text> 367 + <View 368 + style={[zero.layout.flex.direction.row, zero.gap.all[2]]} 369 + > 370 + <View style={{ flex: 1 }}> 371 + <Input 372 + placeholder={t( 373 + "branding-site-description-placeholder", 374 + )} 375 + value={siteDescription} 376 + onChangeText={setSiteDescription} 377 + /> 378 + </View> 379 + <Button 380 + onPress={() => 381 + uploadText("siteDescription", siteDescription) 382 + } 383 + disabled={uploading || !siteDescription.trim()} 384 + width="min" 385 + style={{ height: 42 }} 386 + > 387 + {t("update")} 388 + </Button> 389 + </View> 390 + </View> 391 + </SettingsRowItem> 392 + </MenuItem> 393 + <MenuSeparator /> 394 + <MenuItem> 395 + <SettingsRowItem> 396 + <View style={[zero.gap.all[2], { flex: 1 }]}> 397 + <Text size="sm" weight="semibold"> 398 + {t("branding-default-streamer")} 399 + </Text> 400 + <Text size="xs" color="muted"> 401 + {t("branding-current", { 402 + value: 403 + currentDefaultStreamer?.data || 404 + t("branding-default-streamer-none"), 405 + })} 406 + </Text> 407 + <View 408 + style={[zero.layout.flex.direction.row, zero.gap.all[2]]} 409 + > 410 + <View style={{ flex: 1 }}> 411 + <Input 412 + placeholder={t( 413 + "branding-default-streamer-placeholder", 414 + )} 415 + value={defaultStreamer} 416 + onChangeText={setDefaultStreamer} 417 + /> 418 + </View> 419 + <Button 420 + onPress={() => 421 + uploadText("defaultStreamer", defaultStreamer) 422 + } 423 + disabled={uploading || !defaultStreamer.trim()} 424 + width="min" 425 + style={{ height: 42 }} 426 + > 427 + {t("update")} 428 + </Button> 429 + </View> 430 + <Button 431 + variant="destructive" 432 + onPress={() => deleteBlob("defaultStreamer")} 433 + disabled={uploading} 434 + > 435 + {t("branding-clear-default-streamer")} 436 + </Button> 437 + </View> 438 + </SettingsRowItem> 439 + </MenuItem> 440 + </MenuGroup> 441 + 442 + <MenuLabel>{t("branding-colors")}</MenuLabel> 443 + <MenuGroup> 444 + <MenuItem> 445 + <SettingsRowItem> 446 + <View style={[zero.gap.all[2], { flex: 1 }]}> 447 + <Text size="sm" weight="semibold"> 448 + {t("branding-primary-color")} 449 + </Text> 450 + <Text size="xs" color="muted"> 451 + {t("branding-current", { 452 + value: currentPrimaryColor?.data || "#6366f1", 453 + })} 454 + </Text> 455 + <View 456 + style={[zero.layout.flex.direction.row, zero.gap.all[2]]} 457 + > 458 + <View style={{ flex: 1 }}> 459 + <Input 460 + placeholder={t("branding-primary-color-placeholder")} 461 + value={primaryColor} 462 + onChangeText={setPrimaryColor} 463 + /> 464 + </View> 465 + <Button 466 + onPress={() => uploadText("primaryColor", primaryColor)} 467 + disabled={uploading || !primaryColor.trim()} 468 + width="min" 469 + style={{ height: 42 }} 470 + > 471 + {t("update")} 472 + </Button> 473 + </View> 474 + </View> 475 + </SettingsRowItem> 476 + </MenuItem> 477 + <MenuSeparator /> 478 + <MenuItem> 479 + <SettingsRowItem> 480 + <View style={[zero.gap.all[2], { flex: 1 }]}> 481 + <Text size="sm" weight="semibold"> 482 + {t("branding-accent-color")} 483 + </Text> 484 + <Text size="xs" color="muted"> 485 + {t("branding-current", { 486 + value: currentAccentColor?.data || "#8b5cf6", 487 + })} 488 + </Text> 489 + <View 490 + style={[zero.layout.flex.direction.row, zero.gap.all[2]]} 491 + > 492 + <View style={{ flex: 1 }}> 493 + <Input 494 + placeholder={t("branding-accent-color-placeholder")} 495 + value={accentColor} 496 + onChangeText={setAccentColor} 497 + /> 498 + </View> 499 + <Button 500 + onPress={() => uploadText("accentColor", accentColor)} 501 + disabled={uploading || !accentColor.trim()} 502 + width="min" 503 + style={{ height: 42 }} 504 + > 505 + {t("update")} 506 + </Button> 507 + </View> 508 + </View> 509 + </SettingsRowItem> 510 + </MenuItem> 511 + </MenuGroup> 518 512 519 - <Text size="sm" color="muted" style={{ marginTop: 16 }}> 520 - {Platform.OS !== "web" && 521 - "Image uploads are only available on web."} 522 - </Text> 513 + <MenuLabel>{t("branding-images")}</MenuLabel> 514 + <MenuGroup> 515 + <MenuItem> 516 + <SettingsRowItem> 517 + <View style={[zero.gap.all[2], { flex: 1 }]}> 518 + <Text size="sm" weight="semibold"> 519 + {t("branding-main-logo")} 520 + </Text> 521 + <MenuInfo 522 + description={t("branding-main-logo-description")} 523 + /> 524 + {currentLogo?.data && ( 525 + <Image 526 + source={{ uri: currentLogo.data }} 527 + style={{ 528 + width: 200, 529 + height: 100, 530 + resizeMode: "contain", 531 + }} 532 + /> 533 + )} 534 + <View 535 + style={[zero.layout.flex.direction.row, zero.gap.all[2]]} 536 + > 537 + <Button 538 + onPress={() => 539 + handleFileSelect( 540 + "mainLogo", 541 + "image/svg+xml,image/png,image/jpeg", 542 + ) 543 + } 544 + disabled={uploading || Platform.OS !== "web"} 545 + width="min" 546 + style={{ height: 42 }} 547 + > 548 + {t("branding-upload-logo")} 549 + </Button> 550 + <Button 551 + variant="destructive" 552 + onPress={() => deleteBlob("mainLogo")} 553 + disabled={uploading} 554 + width="min" 555 + style={{ height: 42 }} 556 + > 557 + {t("branding-delete-logo")} 558 + </Button> 559 + </View> 560 + </View> 561 + </SettingsRowItem> 562 + </MenuItem> 563 + <MenuSeparator /> 564 + <MenuItem> 565 + <SettingsRowItem> 566 + <View style={[zero.gap.all[2], { flex: 1 }]}> 567 + <Text size="sm" weight="semibold"> 568 + {t("branding-favicon")} 569 + </Text> 570 + <MenuInfo description={t("branding-favicon-description")} /> 571 + {currentFavicon?.data && ( 572 + <Image 573 + source={{ uri: currentFavicon.data }} 574 + style={{ width: 64, height: 64, resizeMode: "contain" }} 575 + /> 576 + )} 577 + <View 578 + style={[zero.layout.flex.direction.row, zero.gap.all[2]]} 579 + > 580 + <Button 581 + onPress={() => 582 + handleFileSelect( 583 + "favicon", 584 + "image/svg+xml,image/png,image/x-icon", 585 + ) 586 + } 587 + disabled={uploading || Platform.OS !== "web"} 588 + width="min" 589 + style={{ height: 42 }} 590 + > 591 + {t("branding-upload-favicon")} 592 + </Button> 593 + <Button 594 + variant="destructive" 595 + onPress={() => deleteBlob("favicon")} 596 + disabled={uploading} 597 + width="min" 598 + style={{ height: 42 }} 599 + > 600 + {t("branding-delete-favicon")} 601 + </Button> 602 + </View> 603 + </View> 604 + </SettingsRowItem> 605 + </MenuItem> 606 + <MenuSeparator /> 607 + <MenuItem> 608 + <View style={[zero.gap.all[2], { flex: 1 }]}> 609 + <Text size="sm" weight="semibold"> 610 + {t("branding-sidebar-bg")} 611 + </Text> 612 + <MenuInfo 613 + description={t("branding-sidebar-bg-description")} 614 + /> 615 + {currentSidebarBg?.data && ( 616 + <> 617 + <Image 618 + source={{ uri: currentSidebarBg.data }} 619 + style={{ 620 + width: 200, 621 + height: 200, 622 + resizeMode: "contain", 623 + }} 624 + /> 625 + <Text size="xs" color="muted"> 626 + {currentSidebarBg?.height || "unknown"} x{" "} 627 + {currentSidebarBg?.width || "unknown"} 628 + </Text> 629 + </> 630 + )} 631 + <View 632 + style={[zero.layout.flex.direction.row, zero.gap.all[2]]} 633 + > 634 + <Button 635 + onPress={() => 636 + handleFileSelect( 637 + "sidebarBackgroundImage", 638 + "image/svg+xml,image/png,image/jpeg", 639 + ) 640 + } 641 + disabled={uploading || Platform.OS !== "web"} 642 + width="min" 643 + style={{ height: 42 }} 644 + > 645 + {t("branding-upload-background")} 646 + </Button> 647 + <Button 648 + variant="destructive" 649 + onPress={() => deleteBlob("sidebarBackgroundImage")} 650 + disabled={uploading} 651 + width="min" 652 + style={{ height: 42 }} 653 + > 654 + {t("branding-delete-background")} 655 + </Button> 656 + </View> 657 + </View> 658 + </MenuItem> 659 + <MenuSeparator /> 660 + {Platform.OS !== "web" && ( 661 + <MenuItem> 662 + <SettingsRowItem> 663 + <Text size="sm" color="muted"> 664 + {t("branding-web-only")} 665 + </Text> 666 + </SettingsRowItem> 667 + </MenuItem> 668 + )} 669 + </MenuGroup> 670 + </MenuContainer> 523 671 </View> 524 672 </View> 525 673 </ScrollView>
+2 -1
js/app/components/settings/components/settings-navigation-item.tsx
··· 50 50 51 51 export function SettingsRowItem({ children, onPress }: SettingsRowItemProps) { 52 52 return ( 53 - <Pressable onPress={onPress}> 53 + <Pressable onPress={onPress} style={{ width: "100%" }}> 54 54 {({ pressed }) => ( 55 55 <View 56 56 style={[ 57 57 zero.px[3], 58 58 zero.py[2], 59 + zero.w.percent[100], 59 60 zero.layout.flex.row, 60 61 zero.layout.flex.justify.between, 61 62 zero.layout.flex.align.center,
+29
js/app/components/settings/settings.tsx
··· 5 5 MenuSeparator, 6 6 Text, 7 7 useDanmuUnlocked, 8 + useDID, 9 + useStreamplaceStore, 8 10 useTranslation, 9 11 View, 10 12 zero, 11 13 } from "@streamplace/components"; 14 + import AQLink from "components/aqlink"; 15 + import { SettingsNavigationItem } from "components/settings/components/settings-navigation-item"; 16 + import { 17 + Brush, 18 + Globe, 19 + Info, 20 + Lock, 21 + LogIn, 22 + Shield, 23 + Video, 24 + } from "lucide-react-native"; 25 + import { ImageBackground, Pressable, ScrollView } from "react-native"; 12 26 import { 13 27 SettingsNavigationItem, 14 28 SettingsRowItem, ··· 44 58 } 45 59 return { name: route.name, params: route.params }; 46 60 }); 61 + 62 + const adminDids = useStreamplaceStore((state) => state.adminDIDs); 63 + const did = useDID(); 64 + 65 + // Determine if the user is an admin 66 + const isAdmin = did && adminDids && adminDids.includes(did) ? true : false; 47 67 48 68 const { t } = useTranslation("settings"); 49 69 ··· 138 158 title={t("danmu")} 139 159 screen="DanmuCategory" 140 160 icon={Mu as any} 161 + /> 162 + </MenuGroup> 163 + )} 164 + {isAdmin && ( 165 + <MenuGroup> 166 + <SettingsNavigationItem 167 + title={t("branding")} 168 + screen="BrandingAdmin" 169 + icon={Brush} 141 170 /> 142 171 </MenuGroup> 143 172 )}
+12 -11
js/app/src/router.tsx
··· 22 22 useTheme, 23 23 useToast, 24 24 } from "@streamplace/components"; 25 - import { BrandingAdmin, Provider, Settings } from "components"; 25 + import { Provider, Settings } from "components"; 26 26 import AQLink from "components/aqlink"; 27 27 import Login from "components/login/login"; 28 28 import LoginModal from "components/login/login-modal"; ··· 78 78 import HomeScreen from "./screens/home"; 79 79 80 80 import { useUrl } from "@streamplace/components"; 81 + import { BrandingAdmin } from "components/settings/branding-admin"; 81 82 import { LanguagesCategorySettings } from "components/settings/languages-category-settings"; 82 83 import RecommendationsManager from "components/settings/recommendations-manager"; 83 84 import Constants from "expo-constants"; ··· 129 130 LanguagesCategory: undefined; 130 131 DeveloperSettings: undefined; 131 132 KeyManagement: undefined; 133 + BrandingAdmin: undefined; 132 134 }; 133 135 134 136 type RootStackParamList = { ··· 136 138 Multi: { config: string }; 137 139 Support: undefined; 138 140 Settings: NavigatorScreenParams<SettingsStackParamList>; 139 - BrandingAdmin: undefined; 140 141 KeyManagement: undefined; 141 142 GoLive: undefined; 142 143 LiveDashboard: undefined; ··· 185 186 DanmuCategory: "settings/danmu", 186 187 AdvancedCategory: "settings/advanced", 187 188 DeveloperSettings: "settings/developer", 189 + BrandingAdmin: "settings/branding", 188 190 }, 189 191 }, 190 - BrandingAdmin: "admin", 191 192 KeyManagement: "key-management", 192 193 GoLive: "golive", 193 194 LiveDashboard: "live", ··· 609 610 }} 610 611 /> 611 612 <Drawer.Screen 612 - name="BrandingAdmin" 613 - component={BrandingAdmin} 614 - options={{ 615 - drawerLabel: () => <Text variant="h5">Branding Admin</Text>, 616 - drawerItemStyle: { display: "none" }, 617 - }} 618 - /> 619 - <Drawer.Screen 620 613 name="KeyManagement" 621 614 component={KeyManager} 622 615 options={{ ··· 848 841 name="KeyManagement" 849 842 component={KeyManager} 850 843 options={{ headerTitle: "Key Manager", title: "Key Manager" }} 844 + /> 845 + <Drawer.Screen 846 + name="BrandingAdmin" 847 + component={BrandingAdmin} 848 + options={{ 849 + drawerLabel: () => <Text variant="h5">Branding Admin</Text>, 850 + drawerItemStyle: { display: "none" }, 851 + }} 851 852 /> 852 853 </Stack.Navigator> 853 854 );
+52
js/components/locales/en-US/settings.ftl
··· 140 140 go-to-dashboard = Go to Dashboard 141 141 need-setup-live-dashboard = Need to set up streaming first? Visit the live dashboard 142 142 no-languages-found = No languages found 143 + 144 + ## Branding Administration 145 + branding-admin = Branding Administration 146 + branding-admin-description = Customize your Streamplace instance 147 + branding-login-required = Please log in to manage branding 148 + branding-configuration = Configuration 149 + branding-text-settings = Text Settings 150 + branding-colors = Colors 151 + branding-images = Images 152 + 153 + ## Branding Fields 154 + branding-broadcaster-did = Broadcaster DID 155 + branding-broadcaster-did-description = Leave empty to use server default 156 + branding-site-title = Site Title 157 + branding-site-title-placeholder = Enter new site title 158 + branding-site-description = Site Description 159 + branding-site-description-placeholder = Enter site description 160 + branding-default-streamer = Default Streamer 161 + branding-default-streamer-none = None 162 + branding-default-streamer-placeholder = did:plc:... 163 + branding-clear-default-streamer = Clear Default Streamer 164 + branding-primary-color = Primary Color 165 + branding-primary-color-placeholder = #6366f1 166 + branding-accent-color = Accent Color 167 + branding-accent-color-placeholder = #8b5cf6 168 + branding-main-logo = Main Logo 169 + branding-main-logo-description = SVG, PNG, or JPEG (max 500KB) 170 + branding-favicon = Favicon 171 + branding-favicon-description = SVG, PNG, or ICO (max 100KB) 172 + branding-sidebar-bg = Sidebar Background Image 173 + branding-sidebar-bg-description = SVG, PNG, or JPEG (max 500kb) - appears aligned to bottom of sidebar, full width. Upload an image with opacity for best results, as there is not currently a separate opacity option. 174 + branding-current = Current: { $value } 175 + branding-dimensions = { $height } x { $width } 176 + 177 + ## Branding Actions 178 + branding-upload-logo = Upload Logo 179 + branding-delete-logo = Delete Logo 180 + branding-upload-favicon = Upload Favicon 181 + branding-delete-favicon = Delete Favicon 182 + branding-upload-background = Upload Background 183 + branding-delete-background = Delete Background 184 + branding-web-only = Image uploads are only available on web. 185 + 186 + ## Branding Toast Messages 187 + branding-not-authenticated = Please log in first 188 + branding-empty-value = Please enter a value 189 + branding-update-success = { $key } updated successfully 190 + branding-upload-success = { $key } uploaded successfully 191 + branding-delete-success = { $key } deleted successfully 192 + branding-upload-failed = Failed to upload 193 + branding-delete-failed = Failed to delete 194 + branding-not-available = File uploads are only available on web
+1 -2
js/components/src/components/ui/menu.tsx
··· 224 224 ref={ref as any} 225 225 style={mergeStyles( 226 226 px[4], 227 - py[2], 227 + pt[2], 228 228 { color: theme.colors.textMuted }, 229 229 a.fontSize.base, 230 230 style, ··· 274 274 style={mergeStyles( 275 275 { color: theme.colors.textMuted, marginTop: -8 }, 276 276 pt[1], 277 - pl[4], 278 277 pb[2], 279 278 fontSize.sm, 280 279 style,