clawport-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/.env.example +35 -0
  2. package/BRANDING.md +131 -0
  3. package/CLAUDE.md +252 -0
  4. package/README.md +262 -0
  5. package/SETUP.md +337 -0
  6. package/app/agents/[id]/page.tsx +727 -0
  7. package/app/api/agents/route.ts +12 -0
  8. package/app/api/chat/[id]/route.ts +139 -0
  9. package/app/api/cron-runs/route.ts +13 -0
  10. package/app/api/crons/route.ts +12 -0
  11. package/app/api/kanban/chat/[id]/route.ts +119 -0
  12. package/app/api/kanban/chat-history/[ticketId]/route.ts +36 -0
  13. package/app/api/memory/route.ts +12 -0
  14. package/app/api/transcribe/route.ts +37 -0
  15. package/app/api/tts/route.ts +42 -0
  16. package/app/chat/[id]/page.tsx +10 -0
  17. package/app/chat/page.tsx +200 -0
  18. package/app/crons/page.tsx +870 -0
  19. package/app/docs/page.tsx +399 -0
  20. package/app/favicon.ico +0 -0
  21. package/app/globals.css +692 -0
  22. package/app/kanban/page.tsx +327 -0
  23. package/app/layout.tsx +45 -0
  24. package/app/memory/page.tsx +685 -0
  25. package/app/page.tsx +817 -0
  26. package/app/providers.tsx +37 -0
  27. package/app/settings/page.tsx +901 -0
  28. package/app/settings-provider.tsx +209 -0
  29. package/components/AgentAvatar.tsx +54 -0
  30. package/components/AgentNode.tsx +122 -0
  31. package/components/Breadcrumbs.tsx +126 -0
  32. package/components/DynamicFavicon.tsx +62 -0
  33. package/components/ErrorState.tsx +97 -0
  34. package/components/FeedView.tsx +494 -0
  35. package/components/GlobalSearch.tsx +571 -0
  36. package/components/GridView.tsx +532 -0
  37. package/components/ManorMap.tsx +157 -0
  38. package/components/MobileSidebar.tsx +251 -0
  39. package/components/NavLinks.tsx +271 -0
  40. package/components/OnboardingWizard.tsx +1067 -0
  41. package/components/Sidebar.tsx +115 -0
  42. package/components/ThemeToggle.tsx +108 -0
  43. package/components/chat/AgentList.tsx +537 -0
  44. package/components/chat/ConversationView.tsx +1047 -0
  45. package/components/chat/FileAttachment.tsx +140 -0
  46. package/components/chat/MediaPreview.tsx +111 -0
  47. package/components/chat/VoiceMessage.tsx +139 -0
  48. package/components/crons/PipelineGraph.tsx +327 -0
  49. package/components/crons/WeeklySchedule.tsx +630 -0
  50. package/components/docs/AgentsSection.tsx +209 -0
  51. package/components/docs/ApiReferenceSection.tsx +256 -0
  52. package/components/docs/ArchitectureSection.tsx +221 -0
  53. package/components/docs/ComponentsSection.tsx +253 -0
  54. package/components/docs/CronSystemSection.tsx +235 -0
  55. package/components/docs/DocSection.tsx +346 -0
  56. package/components/docs/GettingStartedSection.tsx +169 -0
  57. package/components/docs/ThemingSection.tsx +257 -0
  58. package/components/docs/TroubleshootingSection.tsx +200 -0
  59. package/components/kanban/AgentPicker.tsx +321 -0
  60. package/components/kanban/CreateTicketModal.tsx +333 -0
  61. package/components/kanban/KanbanBoard.tsx +70 -0
  62. package/components/kanban/KanbanColumn.tsx +166 -0
  63. package/components/kanban/TicketCard.tsx +245 -0
  64. package/components/kanban/TicketDetailPanel.tsx +850 -0
  65. package/components/ui/badge.tsx +48 -0
  66. package/components/ui/button.tsx +64 -0
  67. package/components/ui/card.tsx +92 -0
  68. package/components/ui/dialog.tsx +158 -0
  69. package/components/ui/scroll-area.tsx +58 -0
  70. package/components/ui/separator.tsx +28 -0
  71. package/components/ui/skeleton.tsx +27 -0
  72. package/components/ui/tabs.tsx +91 -0
  73. package/components/ui/tooltip.tsx +57 -0
  74. package/components.json +23 -0
  75. package/docs/API.md +648 -0
  76. package/docs/COMPONENTS.md +1059 -0
  77. package/docs/THEMING.md +795 -0
  78. package/lib/agents-registry.ts +35 -0
  79. package/lib/agents.json +282 -0
  80. package/lib/agents.test.ts +367 -0
  81. package/lib/agents.ts +32 -0
  82. package/lib/anthropic.test.ts +422 -0
  83. package/lib/anthropic.ts +220 -0
  84. package/lib/api-error.ts +16 -0
  85. package/lib/audio-recorder.test.ts +72 -0
  86. package/lib/audio-recorder.ts +169 -0
  87. package/lib/conversations.test.ts +331 -0
  88. package/lib/conversations.ts +117 -0
  89. package/lib/cron-pipelines.test.ts +69 -0
  90. package/lib/cron-pipelines.ts +58 -0
  91. package/lib/cron-runs.test.ts +118 -0
  92. package/lib/cron-runs.ts +67 -0
  93. package/lib/cron-utils.test.ts +222 -0
  94. package/lib/cron-utils.ts +160 -0
  95. package/lib/crons.test.ts +502 -0
  96. package/lib/crons.ts +114 -0
  97. package/lib/env.test.ts +44 -0
  98. package/lib/env.ts +14 -0
  99. package/lib/kanban/automation.test.ts +245 -0
  100. package/lib/kanban/automation.ts +143 -0
  101. package/lib/kanban/chat-store.test.ts +149 -0
  102. package/lib/kanban/chat-store.ts +81 -0
  103. package/lib/kanban/store.test.ts +238 -0
  104. package/lib/kanban/store.ts +98 -0
  105. package/lib/kanban/types.ts +50 -0
  106. package/lib/kanban/useAgentWork.ts +78 -0
  107. package/lib/memory.ts +45 -0
  108. package/lib/multimodal.test.ts +219 -0
  109. package/lib/multimodal.ts +68 -0
  110. package/lib/pipeline.integration.test.ts +343 -0
  111. package/lib/sanitize.ts +194 -0
  112. package/lib/settings.test.ts +137 -0
  113. package/lib/settings.ts +94 -0
  114. package/lib/styles.ts +24 -0
  115. package/lib/themes.ts +9 -0
  116. package/lib/transcribe.test.ts +141 -0
  117. package/lib/transcribe.ts +111 -0
  118. package/lib/types.ts +66 -0
  119. package/lib/utils.ts +6 -0
  120. package/lib/validation.test.ts +132 -0
  121. package/lib/validation.ts +80 -0
  122. package/next.config.ts +7 -0
  123. package/package.json +56 -0
  124. package/postcss.config.mjs +7 -0
  125. package/public/file.svg +1 -0
  126. package/public/globe.svg +1 -0
  127. package/public/next.svg +1 -0
  128. package/public/vercel.svg +1 -0
  129. package/public/window.svg +1 -0
  130. package/scripts/setup.mjs +215 -0
  131. package/tsconfig.json +34 -0
  132. package/vitest.config.ts +17 -0
