From 998ac641f9f871351f1065a01ef9083f733ef628 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Sun, 1 Mar 2026 13:08:58 +0100 Subject: [PATCH] refactor: externalize shared primitives, remove shadcn mixing in tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shared components (components/ui/): - slider-row.tsx: SliderRow — label + display value + Slider, replaces inline slider blocks in FileConverter, QROptions - color-input.tsx: ColorInput — color swatch + hex text input pair, replaces repeated inline patterns in QROptions, KeyframeProperties, FaviconGenerator Media tool (FileConverter.tsx): - Remove all shadcn Select/SelectTrigger/SelectContent/SelectItem - Replace with native setProp('backgroundColor', e.target.value)} - disabled={!hasBg} - className={cn('w-8 h-8 rounded-lg cursor-pointer border border-border/40 bg-transparent shrink-0 p-0.5', !hasBg && 'opacity-30 cursor-not-allowed')} - /> - setProp('backgroundColor', e.target.value)} - disabled={!hasBg} - placeholder="none" - className="flex-1 bg-transparent border border-border/40 rounded-lg px-3 py-1.5 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 placeholder:text-muted-foreground/30 disabled:opacity-30" - /> - + setProp('backgroundColor', v)} + disabled={!hasBg} + /> setProp('borderRadius', v)} /> diff --git a/components/favicon/FaviconGenerator.tsx b/components/favicon/FaviconGenerator.tsx index c999013..0246fde 100644 --- a/components/favicon/FaviconGenerator.tsx +++ b/components/favicon/FaviconGenerator.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import { Download, Loader2, Code2, Globe, Layout, FileImage } from 'lucide-react'; import { FaviconFileUpload } from './FaviconFileUpload'; +import { ColorInput } from '@/components/ui/color-input'; import { CodeSnippet } from './CodeSnippet'; import { generateFaviconSet } from '@/lib/favicon/faviconService'; import { downloadBlobsAsZip } from '@/lib/media/utils/fileUtils'; @@ -145,37 +146,17 @@ export function FaviconGenerator() {
-
- setOptions({ ...options, backgroundColor: e.target.value })} - className="w-8 h-8 rounded-lg cursor-pointer border border-border/40 bg-transparent shrink-0 p-0.5" - /> - setOptions({ ...options, backgroundColor: e.target.value })} - className={cn(inputCls, 'py-1')} - /> -
+ setOptions({ ...options, backgroundColor: v })} + />
-
- setOptions({ ...options, themeColor: e.target.value })} - className="w-8 h-8 rounded-lg cursor-pointer border border-border/40 bg-transparent shrink-0 p-0.5" - /> - setOptions({ ...options, themeColor: e.target.value })} - className={cn(inputCls, 'py-1')} - /> -
+ setOptions({ ...options, themeColor: v })} + />
diff --git a/components/media/ConversionOptions.tsx b/components/media/ConversionOptions.tsx deleted file mode 100644 index e20acfb..0000000 --- a/components/media/ConversionOptions.tsx +++ /dev/null @@ -1,271 +0,0 @@ -'use client'; - -import * as React from 'react'; -import { Slider } from '@/components/ui/slider'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import type { ConversionOptions, ConversionFormat } from '@/types/media'; - -interface ConversionOptionsProps { - inputFormat: ConversionFormat; - outputFormat: ConversionFormat; - options: ConversionOptions; - onOptionsChange: (options: ConversionOptions) => void; - disabled?: boolean; -} - -export function ConversionOptionsPanel({ - inputFormat, - outputFormat, - options, - onOptionsChange, - disabled = false, -}: ConversionOptionsProps) { - const [isExpanded, setIsExpanded] = React.useState(false); - - const handleOptionChange = (key: string, value: any) => { - onOptionsChange({ ...options, [key]: value }); - }; - - const renderVideoOptions = () => ( -
- {/* Video Codec */} -
- - -
- - {/* Video Bitrate */} -
-
- - {options.videoBitrate || '2M'} -
- handleOptionChange('videoBitrate', `${vals[0]}M`)} - disabled={disabled} - /> -

Higher bitrate = better quality, larger file

-
- - {/* Resolution */} -
- - -
- - {/* FPS */} -
- - -
- - {/* Audio Bitrate */} -
-
- - {options.audioBitrate || '128k'} -
- handleOptionChange('audioBitrate', `${vals[0]}k`)} - disabled={disabled} - /> -
-
- ); - - const renderAudioOptions = () => ( -
- {/* Audio Codec */} -
- - -
- - {/* Bitrate */} -
-
- - {options.audioBitrate || '192k'} -
- handleOptionChange('audioBitrate', `${vals[0]}k`)} - disabled={disabled} - /> -
- - {/* Sample Rate */} -
- - -
- - {/* Channels */} -
- - -
-
- ); - - const renderImageOptions = () => ( -
- {/* Quality */} -
-
- - {options.imageQuality || 85}% -
- handleOptionChange('imageQuality', vals[0])} - disabled={disabled} - /> -
- - {/* Width */} -
- - handleOptionChange('imageWidth', e.target.value ? parseInt(e.target.value) : undefined)} - placeholder="Original" - disabled={disabled} - /> -

