From 97abf597ffc5f6979545bce9283d82aaee05af5d Mon Sep 17 00:00:00 2001 From: Willxup Date: Thu, 13 Aug 2026 17:46:30 +0800 Subject: [PATCH 1/6] feat: improve daily image management --- README.md | 4 +- README.zh.md | 4 +- web/src/components/ui/copy-link-control.tsx | 39 ++++++-- .../images/image-detail-page.test.tsx | 91 +++++++++++++++++++ web/src/features/images/image-detail-page.tsx | 44 ++++++++- .../features/images/image-list-page.test.tsx | 62 +++++++++++++ web/src/features/images/image-list-page.tsx | 44 ++++++++- .../migrations/migration-image-page.test.tsx | 19 ++++ .../migrations/migration-image-page.tsx | 16 +++- web/src/features/upload/upload-page.test.tsx | 63 ++++++++++++- web/src/features/upload/upload-page.tsx | 29 +++++- web/src/i18n/en-US.json | 10 ++ web/src/i18n/zh-CN.json | 10 ++ web/src/lib/image-links.ts | 4 + 14 files changed, 415 insertions(+), 24 deletions(-) create mode 100644 web/src/features/images/image-detail-page.test.tsx diff --git a/README.md b/README.md index ab297a6..783ef59 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ ImageSilo is a Docker-first image host built as one Go process with SQLite and l Docker is the supported production runtime. The following starts a local evaluation instance with a named volume: ```bash -export IMAGESILO_IMAGE=ghcr.io/willxup/imagesilo:v0.2.0 +export IMAGESILO_IMAGE=ghcr.io/willxup/imagesilo:latest docker pull "$IMAGESILO_IMAGE" docker volume create imagesilo-data @@ -87,7 +87,7 @@ Enable the capability in `docker-compose.yaml` and mount `/data/migrations` writ ```yaml services: imagesilo: - image: ghcr.io/willxup/imagesilo:v0.2.0 + image: ghcr.io/willxup/imagesilo:latest environment: IMAGESILO_MIGRATION_MUTATIONS: "true" volumes: diff --git a/README.zh.md b/README.zh.md index f49971b..81e8001 100644 --- a/README.zh.md +++ b/README.zh.md @@ -57,7 +57,7 @@ ImageSilo 是一个 Docker 优先的自托管图床:单个 Go 进程、SQLite Docker 是唯一支持的生产运行方式。以下命令使用 named volume 启动一个本地体验实例: ```bash -export IMAGESILO_IMAGE=ghcr.io/willxup/imagesilo:v0.2.0 +export IMAGESILO_IMAGE=ghcr.io/willxup/imagesilo:latest docker pull "$IMAGESILO_IMAGE" docker volume create imagesilo-data @@ -87,7 +87,7 @@ docker run --detach \ ```yaml services: imagesilo: - image: ghcr.io/willxup/imagesilo:v0.2.0 + image: ghcr.io/willxup/imagesilo:latest environment: IMAGESILO_MIGRATION_MUTATIONS: "true" volumes: diff --git a/web/src/components/ui/copy-link-control.tsx b/web/src/components/ui/copy-link-control.tsx index cf27fda..b2e560b 100644 --- a/web/src/components/ui/copy-link-control.tsx +++ b/web/src/components/ui/copy-link-control.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { readLocalStorage, writeLocalStorage } from '../../lib/browser-storage' -import { copyText, imageLinks, type LinkableImage, type LinkFormat } from '../../lib/image-links' +import { copyText, imageLinksText, type LinkableImage, type LinkFormat } from '../../lib/image-links' import { Button } from './button' import { DropdownItem, DropdownMenu } from './dropdown-menu' import { Icon, type IconName } from './icon' @@ -23,21 +23,42 @@ function initialFormat(): LinkFormat { } export function CopyLinkControl({ image, compact = false, onCopied }: { image: LinkableImage; compact?: boolean; onCopied?: (format: LinkFormat) => void }) { + return +} + +export function CopyLinksControl({ + images, + compact = false, + label, + ariaLabel, + onCopied, +}: { + images: readonly LinkableImage[] + compact?: boolean + label?: string + ariaLabel?: (formatLabel: string) => string + onCopied?: (format: LinkFormat) => void +}) { const { t } = useTranslation() const [format, setFormat] = useState(initialFormat) const [open, setOpen] = useState(false) const [copying, setCopying] = useState(false) - const links = imageLinks(image) + const count = images.length useEffect(() => { writeLocalStorage(storageKey, format) }, [format]) async function copy() { + if (count === 0) return setCopying(true) try { - await copyText(links[format]) - toast.success(t('toast.linkCopied', { format: t(`images.linkFormatShort.${format}`) })) + await copyText(imageLinksText(images, format)) + toast.success( + count === 1 + ? t('toast.linkCopied', { format: t(`images.linkFormatShort.${format}`) }) + : t('toast.linksCopied', { count, format: t(`images.linkFormatShort.${format}`) }), + ) onCopied?.(format) } catch { toast.error(t('toast.copyFailed')) @@ -53,12 +74,16 @@ export function CopyLinkControl({ image, compact = false, onCopied }: { image: L size={compact ? 'xs' : 'sm'} variant="outline" type="button" - disabled={copying} - aria-label={t('images.copySelectedFormat', { format: t(`images.linkFormatShort.${format}`) })} + disabled={copying || count === 0} + aria-label={ariaLabel?.(t(`images.linkFormatShort.${format}`)) ?? ( + count === 1 + ? t('images.copySelectedFormat', { format: t(`images.linkFormatShort.${format}`) }) + : t('images.copySelectedLinksFormat', { count, format: t(`images.linkFormatShort.${format}`) }) + )} onClick={() => void copy()} > - {t('common.copy')} + {label ?? t('common.copy')} ({ apiRequest: vi.fn() })) + +const imageId = '019c1234-5678-7abc-8def-0123456789ab' +const image = { + id: imageId, + originalName: 'sample.jpg', + mimeType: 'image/jpeg', + extension: '.jpg', + width: 800, + height: 600, + sourceSize: 1000, + storedSize: 900, + sourceSha256: 'a'.repeat(64), + storedSha256: 'b'.repeat(64), + processingSummary: { + action: 'preserve', + sourceFormat: 'jpeg', + storedFormat: 'jpeg', + preserved: true, + compressionEnabled: false, + conversionEnabled: false, + }, + visibility: 'public', + uploadedVia: 'admin', + standardUrl: `/image/${imageId}`, + thumbnailUrl: `/api/v1/images/${imageId}/thumbnail`, + createdAt: '2026-07-29T00:00:00Z', + aliases: [], +} as ImageDetail + +function renderPage() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) + render( + + + + } /> + + + , + ) +} + +describe('ImageDetailPage', () => { + afterEach(cleanup) + + beforeEach(() => { + vi.mocked(apiRequest).mockReset() + vi.mocked(apiRequest).mockImplementation(async (path) => { + if (String(path) === '/api/v1/aliases') { + return { + id: '019c1234-5678-7abc-8def-0123456789ac', + path: '/i/2026/08/sample.jpg', + imageId, + source: 'admin', + createdAt: '2026-08-13T00:00:00Z', + } as ImageAlias + } + return image + }) + }) + + it('creates a historical path directly for the current image', async () => { + renderPage() + await screen.findByRole('img', { name: 'sample.jpg' }) + + fireEvent.change(screen.getByLabelText('新历史路径'), { target: { value: ' /i/2026/08/sample.jpg ' } }) + fireEvent.click(screen.getByRole('button', { name: '添加历史路径' })) + + await waitFor(() => { + const creation = vi.mocked(apiRequest).mock.calls.find(([path]) => path === '/api/v1/aliases') + expect(creation?.[1]?.method).toBe('POST') + expect(JSON.parse(String(creation?.[1]?.body))).toEqual({ + path: '/i/2026/08/sample.jpg', + imageId, + source: 'admin', + }) + }) + await waitFor(() => expect(screen.getByLabelText('新历史路径')).toHaveValue('')) + }) +}) diff --git a/web/src/features/images/image-detail-page.tsx b/web/src/features/images/image-detail-page.tsx index 9d18bea..3826216 100644 --- a/web/src/features/images/image-detail-page.tsx +++ b/web/src/features/images/image-detail-page.tsx @@ -1,5 +1,5 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState, type FormEvent } from 'react' import { useTranslation } from 'react-i18next' import { useLocation, useNavigate, useParams } from 'react-router-dom' import { toast } from 'sonner' @@ -10,9 +10,10 @@ import { ComponentCard } from '../../components/ui/component-card' import { ConfirmDialog } from '../../components/ui/confirm-dialog' import { CopyLinkControl } from '../../components/ui/copy-link-control' import { Icon } from '../../components/ui/icon' +import { Input } from '../../components/ui/input' import { apiRequest } from '../../lib/api-client' import { formatBytes } from '../../lib/image-links' -import type { DeleteImageResult, ImageDetail, Visibility, WebPConversionResult } from '../../lib/api-types' +import type { DeleteImageResult, ImageAlias, ImageDetail, Visibility, WebPConversionResult } from '../../lib/api-types' export function ImageDetailPage() { const { t } = useTranslation() @@ -24,6 +25,7 @@ export function ImageDetailPage() { const [leaving, setLeaving] = useState(false) const [deleteOpen, setDeleteOpen] = useState(false) const [convertOpen, setConvertOpen] = useState(false) + const [aliasPath, setAliasPath] = useState('') useEffect(() => () => { if (returnTimer.current !== null) window.clearTimeout(returnTimer.current) @@ -73,6 +75,28 @@ export function ImageDetailPage() { }, onError: () => toast.error(t('toast.webpFailed'), { id: 'detail-conversion' }), }) + const aliasCreation = useMutation({ + mutationFn: (path: string) => + apiRequest('/api/v1/aliases', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path, imageId, source: 'admin' }), + }), + onMutate: () => toast.loading(t('images.aliasSaving'), { id: 'detail-alias' }), + onSuccess: async () => { + setAliasPath('') + await queryClient.invalidateQueries({ queryKey: ['image', imageId] }) + await queryClient.invalidateQueries({ queryKey: ['aliases'] }) + toast.success(t('toast.aliasCreated'), { id: 'detail-alias' }) + }, + onError: () => toast.error(t('images.aliasCreateFailed'), { id: 'detail-alias' }), + }) + + function createAlias(event: FormEvent) { + event.preventDefault() + const path = aliasPath.trim() + if (path) aliasCreation.mutate(path) + } if (query.isLoading) return

