Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/components/ui/CopyButton.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { useState } from 'react';
import { Check, Copy } from 'lucide-react';
import { Button } from './Button';
import { useUi } from '@/i18n/shared';

interface CopyButtonProps {
value: string;
label?: string;
disabled?: boolean;
}

export function CopyButton({ value, label = 'Copy', disabled }: CopyButtonProps) {
export function CopyButton({ value, label, disabled }: CopyButtonProps) {
const ui = useUi();
const [copied, setCopied] = useState(false);

const handleCopy = async () => {
Expand All @@ -25,7 +27,7 @@ export function CopyButton({ value, label = 'Copy', disabled }: CopyButtonProps)
return (
<Button variant="secondary" onClick={handleCopy} disabled={disabled || !value}>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
{copied ? 'Copied' : label}
{copied ? ui.copied : (label ?? ui.copy)}
</Button>
);
}
4 changes: 3 additions & 1 deletion src/components/ui/CopyImageButton.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { Check, Copy } from 'lucide-react';
import { clipboardService } from '@/services/clipboard.service';
import { useUi } from '@/i18n/shared';
import { Button } from './Button';

interface CopyImageButtonProps {
Expand All @@ -11,6 +12,7 @@ interface CopyImageButtonProps {

/** Copies an image to the clipboard, with brief "Copied" / error feedback. */
export function CopyImageButton({ blob, disabled }: CopyImageButtonProps) {
const ui = useUi();
const [state, setState] = useState<'idle' | 'copied' | 'error'>('idle');
// Only decide support after mount so SSR and the first client render match
// (clipboardService.supported is false on the server, true in the browser).
Expand All @@ -35,7 +37,7 @@ export function CopyImageButton({ blob, disabled }: CopyImageButtonProps) {
return (
<Button variant="secondary" onClick={handleCopy} disabled={disabled || !blob}>
{state === 'copied' ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
{state === 'copied' ? 'Copied' : state === 'error' ? 'Copy failed' : 'Copy image'}
{state === 'copied' ? ui.copied : state === 'error' ? ui.copyFailed : ui.copyImage}
</Button>
);
}
6 changes: 4 additions & 2 deletions src/components/ui/DownloadTextButton.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Download } from 'lucide-react';
import { downloadService } from '@/services/download';
import { useUi } from '@/i18n/shared';
import { Button } from './Button';

interface DownloadTextButtonProps {
Expand All @@ -10,12 +11,13 @@ interface DownloadTextButtonProps {
}

/** Download a string as a file (client-side, via the shared download service). */
export function DownloadTextButton({ text, filename, mime = 'text/plain;charset=utf-8', label = 'Download' }: DownloadTextButtonProps) {
export function DownloadTextButton({ text, filename, mime = 'text/plain;charset=utf-8', label }: DownloadTextButtonProps) {
const ui = useUi();
const onClick = () => downloadService.download(new Blob([text], { type: mime }), filename);
return (
<Button variant="secondary" onClick={onClick} disabled={!text}>
<Download className="h-4 w-4" />
{label}
{label ?? ui.download}
</Button>
);
}
4 changes: 3 additions & 1 deletion src/components/ui/Dropzone.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useCallback, useState } from 'react';
import { useUi } from '@/i18n/shared';

export interface DropzoneProps {
onDrop: (files: File[]) => void | Promise<void>;
Expand All @@ -8,6 +9,7 @@ export interface DropzoneProps {
}

export function Dropzone({ onDrop, accept, multiple = true, children }: DropzoneProps) {
const ui = useUi();
const [isDragging, setIsDragging] = useState(false);

const handleDragOver = useCallback((e: React.DragEvent) => {
Expand Down Expand Up @@ -41,7 +43,7 @@ export function Dropzone({ onDrop, accept, multiple = true, children }: Dropzone
className={`block cursor-pointer border-[3px] border-dashed p-8 text-center transition-all ${isDragging ? 'border-accent bg-accent/10 shadow-brutal' : 'border-border hover:shadow-brutal'}`}
>
<input type="file" id="file-input" accept={accept} multiple={multiple} onChange={handleFileInput} className="hidden" />
{children || <p>Drop files here or click to browse</p>}
{children || <p>{ui.dropzone}</p>}
</label>
);
}
4 changes: 3 additions & 1 deletion src/components/ui/EditInAnnotatorButton.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { PenTool } from 'lucide-react';
import { sendImageToAnnotator } from '@/services/handoff';
import { useUi } from '@/i18n/shared';
import { Button } from './Button';

interface EditInAnnotatorButtonProps {
Expand All @@ -11,6 +12,7 @@ interface EditInAnnotatorButtonProps {

/** Opens the Image Annotator pre-loaded with this image (via IndexedDB handoff). */
export function EditInAnnotatorButton({ blob, filename = 'image.png', disabled }: EditInAnnotatorButtonProps) {
const ui = useUi();
const handleClick = async () => {
if (!blob) return;
const resolved = typeof blob === 'function' ? await blob() : blob;
Expand All @@ -20,7 +22,7 @@ export function EditInAnnotatorButton({ blob, filename = 'image.png', disabled }
return (
<Button variant="secondary" onClick={handleClick} disabled={disabled || !blob}>
<PenTool className="h-4 w-4" />
Edit in Annotator
{ui.editAnnotator}
</Button>
);
}
8 changes: 5 additions & 3 deletions src/components/ui/ImageResult.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { ResultActions } from './ResultActions';
import { useUi } from '@/i18n/shared';
import { formatBytes } from '@/tools/image/canvas.lib';

interface ImageResultProps {
Expand All @@ -11,6 +12,7 @@ interface ImageResultProps {

/** Shows an image result: preview, output size (and % change), download. */
export function ImageResult({ blob, filename, originalSize }: ImageResultProps) {
const ui = useUi();
const [url, setUrl] = useState('');

useEffect(() => {
Expand All @@ -25,7 +27,7 @@ export function ImageResult({ blob, filename, originalSize }: ImageResultProps)
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-3 text-sm">
<span className="font-bold uppercase tracking-wide text-muted-foreground">Result</span>
<span className="font-bold uppercase tracking-wide text-muted-foreground">{ui.result}</span>
<span className="font-mono">{formatBytes(blob.size)}</span>
{reduction !== null && (
<span
Expand All @@ -35,14 +37,14 @@ export function ImageResult({ blob, filename, originalSize }: ImageResultProps)
: 'font-bold text-red-600 dark:text-red-400'
}
>
{reduction >= 0 ? `−${reduction}% smaller` : `+${-reduction}% larger`}
{reduction >= 0 ? `−${reduction}% ${ui.smaller}` : `+${-reduction}% ${ui.larger}`}
</span>
)}
</div>
{url && (
<img
src={url}
alt="Result preview"
alt={ui.resultAlt}
className="max-h-[70vh] w-auto border-2 border-border bg-white"
/>
)}
Expand Down
6 changes: 4 additions & 2 deletions src/components/ui/LoadFileButton.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useRef } from 'react';
import { FileUp } from 'lucide-react';
import { useUi } from '@/i18n/shared';
import { Button } from './Button';

interface LoadFileButtonProps {
Expand All @@ -11,7 +12,8 @@ interface LoadFileButtonProps {
}

/** A small button that reads a text file from disk and hands back its contents. */
export function LoadFileButton({ onLoad, accept, label = 'Load file' }: LoadFileButtonProps) {
export function LoadFileButton({ onLoad, accept, label }: LoadFileButtonProps) {
const ui = useUi();
const ref = useRef<HTMLInputElement>(null);

const handle = async (e: React.ChangeEvent<HTMLInputElement>) => {
Expand All @@ -25,7 +27,7 @@ export function LoadFileButton({ onLoad, accept, label = 'Load file' }: LoadFile
<>
<Button variant="secondary" onClick={() => ref.current?.click()}>
<FileUp className="h-4 w-4" />
{label}
{label ?? ui.loadFile}
</Button>
<input ref={ref} type="file" accept={accept} onChange={handle} className="hidden" />
</>
Expand Down
4 changes: 3 additions & 1 deletion src/components/ui/ResultActions.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Download } from 'lucide-react';
import { downloadService } from '@/services/download';
import { useUi } from '@/i18n/shared';
import { Button } from './Button';
import { CopyImageButton } from './CopyImageButton';
import { EditInAnnotatorButton } from './EditInAnnotatorButton';
Expand All @@ -11,6 +12,7 @@ export interface ResultActionsProps {
}

export function ResultActions({ blob, filename, disabled }: ResultActionsProps) {
const ui = useUi();
const handleDownload = async () => {
if (!blob) return;
await downloadService.download(blob, filename);
Expand All @@ -22,7 +24,7 @@ export function ResultActions({ blob, filename, disabled }: ResultActionsProps)
<div className="flex flex-wrap gap-2">
<Button onClick={handleDownload} disabled={disabled || !blob}>
<Download className="h-4 w-4" />
Download {filename}
{ui.download} {filename}
</Button>
{isImage && <CopyImageButton blob={blob} disabled={disabled} />}
{isImage && <EditInAnnotatorButton blob={blob} filename={filename} disabled={disabled} />}
Expand Down
39 changes: 39 additions & 0 deletions src/i18n/shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { useEffect, useState } from 'react';
import type { Lang } from './config';

/**
* Client-side current-locale hook for shared components that aren't passed a `lang`
* prop. Reads the URL (/id/… → 'id') and re-checks on every view-transition
* navigation. SSR returns 'en'; it settles to the real locale on hydration.
*/
export function useLang(): Lang {
const [lang, setLang] = useState<Lang>('en');
useEffect(() => {
const detect = () => setLang(/^\/id(\/|$)/.test(location.pathname) ? 'id' : 'en');
detect();
document.addEventListener('astro:page-load', detect);
return () => document.removeEventListener('astro:page-load', detect);
}, []);
return lang;
}

/** Common strings shared across UI components (Copy, Download, Dropzone, …). */
const SHARED = {
en: {
copy: 'Copy', copied: 'Copied', copyImage: 'Copy image', copyFailed: 'Copy failed',
download: 'Download', loadFile: 'Load file', editAnnotator: 'Edit in Annotator',
dropzone: 'Drop files here or click to browse', result: 'Result', resultAlt: 'Result preview',
smaller: 'smaller', larger: 'larger',
},
id: {
copy: 'Salin', copied: 'Tersalin', copyImage: 'Salin gambar', copyFailed: 'Gagal menyalin',
download: 'Unduh', loadFile: 'Muat file', editAnnotator: 'Edit di Anotator',
dropzone: 'Letakkan file di sini atau klik untuk menelusuri', result: 'Hasil', resultAlt: 'Pratinjau hasil',
smaller: 'lebih kecil', larger: 'lebih besar',
},
} satisfies Record<Lang, Record<string, string>>;

/** Shared UI strings for the current locale. */
export function useUi() {
return SHARED[useLang()];
}
Loading