Document the library components and test dropped folder reading
This commit is contained in:
@@ -44,7 +44,7 @@ npm run lint:tokens # scripts/check-tokens.mjs, the design system guardrail
|
||||
- `src/lib/server/` is server only code (see below).
|
||||
- `src/lib/components/shell/` holds the app shell: Sidebar, TopBar, TabsBar, MobileHeader, AccountMenu, and `navLinks.ts`.
|
||||
- `src/lib/components/player/` holds the player UI: PlayerBar, PlayerDock, Queue, TrackInfo, TransportControls.
|
||||
- `src/lib/components/music/` holds the components that connect the UI to app state (the player, album data): TrackList, PlayAllButton, TrackContextMenu, AlbumContextMenu.
|
||||
- `src/lib/components/music/` holds the components that connect the UI to app state (the player, album data): TrackList, PlayAllButton, TrackContextMenu, AlbumContextMenu, GenrePicker, TrackStatusBadge. The `library/` subfolder holds the manage-music sections (tracks, albums editor, upload queue).
|
||||
- `src/lib/components/ui/` holds the design system, which is presentation only. ESLint forbids importing `$lib/player/*`, `$lib/data/*`, `$lib/server/*`, `components/music`, `components/shell` and `components/player` from it; it can use `$lib/state/*`, `$lib/utils/*`, `$lib/actions/*`, `$lib/theme/*` and `$lib/types`. It is split into five categories:
|
||||
- `primitives/` for generic controls with no music domain knowledge (Button, Input, Chip, Slider, and so on).
|
||||
- `overlay/` for anything that floats above the content (Modal, ContextMenu, GlassMenu, ConfirmDialog, and so on).
|
||||
@@ -70,7 +70,7 @@ The design system lives in `src/routes/layout.css` (`@theme` tokens plus `@utili
|
||||
- **Spacing:** stick to the Tailwind scale in `.5` steps and avoid quarters like `.25`/`.75`, unless the semantic token calls for it (for example `mb-4.5` in `SectionHeading`).
|
||||
- **Colors:** always use `--mf-*`/`text-*`/`bg-*`/`border-*` from `theme.css`/`tokens.ts`. Never a literal hex or `rgba()` inside a `.svelte` file; those literals only belong in `theme.css`/`tokens.ts` as the source of truth.
|
||||
- **Inline `style="…"` and arbitrary `-[…]` values:** only the ones whitelisted in `scripts/check-tokens.mjs` (the stagger variable `--i`, tile hue, menu position, progress width, `text-[clamp(…)]`, `leading-[…]` on login's display type, `grid-cols-[auto_1fr]`, `max-h-[85dvh]`, `my-`/`py-[Ndvh]` for vertical centering, `transition-[filter]` on genre tiles, and `bg-[image:var(--mf-*)]`). Everything else should become a token.
|
||||
- Reach for these base components before creating a new one: `Artwork`, `Avatar`, `ListRow`, `MediaIdentity`, `TrackList`, `MediaCard`, `PageHeader`, `ContextMenu`, `ConfirmDialog`, `PlayButton`, `Slider`, `Logo`, `Chip`, `SegmentedControl`, `CoverForm`.
|
||||
- Reach for these base components before creating a new one: `Artwork`, `Avatar`, `ListRow`, `MediaIdentity`, `TrackList`, `MediaCard`, `PageHeader`, `ContextMenu`, `ConfirmDialog`, `PlayButton`, `Slider`, `Logo`, `Chip`, `ChipGroup`, `SegmentedControl`, `Tabs`, `SearchInput`, `StatusBadge`, `SelectionBar`, `DataGrid`, `FileDropzone`, `CoverForm`.
|
||||
- **Icon stroke:** one global rule (`svg.lucide { stroke-width: 1.5 }` in `layout.css`) sets the stroke for every icon. Never pass `strokeWidth` to an icon, since any CSS rule overrides the SVG attribute and it would do nothing. For a different stroke on one icon, use a Tailwind `stroke-*` utility.
|
||||
|
||||
## App shell
|
||||
|
||||
@@ -35,7 +35,7 @@ Global chrome on every app page: sidebar (desktop) or tab bar (mobile), top bar
|
||||
- **Home `/`**: a greeting, weekly listening stats, "continue listening" (recently played merged with the in-session history), the featured playlist with its first tracks (play and shuffle load the full list on demand), the user's mixes and playlists. Anonymous visitors are redirected to `/explore`.
|
||||
- **Explore `/explore`**: genre tiles when there is no query, otherwise search results grouped by songs, albums, users and playlists with filter chips and counts taken from the API totals. Songs paginate with infinite scroll. Track and album context menus offer play next, add to queue and add to playlist.
|
||||
- **Liked `/liked`**: the songs the user liked, newest first, with unlike per row.
|
||||
- **Library `/library`, `/library/albums`, `/library/uploads`**: the manage-music area, one route per section behind shared tabs. Songs lists the user's uploads with a confirmed delete; Albums lists the user's albums and edits them at `/library/albums/new` and `/library/albums/:id`; Uploads is the upload queue: the browser sends each file straight to storage through presigned URLs (`/api/uploads`, with progress), `/api/uploads/complete` creates the track unpublished and optionally adds it to an album, and `/api/uploads/status` follows the processing. Genres and cover apply to the whole queue and the queue survives tab changes. `/upload` redirects here.
|
||||
- **Library `/library`, `/library/albums`, `/library/uploads`**: the manage-music area, one route per section behind shared tabs. Songs lists the user's uploads, filtered on the server (`?q=` and `?status=`) with counters, and lets the owner publish, unpublish and bulk delete them (a track is created unpublished and is only visible to its owner until published; processing tracks refresh on their own); Albums lists the user's albums and edits them at `/library/albums/new` and `/library/albums/:id`; Uploads is the upload queue: the browser sends each file straight to storage through presigned URLs (`/api/uploads`, with progress), `/api/uploads/complete` creates the track unpublished and optionally adds it to an album, and `/api/uploads/status` follows the processing. Genres and cover apply to the whole queue and the queue survives tab changes. `/upload` redirects here.
|
||||
- **Albums `/albums`, `/albums/:id`**: list and create albums. The owner can edit, delete and add or remove tracks from their library.
|
||||
- **Playlists `/playlists`, `/playlists/:id`**: list and create playlists. The owner can edit, delete and remove tracks; everyone else gets a read only view.
|
||||
- **Mix `/mixes/:id`**: a generated mix with play, shuffle and a track menu.
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { filesFromDrop } from './uploadQueue';
|
||||
|
||||
function fileEntry(name: string) {
|
||||
const file = new File(['x'], name);
|
||||
return {
|
||||
isFile: true,
|
||||
isDirectory: false,
|
||||
file: (resolve: (file: File) => void) => resolve(file)
|
||||
};
|
||||
}
|
||||
|
||||
function directoryEntry(children: unknown[]) {
|
||||
let served = false;
|
||||
return {
|
||||
isFile: false,
|
||||
isDirectory: true,
|
||||
createReader: () => ({
|
||||
readEntries: (resolve: (entries: unknown[]) => void) => {
|
||||
resolve(served ? [] : children);
|
||||
served = true;
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function transfer(entries: unknown[], files: File[] = []) {
|
||||
return {
|
||||
items: entries.map((entry) => ({ webkitGetAsEntry: () => entry })),
|
||||
files
|
||||
} as unknown as DataTransfer;
|
||||
}
|
||||
|
||||
describe('filesFromDrop', () => {
|
||||
it('collects files from nested folders', async () => {
|
||||
const dropped = transfer([
|
||||
fileEntry('a.mp3'),
|
||||
directoryEntry([fileEntry('b.mp3'), directoryEntry([fileEntry('c.mp3')])])
|
||||
]);
|
||||
const files = await filesFromDrop(dropped);
|
||||
expect(files.map((file) => file.name).sort()).toEqual(['a.mp3', 'b.mp3', 'c.mp3']);
|
||||
});
|
||||
|
||||
it('falls back to the plain file list without entries', async () => {
|
||||
const plain = new File(['x'], 'd.mp3');
|
||||
const dropped = {
|
||||
items: [{ webkitGetAsEntry: () => null }],
|
||||
files: [plain]
|
||||
} as unknown as DataTransfer;
|
||||
expect(await filesFromDrop(dropped)).toEqual([plain]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user