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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
38 changes: 37 additions & 1 deletion web/e2e/desktop.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,53 @@ test('desktop administrator completes upload, management, alias, settings, theme
await uploadTinyImage(page, imageName)
await page.getByRole('button', { name: '复制直链', exact: true }).click()
await expect.poll(() => readClipboard(page)).toContain('/image/')
await page.setViewportSize({ width: 1024, height: 768 })
await page.getByRole('button', { name: /选择链接格式/ }).click()
const uploadFormatMenu = page.locator('.upload-queue .ui-dropdown-panel')
await expect(uploadFormatMenu).toBeVisible()
const [uploadContentBox, uploadMenuBox] = await Promise.all([
page.locator('.tail-content').boundingBox(),
uploadFormatMenu.boundingBox(),
])
expect(uploadContentBox).not.toBeNull()
expect(uploadMenuBox).not.toBeNull()
expect(uploadMenuBox!.x).toBeGreaterThanOrEqual(uploadContentBox!.x)
await page.getByRole('button', { name: '复制 Markdown', exact: true }).click()
await page.getByRole('button', { name: /复制.*MD/ }).click()
await expect.poll(() => readClipboard(page)).toContain('![')
await page.setViewportSize({ width: 1280, height: 720 })

await page.getByRole('link', { name: '图片管理' }).click()
const thumbnail = page.getByRole('img', { name: imageName })
await expect(thumbnail).toBeVisible()
await expect(thumbnail).toHaveAttribute('src', /\/api\/v1\/images\/.+\/thumbnail/)
const imageCheckbox = page.getByRole('checkbox', { name: `选择图片 ${imageName}` })
await imageCheckbox.check()
const [batchCopyButtonBox, batchFormatButtonBox] = await Promise.all([
page.locator('.floating-batch-toolbar .copy-link-main').boundingBox(),
page.locator('.floating-batch-toolbar .copy-link-caret').boundingBox(),
])
expect(batchCopyButtonBox).not.toBeNull()
expect(batchFormatButtonBox).not.toBeNull()
expect(batchFormatButtonBox!.height).toBe(batchCopyButtonBox!.height)
await page.locator('.floating-batch-toolbar .copy-link-caret').click()
const batchFormatMenu = page.locator('.floating-batch-toolbar .copy-format-menu')
await expect(batchFormatMenu).toBeVisible()
await page.waitForTimeout(200)
const [batchToolbarBox, batchFormatMenuBox] = await Promise.all([
page.locator('.floating-batch-toolbar').boundingBox(),
batchFormatMenu.boundingBox(),
])
expect(batchToolbarBox).not.toBeNull()
expect(batchFormatMenuBox).not.toBeNull()
expect(batchFormatMenuBox!.y + batchFormatMenuBox!.height).toBeLessThanOrEqual(batchToolbarBox!.y)
await imageCheckbox.uncheck()
await page.locator('article').filter({ hasText: imageName }).click()
await expect(page.getByRole('heading', { name: imageName })).toBeVisible()
await expect(page).toHaveURL(/\/admin\/images\/[^/]+$/)

await page.getByRole('button', { name: '选择链接格式' }).click()
await page.getByRole('button', { name: '复制 Markdown' }).click()
await page.getByRole('button', { name: '复制 Markdown', exact: true }).click()
await page.getByRole('button', { name: /复制.*MD/ }).click()
await expect.poll(() => readClipboard(page)).toContain('![')

Expand Down
11 changes: 10 additions & 1 deletion web/e2e/mobile.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ test('mobile administrator can login, upload, manage, and copy a link', async ({
await card.getByRole('button', { name: '改为公开' }).click()
await expect(card.getByText('公开', { exact: true })).toBeVisible()

const imageCheckbox = card.getByRole('checkbox', { name: `选择图片 ${imageName}` })
await imageCheckbox.check()
const batchToolbar = page.locator('.floating-batch-toolbar')
await batchToolbar.locator('.copy-link-caret').click()
await page.getByRole('button', { name: '复制 BBCode', exact: true }).click()
await batchToolbar.getByRole('button', { name: /复制选中 1 张图片的BBCode/ }).click()
await expect.poll(() => readClipboard(page)).toContain('[img]')
await imageCheckbox.uncheck()

const menuButton = page.getByRole('button', { name: '打开菜单' })
await menuButton.focus()
await menuButton.press('Enter')
Expand All @@ -27,6 +36,6 @@ test('mobile administrator can login, upload, manage, and copy a link', async ({
const migrationPath = '/i/2026/08/migration-mobile.webp'
const migrationCard = page.locator('article').filter({ hasText: migrationPath })
await expect(migrationCard).toBeVisible()
await migrationCard.getByRole('button', { name: '复制直链', exact: true }).click()
await migrationCard.getByRole('button', { name: '复制BBCode', exact: true }).click()
await expect.poll(() => readClipboard(page)).toContain(migrationPath)
})
55 changes: 46 additions & 9 deletions web/src/components/ui/copy-link-control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -22,22 +22,55 @@ function initialFormat(): LinkFormat {
return formats.includes(value as LinkFormat) ? (value as LinkFormat) : 'direct'
}

export function CopyLinkControl({ image, compact = false, onCopied }: { image: LinkableImage; compact?: boolean; onCopied?: (format: LinkFormat) => void }) {
export function CopyLinkControl({
image,
compact = false,
menuAlign = 'right',
onCopied,
}: {
image: LinkableImage
compact?: boolean
menuAlign?: 'left' | 'right'
onCopied?: (format: LinkFormat) => void
}) {
return <CopyLinksControl images={[image]} compact={compact} menuAlign={menuAlign} onCopied={onCopied} />
}

export function CopyLinksControl({
images,
compact = false,
label,
ariaLabel,
menuAlign = 'right',
onCopied,
}: {
images: readonly LinkableImage[]
compact?: boolean
label?: string
ariaLabel?: (formatLabel: string) => string
menuAlign?: 'left' | 'right'
onCopied?: (format: LinkFormat) => void
}) {
const { t } = useTranslation()
const [format, setFormat] = useState<LinkFormat>(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'))
Expand All @@ -53,17 +86,21 @@ 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()}
>
<Icon name={copying ? 'loader' : 'copy'} className={copying ? 'h-4 w-4 animate-spin' : 'h-4 w-4'} />
<span className="image-action-label">{t('common.copy')}</span>
<span className="image-action-label">{label ?? t('common.copy')}</span>
</Button>
<DropdownMenu
open={open}
onOpenChange={setOpen}
align="right"
align={menuAlign}
className="copy-format-menu"
trigger={
<button
Expand Down
91 changes: 91 additions & 0 deletions web/src/features/images/image-detail-page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import '../../i18n/config'
import { apiRequest } from '../../lib/api-client'
import type { ImageAlias, ImageDetail } from '../../lib/api-types'
import { ImageDetailPage } from './image-detail-page'

vi.mock('../../lib/api-client', () => ({ 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(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[`/admin/images/${imageId}`]}>
<Routes>
<Route path="/admin/images/:imageId" element={<ImageDetailPage />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>,
)
}

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(''))
})
})
44 changes: 42 additions & 2 deletions web/src/features/images/image-detail-page.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -73,6 +75,28 @@ export function ImageDetailPage() {
},
onError: () => toast.error(t('toast.webpFailed'), { id: 'detail-conversion' }),
})
const aliasCreation = useMutation({
mutationFn: (path: string) =>
apiRequest<ImageAlias>('/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<HTMLFormElement>) {
event.preventDefault()
const path = aliasPath.trim()
if (path) aliasCreation.mutate(path)
}

if (query.isLoading) return <p className="text-muted-foreground">{t('common.loading')}</p>
if (query.isError || !query.data) return <p className="text-danger">{t('images.detailFailed')}</p>
Expand Down Expand Up @@ -113,6 +137,22 @@ export function ImageDetailPage() {
</dl>
</ComponentCard>
<ComponentCard title={t('images.aliases')}>
<form className="mb-4 flex flex-col gap-2 sm:flex-row" onSubmit={createAlias}>
<label className="sr-only" htmlFor="detail-alias-path">{t('images.aliasPath')}</label>
<Input
className="min-w-0 flex-1"
id="detail-alias-path"
value={aliasPath}
onChange={(event) => setAliasPath(event.target.value)}
placeholder="/i/2026/08/example.jpg"
maxLength={2048}
required
/>
<Button type="submit" variant="outline" disabled={!aliasPath.trim() || aliasCreation.isPending}>
<Icon name={aliasCreation.isPending ? 'loader' : 'plus'} className={aliasCreation.isPending ? 'animate-spin' : ''} />
{aliasCreation.isPending ? t('images.aliasSaving') : t('images.addAlias')}
</Button>
</form>
{image.aliases.length === 0 ? <p className="text-muted-foreground">{t('images.noAliases')}</p> : <ul className="grid gap-3">{image.aliases.map((alias) => <li className="rounded-xl bg-canvas p-3" key={alias.id}><code className="break-all text-sm">{alias.path}</code><p className="mt-1 text-xs text-muted-foreground">{alias.source}</p></li>)}</ul>}
</ComponentCard>
</div>
Expand Down
Loading