Leave empty to keep original

-
- - {/* Height */} -
- - handleOptionChange('imageHeight', e.target.value ? parseInt(e.target.value) : undefined)} - placeholder="Original" - disabled={disabled} - /> -

Leave empty to maintain aspect ratio

-
-
- ); - - return ( - <> - {outputFormat.category === 'video' && renderVideoOptions()} - {outputFormat.category === 'audio' && renderAudioOptions()} - {outputFormat.category === 'image' && renderImageOptions()} - - ); -} diff --git a/components/media/FileConverter.tsx b/components/media/FileConverter.tsx index 4da4937..d8dac52 100644 --- a/components/media/FileConverter.tsx +++ b/components/media/FileConverter.tsx @@ -1,14 +1,7 @@ 'use client'; import * as React from 'react'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { Slider } from '@/components/ui/slider'; +import { SliderRow } from '@/components/ui/slider-row'; import { FileUpload } from './FileUpload'; import { ConversionPreview } from './ConversionPreview'; import { toast } from 'sonner'; @@ -30,6 +23,9 @@ type MobileTab = 'upload' | 'convert'; const actionBtn = 'flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs glass rounded-md border border-border/30 text-muted-foreground hover:text-primary hover:border-primary/30 hover:bg-primary/10 transition-all disabled:opacity-40 disabled:cursor-not-allowed'; +const selectCls = + 'w-full bg-transparent border border-border/40 rounded-lg px-2.5 py-1.5 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 cursor-pointer disabled:opacity-40'; + export function FileConverter() { const [selectedFiles, setSelectedFiles] = React.useState([]); const [inputFormat, setInputFormat] = React.useState(); @@ -58,7 +54,6 @@ export function FileConverter() { setCompatibleFormats(compat); if (compat.length > 0 && !outputFormat) setOutputFormat(compat[0]); toast.success(`Detected: ${fmt.name} · ${selectedFiles.length} file${selectedFiles.length > 1 ? 's' : ''}`); - // Auto-advance to convert tab on mobile setMobileTab('convert'); } else { toast.error('Could not detect file format'); @@ -208,7 +203,7 @@ export function FileConverter() { {/* ── Main layout ─────────────────────────────────────── */}
{/* Left: upload zone */} @@ -246,7 +241,6 @@ export function FileConverter() { mobileTab !== 'convert' && 'hidden lg:flex' )} > - {/* Options panel */} {inputFormat && compatibleFormats.length > 0 ? (
{/* Detected format */} @@ -290,90 +284,70 @@ export function FileConverter() { <>
Video Codec - + + + + + +
-
-
- Video Bitrate - {conversionOptions.videoBitrate || '2M'} -
- setOpt({ videoBitrate: `${v[0]}M` })} - disabled={isConverting} - /> -
+ setOpt({ videoBitrate: `${v}M` })} + disabled={isConverting} + />
Resolution - + + + + + +
FPS - + + + + + +
-
-
- Audio Bitrate - {conversionOptions.audioBitrate || '128k'} -
- setOpt({ audioBitrate: `${v[0]}k` })} - disabled={isConverting} - /> -
+ setOpt({ audioBitrate: `${v}k` })} + disabled={isConverting} + /> )} @@ -382,73 +356,57 @@ export function FileConverter() { <>
Codec - + + + + + + +
-
-
- Bitrate - {conversionOptions.audioBitrate || '192k'} -
- setOpt({ audioBitrate: `${v[0]}k` })} - disabled={isConverting} - /> -
+ setOpt({ audioBitrate: `${v}k` })} + disabled={isConverting} + />
Sample Rate - + + + + +
Channels - + + + +
@@ -457,18 +415,14 @@ export function FileConverter() { {/* Image options */} {outputFormat.category === 'image' && ( <> -
-
- Quality - {conversionOptions.imageQuality ?? 85}% -
- setOpt({ imageQuality: v[0] })} - disabled={isConverting} - /> -
+ setOpt({ imageQuality: v })} + disabled={isConverting} + />
{(['imageWidth', 'imageHeight'] as const).map((key) => ( @@ -496,11 +450,7 @@ export function FileConverter() {
-
- onBackgroundColorChange(e.target.value)} - className={cn( - 'w-8 h-8 rounded-lg cursor-pointer border border-border/40 bg-transparent shrink-0 p-0.5 transition-opacity', - isTransparent && 'opacity-30 cursor-not-allowed' - )} - /> - onBackgroundColorChange(e.target.value)} - className={cn(inputCls, isTransparent && 'opacity-30')} - /> -
+
{/* Margin */} -
-
- - Margin - - {margin} -
- onMarginChange(v)} - min={0} - max={8} - step={1} - /> -
+ ); } diff --git a/components/ui/color-input.tsx b/components/ui/color-input.tsx new file mode 100644 index 0000000..ca2fd43 --- /dev/null +++ b/components/ui/color-input.tsx @@ -0,0 +1,39 @@ +import { cn } from '@/lib/utils/cn'; + +interface ColorInputProps { + value: string; + onChange: (color: string) => void; + disabled?: boolean; + className?: string; +} + +/** + * Colour swatch (type="color") + hex text input pair. + * Renders them in a flex row at equal height. Disabled state dims both inputs. + */ +export function ColorInput({ value, onChange, disabled, className }: ColorInputProps) { + return ( +
+ onChange(e.target.value)} + disabled={disabled} + className={cn( + 'w-8 h-8 rounded-lg cursor-pointer border border-border/40 bg-transparent shrink-0 p-0.5 transition-opacity', + disabled && 'opacity-30 cursor-not-allowed' + )} + /> + onChange(e.target.value)} + disabled={disabled} + className={cn( + 'flex-1 bg-transparent border border-border/40 rounded-lg px-3 py-1.5 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 placeholder:text-muted-foreground/30', + disabled && 'opacity-30' + )} + /> +
+ ); +} diff --git a/components/ui/slider-row.tsx b/components/ui/slider-row.tsx new file mode 100644 index 0000000..dd6d3ab --- /dev/null +++ b/components/ui/slider-row.tsx @@ -0,0 +1,37 @@ +import { Slider } from '@/components/ui/slider'; + +interface SliderRowProps { + label: string; + display: string; + value: number; + min: number; + max: number; + step?: number; + onChange: (v: number) => void; + disabled?: boolean; +} + +/** + * Shared label+display header + Slider. + * For the keyframe editor's slider+number-input variant, use the local SliderRow in KeyframeProperties.tsx. + */ +export function SliderRow({ label, display, value, min, max, step = 1, onChange, disabled }: SliderRowProps) { + return ( +
+
+ + {label} + + {display} +
+ onChange(v)} + disabled={disabled} + /> +
+ ); +}