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
7 changes: 7 additions & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Gecko Asset Manager — a searchable library for your media." />
<title>Gecko Asset Manager</title>
<!-- From src/ rather than public/, so Vite fingerprints them: nginx serves every
.svg as immutable for a year, which is only safe for a hashed name. The PNG is
for browsers without SVG favicons; sizes="32x32" stops Chrome from preferring
it over the SVG. -->
<link rel="icon" href="/src/assets/favicon-32.png" sizes="32x32" type="image/png" />
<link rel="icon" href="/src/assets/favicon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/src/assets/apple-touch-icon.png" />
<script>
// Applied before first paint, so a dark-mode user never sees a white flash on
// load. The store takes over once React mounts.
Expand Down
Binary file added frontend/src/assets/apple-touch-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added frontend/src/assets/favicon-32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 20 additions & 0 deletions frontend/src/assets/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
57 changes: 57 additions & 0 deletions frontend/src/components/AddPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { X } from 'lucide-react'
import UploadZone from '@/components/UploadZone'
import UrlImport from '@/components/UrlImport'

export const ADD_PANEL_ID = 'library-add-panel'

interface Props {
open: boolean
onClose: () => void
}

/**
* The two ways into the library — files and links — folded behind the library's + button.
*
* Hidden rather than unmounted when closed. Unmounting would throw away a half-pasted
* link and the import options with it, and would take an upload's progress bar off the
* page while the upload itself carried on in the store; closing the panel is putting
* it out of the way, not cancelling anything.
*
* The `hidden` attribute goes on an element with no display utility of its own:
* Tailwind's `[hidden]` rule sits in the base layer, so a `flex` or `grid` class on the
* same element would win and the panel would never close.
*/
export default function AddPanel({ open, onClose }: Props) {
return (
<section
id={ADD_PANEL_ID}
hidden={!open}
aria-labelledby={`${ADD_PANEL_ID}-title`}
className="rounded-lg border border-gray-200 bg-white p-3 dark:border-gray-700 dark:bg-gray-800"
>
<div className="mb-2 flex items-center justify-between gap-2">
<h2
id={`${ADD_PANEL_ID}-title`}
className="text-sm font-medium text-gray-900 dark:text-gray-100"
>
Add to library
</h2>
<button
type="button"
onClick={onClose}
className="btn btn-ghost p-1"
// Named for what it closes: the asset panel beside the grid has a Close too,
// and both can be on screen at once.
aria-label="Close add panel"
title="Close"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="space-y-3">
<UploadZone />
<UrlImport />
</div>
</section>
)
}
105 changes: 104 additions & 1 deletion frontend/src/views/LibraryView.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { act, render, screen, waitFor, within } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {
afterEach,
Expand Down Expand Up @@ -312,6 +312,109 @@ describe('LibraryView selection', () => {
})
})

