Develop (#55)
feat: optimize dashboard/search UX and simplify extension architecture Redesign Dashboard with compact tool grid and recently used tools Improve TopBar search UX with Cmd/Ctrl+K shortcut and better history navigation Reorganize project structure into src/ Migrate i18n from react-i18next to chrome.i18n Remove runtime language switch and settings page Remove HTML/Markdown conversion tools Clean up unused code, dead animations, redundant comments, and imports Improve component consistency with shadcn/ui patterns Replace hardcoded strings/colors with i18n tokens and theme tokens Add comprehensive project documentation and coding standards Fix CI artifact upload workflow and multiple TypeScript/test issues Includes various refactors, UI polish, i18n cleanup, CI improvements, and maintenance updates across the codebase.
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Clock } from 'lucide-react';
|
||||
import CopyButton from '@/components/CopyButton';
|
||||
import { useSnackbar } from '@/components/GlobalSnackbar';
|
||||
import type { UnitType } from './constants';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface LiveClockProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
unit: UnitType;
|
||||
onUseNow: (val: number) => void;
|
||||
}
|
||||
|
||||
const LiveClock = React.memo(({ unit, onUseNow, className, ...props }: LiveClockProps) => {
|
||||
const { t } = useI18n('timestamp');
|
||||
const { showMessage } = useSnackbar();
|
||||
const onUseNowRef = useRef(onUseNow);
|
||||
|
||||
const [currentDisplay, setCurrentDisplay] = useState(() => {
|
||||
const initNow = Date.now();
|
||||
return {
|
||||
rawTime: initNow,
|
||||
text: String(Math.floor(initNow / (unit === 'ms' ? 1 : 1000))),
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
onUseNowRef.current = onUseNow;
|
||||
}, [onUseNow]);
|
||||
|
||||
useEffect(() => {
|
||||
const tick = () => {
|
||||
const rightNow = Date.now();
|
||||
const nextText = String(Math.floor(rightNow / (unit === 'ms' ? 1 : 1000)));
|
||||
|
||||
setCurrentDisplay((prev) => {
|
||||
if (prev.text === nextText) return prev;
|
||||
return { rawTime: rightNow, text: nextText };
|
||||
});
|
||||
};
|
||||
|
||||
const tickId = setInterval(tick, 200);
|
||||
return () => clearInterval(tickId);
|
||||
}, [unit]);
|
||||
|
||||
const handleUseNow = useCallback(() => {
|
||||
onUseNowRef.current(currentDisplay.rawTime);
|
||||
showMessage?.(t('timestamp:usedSuccess'), { severity: 'success' });
|
||||
}, [currentDisplay.rawTime, showMessage, t]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 h-10 rounded-lg border border-border/80 bg-secondary/50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="text-muted-foreground font-bold text-[10px] uppercase tracking-wider whitespace-nowrap shrink-0 selection:bg-transparent select-none">
|
||||
{t('timestamp:currentTs')}
|
||||
</span>
|
||||
|
||||
<span className="flex-1 font-mono font-bold text-foreground text-sm tracking-tight leading-none truncate tabular-nums">
|
||||
{currentDisplay.text}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUseNow}
|
||||
title={t('timestamp:useNowTooltip')}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md border border-input bg-background text-muted-foreground shadow-sm hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
<CopyButton
|
||||
text={currentDisplay.text}
|
||||
tooltip={t('timestamp:copyTsTooltip')}
|
||||
className="h-7 w-7 rounded-md border"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
LiveClock.displayName = 'LiveClock';
|
||||
|
||||
export default LiveClock;
|
||||
@@ -0,0 +1,119 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import dayjs from '@/utils/dayjs';
|
||||
import CopyButton from '@/components/CopyButton';
|
||||
import type { UnitType } from './constants';
|
||||
import { DATE_FORMAT } from './constants';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ResultViewProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
result: string;
|
||||
mode: 'ts2dt' | 'dt2ts';
|
||||
unit: UnitType;
|
||||
zone: string;
|
||||
|
||||
showEmptyPlaceholder?: boolean;
|
||||
}
|
||||
|
||||
const ResultView = React.memo(
|
||||
({
|
||||
result,
|
||||
mode,
|
||||
unit,
|
||||
zone,
|
||||
showEmptyPlaceholder = false,
|
||||
className,
|
||||
...props
|
||||
}: ResultViewProps) => {
|
||||
const { t } = useI18n('timestamp');
|
||||
|
||||
const extraInfo = useMemo(() => {
|
||||
if (!result) return null;
|
||||
const d =
|
||||
mode === 'ts2dt'
|
||||
? dayjs(result, DATE_FORMAT).tz(zone)
|
||||
: unit === 'ms'
|
||||
? dayjs(Number(result))
|
||||
: dayjs.unix(Number(result));
|
||||
|
||||
return {
|
||||
relative: d.fromNow(),
|
||||
iso: d.toISOString(),
|
||||
utc: d.utc().format(DATE_FORMAT) + ' UTC',
|
||||
};
|
||||
}, [result, mode, zone, unit]);
|
||||
|
||||
if (!result) {
|
||||
if (!showEmptyPlaceholder) return null;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center text-sm font-medium border border-dashed border-border/60 rounded-xl py-12 px-4 text-center text-muted-foreground bg-muted/20 min-h-[320px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{t('timestamp:resultEmpty')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col w-full', className)} {...props}>
|
||||
{/* 顶部小标签 */}
|
||||
<span className="block text-muted-foreground/90 mb-2.5 text-xs font-semibold tracking-wider uppercase">
|
||||
{t('timestamp:resultLabel')}
|
||||
</span>
|
||||
|
||||
<div className="bg-card text-card-foreground border border-border p-4 sm:p-5 rounded-xl relative mb-3.5 shadow-sm flex justify-between items-center gap-4 focus-within:ring-1 focus-within:ring-ring">
|
||||
<span className="font-mono font-extrabold text-foreground break-all text-xl sm:text-2xl tracking-tight leading-tight select-all tabular-nums">
|
||||
{result}
|
||||
</span>
|
||||
<CopyButton
|
||||
text={result}
|
||||
tooltip={t('timestamp:copyResultTooltip')}
|
||||
className="h-8 w-8 rounded-md shrink-0 border"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/40 p-4 rounded-xl border border-border/50 flex flex-col gap-3">
|
||||
{[
|
||||
{ label: t('timestamp:relativeTime'), value: extraInfo?.relative },
|
||||
{ label: t('timestamp:iso8601'), value: extraInfo?.iso, isMono: true },
|
||||
{ label: t('timestamp:utcTime'), value: extraInfo?.utc, isMono: true },
|
||||
].map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-1.5 py-0.5 border-b border-border/30 last:border-0 pb-2 sm:pb-0 last:pb-0"
|
||||
>
|
||||
<span className="text-muted-foreground font-semibold text-xs shrink-0 select-none">
|
||||
{item.label}
|
||||
</span>
|
||||
<div className="flex items-center justify-between sm:justify-end gap-2 min-w-0 w-full sm:w-auto">
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs text-foreground/90 font-medium break-all text-left sm:text-right tabular-nums',
|
||||
item.isMono && 'font-mono text-[11px]',
|
||||
)}
|
||||
>
|
||||
{item.value}
|
||||
</span>
|
||||
{item.value && (
|
||||
<CopyButton
|
||||
text={item.value}
|
||||
tooltip={t('timestamp:copyTooltip')}
|
||||
className="h-6 w-6 rounded-md border shrink-0 text-muted-foreground"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ResultView.displayName = 'ResultView';
|
||||
|
||||
export default ResultView;
|
||||
@@ -0,0 +1,6 @@
|
||||
export const DATE_FORMAT = 'YYYY/MM/DD HH:mm:ss';
|
||||
|
||||
export const ZONES = ['Asia/Shanghai', 'America/New_York', 'Europe/London'] as const;
|
||||
|
||||
export type UnitType = 'ms' | 's';
|
||||
export type ZoneType = (typeof ZONES)[number];
|
||||
@@ -0,0 +1,109 @@
|
||||
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
||||
import { ZONES } from './constants';
|
||||
import LiveClock from './LiveClock';
|
||||
import ResultView from './ResultView';
|
||||
import { useTimestampConverter } from './useTimestampConverter';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
export default function Index() {
|
||||
const { t } = useI18n('timestamp');
|
||||
|
||||
const {
|
||||
mode,
|
||||
input,
|
||||
unit,
|
||||
zone,
|
||||
result,
|
||||
error,
|
||||
setMode,
|
||||
setInput,
|
||||
setUnit,
|
||||
setZone,
|
||||
handleUseNow,
|
||||
} = useTimestampConverter();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground antialiased selection:bg-primary/20">
|
||||
<div className="max-w-7xl mx-auto p-4 sm:p-6 space-y-4">
|
||||
<LiveClock unit={unit} onUseNow={handleUseNow} />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch">
|
||||
<div className="p-5 rounded-xl border border-border bg-card text-card-foreground shadow-sm flex flex-col justify-between">
|
||||
<div className="flex flex-col gap-4">
|
||||
<SwitchButtonGroup
|
||||
value={mode}
|
||||
options={[
|
||||
{ value: 'ts2dt', label: t('timestamp:tsToDate') },
|
||||
{ value: 'dt2ts', label: t('timestamp:dateToTs') },
|
||||
]}
|
||||
onChange={(newMode) => setMode(newMode as 'ts2dt' | 'dt2ts')}
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={
|
||||
mode === 'ts2dt' ? t('timestamp:placeholderTs') : t('timestamp:placeholderDate')
|
||||
}
|
||||
value={input}
|
||||
onChange={(e: { target: { value: string } }) => setInput(e.target.value)}
|
||||
className={cn(
|
||||
'font-mono font-semibold h-10 shadow-sm placeholder:text-muted-foreground/60 focus:bg-background',
|
||||
error && 'border-destructive focus-visible:ring-destructive',
|
||||
)}
|
||||
/>
|
||||
|
||||
{error && <p className="text-destructive text-xs font-medium px-0.5">{error}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-stretch gap-3 w-full">
|
||||
<SwitchButtonGroup
|
||||
value={unit}
|
||||
options={[
|
||||
{ value: 'ms', label: t('timestamp:unitMs') },
|
||||
{ value: 's', label: t('timestamp:unitS') },
|
||||
]}
|
||||
onChange={(v) => setUnit(v as 'ms' | 's')}
|
||||
size="small"
|
||||
className="sm:w-auto shrink-0"
|
||||
/>
|
||||
|
||||
<Select value={zone} onValueChange={(v: string) => setZone(v as typeof zone)}>
|
||||
<SelectTrigger className="flex-1 font-mono font-semibold h-9 shadow-sm bg-background">
|
||||
<SelectValue placeholder="选择时区" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-64 font-mono">
|
||||
{ZONES.map((z) => (
|
||||
<SelectItem
|
||||
key={z}
|
||||
value={z}
|
||||
className="text-xs font-semibold focus:bg-accent cursor-pointer"
|
||||
>
|
||||
{z}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-5 rounded-xl border border-border bg-card text-card-foreground shadow-sm h-full flex flex-col">
|
||||
<ResultView result={result} mode={mode} unit={unit} zone={zone} showEmptyPlaceholder />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import dayjs from '@/utils/dayjs';
|
||||
import type { UnitType, ZoneType } from './constants';
|
||||
import { DATE_FORMAT } from './constants';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
import { useContextMenuData } from '@/utils/useContextMenuData';
|
||||
|
||||
export interface UseTimestampConverterReturn {
|
||||
mode: 'ts2dt' | 'dt2ts';
|
||||
input: string;
|
||||
unit: UnitType;
|
||||
zone: ZoneType;
|
||||
result: string;
|
||||
error: string;
|
||||
|
||||
setMode: (mode: 'ts2dt' | 'dt2ts') => void;
|
||||
setInput: (value: string) => void;
|
||||
setUnit: (unit: UnitType) => void;
|
||||
setZone: (zone: ZoneType) => void;
|
||||
handleUseNow: (now: number) => void;
|
||||
}
|
||||
|
||||
function isTimestampLike(input: string): boolean {
|
||||
const trimmed = input.trim();
|
||||
return /^\d+$/.test(trimmed) && trimmed.length >= 10;
|
||||
}
|
||||
|
||||
export function useTimestampConverter(): UseTimestampConverterReturn {
|
||||
const { t } = useI18n('timestamp');
|
||||
const [mode, setMode] = useState<'ts2dt' | 'dt2ts'>('ts2dt');
|
||||
const [unit, setUnit] = useState<UnitType>('ms');
|
||||
const [zone, setZone] = useState<ZoneType>('Asia/Shanghai');
|
||||
|
||||
const [input, setInput] = useState(() => String(Date.now()));
|
||||
|
||||
const conversionPipeline = useMemo(() => {
|
||||
const rawInput = input.trim();
|
||||
if (!rawInput) return { result: '', error: '' };
|
||||
|
||||
if (mode === 'ts2dt') {
|
||||
const num = Number(rawInput);
|
||||
if (isNaN(num)) {
|
||||
return { result: '', error: t('timestamp:errors.invalidNumber') };
|
||||
}
|
||||
const d = unit === 'ms' ? dayjs(num) : dayjs.unix(num);
|
||||
if (!d.isValid()) {
|
||||
return { result: '', error: t('timestamp:errors.invalidTimestamp') };
|
||||
}
|
||||
return { result: d.tz(zone).format(DATE_FORMAT), error: '' };
|
||||
} else {
|
||||
const d = dayjs.tz(rawInput, DATE_FORMAT, zone);
|
||||
if (!d.isValid()) {
|
||||
return { result: '', error: t('timestamp:errors.invalidFormat') };
|
||||
}
|
||||
const ms = d.valueOf();
|
||||
const outputTs = unit === 'ms' ? String(ms) : String(Math.floor(ms / 1000));
|
||||
return { result: outputTs, error: '' };
|
||||
}
|
||||
}, [input, mode, unit, zone, t]);
|
||||
|
||||
const { result, error } = conversionPipeline;
|
||||
|
||||
const handleContextMenuData = useCallback((payload: string) => {
|
||||
const trimmed = payload.trim();
|
||||
if (isTimestampLike(trimmed)) {
|
||||
setMode('ts2dt');
|
||||
const detectedUnit: UnitType = trimmed.length >= 13 ? 'ms' : 's';
|
||||
setUnit(detectedUnit);
|
||||
setInput(trimmed);
|
||||
} else {
|
||||
const d = dayjs(trimmed);
|
||||
if (d.isValid()) {
|
||||
setMode('dt2ts');
|
||||
setInput(d.format(DATE_FORMAT));
|
||||
} else {
|
||||
setMode('ts2dt');
|
||||
setInput(trimmed);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useContextMenuData({ featureKey: 'timestamp', onData: handleContextMenuData });
|
||||
|
||||
const handleUseNow = useCallback(
|
||||
(now: number) => {
|
||||
if (mode === 'ts2dt') {
|
||||
setInput(String(unit === 'ms' ? now : Math.floor(now / 1000)));
|
||||
} else {
|
||||
setInput(dayjs(now).tz(zone).format(DATE_FORMAT));
|
||||
}
|
||||
},
|
||||
[mode, unit, zone],
|
||||
);
|
||||
|
||||
const handleSetMode = useCallback(
|
||||
(newMode: 'ts2dt' | 'dt2ts') => {
|
||||
setMode(newMode);
|
||||
if (result && !error) {
|
||||
setInput(result);
|
||||
}
|
||||
},
|
||||
[result, error],
|
||||
);
|
||||
|
||||
return {
|
||||
mode,
|
||||
input,
|
||||
unit,
|
||||
zone,
|
||||
result,
|
||||
error,
|
||||
setMode: handleSetMode,
|
||||
setInput,
|
||||
setUnit,
|
||||
setZone,
|
||||
handleUseNow,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user