@@ -0,0 +1,209 @@
1
+ 'use client'
2
+
3
+ import { createContext, useCallback, useContext, useEffect, useState } from 'react'
4
+ import type { Agent } from '@/lib/types'
5
+ import {
6
+ type ClawPortSettings,
7
+ type AgentOverride,
8
+ DEFAULTS,
9
+ loadSettings,
10
+ saveSettings,
11
+ hexToAccentFill,
12
+ hexToContrastText,
13
+ } from '@/lib/settings'
14
+
15
+ interface AgentDisplay {
16
+ emoji: string
17
+ profileImage?: string
18
+ emojiOnly?: boolean
19
+ }
20
+
21
+ interface SettingsContextValue {
22
+ settings: ClawPortSettings
23
+ setAccentColor: (color: string | null) => void
24
+ setPortalName: (name: string | null) => void
25
+ setPortalSubtitle: (subtitle: string | null) => void
26
+ setPortalEmoji: (emoji: string | null) => void
27
+ setPortalIcon: (icon: string | null) => void
28
+ setIconBgHidden: (hidden: boolean) => void
29
+ setEmojiOnly: (emojiOnly: boolean) => void
30
+ setOperatorName: (name: string | null) => void
31
+ setAgentOverride: (agentId: string, override: AgentOverride) => void
32
+ clearAgentOverride: (agentId: string) => void
33
+ getAgentDisplay: (agent: Agent) => AgentDisplay
34
+ resetAll: () => void
35
+ }
36
+
37
+ const SettingsContext = createContext<SettingsContextValue>({
38
+ settings: { accentColor: null, portalName: null, portalSubtitle: null, portalEmoji: null, portalIcon: null, iconBgHidden: false, emojiOnly: false, operatorName: null, agentOverrides: {} },
39
+ setAccentColor: () => {},
40
+ setPortalName: () => {},
41
+ setPortalSubtitle: () => {},
42
+ setPortalEmoji: () => {},
43
+ setPortalIcon: () => {},
44
+ setIconBgHidden: () => {},
45
+ setEmojiOnly: () => {},
46
+ setOperatorName: () => {},
47
+ setAgentOverride: () => {},
48
+ clearAgentOverride: () => {},
49
+ getAgentDisplay: (agent) => ({ emoji: agent.emoji }),
50
+ resetAll: () => {},
51
+ })
52
+
53
+ export function SettingsProvider({ children }: { children: React.ReactNode }) {
54
+ // Initialize with defaults so server and client render the same HTML.
55
+ // Hydrate from localStorage after mount to avoid hydration mismatch.
56
+ const [settings, setSettings] = useState<ClawPortSettings>({ ...DEFAULTS })
57
+
58
+ useEffect(() => {
59
+ setSettings(loadSettings())
60
+ }, [])
61
+
62
+ // Apply accent color CSS variables when settings change
63
+ useEffect(() => {
64
+ const el = document.documentElement.style
65
+ if (settings.accentColor) {
66
+ el.setProperty('--accent', settings.accentColor)
67
+ el.setProperty('--accent-fill', hexToAccentFill(settings.accentColor))
68
+ el.setProperty('--accent-contrast', hexToContrastText(settings.accentColor))
69
+ } else {
70
+ el.removeProperty('--accent')
71
+ el.removeProperty('--accent-fill')
72
+ el.removeProperty('--accent-contrast')
73
+ }
74
+ }, [settings.accentColor])
75
+
76
+ const update = useCallback((next: ClawPortSettings) => {
77
+ setSettings(next)
78
+ saveSettings(next)
79
+ }, [])
80
+
81
+ const setAccentColor = useCallback(
82
+ (color: string | null) => {
83
+ update({ ...settings, accentColor: color })
84
+ },
85
+ [settings, update],
86
+ )
87
+
88
+ const setPortalName = useCallback(
89
+ (name: string | null) => {
90
+ update({ ...settings, portalName: name || null })
91
+ },
92
+ [settings, update],
93
+ )
94
+
95
+ const setPortalSubtitle = useCallback(
96
+ (subtitle: string | null) => {
97
+ update({ ...settings, portalSubtitle: subtitle || null })
98
+ },
99
+ [settings, update],
100
+ )
101
+
102
+ const setPortalEmoji = useCallback(
103
+ (emoji: string | null) => {
104
+ update({ ...settings, portalEmoji: emoji || null })
105
+ },
106
+ [settings, update],
107
+ )
108
+
109
+ const setPortalIcon = useCallback(
110
+ (icon: string | null) => {
111
+ update({ ...settings, portalIcon: icon })
112
+ },
113
+ [settings, update],
114
+ )
115
+
116
+ const setIconBgHidden = useCallback(
117
+ (hidden: boolean) => {
118
+ update({ ...settings, iconBgHidden: hidden })
119
+ },
120
+ [settings, update],
121
+ )
122
+
123
+ const setEmojiOnly = useCallback(
124
+ (emojiOnly: boolean) => {
125
+ update({ ...settings, emojiOnly })
126
+ },
127
+ [settings, update],
128
+ )
129
+
130
+ const setOperatorName = useCallback(
131
+ (name: string | null) => {
132
+ update({ ...settings, operatorName: name || null })
133
+ },
134
+ [settings, update],
135
+ )
136
+
137
+ const setAgentOverride = useCallback(
138
+ (agentId: string, override: AgentOverride) => {
139
+ const existing = settings.agentOverrides[agentId] || {}
140
+ update({
141
+ ...settings,
142
+ agentOverrides: {
143
+ ...settings.agentOverrides,
144
+ [agentId]: { ...existing, ...override },
145
+ },
146
+ })
147
+ },
148
+ [settings, update],
149
+ )
150
+
151
+ const clearAgentOverride = useCallback(
152
+ (agentId: string) => {
153
+ const { [agentId]: _, ...rest } = settings.agentOverrides
154
+ update({ ...settings, agentOverrides: rest })
155
+ },
156
+ [settings, update],
157
+ )
158
+
159
+ const getAgentDisplay = useCallback(
160
+ (agent: Agent): AgentDisplay => {
161
+ const override = settings.agentOverrides[agent.id]
162
+ return {
163
+ emoji: override?.emoji || agent.emoji,
164
+ profileImage: override?.profileImage,
165
+ emojiOnly: settings.emojiOnly,
166
+ }
167
+ },
168
+ [settings.agentOverrides, settings.emojiOnly],
169
+ )
170
+
171
+ const resetAll = useCallback(() => {
172
+ const defaults: ClawPortSettings = {
173
+ accentColor: null,
174
+ portalName: null,
175
+ portalSubtitle: null,
176
+ portalEmoji: null,
177
+ portalIcon: null,
178
+ iconBgHidden: false,
179
+ emojiOnly: false,
180
+ operatorName: null,
181
+ agentOverrides: {},
182
+ }
183
+ update(defaults)
184
+ }, [update])
185
+
186
+ return (
187
+ <SettingsContext.Provider
188
+ value={{
189
+ settings,
190
+ setAccentColor,
191
+ setPortalName,
192
+ setPortalSubtitle,
193
+ setPortalEmoji,
194
+ setPortalIcon,
195
+ setIconBgHidden,
196
+ setEmojiOnly,
197
+ setOperatorName,
198
+ setAgentOverride,
199
+ clearAgentOverride,
200
+ getAgentDisplay,
201
+ resetAll,
202
+ }}
203
+ >
204
+ {children}
205
+ </SettingsContext.Provider>
206
+ )
207
+ }
208
+
209
+ export const useSettings = () => useContext(SettingsContext)
@@ -0,0 +1,54 @@
1
+ 'use client'
2
+
3
+ import type { CSSProperties } from 'react'
4
+ import type { Agent } from '@/lib/types'
5
+ import { useSettings } from '@/app/settings-provider'
6
+
7
+ interface AgentAvatarProps {
8
+ agent: Agent
9
+ size: number
10
+ borderRadius?: number
11
+ style?: CSSProperties
12
+ }
13
+
14
+ export function AgentAvatar({ agent, size, borderRadius, style }: AgentAvatarProps) {
15
+ const { getAgentDisplay } = useSettings()
16
+ const display = getAgentDisplay(agent)
17
+ const radius = borderRadius ?? Math.round(size * 0.27)
18
+
19
+ if (display.profileImage) {
20
+ return (
21
+ <img
22
+ src={display.profileImage}
23
+ alt={agent.name}
24
+ style={{
25
+ width: size,
26
+ height: size,
27
+ borderRadius: radius,
28
+ objectFit: 'cover',
29
+ flexShrink: 0,
30
+ ...style,
31
+ }}
32
+ />
33
+ )
34
+ }
35
+
36
+ return (
37
+ <div
38
+ style={{
39
+ width: size,
40
+ height: size,
41
+ borderRadius: radius,
42
+ background: display.emojiOnly ? 'transparent' : `${agent.color}20`,
43
+ display: 'flex',
44
+ alignItems: 'center',
45
+ justifyContent: 'center',
46
+ fontSize: Math.round(size * 0.55),
47
+ flexShrink: 0,
48
+ ...style,
49
+ }}
50
+ >
51
+ {display.emoji}
52
+ </div>
53
+ )
54
+ }
@@ -0,0 +1,122 @@
1
+ "use client"
2
+ import { Handle, Position, type NodeProps } from "@xyflow/react"
3
+ import type { Agent, CronJob } from "@/lib/types"
4
+ import { AgentAvatar } from "@/components/AgentAvatar"
5
+
6
+ type AgentNodeData = Agent & { crons: CronJob[] } & Record<string, unknown>
7
+
8
+ export function AgentNode({ data, selected }: NodeProps) {
9
+ const agent = data as AgentNodeData
10
+ const hasCrons = agent.crons && agent.crons.length > 0
11
+ const hasErrors = hasCrons && agent.crons.some((c: CronJob) => c.status === "error")
12
+ const cronCount = hasCrons ? agent.crons.length : 0
13
+
14
+ return (
15
+ <div
16
+ className={`hover-lift focus-ring${selected ? " node-selected" : ""}`}
17
+ title={agent.title}
18
+ style={{
19
+ background: "var(--material-regular)",
20
+ backdropFilter: "blur(20px) saturate(180%)",
21
+ WebkitBackdropFilter: "blur(20px) saturate(180%)",
22
+ borderRadius: "var(--radius-md)",
23
+ borderTop: `2px solid ${agent.color}`,
24
+ borderRight: `1px solid ${selected ? "var(--accent)" : "var(--separator)"}`,
25
+ borderBottom: `1px solid ${selected ? "var(--accent)" : "var(--separator)"}`,
26
+ borderLeft: `1px solid ${selected ? "var(--accent)" : "var(--separator)"}`,
27
+ padding: "var(--space-3) var(--space-4)",
28
+ minWidth: 200,
29
+ maxWidth: 220,
30
+ cursor: "pointer",
31
+ position: "relative",
32
+ boxShadow: selected ? "0 0 0 1px var(--accent), var(--shadow-card)" : "var(--shadow-card)",
33
+ }}
34
+ >
35
+ {/* Emoji + Name row */}
36
+ <div
37
+ style={{
38
+ display: "flex",
39
+ alignItems: "center",
40
+ gap: "var(--space-2)",
41
+ marginBottom: "var(--space-1)",
42
+ }}
43
+ >
44
+ <AgentAvatar agent={agent} size={30} borderRadius={8} />
45
+ <div
46
+ style={{
47
+ fontSize: "var(--text-body)",
48
+ fontWeight: "var(--weight-semibold)",
49
+ color: "var(--text-primary)",
50
+ whiteSpace: "nowrap",
51
+ overflow: "hidden",
52
+ textOverflow: "ellipsis",
53
+ }}
54
+ >
55
+ {agent.name}
56
+ </div>
57
+ </div>
58
+
59
+ {/* Title */}
60
+ <div
61
+ style={{
62
+ fontSize: "var(--text-caption1)",
63
+ color: "var(--text-secondary)",
64
+ whiteSpace: "nowrap",
65
+ overflow: "hidden",
66
+ textOverflow: "ellipsis",
67
+ marginTop: 1,
68
+ }}
69
+ >
70
+ {agent.title}
71
+ </div>
72
+
73
+ {/* Description snippet */}
74
+ {agent.description && (
75
+ <div
76
+ style={{
77
+ fontSize: "var(--text-caption2)",
78
+ color: "var(--text-tertiary)",
79
+ whiteSpace: "nowrap",
80
+ overflow: "hidden",
81
+ textOverflow: "ellipsis",
82
+ marginTop: 2,
83
+ }}
84
+ >
85
+ {agent.description}
86
+ </div>
87
+ )}
88
+
89
+ {/* Cron health row */}
90
+ {hasCrons && (
91
+ <div
92
+ style={{
93
+ display: "flex",
94
+ alignItems: "center",
95
+ gap: 4,
96
+ marginTop: "var(--space-2)",
97
+ fontSize: "var(--text-caption2)",
98
+ color: hasErrors ? "var(--system-red)" : "var(--system-green)",
99
+ }}
100
+ >
101
+ <div
102
+ className={hasErrors ? "animate-error-pulse" : ""}
103
+ style={{
104
+ width: 6,
105
+ height: 6,
106
+ borderRadius: "50%",
107
+ background: hasErrors ? "var(--system-red)" : "var(--system-green)",
108
+ flexShrink: 0,
109
+ }}
110
+ />
111
+ {cronCount} cron{cronCount !== 1 ? "s" : ""} · {hasErrors ? "errors" : "healthy"}
112
+ </div>
113
+ )}
114
+
115
+ {/* Handles - invisible */}
116
+ <Handle type="target" position={Position.Top} style={{ opacity: 0 }} />
117
+ <Handle type="source" position={Position.Bottom} style={{ opacity: 0 }} />
118
+ </div>
119
+ )
120
+ }
121
+
122
+ export const nodeTypes = { agentNode: AgentNode }
@@ -0,0 +1,126 @@
1
+ 'use client';
2
+
3
+ import Link from 'next/link';
4
+ import { ChevronRight } from 'lucide-react';
5
+
6
+ export interface BreadcrumbItem {
7
+ label: string;
8
+ href?: string;
9
+ icon?: React.ReactNode;
10
+ }
11
+
12
+ export function Breadcrumbs({ items }: { items: BreadcrumbItem[] }) {
13
+ if (items.length === 0) return null;
14
+
15
+ return (
16
+ <nav
17
+ aria-label="Breadcrumb"
18
+ className="animate-fade-in"
19
+ style={{
20
+ height: '32px',
21
+ display: 'flex',
22
+ alignItems: 'center',
23
+ gap: '4px',
24
+ fontSize: '12px',
25
+ lineHeight: 1,
26
+ fontWeight: 500,
27
+ }}
28
+ >
29
+ <ol
30
+ style={{
31
+ display: 'flex',
32
+ alignItems: 'center',
33
+ gap: '4px',
34
+ listStyle: 'none',
35
+ margin: 0,
36
+ padding: 0,
37
+ }}
38
+ >
39
+ {items.map((item, index) => {
40
+ const isLast = index === items.length - 1;
41
+
42
+ return (
43
+ <li
44
+ key={item.label}
45
+ style={{
46
+ display: 'flex',
47
+ alignItems: 'center',
48
+ gap: '4px',
49
+ minWidth: 0,
50
+ }}
51
+ >
52
+ {index > 0 && (
53
+ <ChevronRight
54
+ size={12}
55
+ style={{
56
+ color: 'var(--text-quaternary)',
57
+ flexShrink: 0,
58
+ }}
59
+ aria-hidden="true"
60
+ />
61
+ )}
62
+
63
+ {isLast || !item.href ? (
64
+ <span
65
+ style={{
66
+ color: 'var(--text-primary)',
67
+ fontWeight: 600,
68
+ overflow: 'hidden',
69
+ textOverflow: 'ellipsis',
70
+ whiteSpace: 'nowrap',
71
+ maxWidth: '200px',
72
+ }}
73
+ aria-current="page"
74
+ >
75
+ {item.icon && (
76
+ <span
77
+ style={{
78
+ display: 'inline-flex',
79
+ verticalAlign: 'middle',
80
+ marginRight: '4px',
81
+ }}
82
+ >
83
+ {item.icon}
84
+ </span>
85
+ )}
86
+ {item.label}
87
+ </span>
88
+ ) : (
89
+ <Link
90
+ href={item.href}
91
+ className="breadcrumb-link focus-ring"
92
+ style={{
93
+ color: 'var(--text-secondary)',
94
+ textDecoration: 'none',
95
+ overflow: 'hidden',
96
+ textOverflow: 'ellipsis',
97
+ whiteSpace: 'nowrap',
98
+ maxWidth: '200px',
99
+ borderRadius: '4px',
100
+ padding: '2px 4px',
101
+ margin: '-2px -4px',
102
+ transition: 'color 100ms var(--ease-smooth)',
103
+ }}
104
+ aria-label={item.label}
105
+ >
106
+ {item.icon && (
107
+ <span
108
+ style={{
109
+ display: 'inline-flex',
110
+ verticalAlign: 'middle',
111
+ marginRight: '4px',
112
+ }}
113
+ >
114
+ {item.icon}
115
+ </span>
116
+ )}
117
+ {item.label}
118
+ </Link>
119
+ )}
120
+ </li>
121
+ );
122
+ })}
123
+ </ol>
124
+ </nav>
125
+ );
126
+ }
@@ -0,0 +1,62 @@
1
+ 'use client'
2
+
3
+ import { useEffect } from 'react'
4
+ import { useSettings } from '@/app/settings-provider'
5
+
6
+ /**
7
+ * Dynamically sets the browser tab favicon based on the current
8
+ * portal logo settings (uploaded image or emoji).
9
+ */
10
+ export function DynamicFavicon() {
11
+ const { settings } = useSettings()
12
+
13
+ useEffect(() => {
14
+ const emoji = settings.portalEmoji ?? '\ud83e\udd9e'
15
+ const icon = settings.portalIcon
16
+ const accentColor = settings.accentColor
17
+ const bgHidden = settings.iconBgHidden
18
+
19
+ // Find or create the favicon link element
20
+ let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]')
21
+ if (!link) {
22
+ link = document.createElement('link')
23
+ link.rel = 'icon'
24
+ document.head.appendChild(link)
25
+ }
26
+
27
+ if (icon) {
28
+ // Uploaded image — use directly as favicon
29
+ link.href = icon
30
+ link.type = 'image/jpeg'
31
+ return
32
+ }
33
+
34
+ // Emoji — render to canvas
35
+ const size = 64
36
+ const canvas = document.createElement('canvas')
37
+ canvas.width = size
38
+ canvas.height = size
39
+ const ctx = canvas.getContext('2d')
40
+ if (!ctx) return
41
+
42
+ if (!bgHidden) {
43
+ // Draw colored background circle
44
+ const color = accentColor ?? '#f5c518'
45
+ ctx.fillStyle = color
46
+ ctx.beginPath()
47
+ ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2)
48
+ ctx.fill()
49
+ }
50
+
51
+ // Draw emoji centered
52
+ ctx.textAlign = 'center'
53
+ ctx.textBaseline = 'middle'
54
+ ctx.font = `${bgHidden ? 56 : 40}px serif`
55
+ ctx.fillText(emoji, size / 2, size / 2 + 2)
56
+
57
+ link.href = canvas.toDataURL('image/png')
58
+ link.type = 'image/png'
59
+ }, [settings.portalIcon, settings.portalEmoji, settings.accentColor, settings.iconBgHidden])
60
+
61
+ return null
62
+ }
@@ -0,0 +1,97 @@
1
+ 'use client'
2
+
3
+ import { RotateCcw } from 'lucide-react'
4
+
5
+ interface ErrorStateProps {
6
+ message: string
7
+ onRetry?: () => void
8
+ }
9
+
10
+ export function ErrorState({ message, onRetry }: ErrorStateProps) {
11
+ return (
12
+ <div
13
+ className="flex items-center justify-center h-full"
14
+ role="alert"
15
+ style={{ background: 'var(--bg)' }}
16
+ >
17
+ <div style={{ textAlign: 'center', padding: '0 24px', maxWidth: 360 }}>
18
+ <div style={{
19
+ width: 56,
20
+ height: 56,
21
+ borderRadius: '50%',
22
+ background: 'var(--fill-secondary)',
23
+ display: 'flex',
24
+ alignItems: 'center',
25
+ justifyContent: 'center',
26
+ fontSize: 24,
27
+ margin: '0 auto 16px',
28
+ }}>
29
+ <svg
30
+ width="24"
31
+ height="24"
32
+ viewBox="0 0 24 24"
33
+ fill="none"
34
+ stroke="currentColor"
35
+ strokeWidth="1.5"
36
+ strokeLinecap="round"
37
+ strokeLinejoin="round"
38
+ style={{ color: 'var(--text-secondary)' }}
39
+ >
40
+ <circle cx="12" cy="12" r="10" />
41
+ <line x1="12" y1="8" x2="12" y2="12" />
42
+ <line x1="12" y1="16" x2="12.01" y2="16" />
43
+ </svg>
44
+ </div>
45
+
46
+ <div style={{
47
+ fontSize: 17,
48
+ fontWeight: 600,
49
+ color: 'var(--text-primary)',
50
+ marginBottom: 4,
51
+ }}>
52
+ Something went wrong
53
+ </div>
54
+
55
+ <p style={{
56
+ fontSize: 14,
57
+ lineHeight: 1.5,
58
+ color: 'var(--text-secondary)',
59
+ margin: '0 0 20px',
60
+ }}>
61
+ {message}
62
+ </p>
63
+
64
+ {onRetry && (
65
+ <button
66
+ onClick={onRetry}
67
+ className="focus-ring btn-scale"
68
+ style={{
69
+ height: 40,
70
+ padding: '0 20px',
71
+ borderRadius: 'var(--radius-md)',
72
+ background: 'var(--fill-secondary)',
73
+ color: 'var(--text-primary)',
74
+ fontWeight: 600,
75
+ fontSize: 14,
76
+ border: 'none',
77
+ cursor: 'pointer',
78
+ transition: 'all 150ms var(--ease-spring)',
79
+ display: 'flex',
80
+ alignItems: 'center',
81
+ gap: 6,
82
+ }}
83
+ onMouseEnter={(e) => {
84
+ e.currentTarget.style.background = 'var(--fill-primary)';
85
+ }}
86
+ onMouseLeave={(e) => {
87
+ e.currentTarget.style.background = 'var(--fill-secondary)';
88
+ }}
89
+ >
90
+ <RotateCcw size={16} />
91
+ Try Again
92
+ </button>
93
+ )}
94
+ </div>
95
+ </div>
96
+ )
97
+ }