flora is a fast and secure runtime that lets you write discord bots for your servers, with a rich TypeScript SDK, without worrying about running infrastructure. [mirror]
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor(frontend): use ts-pattern

+519 -456
+84 -77
apps/frontend/src/components/editor/editor-side-panel.tsx
··· 9 9 import { cn } from '@/lib/utils' 10 10 import { Copy, Upload } from 'lucide-react' 11 11 import type { RefObject } from 'react' 12 + import { match, P } from 'ts-pattern' 12 13 13 14 import { formatLogLine } from './editor-utils' 14 15 ··· 62 63 logsAreaRef, 63 64 logsState 64 65 }: EditorSidePanelProps) { 65 - return ( 66 - <div className='h-full w-72 border-l bg-muted/10'> 67 - <div className='flex h-12 items-center justify-between px-3'> 68 - <div className='text-sm font-medium'>Deploy</div> 69 - <div className='flex items-center gap-1'> 70 - {deployError 71 - ? ( 72 - <DropdownMenu> 73 - <DropdownMenuTrigger 74 - render={ 75 - <button 76 - type='button' 77 - className={deployButtonClass} 78 - > 79 - <Upload className='h-3 w-3' /> 80 - {deployLabel} 81 - </button> 82 - } 83 - /> 84 - <DropdownMenuContent align='end' className='w-[32rem] p-3'> 85 - <div className='space-y-3'> 86 - <div> 87 - <div className='text-[11px] font-semibold uppercase tracking-wide text-muted-foreground'> 88 - Error 89 - </div> 90 - <div className='mt-1 max-h-28 overflow-auto rounded border bg-muted/40 p-2 font-mono text-xs text-destructive'> 91 - {deployError} 92 - </div> 93 - </div> 94 - <div> 95 - <div className='text-[11px] font-semibold uppercase tracking-wide text-muted-foreground'> 96 - Uploaded Files 97 - </div> 98 - <div className='mt-1 max-h-28 overflow-auto rounded border bg-muted/40 p-2 font-mono text-xs'> 99 - {deployUploadedFiles.length > 0 100 - ? deployUploadedFiles.map((path) => <div key={path}>{path}</div>) 101 - : <div>(none)</div>} 102 - </div> 103 - </div> 104 - <div> 105 - <div className='text-[11px] font-semibold uppercase tracking-wide text-muted-foreground'> 106 - Build Logs 107 - </div> 108 - <div className='mt-1 max-h-40 overflow-auto rounded border bg-muted/40 p-2 font-mono text-xs'> 109 - {deployBuildLogs.length > 0 110 - ? deployBuildLogs.map((line) => <div key={line}>{line}</div>) 111 - : <div>(none)</div>} 112 - </div> 113 - </div> 114 - <div className='flex justify-end gap-2'> 115 - <button 116 - type='button' 117 - onClick={onDeploy} 118 - disabled={!guildId || fileCount === 0 || isDeploying} 119 - className='inline-flex items-center gap-1 rounded border px-2 py-1 text-xs font-medium hover:bg-accent disabled:opacity-50' 120 - > 121 - <Upload className='h-3.5 w-3.5' /> 122 - Retry 123 - </button> 124 - <button 125 - type='button' 126 - onClick={onCopyDeployDetails} 127 - className='inline-flex items-center gap-1 rounded border px-2 py-1 text-xs font-medium hover:bg-accent' 128 - > 129 - <Copy className='h-3.5 w-3.5' /> 130 - {copyState === 'copied' ? 'Copied' : 'Copy'} 131 - </button> 132 - </div> 133 - </div> 134 - </DropdownMenuContent> 135 - </DropdownMenu> 136 - ) 137 - : ( 66 + const uploadedFilesContent = match(deployUploadedFiles.length) 67 + .with(0, () => <div>(none)</div>) 68 + .otherwise(() => deployUploadedFiles.map((path) => <div key={path}>{path}</div>)) 69 + 70 + const buildLogsContent = match(deployBuildLogs.length) 71 + .with(0, () => <div>(none)</div>) 72 + .otherwise(() => deployBuildLogs.map((line) => <div key={line}>{line}</div>)) 73 + 74 + const deployAction = match(deployError) 75 + .with(P.string, (errorMessage) => ( 76 + <DropdownMenu> 77 + <DropdownMenuTrigger 78 + render={ 79 + <button 80 + type='button' 81 + className={deployButtonClass} 82 + > 83 + <Upload className='h-3 w-3' /> 84 + {deployLabel} 85 + </button> 86 + } 87 + /> 88 + <DropdownMenuContent align='end' className='w-[32rem] p-3'> 89 + <div className='space-y-3'> 90 + <div> 91 + <div className='text-[11px] font-semibold uppercase tracking-wide text-muted-foreground'> 92 + Error 93 + </div> 94 + <div className='mt-1 max-h-28 overflow-auto rounded border bg-muted/40 p-2 font-mono text-xs text-destructive'> 95 + {errorMessage} 96 + </div> 97 + </div> 98 + <div> 99 + <div className='text-[11px] font-semibold uppercase tracking-wide text-muted-foreground'> 100 + Uploaded Files 101 + </div> 102 + <div className='mt-1 max-h-28 overflow-auto rounded border bg-muted/40 p-2 font-mono text-xs'> 103 + {uploadedFilesContent} 104 + </div> 105 + </div> 106 + <div> 107 + <div className='text-[11px] font-semibold uppercase tracking-wide text-muted-foreground'> 108 + Build Logs 109 + </div> 110 + <div className='mt-1 max-h-40 overflow-auto rounded border bg-muted/40 p-2 font-mono text-xs'> 111 + {buildLogsContent} 112 + </div> 113 + </div> 114 + <div className='flex justify-end gap-2'> 138 115 <button 139 116 type='button' 140 117 onClick={onDeploy} 141 118 disabled={!guildId || fileCount === 0 || isDeploying} 142 - className={deployButtonClass} 119 + className='inline-flex items-center gap-1 rounded border px-2 py-1 text-xs font-medium hover:bg-accent disabled:opacity-50' 143 120 > 144 - <Upload className='h-3 w-3' /> 145 - {deployLabel} 121 + <Upload className='h-3.5 w-3.5' /> 122 + Retry 146 123 </button> 147 - )} 124 + <button 125 + type='button' 126 + onClick={onCopyDeployDetails} 127 + className='inline-flex items-center gap-1 rounded border px-2 py-1 text-xs font-medium hover:bg-accent' 128 + > 129 + <Copy className='h-3.5 w-3.5' /> 130 + {copyState === 'copied' ? 'Copied' : 'Copy'} 131 + </button> 132 + </div> 133 + </div> 134 + </DropdownMenuContent> 135 + </DropdownMenu> 136 + )) 137 + .otherwise(() => ( 138 + <button 139 + type='button' 140 + onClick={onDeploy} 141 + disabled={!guildId || fileCount === 0 || isDeploying} 142 + className={deployButtonClass} 143 + > 144 + <Upload className='h-3 w-3' /> 145 + {deployLabel} 146 + </button> 147 + )) 148 + 149 + return ( 150 + <div className='h-full w-72 border-l bg-muted/10'> 151 + <div className='flex h-12 items-center justify-between px-3'> 152 + <div className='text-sm font-medium'>Deploy</div> 153 + <div className='flex items-center gap-1'> 154 + {deployAction} 148 155 </div> 149 156 </div> 150 157 <Separator />
+85 -78
apps/frontend/src/components/editor/workspace-sidebar.tsx
··· 21 21 Upload 22 22 } from 'lucide-react' 23 23 import type { MouseEvent as ReactMouseEvent, RefObject } from 'react' 24 + import { match, P } from 'ts-pattern' 24 25 25 26 import { formatLogLine, getLanguageFromPath } from './editor-utils' 26 27 import type { FileTreeNode, TreeSelection } from './types' ··· 149 150 ) 150 151 } 151 152 153 + const uploadedFilesContent = match(deployUploadedFiles.length) 154 + .with(0, () => <div>(none)</div>) 155 + .otherwise(() => deployUploadedFiles.map((path) => <div key={path}>{path}</div>)) 156 + 157 + const buildLogsContent = match(deployBuildLogs.length) 158 + .with(0, () => <div>(none)</div>) 159 + .otherwise(() => deployBuildLogs.map((line) => <div key={line}>{line}</div>)) 160 + 161 + const deployAction = match(deployError) 162 + .with(P.string, (errorMessage) => ( 163 + <DropdownMenu> 164 + <DropdownMenuTrigger 165 + render={ 166 + <button 167 + type='button' 168 + className={deployButtonClass} 169 + > 170 + <Upload className='h-3 w-3' /> 171 + {deployLabel} 172 + </button> 173 + } 174 + /> 175 + <DropdownMenuContent align='end' className='w-[32rem] p-3'> 176 + <div className='space-y-3'> 177 + <div> 178 + <div className='text-[11px] font-semibold uppercase tracking-wide text-muted-foreground'> 179 + Error 180 + </div> 181 + <div className='mt-1 max-h-28 overflow-auto rounded border bg-muted/40 p-2 font-mono text-xs text-destructive'> 182 + {errorMessage} 183 + </div> 184 + </div> 185 + <div> 186 + <div className='text-[11px] font-semibold uppercase tracking-wide text-muted-foreground'> 187 + Uploaded Files 188 + </div> 189 + <div className='mt-1 max-h-28 overflow-auto rounded border bg-muted/40 p-2 font-mono text-xs'> 190 + {uploadedFilesContent} 191 + </div> 192 + </div> 193 + <div> 194 + <div className='text-[11px] font-semibold uppercase tracking-wide text-muted-foreground'> 195 + Build Logs 196 + </div> 197 + <div className='mt-1 max-h-40 overflow-auto rounded border bg-muted/40 p-2 font-mono text-xs'> 198 + {buildLogsContent} 199 + </div> 200 + </div> 201 + <div className='flex justify-end gap-2'> 202 + <button 203 + type='button' 204 + onClick={onDeploy} 205 + disabled={!guildId || fileCount === 0 || isDeploying} 206 + className='inline-flex items-center gap-1 rounded border px-2 py-1 text-xs font-medium hover:bg-accent disabled:opacity-50' 207 + > 208 + <Upload className='h-3.5 w-3.5' /> 209 + Retry 210 + </button> 211 + <button 212 + type='button' 213 + onClick={onCopyDeployDetails} 214 + className='inline-flex items-center gap-1 rounded border px-2 py-1 text-xs font-medium hover:bg-accent' 215 + > 216 + <Copy className='h-3.5 w-3.5' /> 217 + {copyState === 'copied' ? 'Copied' : 'Copy'} 218 + </button> 219 + </div> 220 + </div> 221 + </DropdownMenuContent> 222 + </DropdownMenu> 223 + )) 224 + .otherwise(() => ( 225 + <button 226 + type='button' 227 + onClick={onDeploy} 228 + disabled={!guildId || fileCount === 0 || isDeploying} 229 + className={deployButtonClass} 230 + > 231 + <Upload className='h-3 w-3' /> 232 + {deployLabel} 233 + </button> 234 + )) 235 + 152 236 return ( 153 237 <div className='h-full w-72 border-r bg-muted/10'> 154 238 <div className='flex h-12 items-center justify-between px-2'> ··· 176 260 </Tooltip> 177 261 </TooltipProvider> 178 262 )} 179 - {deployError 180 - ? ( 181 - <DropdownMenu> 182 - <DropdownMenuTrigger 183 - render={ 184 - <button 185 - type='button' 186 - className={deployButtonClass} 187 - > 188 - <Upload className='h-3 w-3' /> 189 - {deployLabel} 190 - </button> 191 - } 192 - /> 193 - <DropdownMenuContent align='end' className='w-[32rem] p-3'> 194 - <div className='space-y-3'> 195 - <div> 196 - <div className='text-[11px] font-semibold uppercase tracking-wide text-muted-foreground'> 197 - Error 198 - </div> 199 - <div className='mt-1 max-h-28 overflow-auto rounded border bg-muted/40 p-2 font-mono text-xs text-destructive'> 200 - {deployError} 201 - </div> 202 - </div> 203 - <div> 204 - <div className='text-[11px] font-semibold uppercase tracking-wide text-muted-foreground'> 205 - Uploaded Files 206 - </div> 207 - <div className='mt-1 max-h-28 overflow-auto rounded border bg-muted/40 p-2 font-mono text-xs'> 208 - {deployUploadedFiles.length > 0 209 - ? deployUploadedFiles.map((path) => <div key={path}>{path}</div>) 210 - : <div>(none)</div>} 211 - </div> 212 - </div> 213 - <div> 214 - <div className='text-[11px] font-semibold uppercase tracking-wide text-muted-foreground'> 215 - Build Logs 216 - </div> 217 - <div className='mt-1 max-h-40 overflow-auto rounded border bg-muted/40 p-2 font-mono text-xs'> 218 - {deployBuildLogs.length > 0 219 - ? deployBuildLogs.map((line) => <div key={line}>{line}</div>) 220 - : <div>(none)</div>} 221 - </div> 222 - </div> 223 - <div className='flex justify-end gap-2'> 224 - <button 225 - type='button' 226 - onClick={onDeploy} 227 - disabled={!guildId || fileCount === 0 || isDeploying} 228 - className='inline-flex items-center gap-1 rounded border px-2 py-1 text-xs font-medium hover:bg-accent disabled:opacity-50' 229 - > 230 - <Upload className='h-3.5 w-3.5' /> 231 - Retry 232 - </button> 233 - <button 234 - type='button' 235 - onClick={onCopyDeployDetails} 236 - className='inline-flex items-center gap-1 rounded border px-2 py-1 text-xs font-medium hover:bg-accent' 237 - > 238 - <Copy className='h-3.5 w-3.5' /> 239 - {copyState === 'copied' ? 'Copied' : 'Copy'} 240 - </button> 241 - </div> 242 - </div> 243 - </DropdownMenuContent> 244 - </DropdownMenu> 245 - ) 246 - : ( 247 - <button 248 - type='button' 249 - onClick={onDeploy} 250 - disabled={!guildId || fileCount === 0 || isDeploying} 251 - className={deployButtonClass} 252 - > 253 - <Upload className='h-3 w-3' /> 254 - {deployLabel} 255 - </button> 256 - )} 263 + {deployAction} 257 264 </div> 258 265 </div> 259 266 <Separator />
+279 -236
apps/frontend/src/components/features/DeploymentHistory.tsx
··· 17 17 import { formatDistanceToNow } from 'date-fns' 18 18 import { ChevronDown, Loader2, RotateCcw } from 'lucide-react' 19 19 import { lazy, Suspense, useEffect, useMemo, useState } from 'react' 20 + import { match } from 'ts-pattern' 20 21 21 22 const LazyMultiFileDiff = lazy(async () => { 22 23 const mod = await import('@pierre/diffs/react') ··· 147 148 .sort((a, b) => a.path.localeCompare(b.path)) 148 149 }, [baseRevisionQuery.data?.files, selectedRevisionQuery.data?.files]) 149 150 150 - return ( 151 - <div className='flex h-full min-h-0 flex-col gap-4'> 152 - <div className='rounded-lg border bg-card p-4'> 153 - {!selectedRevision 154 - ? ( 155 - <div className='text-sm text-muted-foreground'> 156 - Select a revision to inspect metadata and source diffs. 151 + const revisionSummary = match({ 152 + hasRevision: Boolean(selectedRevision), 153 + shouldLoadDiffs, 154 + isLoading: selectedRevisionQuery.isLoading, 155 + isError: selectedRevisionQuery.isError 156 + }) 157 + .with({ hasRevision: false }, () => ( 158 + <div className='text-sm text-muted-foreground'> 159 + Select a revision to inspect metadata and source diffs. 160 + </div> 161 + )) 162 + .with( 163 + { hasRevision: true, shouldLoadDiffs: true, isLoading: true }, 164 + () => ( 165 + <div className='flex items-center gap-2 text-sm text-muted-foreground'> 166 + <Loader2 className='size-4 animate-spin' /> 167 + Loading Revision Details… 168 + </div> 169 + ) 170 + ) 171 + .with({ hasRevision: true, isError: true }, () => ( 172 + <div className='text-sm text-destructive'> 173 + Failed to load revision: {toErrorMessage(selectedRevisionQuery.error)} 174 + </div> 175 + )) 176 + .otherwise(() => { 177 + if (!selectedRevision) return null 178 + 179 + return ( 180 + <div className='space-y-3'> 181 + <div className='flex items-center justify-between gap-2'> 182 + <div> 183 + <div className='flex items-center gap-2 text-sm font-medium'> 184 + <Badge 185 + variant='outline' 186 + className={cn('border-0', statusBadgeClass(selectedRevision.status))} 187 + > 188 + {formatStatusLabel(selectedRevision.status)} 189 + </Badge> 190 + <span>Revision {selectedRevision.id}</span> 191 + </div> 192 + <div className='text-xs text-muted-foreground'> 193 + {formatDateTime(selectedRevision.deployed_at)} 194 + </div> 157 195 </div> 158 - ) 159 - : shouldLoadDiffs && selectedRevisionQuery.isLoading 160 - ? ( 161 - <div className='flex items-center gap-2 text-sm text-muted-foreground'> 162 - <Loader2 className='size-4 animate-spin' /> 163 - Loading Revision Details… 196 + {!isLatestRevision 197 + ? ( 198 + <Button 199 + size='sm' 200 + variant='outline' 201 + disabled={rollbackMutation.isPending || selectedRevision.status !== 'success'} 202 + onClick={() => { 203 + if (selectedRevision.id) rollbackMutation.mutate(selectedRevision.id) 204 + }} 205 + > 206 + {rollbackMutation.isPending 207 + ? ( 208 + <> 209 + <Loader2 className='mr-1 size-4 animate-spin' /> 210 + Rolling back… 211 + </> 212 + ) 213 + : ( 214 + <> 215 + <RotateCcw className='mr-1 size-4' /> 216 + Rollback to this 217 + </> 218 + )} 219 + </Button> 220 + ) 221 + : null} 222 + </div> 223 + 224 + {rollbackMutation.isError 225 + ? ( 226 + <div className='text-xs text-destructive'> 227 + Rollback failed: {toErrorMessage(rollbackMutation.error)} 228 + </div> 229 + ) 230 + : null} 231 + 232 + <div className='grid gap-3 md:grid-cols-2'> 233 + <div className='rounded-md border p-2'> 234 + <div className='text-[11px] text-muted-foreground'>Source</div> 235 + <div className='mt-1 text-sm font-medium'>{selectedRevision.deploy_source}</div> 164 236 </div> 165 - ) 166 - : selectedRevisionQuery.isError 167 - ? ( 168 - <div className='text-sm text-destructive'> 169 - Failed to load revision: {toErrorMessage(selectedRevisionQuery.error)} 237 + <div className='rounded-md border p-2'> 238 + <div className='text-[11px] text-muted-foreground'>Actor</div> 239 + <div className='mt-1 text-sm font-medium'> 240 + {formatActor(selectedRevision.actor)} 241 + </div> 170 242 </div> 171 - ) 172 - : ( 173 - <div className='space-y-3'> 174 - <div className='flex items-center justify-between gap-2'> 175 - <div> 176 - <div className='flex items-center gap-2 text-sm font-medium'> 177 - <Badge 178 - variant='outline' 179 - className={cn('border-0', statusBadgeClass(selectedRevision.status))} 180 - > 181 - {formatStatusLabel(selectedRevision.status)} 182 - </Badge> 183 - <span>Revision {selectedRevision.id}</span> 184 - </div> 185 - <div className='text-xs text-muted-foreground'> 186 - {formatDateTime(selectedRevision.deployed_at)} 187 - </div> 188 - </div> 189 - {!isLatestRevision 190 - ? ( 191 - <Button 192 - size='sm' 193 - variant='outline' 194 - disabled={rollbackMutation.isPending || selectedRevision.status !== 'success'} 195 - onClick={() => { 196 - if (selectedRevision.id) rollbackMutation.mutate(selectedRevision.id) 197 - }} 198 - > 199 - {rollbackMutation.isPending 200 - ? ( 201 - <> 202 - <Loader2 className='mr-1 size-4 animate-spin' /> 203 - Rolling back… 204 - </> 205 - ) 206 - : ( 207 - <> 208 - <RotateCcw className='mr-1 size-4' /> 209 - Rollback to this 210 - </> 211 - )} 212 - </Button> 213 - ) 214 - : null} 243 + </div> 244 + 245 + <div className='grid grid-cols-2 gap-3 text-xs md:grid-cols-3'> 246 + <div className='rounded-md border p-2'> 247 + <div className='text-muted-foreground'>Entry</div> 248 + <div className='font-mono'>{selectedRevision.entry}</div> 249 + </div> 250 + <div className='rounded-md border p-2'> 251 + <div className='text-muted-foreground'>Build</div> 252 + <div className='font-mono'>{selectedRevision.build_id ?? '—'}</div> 253 + </div> 254 + <div className='rounded-md border p-2'> 255 + <div className='text-muted-foreground'>Base Revision</div> 256 + <div className='font-mono'> 257 + {selectedRevision.base_revision_id 258 + ? shortId(selectedRevision.base_revision_id) 259 + : '—'} 215 260 </div> 261 + </div> 262 + </div> 216 263 217 - {rollbackMutation.isError 218 - ? ( 219 - <div className='text-xs text-destructive'> 220 - Rollback failed: {toErrorMessage(rollbackMutation.error)} 221 - </div> 222 - ) 223 - : null} 264 + {selectedRevision.error_message 265 + ? ( 266 + <pre className='overflow-x-auto rounded border bg-muted/30 p-2 text-xs text-destructive'> 267 + {selectedRevision.error_message} 268 + </pre> 269 + ) 270 + : null} 271 + </div> 272 + ) 273 + }) 224 274 225 - <div className='grid gap-3 md:grid-cols-2'> 226 - <div className='rounded-md border p-2'> 227 - <div className='text-[11px] text-muted-foreground'>Source</div> 228 - <div className='mt-1 text-sm font-medium'>{selectedRevision.deploy_source}</div> 275 + const diffContent = match({ 276 + shouldLoadDiffs, 277 + isRevisionLoading: selectedRevisionQuery.isLoading, 278 + hasBaseRevision: Boolean(baseRevisionId), 279 + isBaseLoading: baseRevisionQuery.isLoading, 280 + isBaseError: baseRevisionQuery.isError, 281 + hasDiffFiles: diffFiles.length > 0, 282 + diffOpen 283 + }) 284 + .with( 285 + { shouldLoadDiffs: true, isRevisionLoading: true }, 286 + () => <div className='text-sm text-muted-foreground'>Loading revision files…</div> 287 + ) 288 + .with( 289 + { hasBaseRevision: true, isBaseLoading: true }, 290 + () => <div className='text-sm text-muted-foreground'>Loading base revision…</div> 291 + ) 292 + .with({ isBaseError: true }, () => ( 293 + <div className='text-sm text-destructive'> 294 + Failed to load base revision: {toErrorMessage(baseRevisionQuery.error)} 295 + </div> 296 + )) 297 + .with( 298 + { hasBaseRevision: false }, 299 + () => <div className='text-sm text-muted-foreground'>No base revision for this entry.</div> 300 + ) 301 + .with( 302 + { hasDiffFiles: false }, 303 + () => <div className='text-sm text-muted-foreground'>No diffable source-file changes.</div> 304 + ) 305 + .with({ diffOpen: false }, () => null) 306 + .otherwise(() => ( 307 + <div className='max-h-[42dvh] space-y-3 overflow-auto'> 308 + {diffFiles.map((file) => ( 309 + <div key={file.path} className='overflow-hidden rounded-lg border'> 310 + <Suspense 311 + fallback={ 312 + <div className='flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground'> 313 + <Loader2 className='size-3 animate-spin' /> 314 + Loading diff renderer… 229 315 </div> 230 - <div className='rounded-md border p-2'> 231 - <div className='text-[11px] text-muted-foreground'>Actor</div> 232 - <div className='mt-1 text-sm font-medium'> 233 - {formatActor(selectedRevision.actor)} 234 - </div> 235 - </div> 236 - </div> 316 + } 317 + > 318 + <LazyMultiFileDiff 319 + oldFile={{ name: file.path, contents: file.oldContents }} 320 + newFile={{ name: file.path, contents: file.newContents }} 321 + options={{ 322 + diffStyle: 'split', 323 + overflow: 'wrap', 324 + lineDiffType: 'word' 325 + }} 326 + /> 327 + </Suspense> 328 + </div> 329 + ))} 330 + </div> 331 + )) 332 + 333 + const historyContent = match({ 334 + isLoading: historyQuery.isLoading, 335 + isError: historyQuery.isError, 336 + hasEntries: Boolean(historyQuery.data?.length) 337 + }) 338 + .with( 339 + { isLoading: true }, 340 + () => ( 341 + <div className='flex h-full items-center justify-center gap-2 text-sm text-muted-foreground'> 342 + <Loader2 className='size-4 animate-spin' /> 343 + Loading Deployment History… 344 + </div> 345 + ) 346 + ) 347 + .with( 348 + { isError: true }, 349 + () => ( 350 + <div className='flex h-full items-center justify-center text-sm text-destructive'> 351 + Failed to load history: {toErrorMessage(historyQuery.error)} 352 + </div> 353 + ) 354 + ) 355 + .with( 356 + { hasEntries: false }, 357 + () => ( 358 + <div className='flex h-full items-center justify-center text-sm text-muted-foreground'> 359 + No deployments yet for this guild. 360 + </div> 361 + ) 362 + ) 363 + .otherwise(() => ( 364 + <div className='h-full overflow-auto'> 365 + <Table> 366 + <TableHeader className='sticky top-0 bg-background/95 backdrop-blur'> 367 + <TableRow className='hover:bg-transparent'> 368 + <TableHead>Id</TableHead> 369 + <TableHead>Actor</TableHead> 370 + <TableHead>Deployed</TableHead> 371 + <TableHead>Source</TableHead> 372 + <TableHead>Status</TableHead> 373 + <TableHead>Entry</TableHead> 374 + <TableHead>Build</TableHead> 375 + <TableHead>Error</TableHead> 376 + </TableRow> 377 + </TableHeader> 378 + <TableBody> 379 + {historyQuery.data?.map((row) => { 380 + const isSelected = row.id === selectedRevisionId 237 381 238 - <div className='grid grid-cols-2 gap-3 text-xs md:grid-cols-3'> 239 - <div className='rounded-md border p-2'> 240 - <div className='text-muted-foreground'>Entry</div> 241 - <div className='font-mono'>{selectedRevision.entry}</div> 242 - </div> 243 - <div className='rounded-md border p-2'> 244 - <div className='text-muted-foreground'>Build</div> 245 - <div className='font-mono'>{selectedRevision.build_id ?? '—'}</div> 246 - </div> 247 - <div className='rounded-md border p-2'> 248 - <div className='text-muted-foreground'>Base Revision</div> 249 - <div className='font-mono'> 250 - {selectedRevision.base_revision_id 251 - ? shortId(selectedRevision.base_revision_id) 382 + return ( 383 + <TableRow 384 + key={row.id} 385 + data-state={isSelected ? 'selected' : undefined} 386 + className='cursor-pointer' 387 + onClick={() => { 388 + setSelectedRevisionId(row.id) 389 + }} 390 + > 391 + <TableCell className='font-mono text-xs'>{shortId(row.id)}</TableCell> 392 + <TableCell className='text-xs'>{formatActor(row.actor)}</TableCell> 393 + <TableCell className='text-xs whitespace-nowrap'> 394 + {formatTimeAgo(row.deployed_at)} 395 + </TableCell> 396 + <TableCell className='text-xs'>{row.deploy_source}</TableCell> 397 + <TableCell> 398 + <Badge 399 + variant='outline' 400 + className={cn('border-0', statusBadgeClass(row.status))} 401 + > 402 + {row.status} 403 + </Badge> 404 + </TableCell> 405 + <TableCell className='font-mono text-xs'>{row.entry}</TableCell> 406 + <TableCell className='font-mono text-xs'>{row.build_id ?? '—'}</TableCell> 407 + <TableCell className='max-w-60 text-xs'> 408 + {row.error_message 409 + ? ( 410 + <TooltipProvider> 411 + <Tooltip> 412 + <TooltipTrigger> 413 + <span className='block truncate text-destructive'> 414 + {row.error_message} 415 + </span> 416 + </TooltipTrigger> 417 + <TooltipContent className='max-w-lg'> 418 + {row.error_message} 419 + </TooltipContent> 420 + </Tooltip> 421 + </TooltipProvider> 422 + ) 252 423 : '—'} 253 - </div> 254 - </div> 255 - </div> 424 + </TableCell> 425 + </TableRow> 426 + ) 427 + })} 428 + </TableBody> 429 + </Table> 430 + </div> 431 + )) 256 432 257 - {selectedRevision.error_message 258 - ? ( 259 - <pre className='overflow-x-auto rounded border bg-muted/30 p-2 text-xs text-destructive'> 260 - {selectedRevision.error_message} 261 - </pre> 262 - ) 263 - : null} 264 - </div> 265 - )} 433 + return ( 434 + <div className='flex h-full min-h-0 flex-col gap-4'> 435 + <div className='rounded-lg border bg-card p-4'> 436 + {revisionSummary} 266 437 </div> 267 438 268 439 <Collapsible open={diffOpen} onOpenChange={setDiffOpen} className='rounded-lg border bg-card'> ··· 274 445 <ChevronDown className={cn('size-4 transition-transform', diffOpen && 'rotate-180')} /> 275 446 </CollapsibleTrigger> 276 447 <CollapsibleContent className='border-t p-3'> 277 - {shouldLoadDiffs && selectedRevisionQuery.isLoading 278 - ? <div className='text-sm text-muted-foreground'>Loading revision files…</div> 279 - : baseRevisionId && baseRevisionQuery.isLoading 280 - ? <div className='text-sm text-muted-foreground'>Loading base revision…</div> 281 - : baseRevisionQuery.isError 282 - ? ( 283 - <div className='text-sm text-destructive'> 284 - Failed to load base revision: {toErrorMessage(baseRevisionQuery.error)} 285 - </div> 286 - ) 287 - : !baseRevisionId 288 - ? <div className='text-sm text-muted-foreground'>No base revision for this entry.</div> 289 - : !diffFiles.length 290 - ? <div className='text-sm text-muted-foreground'>No diffable source-file changes.</div> 291 - : !diffOpen 292 - ? null 293 - : ( 294 - <div className='max-h-[42dvh] space-y-3 overflow-auto'> 295 - {diffFiles.map((file) => ( 296 - <div key={file.path} className='overflow-hidden rounded-lg border'> 297 - <Suspense 298 - fallback={ 299 - <div className='flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground'> 300 - <Loader2 className='size-3 animate-spin' /> 301 - Loading diff renderer… 302 - </div> 303 - } 304 - > 305 - <LazyMultiFileDiff 306 - oldFile={{ name: file.path, contents: file.oldContents }} 307 - newFile={{ name: file.path, contents: file.newContents }} 308 - options={{ 309 - diffStyle: 'split', 310 - overflow: 'wrap', 311 - lineDiffType: 'word' 312 - }} 313 - /> 314 - </Suspense> 315 - </div> 316 - ))} 317 - </div> 318 - )} 448 + {diffContent} 319 449 </CollapsibleContent> 320 450 </Collapsible> 321 451 322 452 <div className='min-h-0 flex-1 overflow-hidden rounded-lg border bg-card'> 323 - {historyQuery.isLoading 324 - ? ( 325 - <div className='flex h-full items-center justify-center gap-2 text-sm text-muted-foreground'> 326 - <Loader2 className='size-4 animate-spin' /> 327 - Loading Deployment History… 328 - </div> 329 - ) 330 - : historyQuery.isError 331 - ? ( 332 - <div className='flex h-full items-center justify-center text-sm text-destructive'> 333 - Failed to load history: {toErrorMessage(historyQuery.error)} 334 - </div> 335 - ) 336 - : !historyQuery.data?.length 337 - ? ( 338 - <div className='flex h-full items-center justify-center text-sm text-muted-foreground'> 339 - No deployments yet for this guild. 340 - </div> 341 - ) 342 - : ( 343 - <div className='h-full overflow-auto'> 344 - <Table> 345 - <TableHeader className='sticky top-0 bg-background/95 backdrop-blur'> 346 - <TableRow className='hover:bg-transparent'> 347 - <TableHead>Id</TableHead> 348 - <TableHead>Actor</TableHead> 349 - <TableHead>Deployed</TableHead> 350 - <TableHead>Source</TableHead> 351 - <TableHead>Status</TableHead> 352 - <TableHead>Entry</TableHead> 353 - <TableHead>Build</TableHead> 354 - <TableHead>Error</TableHead> 355 - </TableRow> 356 - </TableHeader> 357 - <TableBody> 358 - {historyQuery.data.map((row) => { 359 - const isSelected = row.id === selectedRevisionId 360 - 361 - return ( 362 - <TableRow 363 - key={row.id} 364 - data-state={isSelected ? 'selected' : undefined} 365 - className='cursor-pointer' 366 - onClick={() => { 367 - setSelectedRevisionId(row.id) 368 - }} 369 - > 370 - <TableCell className='font-mono text-xs'>{shortId(row.id)}</TableCell> 371 - <TableCell className='text-xs'>{formatActor(row.actor)}</TableCell> 372 - <TableCell className='text-xs whitespace-nowrap'> 373 - {formatTimeAgo(row.deployed_at)} 374 - </TableCell> 375 - <TableCell className='text-xs'>{row.deploy_source}</TableCell> 376 - <TableCell> 377 - <Badge 378 - variant='outline' 379 - className={cn('border-0', statusBadgeClass(row.status))} 380 - > 381 - {row.status} 382 - </Badge> 383 - </TableCell> 384 - <TableCell className='font-mono text-xs'>{row.entry}</TableCell> 385 - <TableCell className='font-mono text-xs'>{row.build_id ?? '—'}</TableCell> 386 - <TableCell className='max-w-60 text-xs'> 387 - {row.error_message 388 - ? ( 389 - <TooltipProvider> 390 - <Tooltip> 391 - <TooltipTrigger> 392 - <span className='block truncate text-destructive'> 393 - {row.error_message} 394 - </span> 395 - </TooltipTrigger> 396 - <TooltipContent className='max-w-lg'> 397 - {row.error_message} 398 - </TooltipContent> 399 - </Tooltip> 400 - </TooltipProvider> 401 - ) 402 - : '—'} 403 - </TableCell> 404 - </TableRow> 405 - ) 406 - })} 407 - </TableBody> 408 - </Table> 409 - </div> 410 - )} 453 + {historyContent} 411 454 </div> 412 455 </div> 413 456 )
+23 -23
apps/frontend/src/components/sidebar/app-sidebar.tsx
··· 15 15 import { cn } from '@/lib/utils' 16 16 import { domAnimation, LazyMotion, m, useReducedMotion } from 'framer-motion' 17 17 import { BookText, Database, FileCode2, ListChecks, Shield } from 'lucide-react' 18 + import { match } from 'ts-pattern' 18 19 import { useLocation } from 'wouter' 19 20 import DashboardNavigation, { type Route } from './nav-main' 20 21 import { NavUser } from './nav-user' ··· 92 93 ] 93 94 })) || [] 94 95 96 + const guildsContent = match({ isLoading: guilds.loading, hasRoutes: routes.length > 0 }) 97 + .with({ isLoading: true }, () => ( 98 + <div className='space-y-2 px-2'> 99 + <Skeleton className='h-8 w-full' /> 100 + <Skeleton className='h-8 w-full' /> 101 + <Skeleton className='h-8 w-full' /> 102 + </div> 103 + )) 104 + .with({ isLoading: false, hasRoutes: false }, () => ( 105 + <div 106 + className={cn( 107 + 'flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed p-4 text-center', 108 + !isCollapsed && 'mt-3' 109 + )} 110 + > 111 + <Shield className='h-5 w-5 text-muted-foreground' /> 112 + {!isCollapsed && <p className='text-xs text-muted-foreground'>No guilds found</p>} 113 + </div> 114 + )) 115 + .otherwise(() => <DashboardNavigation routes={routes} />) 116 + 95 117 return ( 96 118 <Sidebar variant='inset' collapsible='icon'> 97 119 <SidebarHeader ··· 134 156 </LazyMotion> 135 157 </SidebarHeader> 136 158 <SidebarContent className={cn('gap-4 px-2', !isCollapsed && 'py-4')}> 137 - <div> 138 - {guilds.loading 139 - ? ( 140 - <div className='space-y-2 px-2'> 141 - <Skeleton className='h-8 w-full' /> 142 - <Skeleton className='h-8 w-full' /> 143 - <Skeleton className='h-8 w-full' /> 144 - </div> 145 - ) 146 - : routes.length === 0 147 - ? ( 148 - <div 149 - className={cn( 150 - 'flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed p-4 text-center', 151 - !isCollapsed && 'mt-3' 152 - )} 153 - > 154 - <Shield className='h-5 w-5 text-muted-foreground' /> 155 - {!isCollapsed && <p className='text-xs text-muted-foreground'>No guilds found</p>} 156 - </div> 157 - ) 158 - : <DashboardNavigation routes={routes} />} 159 - </div> 159 + <div>{guildsContent}</div> 160 160 </SidebarContent> 161 161 <SidebarFooter className='px-2'> 162 162 {session && (
+48 -42
apps/frontend/src/pages/dashboard.tsx
··· 7 7 import { Seo } from '@/lib/seo' 8 8 import { Clock4 } from 'lucide-react' 9 9 import { useMemo } from 'react' 10 + import { match } from 'ts-pattern' 10 11 import { useLocation } from 'wouter' 11 12 12 13 function getGuildInitials(name: string) { ··· 28 29 return [...fromRecent, ...rest].slice(0, 2) 29 30 }, [guilds.data, recentGuildIds]) 30 31 32 + const recentGuildsContent = match(recentGuilds.length) 33 + .with( 34 + 0, 35 + () => ( 36 + <div className='rounded-xl border border-dashed p-8 text-sm text-muted-foreground'> 37 + Pick a server from the sidebar to create your recent list. 38 + </div> 39 + ) 40 + ) 41 + .otherwise(() => ( 42 + <div className='grid gap-4 md:grid-cols-2'> 43 + {recentGuilds.map((guild) => ( 44 + <button 45 + key={guild.id} 46 + type='button' 47 + className='group cursor-pointer rounded-2xl border bg-card p-4 text-left transition hover:-translate-y-0.5 hover:shadow-md' 48 + onClick={() => { 49 + setSelectedGuild(guild.id) 50 + setView('overview') 51 + setLocation(`/${guild.id}`) 52 + }} 53 + > 54 + <div className='mb-4 flex items-center gap-3'> 55 + <Avatar className='h-10 w-10'> 56 + <AvatarImage 57 + src={guild.icon 58 + ? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png?size=128` 59 + : undefined} 60 + /> 61 + <AvatarFallback>{getGuildInitials(guild.name)}</AvatarFallback> 62 + </Avatar> 63 + <div> 64 + <div className='font-medium'>{guild.name}</div> 65 + <div className='text-xs text-muted-foreground'> 66 + Guild ID: {guild.id} 67 + </div> 68 + </div> 69 + </div> 70 + <div className='inline-flex items-center gap-2 text-sm text-muted-foreground group-hover:text-foreground'> 71 + <Clock4 className='h-4 w-4' />Continue managing guild 72 + </div> 73 + </button> 74 + ))} 75 + </div> 76 + )) 77 + 31 78 return ( 32 79 <> 33 80 <Seo ··· 51 98 Your most recently used servers. 52 99 </p> 53 100 </div> 54 - {recentGuilds.length === 0 55 - ? ( 56 - <div className='rounded-xl border border-dashed p-8 text-sm text-muted-foreground'> 57 - Pick a server from the sidebar to create your recent list. 58 - </div> 59 - ) 60 - : ( 61 - <div className='grid gap-4 md:grid-cols-2'> 62 - {recentGuilds.map((guild) => ( 63 - <button 64 - key={guild.id} 65 - type='button' 66 - className='group cursor-pointer rounded-2xl border bg-card p-4 text-left transition hover:-translate-y-0.5 hover:shadow-md' 67 - onClick={() => { 68 - setSelectedGuild(guild.id) 69 - setView('overview') 70 - setLocation(`/${guild.id}`) 71 - }} 72 - > 73 - <div className='mb-4 flex items-center gap-3'> 74 - <Avatar className='h-10 w-10'> 75 - <AvatarImage 76 - src={guild.icon 77 - ? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png?size=128` 78 - : undefined} 79 - /> 80 - <AvatarFallback>{getGuildInitials(guild.name)}</AvatarFallback> 81 - </Avatar> 82 - <div> 83 - <div className='font-medium'>{guild.name}</div> 84 - <div className='text-xs text-muted-foreground'> 85 - Guild ID: {guild.id} 86 - </div> 87 - </div> 88 - </div> 89 - <div className='inline-flex items-center gap-2 text-sm text-muted-foreground group-hover:text-foreground'> 90 - <Clock4 className='h-4 w-4' />Continue managing guild 91 - </div> 92 - </button> 93 - ))} 94 - </div> 95 - )} 101 + {recentGuildsContent} 96 102 </section> 97 103 98 104 <section className='space-y-6'>