describe('LibraryView add panel', () => {
// By role, not label: a role query skips what `hidden` hides, a label query does not.
const linkBox = () => screen.queryByRole('textbox', { name: 'Link to import' })

it('stays folded until + is pressed, and + folds it again', async () => {
const user = userEvent.setup()
render(<LibraryView />)
await card('First')

const add = screen.getByRole('button', { name: 'Add to library' })
expect(add).toHaveAttribute('aria-expanded', 'false')
expect(linkBox()).not.toBeInTheDocument()

await user.click(add)
expect(add).toHaveAttribute('aria-expanded', 'true')
expect(linkBox()).toBeVisible()
expect(screen.getByText(/drop files here/i)).toBeVisible()

await user.click(add)
expect(linkBox()).not.toBeInTheDocument()
})

it('closes with its ✕ and puts focus back on +', async () => {
/**
* The ✕ is hidden by its own click, and focus left on a hidden element drops to
* <body> — a keyboard user would be sent back to the top of the page.
*/
const user = userEvent.setup()
render(<LibraryView />)
await card('First')
const add = screen.getByRole('button', { name: 'Add to library' })

await user.click(add)
await user.click(screen.getByRole('button', { name: 'Close add panel' }))

expect(linkBox()).not.toBeInTheDocument()
expect(add).toHaveFocus()
})

it('keeps a half-pasted link and its options through a close', async () => {
const user = userEvent.setup()
render(<LibraryView />)
await card('First')
const add = screen.getByRole('button', { name: 'Add to library' })

await user.click(add)
await user.type(
screen.getByRole('textbox', { name: 'Link to import' }),
'youtu.be/abc'
)
await user.click(screen.getByRole('checkbox', { name: 'Audio only' }))
await user.click(screen.getByRole('button', { name: 'Close add panel' }))
await user.click(add)

expect(linkBox()).toHaveValue('youtu.be/abc')
expect(screen.getByRole('checkbox', { name: 'Audio only' })).toBeChecked()
})

it('opens for files dragged over the library, and only for files', async () => {
/**
* Folded, the drop zone is not on screen to drop onto. Without this, hiding it would
* have quietly removed drag-and-drop.
*/
render(<LibraryView />)
const tile = await card('First')

fireEvent.dragEnter(tile, { dataTransfer: { types: ['text/plain'] } })
expect(linkBox()).not.toBeInTheDocument()

fireEvent.dragEnter(tile, { dataTransfer: { types: ['Files'] } })
expect(linkBox()).toBeVisible()
})

it('shows an upload still running behind a closed panel on the +', async () => {
render(<LibraryView />)
await card('First')

act(() => useLibraryStore.setState({ uploading: true, uploadProgress: 0.45 }))

expect(screen.getByRole('button', { name: 'Add to library' })).toHaveTextContent(
'45%'
)
})

it('offers the panel from an empty library instead of pointing above it', async () => {
const user = userEvent.setup()
vi.spyOn(assetsApi, 'list').mockResolvedValue({
data: [],
total: 0,
limit: 60,
offset: 0,
})
render(<LibraryView />)

await user.click(await screen.findByRole('button', { name: 'Add files or a link' }))

expect(linkBox()).toBeVisible()
expect(
screen.queryByRole('button', { name: 'Add files or a link' })
).not.toBeInTheDocument()
})
})

