Skip to content

Commit 51bad28

Browse files
authored
Add reusable Badge component and refactor badge usage (#37)
Introduces a new Badge component with variants for type, updateable, privileged, status, execution mode, and note. Refactors ScriptCard, ScriptDetailModal, and InstalledScriptsTab to use the new Badge components, improving consistency and maintainability. Also updates DarkModeProvider and layout.tsx for better dark mode handling and fallback.
1 parent 4c435b7 commit 51bad28

File tree

6 files changed

+179
-86
lines changed

6 files changed

+179
-86
lines changed

src/app/_components/Badge.tsx

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
'use client';
2+
3+
import React from 'react';
4+
5+
interface BadgeProps {
6+
variant: 'type' | 'updateable' | 'privileged' | 'status' | 'note' | 'execution-mode';
7+
type?: string;
8+
noteType?: 'info' | 'warning' | 'error';
9+
status?: 'success' | 'failed' | 'in_progress';
10+
executionMode?: 'local' | 'ssh';
11+
children: React.ReactNode;
12+
className?: string;
13+
}
14+
15+
export function Badge({ variant, type, noteType, status, executionMode, children, className = '' }: BadgeProps) {
16+
const getTypeStyles = (scriptType: string) => {
17+
switch (scriptType.toLowerCase()) {
18+
case 'ct':
19+
return 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 border-blue-200 dark:border-blue-700';
20+
case 'addon':
21+
return 'bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-200 border-purple-200 dark:border-purple-700';
22+
case 'vm':
23+
return 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 border-green-200 dark:border-green-700';
24+
case 'pve':
25+
return 'bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-200 border-orange-200 dark:border-orange-700';
26+
default:
27+
return 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 border-gray-200 dark:border-gray-600';
28+
}
29+
};
30+
31+
const getVariantStyles = () => {
32+
switch (variant) {
33+
case 'type':
34+
return `inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border ${type ? getTypeStyles(type) : getTypeStyles('unknown')}`;
35+
36+
case 'updateable':
37+
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 border border-green-200 dark:border-green-700';
38+
39+
case 'privileged':
40+
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200 border border-red-200 dark:border-red-700';
41+
42+
case 'status':
43+
switch (status) {
44+
case 'success':
45+
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 border border-green-200 dark:border-green-700';
46+
case 'failed':
47+
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200 border border-red-200 dark:border-red-700';
48+
case 'in_progress':
49+
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-200 border border-yellow-200 dark:border-yellow-700';
50+
default:
51+
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 border border-gray-200 dark:border-gray-600';
52+
}
53+
54+
case 'execution-mode':
55+
switch (executionMode) {
56+
case 'local':
57+
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 border border-blue-200 dark:border-blue-700';
58+
case 'ssh':
59+
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-200 border border-purple-200 dark:border-purple-700';
60+
default:
61+
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 border border-gray-200 dark:border-gray-600';
62+
}
63+
64+
case 'note':
65+
switch (noteType) {
66+
case 'warning':
67+
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-200 border border-yellow-200 dark:border-yellow-700';
68+
case 'error':
69+
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200 border border-red-200 dark:border-red-700';
70+
default:
71+
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 border border-blue-200 dark:border-blue-700';
72+
}
73+
74+
default:
75+
return 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 border border-gray-200 dark:border-gray-600';
76+
}
77+
};
78+
79+
// Format the text for type badges
80+
const formatText = () => {
81+
if (variant === 'type' && type) {
82+
switch (type.toLowerCase()) {
83+
case 'ct':
84+
return 'LXC';
85+
case 'addon':
86+
return 'ADDON';
87+
case 'vm':
88+
return 'VM';
89+
case 'pve':
90+
return 'PVE';
91+
default:
92+
return type.toUpperCase();
93+
}
94+
}
95+
return children;
96+
};
97+
98+
return (
99+
<span className={`${getVariantStyles()} ${className}`}>
100+
{formatText()}
101+
</span>
102+
);
103+
}
104+
105+
// Convenience components for common use cases
106+
export const TypeBadge = ({ type, className }: { type: string; className?: string }) => (
107+
<Badge variant="type" type={type} className={className}>
108+
{type}
109+
</Badge>
110+
);
111+
112+
export const UpdateableBadge = ({ className }: { className?: string }) => (
113+
<Badge variant="updateable" className={className}>
114+
Updateable
115+
</Badge>
116+
);
117+
118+
export const PrivilegedBadge = ({ className }: { className?: string }) => (
119+
<Badge variant="privileged" className={className}>
120+
Privileged
121+
</Badge>
122+
);
123+
124+
export const StatusBadge = ({ status, children, className }: { status: 'success' | 'failed' | 'in_progress'; children: React.ReactNode; className?: string }) => (
125+
<Badge variant="status" status={status} className={className}>
126+
{children}
127+
</Badge>
128+
);
129+
130+
export const ExecutionModeBadge = ({ mode, children, className }: { mode: 'local' | 'ssh'; children: React.ReactNode; className?: string }) => (
131+
<Badge variant="execution-mode" executionMode={mode} className={className}>
132+
{children}
133+
</Badge>
134+
);
135+
136+
export const NoteBadge = ({ noteType, children, className }: { noteType: 'info' | 'warning' | 'error'; children: React.ReactNode; className?: string }) => (
137+
<Badge variant="note" noteType={noteType} className={className}>
138+
{children}
139+
</Badge>
140+
);

src/app/_components/DarkModeProvider.tsx

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ export function DarkModeProvider({ children }: { children: React.ReactNode }) {
1919

2020
// Initialize theme from localStorage after mount
2121
useEffect(() => {
22-
setMounted(true);
2322
const stored = localStorage.getItem('theme') as Theme;
2423
if (stored && ['light', 'dark', 'system'].includes(stored)) {
2524
setThemeState(stored);
@@ -28,6 +27,7 @@ export function DarkModeProvider({ children }: { children: React.ReactNode }) {
2827
// Set initial isDark state based on current DOM state
2928
const currentlyDark = document.documentElement.classList.contains('dark');
3029
setIsDark(currentlyDark);
30+
setMounted(true);
3131
}, []);
3232

3333
// Update dark mode state and DOM when theme changes
@@ -38,13 +38,16 @@ export function DarkModeProvider({ children }: { children: React.ReactNode }) {
3838
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
3939
const shouldBeDark = theme === 'dark' || (theme === 'system' && systemDark);
4040

41-
setIsDark(shouldBeDark);
42-
43-
// Apply to document
44-
if (shouldBeDark) {
45-
document.documentElement.classList.add('dark');
46-
} else {
47-
document.documentElement.classList.remove('dark');
41+
// Only update if there's actually a change
42+
if (shouldBeDark !== isDark) {
43+
setIsDark(shouldBeDark);
44+
45+
// Apply to document
46+
if (shouldBeDark) {
47+
document.documentElement.classList.add('dark');
48+
} else {
49+
document.documentElement.classList.remove('dark');
50+
}
4851
}
4952
};
5053

@@ -60,7 +63,7 @@ export function DarkModeProvider({ children }: { children: React.ReactNode }) {
6063

6164
mediaQuery.addEventListener('change', handleChange);
6265
return () => mediaQuery.removeEventListener('change', handleChange);
63-
}, [theme, mounted]);
66+
}, [theme, mounted, isDark]);
6467

6568
const setTheme = (newTheme: Theme) => {
6669
setThemeState(newTheme);

src/app/_components/InstalledScriptsTab.tsx

Lines changed: 7 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useState } from 'react';
44
import { api } from '~/trpc/react';
55
import { Terminal } from './Terminal';
6+
import { StatusBadge, ExecutionModeBadge } from './Badge';
67

78
interface InstalledScript {
89
id: number;
@@ -109,32 +110,6 @@ export function InstalledScriptsTab() {
109110
return new Date(dateString).toLocaleString();
110111
};
111112

112-
const getStatusBadge = (status: string): string => {
113-
const baseClasses = 'px-2 py-1 text-xs font-medium rounded-full';
114-
switch (status) {
115-
case 'success':
116-
return `${baseClasses} bg-green-100 text-green-800`;
117-
case 'failed':
118-
return `${baseClasses} bg-red-100 text-red-800`;
119-
case 'in_progress':
120-
return `${baseClasses} bg-yellow-100 text-yellow-800`;
121-
default:
122-
return `${baseClasses} bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200`;
123-
}
124-
};
125-
126-
const getModeBadge = (mode: string): string => {
127-
const baseClasses = 'px-2 py-1 text-xs font-medium rounded-full';
128-
switch (mode) {
129-
case 'local':
130-
return `${baseClasses} bg-blue-100 text-blue-800`;
131-
case 'ssh':
132-
return `${baseClasses} bg-purple-100 text-purple-800`;
133-
default:
134-
return `${baseClasses} bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200`;
135-
}
136-
};
137-
138113
if (isLoading) {
139114
return (
140115
<div className="flex items-center justify-center h-64">
@@ -277,14 +252,14 @@ export function InstalledScriptsTab() {
277252
)}
278253
</td>
279254
<td className="px-6 py-4 whitespace-nowrap">
280-
<span className={getModeBadge(String(script.execution_mode))}>
281-
{String(script.execution_mode).toUpperCase()}
282-
</span>
255+
<ExecutionModeBadge mode={script.execution_mode}>
256+
{script.execution_mode.toUpperCase()}
257+
</ExecutionModeBadge>
283258
</td>
284259
<td className="px-6 py-4 whitespace-nowrap">
285-
<span className={getStatusBadge(String(script.status))}>
286-
{String(script.status).replace('_', ' ').toUpperCase()}
287-
</span>
260+
<StatusBadge status={script.status}>
261+
{script.status.replace('_', ' ').toUpperCase()}
262+
</StatusBadge>
288263
</td>
289264
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
290265
{formatDate(String(script.installation_date))}

src/app/_components/ScriptCard.tsx

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useState } from 'react';
44
import Image from 'next/image';
55
import type { ScriptCard } from '~/types/script';
6+
import { TypeBadge, UpdateableBadge } from './Badge';
67

78
interface ScriptCardProps {
89
script: ScriptCard;
@@ -49,20 +50,8 @@ export function ScriptCard({ script, onClick }: ScriptCardProps) {
4950
<div className="mt-2 space-y-2">
5051
{/* Type and Updateable status on first row */}
5152
<div className="flex items-center space-x-2">
52-
<span className={`inline-flex items-center px-2 py-1 rounded text-xs font-medium ${
53-
script.type === 'ct'
54-
? 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200'
55-
: script.type === 'addon'
56-
? 'bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200'
57-
: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200'
58-
}`}>
59-
{script.type?.toUpperCase() || 'UNKNOWN'}
60-
</span>
61-
{script.updateable && (
62-
<span className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-amber-100 dark:bg-amber-900 text-amber-800 dark:text-amber-200">
63-
Updateable
64-
</span>
65-
)}
53+
<TypeBadge type={script.type ?? 'unknown'} />
54+
{script.updateable && <UpdateableBadge />}
6655
</div>
6756

6857
{/* Download Status */}

src/app/_components/ScriptDetailModal.tsx

Lines changed: 6 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { Script } from "~/types/script";
77
import { DiffViewer } from "./DiffViewer";
88
import { TextViewer } from "./TextViewer";
99
import { ExecutionModeModal } from "./ExecutionModeModal";
10+
import { TypeBadge, UpdateableBadge, PrivilegedBadge, NoteBadge } from "./Badge";
1011

1112
interface ScriptDetailModalProps {
1213
script: Script | null;
@@ -159,25 +160,9 @@ export function ScriptDetailModal({
159160
{script.name}
160161
</h2>
161162
<div className="mt-1 flex items-center space-x-2">
162-
<span
163-
className={`inline-flex items-center rounded-full px-3 py-1 text-sm font-medium ${
164-
script.type === "ct"
165-
? "bg-blue-100 text-blue-800"
166-
: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200"
167-
}`}
168-
>
169-
{script.type.toUpperCase()}
170-
</span>
171-
{script.updateable && (
172-
<span className="inline-flex items-center rounded-full bg-green-100 px-3 py-1 text-sm font-medium text-green-800">
173-
Updateable
174-
</span>
175-
)}
176-
{script.privileged && (
177-
<span className="inline-flex items-center rounded-full bg-red-100 px-3 py-1 text-sm font-medium text-red-800">
178-
Privileged
179-
</span>
180-
)}
163+
<TypeBadge type={script.type} />
164+
{script.updateable && <UpdateableBadge />}
165+
{script.privileged && <PrivilegedBadge />}
181166
</div>
182167
</div>
183168
</div>
@@ -677,17 +662,9 @@ export function ScriptDetailModal({
677662
}`}
678663
>
679664
<div className="flex items-start">
680-
<span
681-
className={`mr-2 inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
682-
noteType === "warning"
683-
? "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200"
684-
: noteType === "error"
685-
? "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
686-
: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
687-
}`}
688-
>
665+
<NoteBadge noteType={noteType as 'info' | 'warning' | 'error'} className="mr-2 flex-shrink-0">
689666
{noteType}
690-
</span>
667+
</NoteBadge>
691668
<span>{noteText}</span>
692669
</div>
693670
</li>

src/app/layout.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,22 @@ export default function RootLayout({
4343
} else {
4444
document.documentElement.classList.remove('dark');
4545
}
46-
} catch (e) {}
46+
} catch (e) {
47+
// Fallback to system preference if localStorage fails
48+
const systemDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
49+
if (systemDark) {
50+
document.documentElement.classList.add('dark');
51+
}
52+
}
4753
})();
4854
`,
4955
}}
5056
/>
5157
</head>
52-
<body className="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 transition-colors">
58+
<body
59+
className="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 transition-colors"
60+
suppressHydrationWarning={true}
61+
>
5362
<DarkModeProvider>
5463
{/* Dark Mode Toggle in top right corner */}
5564
<div className="fixed top-4 right-4 z-50">

0 commit comments

Comments
 (0)