{t('common.loading')}

if (query.isError || !query.data) return

{t('images.detailFailed')}

@@ -113,6 +137,22 @@ export function ImageDetailPage() { +
+ + setAliasPath(event.target.value)} + placeholder="/i/2026/08/example.jpg" + maxLength={2048} + required + /> + +
{image.aliases.length === 0 ?

{t('images.noAliases')}

:
    {image.aliases.map((alias) =>
  • {alias.path}

    {alias.source}

  • )}
}
diff --git a/web/src/features/images/image-list-page.test.tsx b/web/src/features/images/image-list-page.test.tsx index 1ee33e2..4c00a8d 100644 --- a/web/src/features/images/image-list-page.test.tsx +++ b/web/src/features/images/image-list-page.test.tsx @@ -35,10 +35,21 @@ const image = { createdAt: '2026-07-29T00:00:00Z', } as Image +const secondImage = { + ...image, + id: '019c1234-5678-7abc-8def-0123456789ac', + originalName: 'second.png', + mimeType: 'image/png', + extension: '.png', + standardUrl: '/image/019c1234-5678-7abc-8def-0123456789ac', + thumbnailUrl: '/api/v1/images/019c1234-5678-7abc-8def-0123456789ac/thumbnail', +} as Image + describe('ImageListPage', () => { afterEach(cleanup) beforeEach(() => { + window.localStorage.clear() vi.mocked(apiRequest).mockReset() vi.mocked(apiRequest).mockResolvedValue({ items: [image] } as ImageList) }) @@ -96,4 +107,55 @@ describe('ImageListPage', () => { expect(new URL(String(matching?.[0]), 'http://imagesilo.test').searchParams.get('createdFrom')).toBe(expected) }) }) + + it('copies selected image links as one newline-delimited payload', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) + vi.mocked(apiRequest).mockResolvedValue({ items: [image, secondImage] } as ImageList) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render( + + + + + , + ) + + fireEvent.click(await screen.findByRole('checkbox', { name: '选择图片 sample.jpg' })) + fireEvent.click(screen.getByRole('checkbox', { name: '选择图片 second.png' })) + fireEvent.click(screen.getByRole('button', { name: '复制选中 2 张图片的直链' })) + + await waitFor(() => { + expect(writeText).toHaveBeenCalledWith( + 'http://localhost:3000/image/019c1234-5678-7abc-8def-0123456789ab\n' + + 'http://localhost:3000/image/019c1234-5678-7abc-8def-0123456789ac', + ) + }) + }) + + it('restores and updates local library preferences', async () => { + window.localStorage.setItem('imagesilo_image_view_mode', 'list') + window.localStorage.setItem('imagesilo_image_filters_open', 'true') + window.localStorage.setItem('imagesilo_image_advanced_filters_open', 'true') + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render( + + + + + , + ) + + await screen.findByRole('img', { name: 'sample.jpg' }) + expect(document.querySelector('.image-list')).toBeInTheDocument() + expect(screen.getByRole('button', { name: '高级筛选' })).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByRole('button', { name: '收起筛选' })).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: '网格' })) + fireEvent.click(screen.getByRole('button', { name: '收起筛选' })) + await waitFor(() => { + expect(window.localStorage.getItem('imagesilo_image_view_mode')).toBe('grid') + expect(window.localStorage.getItem('imagesilo_image_filters_open')).toBe('false') + }) + }) }) diff --git a/web/src/features/images/image-list-page.tsx b/web/src/features/images/image-list-page.tsx index 44bfcba..b2fb342 100644 --- a/web/src/features/images/image-list-page.tsx +++ b/web/src/features/images/image-list-page.tsx @@ -9,17 +9,22 @@ import { Button } from '../../components/ui/button' import { Card } from '../../components/ui/card' import { Checkbox } from '../../components/ui/checkbox' import { ConfirmDialog } from '../../components/ui/confirm-dialog' -import { CopyLinkControl } from '../../components/ui/copy-link-control' +import { CopyLinkControl, CopyLinksControl } from '../../components/ui/copy-link-control' import { DatePicker } from '../../components/ui/date-picker' import { Icon } from '../../components/ui/icon' import { Input } from '../../components/ui/input' import { Select } from '../../components/ui/select' import { apiRequest } from '../../lib/api-client' +import { readLocalStorage, writeLocalStorage } from '../../lib/browser-storage' import { formatBytes } from '../../lib/image-links' import type { BatchOperationResult, Image, ImageList, Visibility } from '../../lib/api-types' type ViewMode = 'grid' | 'list' +const viewModeStorageKey = 'imagesilo_image_view_mode' +const filtersOpenStorageKey = 'imagesilo_image_filters_open' +const advancedFiltersOpenStorageKey = 'imagesilo_image_advanced_filters_open' + export function ImageListPage() { const { t, i18n } = useTranslation() const queryClient = useQueryClient() @@ -28,11 +33,13 @@ export function ImageListPage() { const searchKey = searchParams.toString() const filters = useMemo(() => normalizedFilterQuery(new URLSearchParams(searchKey)), [searchKey]) const filterValues = useMemo(() => new URLSearchParams(filters), [filters]) - const [filtersOpen, setFiltersOpen] = useState(false) - const [advancedOpen, setAdvancedOpen] = useState(false) + const [filtersOpen, setFiltersOpen] = useState(() => storedBoolean(filtersOpenStorageKey)) + const [advancedOpen, setAdvancedOpen] = useState( + () => hasAdvancedParameters(searchParams) || storedBoolean(advancedFiltersOpenStorageKey), + ) const [createdFrom, setCreatedFrom] = useState(() => dateParameterValue(searchParams.get('createdFrom'))) const [createdTo, setCreatedTo] = useState(() => dateParameterValue(searchParams.get('createdTo'))) - const [viewMode, setViewMode] = useState('grid') + const [viewMode, setViewMode] = useState(storedViewMode) const [selected, setSelected] = useState>(new Set()) const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false) const [deleteSnapshot, setDeleteSnapshot] = useState([]) @@ -47,6 +54,7 @@ export function ImageListPage() { getNextPageParam: (page) => page.nextCursor || undefined, }) const images = useMemo(() => query.data?.pages.flatMap((page) => page.items) ?? [], [query.data]) + const selectedImages = useMemo(() => images.filter((image) => selected.has(image.id)), [images, selected]) const visibilityMutation = useMutation({ mutationFn: ({ id, visibility }: { id: string; visibility: Visibility }) => apiRequest(`/api/v1/images/${id}/visibility`, { @@ -100,9 +108,21 @@ export function ImageListPage() { setSelected(new Set()) setCreatedFrom(dateParameterValue(current.get('createdFrom'))) setCreatedTo(dateParameterValue(current.get('createdTo'))) - setAdvancedOpen(hasAdvancedParameters(current)) + if (hasAdvancedParameters(current)) setAdvancedOpen(true) }, [searchKey]) + useEffect(() => { + writeLocalStorage(filtersOpenStorageKey, String(filtersOpen)) + }, [filtersOpen]) + + useEffect(() => { + writeLocalStorage(advancedFiltersOpenStorageKey, String(advancedOpen)) + }, [advancedOpen]) + + useEffect(() => { + writeLocalStorage(viewModeStorageKey, viewMode) + }, [viewMode]) + function showBatchToast(result: BatchOperationResult, id: string) { const failures = result.items.filter((item) => item.status === 'error' || item.status === 'not_found' || item.status === 'cleanup_pending') if (failures.length > 0) toast.warning(t('toast.batchPartial', { success: result.items.length - failures.length, failed: failures.length }), { id }) @@ -341,6 +361,12 @@ export function ImageListPage() { {t('images.selected', { count: selected.size })}
+ t('images.copySelectedLinksFormat', { count: selectedImages.length, format })} + />