feat: Add theme and dark mode

This commit is contained in:
shiyu
2025-09-08 15:20:49 +08:00
parent f40ff4d751
commit d41b72d0ce
22 changed files with 525 additions and 100 deletions

View File

@@ -35,7 +35,7 @@ function renderExif(exif: Record<string, any>) {
if (items.length === 0) {
return (
<div style={{ textAlign: 'center', padding: 24, color: '#999' }}>
<div style={{ textAlign: 'center', padding: 24, color: 'var(--ant-color-text-tertiary, #999)' }}>
<InfoCircleOutlined style={{ fontSize: 20, marginBottom: 8 }} />
<div>EXIF信息</div>
</div>
@@ -49,11 +49,11 @@ function renderExif(exif: Record<string, any>) {
bordered
items={items.map(item => ({
key: item.key,
label: <span style={{ fontWeight: 500, color: '#595959' }}>{item.label}</span>,
children: <span style={{ color: '#262626' }}>{item.value}</span>
label: <span style={{ fontWeight: 500, color: 'var(--ant-color-text-secondary, #595959)' }}>{item.label}</span>,
children: <span style={{ color: 'var(--ant-color-text, #262626)' }}>{item.value}</span>
}))}
contentStyle={{ padding: '8px 12px' }}
labelStyle={{ padding: '8px 12px', backgroundColor: '#fafafa', width: '30%' }}
labelStyle={{ padding: '8px 12px', backgroundColor: 'var(--ant-color-fill-tertiary, #fafafa)', width: '30%' }}
/>
);
}

View File

@@ -17,11 +17,25 @@ import {
FontSizeOutlined,
} from '@ant-design/icons';
export const getFileIcon = (fileName: string, size: number = 16) => {
const lightenColor = (hex: string, amount: number) => {
const s = hex.replace('#', '');
const n = s.length === 3 ? s.split('').map(c => c + c).join('') : s;
const num = parseInt(n, 16);
if (Number.isNaN(num) || n.length !== 6) return hex;
const r = (num >> 16) & 255;
const g = (num >> 8) & 255;
const b = num & 255;
const mix = (c: number) => Math.round(c + (255 - c) * amount);
const toHex = (v: number) => v.toString(16).padStart(2, '0');
return `#${toHex(mix(r))}${toHex(mix(g))}${toHex(mix(b))}`;
};
export const getFileIcon = (fileName: string, size: number = 16, resolvedMode: 'light' | 'dark' | 'system' = 'light') => {
const ext = fileName.split('.').pop()?.toLowerCase() || '';
const iconStyle: React.CSSProperties = { fontSize: size, marginRight: size === 16 ? 6 : 0 };
const make = (node: React.ReactNode, color: string) => React.cloneElement(node as any, { style: { ...iconStyle, color } });
const adj = (color: string) => (resolvedMode === 'dark' ? lightenColor(color, 0.3) : color);
const make = (node: React.ReactNode, color: string) => React.cloneElement(node as any, { style: { ...iconStyle, color: adj(color) } });
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'tiff'].includes(ext)) return make(<FileImageOutlined />, '#52c41a');
if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm', 'm4v', '3gp'].includes(ext)) return make(<VideoCameraOutlined />, '#fa541c');

View File

@@ -4,6 +4,7 @@ import { FolderFilled, MoreOutlined, EditOutlined, DeleteOutlined, AppstoreOutli
import type { VfsEntry } from '../../../api/client';
import { getFileIcon } from './FileIcons';
import { getAppsForEntry, getDefaultAppForEntry } from '../../../apps/registry';
import { useTheme } from '../../../contexts/ThemeContext';
interface FileListViewProps {
entries: VfsEntry[];
@@ -31,6 +32,19 @@ export const FileListView: React.FC<FileListViewProps> = ({
onContextMenu,
}) => {
const { token } = theme.useToken();
const { resolvedMode } = useTheme();
const lightenColor = (hex: string, amount: number) => {
const s = hex.replace('#', '');
const n = s.length === 3 ? s.split('').map(c => c + c).join('') : s;
const num = parseInt(n, 16);
if (Number.isNaN(num) || n.length !== 6) return hex;
const r = (num >> 16) & 255;
const g = (num >> 8) & 255;
const b = num & 255;
const mix = (c: number) => Math.round(c + (255 - c) * amount);
const toHex = (v: number) => v.toString(16).padStart(2, '0');
return `#${toHex(mix(r))}${toHex(mix(g))}${toHex(mix(b))}`;
};
const columns = [
{
@@ -40,9 +54,9 @@ export const FileListView: React.FC<FileListViewProps> = ({
render: (_: any, r: VfsEntry) => (
<span style={{ cursor: 'pointer', userSelect: 'none' }} onDoubleClick={() => onOpen(r)}>
{r.is_dir ? (
<FolderFilled style={{ color: token.colorPrimary, marginRight: 6 }} />
<FolderFilled style={{ color: resolvedMode === 'dark' ? lightenColor(String(token.colorPrimary || '#111111'), 0.72) : token.colorPrimary, marginRight: 6 }} />
) : (
getFileIcon(r.name, 16)
getFileIcon(r.name, 16, resolvedMode)
)}
{r.name}
{r.type === 'mount' && <Tooltip title="挂载点"><span style={{ marginLeft: 6, fontSize: 10, padding: '0 4px', border: `1px solid ${token.colorBorderSecondary}`, borderRadius: 4 }}>MOUNT</span></Tooltip>}
@@ -105,4 +119,4 @@ export const FileListView: React.FC<FileListViewProps> = ({
}}
/>
);
};
};

View File

@@ -4,6 +4,7 @@ import { FolderFilled, PictureOutlined } from '@ant-design/icons';
import type { VfsEntry } from '../../../api/client';
import { getFileIcon } from './FileIcons';
import { EmptyState } from './EmptyState';
import { useTheme } from '../../../contexts/ThemeContext';
interface Props {
entries: VfsEntry[];
@@ -26,6 +27,28 @@ const formatSize = (size: number) => {
export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, loading, path, onSelect, onSelectRange, onOpen, onContextMenu }) => {
const { token } = theme.useToken();
const { resolvedMode } = useTheme();
const lightenColor = (hex: string, amount: number) => {
const parseHex = (h: string) => {
const s = h.replace('#', '');
const n = s.length === 3 ? s.split('').map(c => c + c).join('') : s;
const num = parseInt(n, 16);
if (Number.isNaN(num) || n.length !== 6) return null;
return {
r: (num >> 16) & 255,
g: (num >> 8) & 255,
b: num & 255,
};
};
const rgb = parseHex(hex);
if (!rgb) return hex;
const mix = (c: number) => Math.round(c + (255 - c) * amount);
const r = mix(rgb.r);
const g = mix(rgb.g);
const b = mix(rgb.b);
const toHex = (v: number) => v.toString(16).padStart(2, '0');
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
};
const containerRef = useRef<HTMLDivElement | null>(null);
const itemRefs = useRef<Record<string, HTMLDivElement | null>>({});
const startRef = useRef<{ x: number, y: number } | null>(null);
@@ -111,9 +134,24 @@ export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, lo
onContextMenu={(e) => onContextMenu(e, ent)}
style={{ userSelect: 'none' }}
>
<div className="thumb" style={{ background: ent.is_dir ? 'linear-gradient(#fafafa,#f2f2f2)' : '#fff' }}>
{ent.is_dir && <FolderFilled style={{ fontSize: 32, color: token.colorPrimary }} />}
{!ent.is_dir && (isImg ? <img src={isImg} alt={ent.name} style={{ maxWidth: '100%', maxHeight: '100%' }} /> : isPictureType ? <PictureOutlined style={{ fontSize: 32, color: '#8c8c8c' }} /> : getFileIcon(ent.name, 32))}
<div className="thumb" style={{ background: 'var(--ant-color-bg-container, #fff)' }}>
{ent.is_dir && (
<FolderFilled
style={{
fontSize: 32,
color: resolvedMode === 'dark' ? lightenColor(String(token.colorPrimary || '#111111'), 0.72) : token.colorPrimary,
}}
/>
)}
{!ent.is_dir && (
isImg ? (
<img src={isImg} alt={ent.name} style={{ maxWidth: '100%', maxHeight: '100%' }} />
) : isPictureType ? (
<PictureOutlined style={{ fontSize: 32, color: resolvedMode === 'dark' ? lightenColor(String(token.colorPrimary || '#111111'), 0.72) : 'var(--ant-color-text-tertiary, #8c8c8c)' }} />
) : (
getFileIcon(ent.name, 32, resolvedMode)
)
)}
{ent.type === 'mount' && <span className="badge">M</span>}
</div>
<Tooltip title={ent.name}><div className="name ellipsis" style={{ userSelect: 'none' }}>{ent.name}</div></Tooltip>
@@ -129,8 +167,8 @@ export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, lo
top: rect.top,
width: rect.width,
height: rect.height,
border: '1px dashed rgba(0,0,0,0.4)',
background: 'rgba(0, 120, 212, 0.08)',
border: '1px dashed var(--ant-color-border, rgba(0,0,0,0.4))',
background: 'var(--ant-color-primary-bg, rgba(0, 120, 212, 0.08))',
zIndex: 999
}}
/>

View File

@@ -32,7 +32,7 @@ const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onSt
case 'success':
return (
<Flex align="center" gap={8}>
<CheckCircleFilled style={{ color: '#52c41a' }} />
<CheckCircleFilled style={{ color: 'var(--ant-color-success, #52c41a)' }} />
<Typography.Text type="secondary" style={{ verticalAlign: 'middle' }}></Typography.Text>
<Button icon={<CopyOutlined />} size="small" onClick={() => handleCopy(file.permanentLink!)} type="text" />
</Flex>
@@ -40,7 +40,7 @@ const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onSt
case 'error':
return (
<Flex align="center" gap={8}>
<CloseCircleFilled style={{ color: '#ff4d4f' }} />
<CloseCircleFilled style={{ color: 'var(--ant-color-error, #ff4d4f)' }} />
<Typography.Text type="danger" title={file.error}></Typography.Text>
</Flex>
);
@@ -71,7 +71,7 @@ const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onSt
borderRadius: 8,
transition: 'background-color 0.2s',
}}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#f0f0f0'; }}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = 'var(--ant-color-fill-tertiary, #f0f0f0)'; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; }}
>
<Flex justify="space-between" align="center" style={{ width: '100%' }}>
@@ -89,4 +89,4 @@ const UploadModal: React.FC<UploadModalProps> = ({ visible, files, onClose, onSt
);
};
export default UploadModal;
export default UploadModal;