describe('LibraryView URL imports', () => {
function importJob(overrides: Partial<ActivityJob> = {}): ActivityJob {
return {
Expand Down
88 changes: 77 additions & 11 deletions frontend/src/views/LibraryView.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Library, X } from 'lucide-react'
import { Library, Loader2, Plus, X } from 'lucide-react'
import type { Asset } from '@/api/assets'
import AddPanel, { ADD_PANEL_ID } from '@/components/AddPanel'
import AssetCard from '@/components/AssetCard'
import AssetDetail from '@/components/AssetDetail'
import DetailDock from '@/components/DetailDock'
import FilterBar from '@/components/FilterBar'
import SelectionBar from '@/components/SelectionBar'
import UploadZone from '@/components/UploadZone'
import UrlImport from '@/components/UrlImport'
import { isActive, useActivityStore } from '@/stores/activity'
import { useLibraryStore } from '@/stores/library'

Expand All @@ -23,6 +22,8 @@ export default function LibraryView() {
const categoryFilter = useLibraryStore((s) => s.categoryFilter)
const sourceFilter = useLibraryStore((s) => s.sourceFilter)
const rejections = useLibraryStore((s) => s.rejections)
const uploading = useLibraryStore((s) => s.uploading)
const uploadProgress = useLibraryStore((s) => s.uploadProgress)

const load = useLibraryStore((s) => s.load)
const loadMore = useLibraryStore((s) => s.loadMore)
Expand All @@ -31,6 +32,10 @@ export default function LibraryView() {
const jobs = useActivityStore((s) => s.jobs)

const [openId, setOpenId] = useState<string | null>(null)
// Folded by default: the drop zone and link box are used in bursts, and left open
// they push the grid down by both their heights on every visit in between.
const [addOpen, setAddOpen] = useState(false)
const addButtonRef = useRef<HTMLButtonElement>(null)
const [selected, setSelected] = useState<Set<string>>(new Set())
// Where a Shift-click range starts. Held in a ref: it steers the next click but
// nothing renders from it.
Expand Down Expand Up @@ -146,6 +151,21 @@ export default function LibraryView() {
anchorRef.current = null
}, [])

// Back to the + that opened it. The ✕ is about to be hidden, and focus left on a
// hidden element drops to <body>, putting a keyboard user back at the top of the page.
const closeAddPanel = useCallback(() => {
setAddOpen(false)
addButtonRef.current?.focus()
}, [])

// Folding the drop zone away must not make dropping impossible, or the panel has
// traded a feature for the space. Files dragged over the library open it, so the zone
// is there by the time the pointer reaches it. Other drags — text, a link, a
// thumbnail — are left alone.
const openForFiles = useCallback((event: React.DragEvent) => {
if (Array.from(event.dataTransfer?.types ?? []).includes('Files')) setAddOpen(true)
}, [])

// Read from the live list so an edit made in the panel is reflected behind it.
const openAsset = useMemo(
() => assets.find((a) => a.id === openId) ?? null,
Expand All @@ -156,15 +176,48 @@ export default function LibraryView() {
query.trim() || typeFilter || tagFilter.length > 0 || categoryFilter || sourceFilter
)

// Shown on the + while the panel is shut, since closing it mid-upload hides the bar
// but not the upload.
const uploadingOutOfSight = uploading && !addOpen
const uploadPercent = `${Math.round(uploadProgress * 100)}%`

return (
<div className="flex h-full min-h-0">
<div className="min-w-0 flex-1 overflow-auto">
<div className="min-w-0 flex-1 overflow-auto" onDragEnter={openForFiles}>
{/* A container query context rather than a max width. What the grid has to
work with is whatever is left beside the detail panel, and the viewport
stops describing that the moment the panel is open and drag-resizable. */}
<div className="@container space-y-4 px-4 py-4">
<UploadZone />
<UrlImport />
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<FilterBar />
</div>
{/* border-transparent makes it exactly the search box's height, which has a
1px border the button otherwise lacks. */}
<button
ref={addButtonRef}
type="button"
onClick={() => setAddOpen((open) => !open)}
aria-expanded={addOpen}
aria-controls={ADD_PANEL_ID}
aria-label="Add to library"
title={
uploadingOutOfSight
? `Uploading… ${uploadPercent}`
: 'Add files or a link'
}
className="btn btn-primary shrink-0 border border-transparent px-3 aria-expanded:bg-blue-800"
>
{uploadingOutOfSight ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
)}
{uploadingOutOfSight ? uploadPercent : 'Add'}
</button>
</div>

<AddPanel open={addOpen} onClose={closeAddPanel} />

{rejections.length > 0 && (
<div className="flex items-start justify-between gap-3 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs dark:border-amber-800 dark:bg-amber-950/30">
Expand Down Expand Up @@ -199,8 +252,6 @@ export default function LibraryView() {
</p>
)}

<FilterBar />

{selected.size > 0 && (
<SelectionBar
selected={selected}
Expand All @@ -215,7 +266,10 @@ export default function LibraryView() {
Loading…
</p>
) : assets.length === 0 ? (
<EmptyState filtering={filtering} />
<EmptyState
filtering={filtering}
onAdd={addOpen ? undefined : () => setAddOpen(true)}
/>
) : (
<>
<div className="flex flex-wrap items-baseline justify-between gap-2">
Expand Down Expand Up @@ -262,7 +316,11 @@ export default function LibraryView() {
)
}

function EmptyState({ filtering }: { filtering: boolean }) {
/**
* `onAdd` is passed only while the add panel is shut. A new user's first screen would
* otherwise say "above" about a drop zone that is folded out of sight.
*/
function EmptyState({ filtering, onAdd }: { filtering: boolean; onAdd?: () => void }) {
return (
<div className="flex flex-col items-center gap-2 py-16 text-center">
<Library className="h-9 w-9 text-gray-400 dark:text-gray-500" />
Expand All @@ -272,8 +330,16 @@ function EmptyState({ filtering }: { filtering: boolean }) {
<p className="max-w-sm text-sm text-gray-600 dark:text-gray-400">
{filtering
? 'Try a different search, or clear the filters.'
: 'Drop some files or paste a link above to get started. You can name and describe them later.'}
: onAdd
? 'Add some files or a link to get started. You can name and describe them later.'
: 'Drop some files or paste a link above to get started. You can name and describe them later.'}
</p>
{!filtering && onAdd && (
<button type="button" onClick={onAdd} className="btn btn-primary mt-2">
<Plus className="h-4 w-4" />
Add files or a link
</button>
)}
</div>
)
}
Loading