Compare commits

...
22 Commits
Author SHA1 Message Date
SirBlob da02748dca Send a named user agent from the content tool 2026-11-03 00:46:39 -05:00
SirBlob 0f90babb7a Add library push to website from the content editor 2026-11-03 00:39:33 -05:00
SirBlob ec57bd73b3 Add games library with IGDB, file explorer and static files script 2026-11-03 00:27:20 -05:00
SirBlob a16444451a Add SQLite library with API, library desktop apps and MobyGames scraper 2026-11-02 23:26:31 -05:00
SirBlob 6674428346 Movie and Tv Show Update 2026-11-01 23:15:56 -05:00
SirBlob 1e5e528c0c Update shows and movies 2026-11-01 22:58:56 -05:00
SirBlob 69abd9a90f Add cached book covers and posters 2026-11-01 22:58:56 -05:00
SirBlob 7e23fdcd8e Show cache status in the TUI 2026-11-01 22:58:56 -05:00
SirBlob dfe3955604 Fall back to OMDb demo on rate limits 2026-11-01 22:58:56 -05:00
SirBlob 4f78883f0b Save OMDb details and posters locally 2026-11-01 22:58:56 -05:00
SirBlob 1f7e88f117 Move scraped book details into data folder 2026-11-01 22:58:56 -05:00
SirBlob 9ce67f7600 Update shows and movies 2026-11-01 22:12:39 -05:00
SirBlob d473aebb79 Add planning to shows and movies in one merged grid 2026-11-01 22:12:39 -05:00
SirBlob cd9a8a186a Add compressed wallpapers from dotfiles 2026-11-01 22:05:40 -05:00
SirBlob 8342304354 Add wallpaper picker and compression script 2026-11-01 22:05:40 -05:00
SirBlob d81ec1b057 Update shows and music 2026-11-01 21:50:05 -05:00
SirBlob 5f5d24cb2d Add hobby stats and graphs to btop 2026-11-01 21:50:05 -05:00
SirBlob 4a52320d4e Remove hover descriptions from book cards 2026-11-01 21:50:05 -05:00
SirBlob 90705bc3e2 Add watching status and groups for shows and movies 2026-11-01 21:50:05 -05:00
SirBlob 7ef3a29ece Cache Goodreads data and slow down bulk requests 2026-11-01 21:50:01 -05:00
SirBlob af73bb5b26 Add Wings of Fire and Silo series 2026-11-01 21:27:33 -05:00
SirBlob 30656cb580 Stack series covers and wrap series into rows 2026-11-01 21:27:33 -05:00
439 changed files with 7004 additions and 693 deletions

No files matched your search

+1
View File
@@ -19,3 +19,4 @@ __pycache__
*.log *.log
.DS_Store .DS_Store
Thumbs.db Thumbs.db
data/lastfm
+6
View File
@@ -2,4 +2,10 @@ DISCORD_TOKEN=
DISCORD_USER_ID= DISCORD_USER_ID=
DISCORD_GUILD_ID= DISCORD_GUILD_ID=
OMDB_API_KEY= OMDB_API_KEY=
IGDB_CLIENT_ID=
IGDB_CLIENT_SECRET=
LASTFM_API_KEY= LASTFM_API_KEY=
LIBRARY_API_KEY=
LIBRARY_API_URL=http://localhost:5000
LIBRARY_PUSH_URL=https://dev.sirblob.co
LIBRARY_PUSH_KEY=
+3 -1
View File
@@ -25,11 +25,13 @@ vite.config.ts.timestamp-*
bun.lock bun.lock
__pycache__/ __pycache__/
.cache/ .cache/
data/ data/lastfm/
data/library/
Update.md Update.md
PROJECT.md PROJECT.md
blogs/ blogs/
# Content tool # Content tool
tools/content/.venv/ tools/content/.venv/
tools/content/.cache/
__pycache__/ __pycache__/
+4 -3
View File
@@ -18,11 +18,12 @@ COPY package.json ./
RUN bun install --production --ignore-scripts RUN bun install --production --ignore-scripts
COPY --from=build /app/build ./build COPY --from=build /app/build ./build
RUN mkdir -p /app/.cache/music /app/data/lastfm && chown -R bun:bun /app/.cache /app/data COPY --from=build /app/static ./static
RUN mkdir -p /app/.cache/music /app/data/lastfm /app/data/library && chown -R bun:bun /app/.cache /app/data
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=3000 ENV PORT=5000
EXPOSE 3000 EXPOSE 5000
USER bun USER bun
CMD ["bun", "./build/index.js"] CMD ["bun", "./build/index.js"]
+82 -18
View File
@@ -17,16 +17,18 @@ The site recreates my [blomarchy](https://git.sirblob.co/SirBlob/blomarchy) setu
## Features ## Features
- **Tiling windows** - every page opens as a window that tiles like Hyprland's dwindle layout, with gradient borders. - **Tiling windows** - every page opens as a window that tiles like Hyprland's dwindle layout, with gradient borders.
- **Quickshell bar** - app launcher, workspaces, clock, music player, live Discord status, battery details and a theme picker. - **Quickshell bar** - app launcher, workspaces, clock, music player, live Discord status, battery details, a theme picker and a wallpaper picker.
- **Music player** - plays a random song from a YouTube playlist, muted until you unmute it, with seek, skip, repeat, volume and a playlist view. - **Music player** - plays a random song from a YouTube playlist, muted until you unmute it, with seek, skip, repeat, volume and a playlist view.
- **Floating windows** - Alt + V or the title bar button pops a window out of the tiling layout so it can be dragged and resized. - **Floating windows** - Alt + V or the title bar button pops a window out of the tiling layout so it can be dragged and resized.
- **btop** - a system monitor window with my real GitHub commit graph, repos as processes and some very scientific CPU and caffeine readings. - **btop** - a system monitor window with my real GitHub commit graph, repos as processes and some very scientific CPU and caffeine readings.
- **Image viewer** - click any image to open it in an imv window with zoom, pan, rotate, pinch and swipe. - **Image viewer** - click any image to open it in an imv window with zoom, pan, rotate, pinch and swipe.
- **File explorer** - a thunar window that browses `static/files` with grid and list views, a gallery of image thumbnails, previews for images, video and audio, and the image viewer for opening pictures.
- **Kitty terminals** - a real command line with history and tab completion. Try `fastfetch`, `whoami` or `cowsay`. - **Kitty terminals** - a real command line with history and tab completion. Try `fastfetch`, `whoami` or `cowsay`.
- **Desktop icons** - grouped into pages, terminal scripts and socials (GitHub, LinkedIn, Devpost, Discord). - **Desktop icons** - grouped into pages, terminal scripts, apps (files, tcg, movies, shows, books, games), games and socials (GitHub, LinkedIn, Devpost, Discord).
- **13 themes** - Cerberus, Flats, Tokyo Night, Night Cat, Catppuccin, Arch Linux, Gruvbox, Nord, Kanagawa, Wintry, Rose, Catppuccin Latte and Rose Pine Dawn, each with its own wallpaper. - **13 themes** - Cerberus, Flats, Tokyo Night, Night Cat, Catppuccin, Arch Linux, Gruvbox, Nord, Kanagawa, Wintry, Rose, Catppuccin Latte and Rose Pine Dawn, each with its own wallpaper.
- **Portfolio** - about, portfolio, projects (open source, packages, hackathons, research, organizations), blog with RSS and links with QR codes. - **Portfolio** - about, portfolio, projects (open source, packages, hackathons, research, organizations), blog with RSS and links with QR codes.
- **Live data** - GitHub stars and forks, npm downloads, AniList anime stats and OMDb posters for shows and movies. - **Library apps** - movies, shows, books and games open as their own windows with status filters, search, sorting, details and a monthly watch and read history. Entries in the same series or collection are grouped together. Games can also be marked as a favorite or liked, with tags and a note, and the about page shows those starred games.
- **Live data** - GitHub stars and forks, npm downloads, AniList anime stats and Last.fm listening stats collected into SQLite.
- **Mobile friendly** - on small screens windows go fullscreen with tabs to switch between them. - **Mobile friendly** - on small screens windows go fullscreen with tabs to switch between them.
## Keybinds ## Keybinds
@@ -69,6 +71,8 @@ Alt stands in for Super, since browsers can't use it.
| `theme [name]` | list or switch themes | | `theme [name]` | list or switch themes |
| `cowsay <text>` | moo | | `cowsay <text>` | moo |
| `btop` | system monitor with my GitHub stats | | `btop` | system monitor with my GitHub stats |
| `thunar` / `files` | browse `static/files` |
| `movies` / `shows` / `books` / `games library` | open the library apps |
| `pacman -S <pkg>` | install things (not really) | | `pacman -S <pkg>` | install things (not really) |
## Running ## Running
@@ -87,7 +91,7 @@ The music player needs `ffmpeg` installed on the server.
docker compose up -d --build docker compose up -d --build
``` ```
The image builds the site with Bun and includes `ffmpeg`. It reads `.env` and serves on port 3000; set `HOST_PORT` to publish it on a different port. The image builds the site with Bun and includes `ffmpeg`. It reads `.env` and serves on port 5000; set `HOST_PORT` to publish it on a different port. Shows, movies and books live in the `library-data` volume and Last.fm data in the `lastfm-data` volume.
## Configuration ## Configuration
@@ -96,48 +100,108 @@ Copy `.env.example` to `.env` and fill in what you need. Every value is optional
| Variable | Used for | | Variable | Used for |
| --- | --- | | --- | --- |
| `DISCORD_TOKEN`, `DISCORD_USER_ID`, `DISCORD_GUILD_ID` | Live Discord status in the bar | | `DISCORD_TOKEN`, `DISCORD_USER_ID`, `DISCORD_GUILD_ID` | Live Discord status in the bar |
| `OMDB_API_KEY` | Posters and details for shows and movies | | `OMDB_API_KEY` | Looking up shows and movies in the content editor |
| `IGDB_CLIENT_ID`, `IGDB_CLIENT_SECRET` | Looking up games on IGDB in the content editor, from a Confidential app in the [Twitch developer console](https://dev.twitch.tv/console) |
| `LASTFM_API_KEY` | Collecting Last.fm scrobbles for the listening stats |
| `LIBRARY_API_KEY` | Secret the content editor sends to change shows, movies, books and games. Set the same value on the server and locally |
| `LIBRARY_API_URL` | Site the content editor talks to, `http://localhost:5000` by default |
| `LIBRARY_PUSH_URL` | Live site the Push to website button copies the library to, for example `https://dev.sirblob.co` |
| `LIBRARY_PUSH_KEY` | `LIBRARY_API_KEY` of that live site, when it differs from the local one |
| `LIBRARY_DATA_DIR` | Folder for `library.db`, `data/library` by default |
| `LASTFM_DATA_DIR` | Folder for `lastfm.db`, `data/lastfm` by default |
## Content ## Content
Site content lives in `content/`. Personal content and config live in `content/`. Shows, movies, books and games are the exception: they live in a SQLite database at `data/library/library.db` (set `LIBRARY_DATA_DIR` to move it) that the server owns, so adding one never needs a commit. Last.fm scrobbles and album covers are collected at runtime into `data/lastfm/lastfm.db` (set `LASTFM_DATA_DIR` to move it). On first start an older `scrobbles.jsonl`, `covers.json`, `track-covers.json` and `images/` in that folder are moved into the database, and those files can be deleted afterwards. Both databases stay out of git.
### Library API
| Request | Does |
| --- | --- |
| `GET /api/library` | Every show, movie, book and game, public |
| `PUT /api/library/media/<imdbId>` | Add or change a show or movie |
| `DELETE /api/library/media/<imdbId>` | Remove a show or movie |
| `PUT /api/library/books/<goodreadsId>` | Add or change a book |
| `DELETE /api/library/books/<goodreadsId>` | Remove a book |
| `PUT /api/library/games/<igdbId>` | Add or change a game |
| `DELETE /api/library/games/<igdbId>` | Remove a game |
| `GET /api/library/export` | Every entry with all its fields and image sources, needs the API key |
| `GET /api/library/images/<id>` | A saved poster, cover or game image |
Changes need `Authorization: Bearer <LIBRARY_API_KEY>`. A `PUT` only changes the fields it sends, and `null` clears one. When a status changes to watching, reading or playing the start date is set, and to watched, read or played the finish date, unless the request sends its own. Posters, covers and game images are downloaded by the server and kept in the database. A site path such as `/img/minecraft.png` is used as it is.
### Content editor ### Content editor
A terminal app built with [Textual](https://textual.textualize.io/) edits the playlist, shows, movies, books, portfolio, projects and themes without touching the JSON by hand. It uses the site's own themes. A terminal app built with [Textual](https://textual.textualize.io/) edits the playlist, shows, movies, books, games, portfolio, projects and themes without touching the JSON by hand. It uses the site's own themes.
```bash ```bash
bun run content:setup bun run content:setup
bun run content bun run content
``` ```
`content:setup` creates a virtual environment in `tools/content/.venv` and installs Textual. Run it once. `content:setup` creates a virtual environment in `tools/content/.venv` and installs Textual, Pillow and pillow-heif. Run it once, and again when `requirements.txt` changes.
| Key | Action | | Key | Action |
| --- | --- | | --- | --- |
| `1` to `9` | Switch between Music, Shows, Movies, Books, Profile, Experience, Leadership, Projects and Themes | | `1` to `9`, `0` | Switch between Music, Shows, Movies, Books, Games, Profile, Experience, Leadership, Projects and Themes |
| `a` | Add (YouTube link, IMDb link or Goodreads link) | | `a` | Add (YouTube link, IMDb link, Goodreads link, or a store link, IGDB link or name for a game) |
| `e` / `Enter` | Edit the selected entry (or change the rating for shows, movies and books) | | `S` | Add books from a Goodreads series |
| `f` | Toggle featured on a project | | `e` / `Enter` | Edit the selected entry (or change the rating for shows, movies, books and games) |
| `g` | Put a show or movie in a group, which the library apps and about page show together, or set a game's tags and note |
| `h` | Set the started and finished dates for a show, movie, book or game |
| `l` | Change a game's store link or image |
| `f` | Toggle featured on a project, or make a game your favorite |
| `k` | Add a game to the games you like (up to 20) |
| `p` | Preview a theme in the editor | | `p` | Preview a theme in the editor |
| `t` | Switch the editor to the next theme | | `t` | Switch the editor to the next theme |
| `s` | Move a book to another shelf, or make a theme the site default | | `s` | Move a book to another shelf, set a show, movie or game status (a game can also be replayable), or make a theme the site default |
| `d` | Delete | | `d` | Delete |
| `K` / `J` | Move a song or entry up or down | | `K` / `J` | Move a song or entry up or down |
| `u` / `U` | Refresh the selected book, or every book, from Goodreads | | `u` / `U` | Refresh the selected book, or every book, from Goodreads; `u` also refreshes a show or movie from OMDb, or a game from IGDB |
| `r` | Reload from disk | | `C` | Save every missing book cover, or every missing show and movie poster and details, into the library |
| `r` | Reload from disk or the library |
| `P` | Push the whole library to the live site |
| `q` | Quit | | `q` | Quit |
Show and movie titles are looked up on OMDb with `OMDB_API_KEY` from `.env`; fill in the name field to skip the lookup. Books are scraped from Goodreads once when added and saved to `books.json`. To check a Goodreads link on its own, run `python3 tools/content/goodreads.py <url>`. The Shows, Movies, Books and Games tabs read and write the library through the API at `LIBRARY_API_URL` using `LIBRARY_API_KEY`, so point it at the live site to update it from anywhere. To work on a local copy instead, keep `LIBRARY_API_URL` on `localhost` and press `P` (or run `bun run library:push`) to copy every show, movie, book and game to `LIBRARY_PUSH_URL`. It asks first, overwrites the entries there with the local ones, removes entries that only exist there, and refuses to push an empty library. Images are downloaded again by the live site from their original sources. Show and movie titles are looked up on OMDb with `OMDB_API_KEY` from `.env` when added; fill in the name field to skip the lookup. Books are scraped from Goodreads when added. To check a Goodreads link on its own, run `python3 tools/content/goodreads.py <url>`.
Games are looked up on IGDB with `IGDB_CLIENT_ID` and `IGDB_CLIENT_SECRET`. A Steam or Xbox link is matched to its IGDB entry directly; any other store link (Nintendo, Epic, GOG and so on) is searched by the name in the link, or by the name you enter. An IGDB link such as `https://www.igdb.com/games/hades-ii` always matches exactly. IGDB fills in the release date, platforms, developers, publishers, genres, summary, rating and a 16:9 artwork image; enter your own image to use it instead. To check a lookup on its own, run `python3 tools/content/igdb.py <link or name>`.
To move the games that used to be listed in `about.json` into the library once, run `bun run games:import`. It removes them from `about.json` only when every game was imported.
| File | Holds | | File | Holds |
| --- | --- | | --- | --- |
| `profile.json` | Name, bio, socials, skills and photos | | `profile.json` | Name, bio, socials, skills and photos |
| `about.json` | Hobbies, games, shows, movies and the AniList username | | `about.json` | Hobbies, setup and the AniList and Last.fm usernames |
| `experience.json` / `leadership.json` | Work experience and leadership roles | | `experience.json` / `leadership.json` | Work experience and leadership roles |
| `projects/*.json` | Open source, packages, hackathons, research groups and organizations | | `projects/*.json` | Open source, packages, hackathons, research groups and organizations |
| `music.json` | YouTube links for the music player | | `music.json` | YouTube links for the music player |
| `books.json` | Books scraped from Goodreads for the about page | | `wallpapers.json` | Wallpapers for the picker, written by `bun run wallpapers` |
| `desktop.json` | Workspaces, desktop icons, the startup command and the default theme | | `desktop.json` | Workspaces, desktop icons, the startup command and the default theme |
| `themes.json` | Color themes for the site and the content editor | | `themes.json` | Color themes for the site and the content editor |
| `blog/*.md` | Blog posts | | `blog/*.md` | Blog posts |
| File in `data/` | Holds |
| --- | --- |
| `library/library.db` | Shows, movies, books, games, their history dates and saved images |
| `lastfm/lastfm.db` | Last.fm scrobbles, album and track cover links and saved cover images |
### Files
Files you drop into `static/files` (any folders inside it too) show up in the file explorer and are served at `/files/...`. Hidden files are skipped.
```sh
bun run files
```
Converts every HEIC or HEIF photo in `static/files` to a JPEG, turned the right way up and without metadata, and removes GPS location data from any other photo in there since the folder is public. Converted originals are moved to `tools/content/.cache/heic-originals`; pass `--delete-originals` to delete them instead, or `--keep-location` to leave location data alone. Run `bun run content:setup` again first to install `pillow-heif`.
A hobby in `about.json` can have a `folder`, such as `"folder": "robotics"`. Clicking that hobby on the about page opens the file explorer in `static/files/robotics`.
### Wallpapers
```sh
bun run wallpapers
```
Converts every image in `~/dotfiles/wallpapers` into a compressed JPEG in `static/wallpapers/gallery`, with a small thumbnail for the picker, and writes the list to `content/wallpapers.json`. Unchanged wallpapers are skipped, so run it again whenever the dotfiles change. Pass a folder to use a different source, or `--force` to convert everything again.
+2 -43
View File
@@ -4,11 +4,7 @@
"setup": { "setup": {
"user": "blob", "user": "blob",
"host": "blomarchy", "host": "blomarchy",
"steam": [ "steam": [" ( ) (", " ) ( ) )", " ( ) ( ("],
" ( ) (",
" ) ( ) )",
" ( ) ( ("
],
"art": [ "art": [
" ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄", " ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄",
" ██ ██▄▄▄▄▄", " ██ ██▄▄▄▄▄",
@@ -37,43 +33,6 @@
"hobbies": [ "hobbies": [
{ "name": "Swimming", "icon": "proicons:person-swim", "description": "I enjoy swimming as a way to relax and stay active. It is my favorite sport." }, { "name": "Swimming", "icon": "proicons:person-swim", "description": "I enjoy swimming as a way to relax and stay active. It is my favorite sport." },
{ "name": "Baking", "icon": "proicons:cake", "description": "I love baking pastries and experimenting with new dessert recipes." }, { "name": "Baking", "icon": "proicons:cake", "description": "I love baking pastries and experimenting with new dessert recipes." },
{ "name": "Robotics", "icon": "proicons:robot", "description": "Building and programming robots to solve problems is fun." } { "name": "Robotics", "icon": "proicons:robot", "description": "Building and programming robots to solve problems is fun.", "folder": "robotics" }
],
"games": [
{ "name": "Minecraft", "image": "/img/minecraft.png", "url": "https://minecraft.net" },
{ "name": "Pokemon TCG Online", "image": "/img/pokemontcg.png", "url": "https://www.pokemon.com/us/pokemon-tcg" },
{ "name": "Fire Watch", "image": "/img/firewatch.png", "url": "https://firewatchgame.com" },
{ "name": "Expedition 33", "image": "/img/e33.png", "url": "https://expedition33.com" },
{ "name": "Mini Motorways", "image": "/img/mini.png", "url": "https://store.steampowered.com/app/1127500/Mini_Motorways/" },
{ "name": "Risk", "image": "/img/risk.png", "url": "https://store.steampowered.com/app/1128810/RISK_Global_Domination/" },
{ "name": "Slay the Spire", "image": "/img/slay_the_spire.png", "url": "https://store.steampowered.com/app/646570/Slay_the_Spire/" },
{ "name": "Slay the Spire 2", "image": "/img/slay_the_spire_2.png", "url": "https://store.steampowered.com/app/2868840/Slay_the_Spire_2/" }
],
"shows": [
{ "name": "Silo", "rating": 10, "url": "https://www.imdb.com/title/tt14688458/" },
{ "name": "D.P. (KDrama)", "rating": 10, "url": "https://www.imdb.com/title/tt13024974/" },
{ "name": "Andor", "rating": 9, "url": "https://www.imdb.com/title/tt9253284/" },
{ "name": "Severance", "rating": 9, "url": "https://www.imdb.com/title/tt11280740/" },
{ "name": "The Mandalorian", "rating": 7, "url": "https://www.imdb.com/title/tt8111088/" },
{ "name": "BEEF", "rating": 9, "url": "https://www.imdb.com/title/tt14403178/" },
{ "name": "Loki", "rating": 10, "url": "https://www.imdb.com/title/tt9140554/" }
],
"movies": [
{ "name": "Interstellar", "rating": 10, "url": "https://www.imdb.com/title/tt0816692/" },
{ "name": "How to Train Your Dragon", "rating": 10, "url": "https://www.imdb.com/title/tt0892769/" },
{ "name": "12 Angry Men", "rating": 10, "url": "https://www.imdb.com/title/tt0050083/" },
{ "name": "The Wild Robot", "rating": 10, "url": "https://www.imdb.com/title/tt29623480/" },
{ "name": "Whiplash", "rating": 10, "url": "https://www.imdb.com/title/tt2582802/" },
{ "name": "Predestination", "rating": 10, "url": "https://www.imdb.com/title/tt2397535/" },
{ "name": "Project Hail Mary", "rating": 10, "url": "https://www.imdb.com/title/tt12042730/" },
{ "name": "Gravity", "rating": 8, "url": "https://www.imdb.com/title/tt1454468/" },
{ "name": "The Shawshank Redemption", "rating": 11, "url": "https://www.imdb.com/title/tt0111161/" },
{ "name": "Oldboy", "rating": 9, "url": "https://www.imdb.com/title/tt0364569/" },
{ "name": "Spider-Man: Into the Spider-Verse", "rating": 10, "url": "https://www.imdb.com/title/tt4633694/" },
{ "name": "Spider-Man: Across the Spider-Verse", "rating": 9, "url": "https://www.imdb.com/title/tt9362722/" },
{ "name": "Avatar", "rating": 10, "url": "https://www.imdb.com/title/tt0499549/" },
{ "name": "Avatar: The Way of Water", "rating": 10, "url": "https://www.imdb.com/title/tt1630029/" },
{ "name": "Avatar: Fire and Ash", "rating": 8, "url": "https://www.imdb.com/title/tt1757678/" },
{ "name": "Blade Runner 2049", "rating": 10, "url": "https://www.imdb.com/title/tt1856101/" }
] ]
} }
-4
View File
@@ -1,4 +0,0 @@
[
{ "goodreadsId": "170448", "url": "https://www.goodreads.com/book/show/170448", "title": "Animal Farm", "authors": ["George Orwell"], "cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1325861570i/170448.jpg", "description": "Animal Farm is the most famous by far of all twentieth-century political allegories. Its account of a group of barnyard animals who revolt against their vicious human master, only to submit to a tyranny erected by their own kind, can fairly be said to have become a universal drama. Orwell is one of the very few modern satirists comparable to Jonathan Swift in power, artistry, and moral authority; in animal farm his spare prose and the logic of his dark comedy brilliantly highlight his stark message.\n \nTaking as his starting point the betrayed promise of the Russian Revolution, Orwell lays out...", "pages": 141, "published": "1996-04-01", "genres": ["Classics", "Fiction", "Dystopia", "Fantasy", "School"], "averageRating": 4.03, "ratingCount": 4853138, "shelf": "read", "rating": 10, "added": "2026-11-01" },
{ "goodreadsId": "61439040", "url": "https://www.goodreads.com/book/show/61439040", "title": "1984", "authors": ["George Orwell"], "cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1657781256i/61439040.jpg", "description": "A masterpiece of rebellion and imprisonment where war is peace freedom is slavery and Big Brother is watching. Thought Police, Big Brother, Orwellian - these words have entered our vocabulary because of George Orwell's classic dystopian novel 1984. The story of one man's Nightmare Odyssey as he pursues a forbidden love affair through a world ruled by warring states and a power structure that controls not only information but also individual thought and memory 1984 is a prophetic haunting tale More relevant than ever before 1984 exposes the worst crimes imaginable the destruction of truth...", "pages": 368, "published": "2022-07-01", "genres": ["Classics", "Fiction", "Science Fiction", "Dystopia", "Literature"], "averageRating": 4.21, "ratingCount": 5815805, "shelf": "read", "rating": 10, "added": "2026-11-01" }
]
+6 -1
View File
@@ -37,7 +37,12 @@
"name": "apps", "name": "apps",
"side": "right", "side": "right",
"icons": [ "icons": [
{ "label": "tcg", "icon": "mdi:pokeball", "open": { "app": "tcg" } } { "label": "files", "icon": "mdi:folder-open-outline", "open": { "app": "files" } },
{ "label": "tcg", "icon": "mdi:pokeball", "open": { "app": "tcg" } },
{ "label": "movies", "icon": "mdi:movie-open-outline", "open": { "app": "movies" } },
{ "label": "shows", "icon": "mdi:television-classic", "open": { "app": "shows" } },
{ "label": "books", "icon": "mdi:bookshelf", "open": { "app": "books" } },
{ "label": "games", "icon": "mdi:gamepad-variant-outline", "open": { "app": "games" } }
] ]
}, },
{ {
+3 -1
View File
@@ -8,6 +8,8 @@
"https://music.youtube.com/watch?v=KfEEm4Zx-EU&si=V0b6oiP4P-fcE6VI", "https://music.youtube.com/watch?v=KfEEm4Zx-EU&si=V0b6oiP4P-fcE6VI",
"https://music.youtube.com/watch?v=fixh0ixT-9U&si=dWLHv9bylwNAu-DD", "https://music.youtube.com/watch?v=fixh0ixT-9U&si=dWLHv9bylwNAu-DD",
"https://music.youtube.com/watch?v=En_6WWOWh8E&si=KSUbzK7M3NFmbAUO", "https://music.youtube.com/watch?v=En_6WWOWh8E&si=KSUbzK7M3NFmbAUO",
"https://music.youtube.com/watch?v=Kf5pXDhx5Vc&si=yUIlI-2md9uYjGu5" "https://music.youtube.com/watch?v=Kf5pXDhx5Vc&si=yUIlI-2md9uYjGu5",
"https://music.youtube.com/watch?v=vH3ScuFjCyU",
"https://music.youtube.com/watch?v=NengL7n9tR8"
] ]
} }
+112
View File
@@ -0,0 +1,112 @@
[
{ "id": "1-a-slient-voice", "name": "A Slient Voice", "image": "/wallpapers/gallery/1-a-slient-voice.jpg", "thumb": "/wallpapers/gallery/thumbs/1-a-slient-voice.jpg" },
{ "id": "7-firewatch", "name": "Firewatch", "image": "/wallpapers/gallery/7-firewatch.jpg", "thumb": "/wallpapers/gallery/thumbs/7-firewatch.jpg" },
{ "id": "planetred", "name": "Planetred", "image": "/wallpapers/gallery/planetred.jpg", "thumb": "/wallpapers/gallery/thumbs/planetred.jpg" },
{ "id": "a-cat-in-a-cup", "name": "A Cat In A Cup", "image": "/wallpapers/gallery/a-cat-in-a-cup.jpg", "thumb": "/wallpapers/gallery/thumbs/a-cat-in-a-cup.jpg" },
{ "id": "anime-cafe-tokyonight", "name": "Anime Cafe Tokyonight", "image": "/wallpapers/gallery/anime-cafe-tokyonight.jpg", "thumb": "/wallpapers/gallery/thumbs/anime-cafe-tokyonight.jpg" },
{ "id": "anime-circuit", "name": "Anime Circuit", "image": "/wallpapers/gallery/anime-circuit.jpg", "thumb": "/wallpapers/gallery/thumbs/anime-circuit.jpg" },
{ "id": "anime-skyline", "name": "Anime Skyline", "image": "/wallpapers/gallery/anime-skyline.jpg", "thumb": "/wallpapers/gallery/thumbs/anime-skyline.jpg" },
{ "id": "astronaut", "name": "Astronaut", "image": "/wallpapers/gallery/astronaut.jpg", "thumb": "/wallpapers/gallery/thumbs/astronaut.jpg" },
{ "id": "astronaut2", "name": "Astronaut2", "image": "/wallpapers/gallery/astronaut2.jpg", "thumb": "/wallpapers/gallery/thumbs/astronaut2.jpg" },
{ "id": "autumn-forest", "name": "Autumn Forest", "image": "/wallpapers/gallery/autumn-forest.jpg", "thumb": "/wallpapers/gallery/thumbs/autumn-forest.jpg" },
{ "id": "awesome", "name": "Awesome", "image": "/wallpapers/gallery/awesome.jpg", "thumb": "/wallpapers/gallery/thumbs/awesome.jpg" },
{ "id": "baby-star", "name": "Baby Star", "image": "/wallpapers/gallery/baby-star.jpg", "thumb": "/wallpapers/gallery/thumbs/baby-star.jpg" },
{ "id": "be-productive-4k", "name": "Be Productive 4K", "image": "/wallpapers/gallery/be-productive-4k.jpg", "thumb": "/wallpapers/gallery/thumbs/be-productive-4k.jpg" },
{ "id": "black-hole", "name": "Black Hole", "image": "/wallpapers/gallery/black-hole.jpg", "thumb": "/wallpapers/gallery/thumbs/black-hole.jpg" },
{ "id": "black-car-girl", "name": "Black Car Girl", "image": "/wallpapers/gallery/black-car-girl.jpg", "thumb": "/wallpapers/gallery/thumbs/black-car-girl.jpg" },
{ "id": "camille-unknown", "name": "Camille Unknown", "image": "/wallpapers/gallery/camille-unknown.jpg", "thumb": "/wallpapers/gallery/thumbs/camille-unknown.jpg" },
{ "id": "cat", "name": "Cat", "image": "/wallpapers/gallery/cat.jpg", "thumb": "/wallpapers/gallery/thumbs/cat.jpg" },
{ "id": "chainsaw-denji-cat", "name": "Chainsaw Denji Cat", "image": "/wallpapers/gallery/chainsaw-denji-cat.jpg", "thumb": "/wallpapers/gallery/thumbs/chainsaw-denji-cat.jpg" },
{ "id": "cherry-blossoms", "name": "Cherry Blossoms", "image": "/wallpapers/gallery/cherry-blossoms.jpg", "thumb": "/wallpapers/gallery/thumbs/cherry-blossoms.jpg" },
{ "id": "clan-connections", "name": "Clan Connections", "image": "/wallpapers/gallery/clan-connections.jpg", "thumb": "/wallpapers/gallery/thumbs/clan-connections.jpg" },
{ "id": "cliff-edge", "name": "Cliff Edge", "image": "/wallpapers/gallery/cliff-edge.jpg", "thumb": "/wallpapers/gallery/thumbs/cliff-edge.jpg" },
{ "id": "colorful-planets", "name": "Colorful Planets", "image": "/wallpapers/gallery/colorful-planets.jpg", "thumb": "/wallpapers/gallery/thumbs/colorful-planets.jpg" },
{ "id": "deer-forest", "name": "Deer Forest", "image": "/wallpapers/gallery/deer-forest.jpg", "thumb": "/wallpapers/gallery/thumbs/deer-forest.jpg" },
{ "id": "delorean", "name": "Delorean", "image": "/wallpapers/gallery/delorean.jpg", "thumb": "/wallpapers/gallery/thumbs/delorean.jpg" },
{ "id": "delorean-pastel", "name": "Delorean Pastel", "image": "/wallpapers/gallery/delorean-pastel.jpg", "thumb": "/wallpapers/gallery/thumbs/delorean-pastel.jpg" },
{ "id": "doom-slayer", "name": "Doom Slayer", "image": "/wallpapers/gallery/doom-slayer.jpg", "thumb": "/wallpapers/gallery/thumbs/doom-slayer.jpg" },
{ "id": "ds-giyu", "name": "Ds Giyu", "image": "/wallpapers/gallery/ds-giyu.jpg", "thumb": "/wallpapers/gallery/thumbs/ds-giyu.jpg" },
{ "id": "evangelion", "name": "Evangelion", "image": "/wallpapers/gallery/evangelion.jpg", "thumb": "/wallpapers/gallery/thumbs/evangelion.jpg" },
{ "id": "everblush-creep", "name": "Everblush Creep", "image": "/wallpapers/gallery/everblush-creep.jpg", "thumb": "/wallpapers/gallery/thumbs/everblush-creep.jpg" },
{ "id": "firewatch-purple", "name": "Firewatch Purple", "image": "/wallpapers/gallery/firewatch-purple.jpg", "thumb": "/wallpapers/gallery/thumbs/firewatch-purple.jpg" },
{ "id": "fishschool", "name": "Fishschool", "image": "/wallpapers/gallery/fishschool.jpg", "thumb": "/wallpapers/gallery/thumbs/fishschool.jpg" },
{ "id": "geisha", "name": "Geisha", "image": "/wallpapers/gallery/geisha.jpg", "thumb": "/wallpapers/gallery/thumbs/geisha.jpg" },
{ "id": "ghost-in-the-shell", "name": "Ghost In The Shell", "image": "/wallpapers/gallery/ghost-in-the-shell.jpg", "thumb": "/wallpapers/gallery/thumbs/ghost-in-the-shell.jpg" },
{ "id": "goblin-slayer", "name": "Goblin Slayer", "image": "/wallpapers/gallery/goblin-slayer.jpg", "thumb": "/wallpapers/gallery/thumbs/goblin-slayer.jpg" },
{ "id": "godzilla-kong-noodles", "name": "Godzilla Kong Noodles", "image": "/wallpapers/gallery/godzilla-kong-noodles.jpg", "thumb": "/wallpapers/gallery/thumbs/godzilla-kong-noodles.jpg" },
{ "id": "goldfish", "name": "Goldfish", "image": "/wallpapers/gallery/goldfish.jpg", "thumb": "/wallpapers/gallery/thumbs/goldfish.jpg" },
{ "id": "hello-worlds", "name": "Hello Worlds", "image": "/wallpapers/gallery/hello-worlds.jpg", "thumb": "/wallpapers/gallery/thumbs/hello-worlds.jpg" },
{ "id": "hollow-knight-2", "name": "Hollow Knight 2", "image": "/wallpapers/gallery/hollow-knight-2.jpg", "thumb": "/wallpapers/gallery/thumbs/hollow-knight-2.jpg" },
{ "id": "hollow-knight-3", "name": "Hollow Knight 3", "image": "/wallpapers/gallery/hollow-knight-3.jpg", "thumb": "/wallpapers/gallery/thumbs/hollow-knight-3.jpg" },
{ "id": "hollow-knight", "name": "Hollow Knight", "image": "/wallpapers/gallery/hollow-knight.jpg", "thumb": "/wallpapers/gallery/thumbs/hollow-knight.jpg" },
{ "id": "home127-dark", "name": "Home127 Dark", "image": "/wallpapers/gallery/home127-dark.jpg", "thumb": "/wallpapers/gallery/thumbs/home127-dark.jpg" },
{ "id": "jellyfish", "name": "Jellyfish", "image": "/wallpapers/gallery/jellyfish.jpg", "thumb": "/wallpapers/gallery/thumbs/jellyfish.jpg" },
{ "id": "jupiter", "name": "Jupiter", "image": "/wallpapers/gallery/jupiter.jpg", "thumb": "/wallpapers/gallery/thumbs/jupiter.jpg" },
{ "id": "kimi-no-nawa", "name": "Kimi No Nawa", "image": "/wallpapers/gallery/kimi-no-nawa.jpg", "thumb": "/wallpapers/gallery/thumbs/kimi-no-nawa.jpg" },
{ "id": "kingler-drawing", "name": "Kingler Drawing", "image": "/wallpapers/gallery/kingler-drawing.jpg", "thumb": "/wallpapers/gallery/thumbs/kingler-drawing.jpg" },
{ "id": "krita-digiart", "name": "Krita Digiart", "image": "/wallpapers/gallery/krita-digiart.jpg", "thumb": "/wallpapers/gallery/thumbs/krita-digiart.jpg" },
{ "id": "large-asf-goodbg", "name": "Large Asf Goodbg", "image": "/wallpapers/gallery/large-asf-goodbg.jpg", "thumb": "/wallpapers/gallery/thumbs/large-asf-goodbg.jpg" },
{ "id": "literal-wallpaper", "name": "Literal Wallpaper", "image": "/wallpapers/gallery/literal-wallpaper.jpg", "thumb": "/wallpapers/gallery/thumbs/literal-wallpaper.jpg" },
{ "id": "makima-oxo", "name": "Makima Oxo", "image": "/wallpapers/gallery/makima-oxo.jpg", "thumb": "/wallpapers/gallery/thumbs/makima-oxo.jpg" },
{ "id": "material-pastel-1", "name": "Material Pastel 1", "image": "/wallpapers/gallery/material-pastel-1.jpg", "thumb": "/wallpapers/gallery/thumbs/material-pastel-1.jpg" },
{ "id": "material-pastel-2", "name": "Material Pastel 2", "image": "/wallpapers/gallery/material-pastel-2.jpg", "thumb": "/wallpapers/gallery/thumbs/material-pastel-2.jpg" },
{ "id": "material-pastel-3", "name": "Material Pastel 3", "image": "/wallpapers/gallery/material-pastel-3.jpg", "thumb": "/wallpapers/gallery/thumbs/material-pastel-3.jpg" },
{ "id": "material-pastel-4", "name": "Material Pastel 4", "image": "/wallpapers/gallery/material-pastel-4.jpg", "thumb": "/wallpapers/gallery/thumbs/material-pastel-4.jpg" },
{ "id": "material-pastel-5", "name": "Material Pastel 5", "image": "/wallpapers/gallery/material-pastel-5.jpg", "thumb": "/wallpapers/gallery/thumbs/material-pastel-5.jpg" },
{ "id": "material-pastel-6", "name": "Material Pastel 6", "image": "/wallpapers/gallery/material-pastel-6.jpg", "thumb": "/wallpapers/gallery/thumbs/material-pastel-6.jpg" },
{ "id": "material-pastel-7", "name": "Material Pastel 7", "image": "/wallpapers/gallery/material-pastel-7.jpg", "thumb": "/wallpapers/gallery/thumbs/material-pastel-7.jpg" },
{ "id": "material-pastel-8", "name": "Material Pastel 8", "image": "/wallpapers/gallery/material-pastel-8.jpg", "thumb": "/wallpapers/gallery/thumbs/material-pastel-8.jpg" },
{ "id": "melting-astro", "name": "Melting Astro", "image": "/wallpapers/gallery/melting-astro.jpg", "thumb": "/wallpapers/gallery/thumbs/melting-astro.jpg" },
{ "id": "monogatari8", "name": "Monogatari8", "image": "/wallpapers/gallery/monogatari8.jpg", "thumb": "/wallpapers/gallery/thumbs/monogatari8.jpg" },
{ "id": "montains", "name": "Montains", "image": "/wallpapers/gallery/montains.jpg", "thumb": "/wallpapers/gallery/thumbs/montains.jpg" },
{ "id": "mountain-snow-minima", "name": "Mountain Snow Minima", "image": "/wallpapers/gallery/mountain-snow-minima.jpg", "thumb": "/wallpapers/gallery/thumbs/mountain-snow-minima.jpg" },
{ "id": "nasa1", "name": "Nasa1", "image": "/wallpapers/gallery/nasa1.jpg", "thumb": "/wallpapers/gallery/thumbs/nasa1.jpg" },
{ "id": "neon-singularity", "name": "Neon Singularity", "image": "/wallpapers/gallery/neon-singularity.jpg", "thumb": "/wallpapers/gallery/thumbs/neon-singularity.jpg" },
{ "id": "nighttab-backdrop", "name": "Nighttab Backdrop", "image": "/wallpapers/gallery/nighttab-backdrop.jpg", "thumb": "/wallpapers/gallery/thumbs/nighttab-backdrop.jpg" },
{ "id": "nord-chainsaw", "name": "Nord Chainsaw", "image": "/wallpapers/gallery/nord-chainsaw.jpg", "thumb": "/wallpapers/gallery/thumbs/nord-chainsaw.jpg" },
{ "id": "nord-minimal-cat", "name": "Nord Minimal Cat", "image": "/wallpapers/gallery/nord-minimal-cat.jpg", "thumb": "/wallpapers/gallery/thumbs/nord-minimal-cat.jpg" },
{ "id": "nord-sky", "name": "Nord Sky", "image": "/wallpapers/gallery/nord-sky.jpg", "thumb": "/wallpapers/gallery/thumbs/nord-sky.jpg" },
{ "id": "nordic-heroin", "name": "Nordic Heroin", "image": "/wallpapers/gallery/nordic-heroin.jpg", "thumb": "/wallpapers/gallery/thumbs/nordic-heroin.jpg" },
{ "id": "p3", "name": "P3", "image": "/wallpapers/gallery/p3.jpg", "thumb": "/wallpapers/gallery/thumbs/p3.jpg" },
{ "id": "pacman-black", "name": "Pacman Black", "image": "/wallpapers/gallery/pacman-black.jpg", "thumb": "/wallpapers/gallery/thumbs/pacman-black.jpg" },
{ "id": "pastel-car-girl", "name": "Pastel Car Girl", "image": "/wallpapers/gallery/pastel-car-girl.jpg", "thumb": "/wallpapers/gallery/thumbs/pastel-car-girl.jpg" },
{ "id": "pastel-car", "name": "Pastel Car", "image": "/wallpapers/gallery/pastel-car.jpg", "thumb": "/wallpapers/gallery/thumbs/pastel-car.jpg" },
{ "id": "pastel-city", "name": "Pastel City", "image": "/wallpapers/gallery/pastel-city.jpg", "thumb": "/wallpapers/gallery/thumbs/pastel-city.jpg" },
{ "id": "peaks", "name": "Peaks", "image": "/wallpapers/gallery/peaks.jpg", "thumb": "/wallpapers/gallery/thumbs/peaks.jpg" },
{ "id": "perfect-blue", "name": "Perfect Blue", "image": "/wallpapers/gallery/perfect-blue.jpg", "thumb": "/wallpapers/gallery/thumbs/perfect-blue.jpg" },
{ "id": "pixel-city", "name": "Pixel City", "image": "/wallpapers/gallery/pixel-city.jpg", "thumb": "/wallpapers/gallery/thumbs/pixel-city.jpg" },
{ "id": "pixel-earth", "name": "Pixel Earth", "image": "/wallpapers/gallery/pixel-earth.jpg", "thumb": "/wallpapers/gallery/thumbs/pixel-earth.jpg" },
{ "id": "planet-minimal", "name": "Planet Minimal", "image": "/wallpapers/gallery/planet-minimal.jpg", "thumb": "/wallpapers/gallery/thumbs/planet-minimal.jpg" },
{ "id": "pokemon-legends-arceus-wallpapers", "name": "Pokemon Legends Arceus Wallpapers", "image": "/wallpapers/gallery/pokemon-legends-arceus-wallpapers.jpg", "thumb": "/wallpapers/gallery/thumbs/pokemon-legends-arceus-wallpapers.jpg" },
{ "id": "rain-world1", "name": "Rain World1", "image": "/wallpapers/gallery/rain-world1.jpg", "thumb": "/wallpapers/gallery/thumbs/rain-world1.jpg" },
{ "id": "rain-world2", "name": "Rain World2", "image": "/wallpapers/gallery/rain-world2.jpg", "thumb": "/wallpapers/gallery/thumbs/rain-world2.jpg" },
{ "id": "rm-rf", "name": "Rm Rf", "image": "/wallpapers/gallery/rm-rf.jpg", "thumb": "/wallpapers/gallery/thumbs/rm-rf.jpg" },
{ "id": "rocket-launch", "name": "Rocket Launch", "image": "/wallpapers/gallery/rocket-launch.jpg", "thumb": "/wallpapers/gallery/thumbs/rocket-launch.jpg" },
{ "id": "sakura", "name": "Sakura", "image": "/wallpapers/gallery/sakura.jpg", "thumb": "/wallpapers/gallery/thumbs/sakura.jpg" },
{ "id": "shiny-colors", "name": "Shiny Colors", "image": "/wallpapers/gallery/shiny-colors.jpg", "thumb": "/wallpapers/gallery/thumbs/shiny-colors.jpg" },
{ "id": "shooting-stars", "name": "Shooting Stars", "image": "/wallpapers/gallery/shooting-stars.jpg", "thumb": "/wallpapers/gallery/thumbs/shooting-stars.jpg" },
{ "id": "siri", "name": "Siri", "image": "/wallpapers/gallery/siri.jpg", "thumb": "/wallpapers/gallery/thumbs/siri.jpg" },
{ "id": "skeleton-heart", "name": "Skeleton Heart", "image": "/wallpapers/gallery/skeleton-heart.jpg", "thumb": "/wallpapers/gallery/thumbs/skeleton-heart.jpg" },
{ "id": "space-juice", "name": "Space Juice", "image": "/wallpapers/gallery/space-juice.jpg", "thumb": "/wallpapers/gallery/thumbs/space-juice.jpg" },
{ "id": "space-man", "name": "Space Man", "image": "/wallpapers/gallery/space-man.jpg", "thumb": "/wallpapers/gallery/thumbs/space-man.jpg" },
{ "id": "space-piano", "name": "Space Piano", "image": "/wallpapers/gallery/space-piano.jpg", "thumb": "/wallpapers/gallery/thumbs/space-piano.jpg" },
{ "id": "sunset-lake", "name": "Sunset Lake", "image": "/wallpapers/gallery/sunset-lake.jpg", "thumb": "/wallpapers/gallery/thumbs/sunset-lake.jpg" },
{ "id": "sushi-dark", "name": "Sushi Dark", "image": "/wallpapers/gallery/sushi-dark.jpg", "thumb": "/wallpapers/gallery/thumbs/sushi-dark.jpg" },
{ "id": "swords", "name": "Swords", "image": "/wallpapers/gallery/swords.jpg", "thumb": "/wallpapers/gallery/thumbs/swords.jpg" },
{ "id": "tabs", "name": "Tabs", "image": "/wallpapers/gallery/tabs.jpg", "thumb": "/wallpapers/gallery/thumbs/tabs.jpg" },
{ "id": "think", "name": "Think", "image": "/wallpapers/gallery/think.jpg", "thumb": "/wallpapers/gallery/thumbs/think.jpg" },
{ "id": "totoro", "name": "Totoro", "image": "/wallpapers/gallery/totoro.jpg", "thumb": "/wallpapers/gallery/thumbs/totoro.jpg" },
{ "id": "trigonometry", "name": "Trigonometry", "image": "/wallpapers/gallery/trigonometry.jpg", "thumb": "/wallpapers/gallery/thumbs/trigonometry.jpg" },
{ "id": "two-astronauts", "name": "Two Astronauts", "image": "/wallpapers/gallery/two-astronauts.jpg", "thumb": "/wallpapers/gallery/thumbs/two-astronauts.jpg" },
{ "id": "underwater", "name": "Underwater", "image": "/wallpapers/gallery/underwater.jpg", "thumb": "/wallpapers/gallery/thumbs/underwater.jpg" },
{ "id": "united-in-severance", "name": "United In Severance", "image": "/wallpapers/gallery/united-in-severance.jpg", "thumb": "/wallpapers/gallery/thumbs/united-in-severance.jpg" },
{ "id": "vintage-ascent", "name": "Vintage Ascent", "image": "/wallpapers/gallery/vintage-ascent.jpg", "thumb": "/wallpapers/gallery/thumbs/vintage-ascent.jpg" },
{ "id": "windows-black", "name": "Windows Black", "image": "/wallpapers/gallery/windows-black.jpg", "thumb": "/wallpapers/gallery/thumbs/windows-black.jpg" },
{ "id": "windows-error", "name": "Windows Error", "image": "/wallpapers/gallery/windows-error.jpg", "thumb": "/wallpapers/gallery/thumbs/windows-error.jpg" },
{ "id": "windows-magenta-blue", "name": "Windows Magenta Blue", "image": "/wallpapers/gallery/windows-magenta-blue.jpg", "thumb": "/wallpapers/gallery/thumbs/windows-magenta-blue.jpg" },
{ "id": "windows-magenta-pink", "name": "Windows Magenta Pink", "image": "/wallpapers/gallery/windows-magenta-pink.jpg", "thumb": "/wallpapers/gallery/thumbs/windows-magenta-pink.jpg" },
{ "id": "windows-xp", "name": "Windows Xp", "image": "/wallpapers/gallery/windows-xp.jpg", "thumb": "/wallpapers/gallery/thumbs/windows-xp.jpg" },
{ "id": "xavier-cuenca-w4-3", "name": "Xavier Cuenca W4 3", "image": "/wallpapers/gallery/xavier-cuenca-w4-3.jpg", "thumb": "/wallpapers/gallery/thumbs/xavier-cuenca-w4-3.jpg" },
{ "id": "yosemite-color-block", "name": "Yosemite Color Block", "image": "/wallpapers/gallery/yosemite-color-block.jpg", "thumb": "/wallpapers/gallery/thumbs/yosemite-color-block.jpg" },
{ "id": "yosemite-lowpoly", "name": "Yosemite Lowpoly", "image": "/wallpapers/gallery/yosemite-lowpoly.jpg", "thumb": "/wallpapers/gallery/thumbs/yosemite-lowpoly.jpg" }
]
+4 -2
View File
@@ -6,11 +6,13 @@ services:
env_file: env_file:
- .env - .env
environment: environment:
PORT: 3000 PORT: 5000
ports: ports:
- "${HOST_PORT:-3000}:3000" - "${HOST_PORT:-5000}:5000"
volumes: volumes:
- lastfm-data:/app/data/lastfm - lastfm-data:/app/data/lastfm
- library-data:/app/data/library
volumes: volumes:
lastfm-data: lastfm-data:
library-data:
+5 -1
View File
@@ -9,7 +9,11 @@
"preview": "vite preview", "preview": "vite preview",
"start": "bun --bun vite build && bun ./build/index.js", "start": "bun --bun vite build && bun ./build/index.js",
"content": "tools/content/.venv/bin/python tools/content/app.py", "content": "tools/content/.venv/bin/python tools/content/app.py",
"content:setup": "python3 -m venv tools/content/.venv && tools/content/.venv/bin/pip install -r tools/content/requirements.txt", "wallpapers": "tools/content/.venv/bin/python tools/content/wallpapers.py",
"games:import": "tools/content/.venv/bin/python tools/content/games_import.py",
"files": "tools/content/.venv/bin/python tools/content/static_files.py",
"library:push": "tools/content/.venv/bin/python tools/content/library_push.py",
"content:setup":"python3 -m venv tools/content/.venv && tools/content/.venv/bin/pip install -r tools/content/requirements.txt",
"prepare": "svelte-kit sync || echo ''", "prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
+5 -1
View File
@@ -17,10 +17,14 @@ export const variables = defineEnvVars({
}, },
OMDB_API_KEY: { OMDB_API_KEY: {
schema: optional, schema: optional,
description: 'OMDb API key used to load posters and details for shows and movies' description: 'OMDb API key the content tool uses to look up shows and movies'
}, },
LASTFM_API_KEY: { LASTFM_API_KEY: {
schema: optional, schema: optional,
description: 'Last.fm API key used to collect scrobbles for the listening stats' description: 'Last.fm API key used to collect scrobbles for the listening stats'
},
LIBRARY_API_KEY: {
schema: optional,
description: 'Secret key the content tool sends to add and change shows, movies and books'
} }
}); });
+104
View File
@@ -0,0 +1,104 @@
import type { Book, RatedTitle } from '#lib/content';
import type { AniListProfile } from '#lib/anilist/types';
import type { ListeningProfile } from '#lib/lastfm/types';
import type { AniListStats, BookStats, CountStat, ListeningStats, ScreenStats } from './types';
const recentDays = 30;
const topLimit = 5;
const statusColors: Record<string, string> = {
CURRENT: 'blue',
REPEATING: 'cyan',
COMPLETED: 'green',
PAUSED: 'yellow',
DROPPED: 'red',
PLANNING: 'magenta'
};
function average(values: number[]): string {
if (!values.length) return '-';
return `${(values.reduce((total, value) => total + value, 0) / values.length).toFixed(1)}/10`;
}
function ratingCounts(ratings: number[]): number[] {
const counts = Array<number>(11).fill(0);
ratings.forEach((rating) => counts[Math.round(rating)]++);
return counts;
}
function topCounts(values: string[], color: string): CountStat[] {
const counts = new Map<string, number>();
values.forEach((value) => counts.set(value, (counts.get(value) ?? 0) + 1));
return [...counts.entries()]
.sort((first, second) => second[1] - first[1])
.slice(0, topLimit)
.map(([label, count]) => ({ label, count, color }));
}
export function bookStats(books: Book[]): BookStats | null {
if (!books.length) return null;
const read = books.filter((book) => book.shelf === 'read');
const ratings = read.flatMap((book) => (book.rating === undefined ? [] : [book.rating]));
const seriesNames = new Set(books.flatMap((book) => (book.series ? [book.series.name] : [])));
const pagesRead = read.reduce((total, book) => total + (book.pages ?? 0), 0);
return {
shelves: [
{ label: 'read', count: read.length, color: 'green' },
{ label: 'reading', count: books.filter((book) => book.shelf === 'reading').length, color: 'blue' },
{ label: 'want to read', count: books.filter((book) => book.shelf === 'want-to-read').length, color: 'magenta' }
],
rows: [
{ label: 'Pages read', value: pagesRead.toLocaleString('en-US') },
{ label: 'Authors', value: `${new Set(read.flatMap((book) => book.authors)).size}` },
{ label: 'Series', value: `${seriesNames.size}` },
{ label: 'Avg rating', value: average(ratings) }
],
topGenres: topCounts(read.flatMap((book) => book.genres), 'cyan'),
ratingCounts: ratingCounts(ratings)
};
}
export function screenStats(titles: RatedTitle[]): ScreenStats {
const ratings = titles.flatMap((title) => (title.rating === undefined ? [] : [title.rating]));
const watching = titles.filter((title) => title.status === 'watching').map((title) => title.name);
const planningCount = titles.filter((title) => title.status === 'planning').length;
const groups = new Set(titles.flatMap((title) => (title.collection ? [title.collection] : [])));
return {
rows: [
{ label: 'Titles', value: `${titles.length}` },
{ label: 'Watching', value: `${watching.length}` },
{ label: 'Planning', value: `${planningCount}` },
{ label: 'Groups', value: `${groups.size}` },
{ label: 'Avg rating', value: average(ratings) }
],
ratingCounts: ratingCounts(ratings),
watching
};
}
export function listeningStats(profile: ListeningProfile): ListeningStats {
const artists = profile.top.month.artists.length ? profile.top.month.artists : profile.top.all.artists;
return {
profileUrl: profile.profileUrl,
rows: [
{ label: 'Scrobbles', value: profile.totals.scrobbles.toLocaleString('en-US') },
{ label: 'Artists', value: profile.totals.artists.toLocaleString('en-US') },
{ label: 'Per day', value: `${profile.averagePerDay}` },
{ label: 'Streak', value: `${profile.currentStreak}d (best ${profile.longestStreak}d)` }
],
daily: profile.daily.slice(-recentDays).map((bucket) => bucket.count),
hourly: profile.hourly.map((bucket) => bucket.count),
topArtists: artists.slice(0, topLimit).map((artist) => ({ label: artist.name, count: artist.count, color: 'red' }))
};
}
export function aniListStats(profile: AniListProfile): AniListStats {
return {
profileUrl: profile.listUrl,
rows: profile.stats,
statuses: profile.statusCounts
.filter((status) => status.count > 0)
.map((status) => ({ label: status.label, count: status.count, color: statusColors[status.status] ?? 'foreground' }))
};
}
+47
View File
@@ -38,3 +38,50 @@ export interface BtopData {
contributions: ContributionCalendar; contributions: ContributionCalendar;
repos: RepoProcess[]; repos: RepoProcess[];
} }
export interface CountStat {
label: string;
count: number;
color: string;
}
export interface StatRow {
label: string;
value: string;
}
export interface BookStats {
shelves: CountStat[];
rows: StatRow[];
topGenres: CountStat[];
ratingCounts: number[];
}
export interface ScreenStats {
rows: StatRow[];
ratingCounts: number[];
watching: string[];
}
export interface ListeningStats {
profileUrl: string;
rows: StatRow[];
daily: number[];
hourly: number[];
topArtists: CountStat[];
}
export interface AniListStats {
profileUrl: string;
rows: StatRow[];
statuses: CountStat[];
}
export interface HobbyStats {
listening: ListeningStats | null;
books: BookStats | null;
shows: ScreenStats;
movies: ScreenStats;
anime: AniListStats | null;
manga: AniListStats | null;
}
+109 -12
View File
@@ -1,28 +1,54 @@
<script lang="ts"> <script lang="ts">
import Icon from '@iconify/svelte'; import Icon from '@iconify/svelte';
import type { Game } from '#lib/content'; import type { Game } from '#lib/content';
import { storeFor } from '#lib/library/stores';
let { game }: { game: Game } = $props(); let { game }: { game: Game } = $props();
const storeNames: Record<string, { label: string; icon: string }> = { const link = $derived(game.url ?? game.igdbUrl ?? '');
'store.steampowered.com': { label: 'Steam', icon: 'mdi:steam' } const store = $derived(storeFor(link));
}; const releaseYear = $derived(game.released?.slice(0, 4) ?? null);
const metaLine = $derived([releaseYear, game.developers[0]].filter(Boolean).join(' · '));
const store = $derived.by(() => {
const host = new URL(game.url).hostname;
return storeNames[host] ?? { label: host.replace(/^www\./, ''), icon: 'mdi:web' };
});
</script> </script>
<a class="game-card" href={game.url} target="_blank" rel="noopener noreferrer"> <a class="game-card" href={link || undefined} target="_blank" rel="noopener noreferrer" title={game.summary ?? game.name}>
<div class="cover"> <div class="cover">
<img src={game.image} alt="" loading="lazy" /> {#if game.image}
<span class="play"><Icon icon="mdi:play" width="16" /> Play</span> <img src={game.image} alt="" loading="lazy" />
{:else}
<span class="placeholder"><Icon icon="mdi:gamepad-variant-outline" width="36" /></span>
{/if}
{#if game.favorite}
<span class="favorite"><Icon icon="mdi:star" width="13" />favorite</span>
{/if}
{#if game.rating !== undefined}
<span class="score"><Icon icon="mdi:star" width="13" />{game.rating}/10</span>
{:else if game.score !== null}
<span class="score community" title="IGDB rating"><Icon icon="mdi:star-outline" width="13" />{game.score}</span>
{/if}
{#if game.status === 'playing'}
<span class="status playing"><Icon icon="mdi:play" width="13" />playing</span>
{:else if game.status === 'planning'}
<span class="status planning"><Icon icon="mdi:bookmark-outline" width="13" />planning</span>
{:else if game.status === 'replayable'}
<span class="status replayable"><Icon icon="mdi:repeat" width="13" />replayable</span>
{/if}
{#if link}
<span class="play"><Icon icon="mdi:play" width="16" /> Play</span>
{/if}
</div> </div>
<div class="details"> <div class="details">
<span class="name">{game.name}</span> <span class="name">{game.name}</span>
<span class="store"><Icon icon={store.icon} width="14" />{store.label}</span> {#if link}
<span class="store"><Icon icon={store.icon} width="14" />{store.label}</span>
{/if}
</div> </div>
{#if metaLine}
<span class="meta">{metaLine}</span>
{/if}
{#if game.genres.length}
<span class="meta">{game.genres.slice(0, 3).join(' · ')}</span>
{/if}
</a> </a>
<style> <style>
@@ -57,6 +83,77 @@
transform: scale(1.05); transform: scale(1.05);
} }
.placeholder {
display: grid;
place-items: center;
height: 100%;
color: var(--muted);
}
.score {
position: absolute;
top: 0.4rem;
right: 0.4rem;
display: inline-flex;
align-items: center;
gap: 0.2rem;
padding: 0 0.4rem;
background: var(--background);
color: var(--yellow);
font-size: 0.78rem;
}
.meta {
overflow: hidden;
color: var(--muted);
font-size: 0.75rem;
white-space: nowrap;
text-overflow: ellipsis;
}
.favorite {
position: absolute;
top: 0.4rem;
left: 0.4rem;
display: inline-flex;
align-items: center;
gap: 0.2rem;
padding: 0 0.4rem;
background: var(--yellow);
color: var(--background);
font-size: 0.75rem;
font-weight: 700;
}
.score.community {
color: var(--muted);
}
.status {
position: absolute;
bottom: 0.4rem;
left: 0.4rem;
display: inline-flex;
align-items: center;
gap: 0.2rem;
padding: 0 0.4rem;
color: var(--background);
font-size: 0.75rem;
font-weight: 700;
}
.status.playing {
background: var(--blue);
}
.status.planning {
background: var(--magenta);
}
.status.replayable {
background: var(--cyan);
}
.play { .play {
position: absolute; position: absolute;
right: 0.5rem; right: 0.5rem;
+23 -19
View File
@@ -17,10 +17,14 @@
<span class="placeholder"><Icon icon={placeholderIcon} width="36" /></span> <span class="placeholder"><Icon icon={placeholderIcon} width="36" /></span>
{/if} {/if}
<span class="rating"><Icon icon="mdi:star" width="13" />{item.rating}/10</span> {#if item.rating !== undefined}
<span class="rating"><Icon icon="mdi:star" width="13" />{item.rating}/10</span>
{/if}
{#if details?.plot} {#if item.status === 'watching'}
<p class="plot">{details.plot}</p> <span class="status watching"><Icon icon="mdi:play" width="13" />watching</span>
{:else if item.status === 'planning'}
<span class="status planning"><Icon icon="mdi:bookmark-outline" width="13" />planning</span>
{/if} {/if}
</div> </div>
@@ -87,24 +91,25 @@
font-size: 0.78rem; font-size: 0.78rem;
} }
.plot { .status {
position: absolute; position: absolute;
right: 0; bottom: 0.4rem;
bottom: 0; left: 0.4rem;
left: 0; display: inline-flex;
margin: 0; align-items: center;
padding: 0.6rem; gap: 0.2rem;
background: color-mix(in srgb, var(--background) 92%, transparent); padding: 0 0.4rem;
border-top: 1px solid var(--accent); color: var(--background);
font-size: 0.75rem; font-size: 0.75rem;
line-height: 1.45; font-weight: 700;
transform: translateY(100%);
transition: transform 0.25s ease;
} }
.media-card:hover .plot, .status.watching {
.media-card:focus-visible .plot { background: var(--blue);
transform: translateY(0); }
.status.planning {
background: var(--magenta);
} }
.text { .text {
@@ -132,8 +137,7 @@
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.poster img, .poster img {
.plot {
transition: none; transition: none;
} }
} }
+1 -31
View File
@@ -31,10 +31,6 @@
{#if showShelf} {#if showShelf}
<span class="shelf {book.shelf}">{shelfLabels[book.shelf]}</span> <span class="shelf {book.shelf}">{shelfLabels[book.shelf]}</span>
{/if} {/if}
{#if book.description}
<p class="description">{book.description}</p>
{/if}
</div> </div>
<span class="title">{book.title}</span> <span class="title">{book.title}</span>
@@ -144,31 +140,6 @@
color: var(--foreground); color: var(--foreground);
} }
.description {
position: absolute;
right: 0;
bottom: 0;
left: 0;
display: -webkit-box;
margin: 0;
padding: 0.55rem;
overflow: hidden;
background: color-mix(in srgb, var(--background) 94%, transparent);
border-top: 1px solid var(--accent);
font-size: 0.72rem;
line-height: 1.45;
-webkit-line-clamp: 8;
line-clamp: 8;
-webkit-box-orient: vertical;
transform: translateY(100%);
transition: transform 0.25s ease;
}
.book-card:hover .description,
.book-card:focus-visible .description {
transform: translateY(0);
}
.title { .title {
display: -webkit-box; display: -webkit-box;
overflow: hidden; overflow: hidden;
@@ -191,8 +162,7 @@
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.cover, .cover,
.cover img, .cover img {
.description {
transition: none; transition: none;
} }
} }
@@ -1,147 +1,70 @@
<script lang="ts"> <script lang="ts">
import Icon from '@iconify/svelte';
import type { BookSeriesGroup } from '#lib/books/series'; import type { BookSeriesGroup } from '#lib/books/series';
import CollectionGroup from '#lib/components/ui/CollectionGroup.svelte';
import BookCard from './BookCard.svelte'; import BookCard from './BookCard.svelte';
let { series }: { series: BookSeriesGroup } = $props(); let { series }: { series: BookSeriesGroup } = $props();
const collapsedCount = 8; const shelfTones = { read: 'green', reading: 'blue', 'want-to-read': 'magenta' };
let showAll = $state(false);
const shownBooks = $derived(showAll ? series.books : series.books.slice(0, collapsedCount));
const total = $derived(series.books.length); const total = $derived(series.books.length);
const shelves = $derived([ const shelves = $derived([
{ id: 'read', label: 'read', count: series.counts.read }, { id: 'read', label: 'read', count: series.counts.read },
{ id: 'reading', label: 'reading', count: series.counts.reading }, { id: 'reading', label: 'reading', count: series.counts.reading },
{ id: 'want-to-read', label: 'want to read', count: series.counts['want-to-read'] } { id: 'want-to-read', label: 'want to read', count: series.counts['want-to-read'] }
]); ] as const);
const covers = $derived(
series.books.map((book) => ({
id: book.goodreadsId,
href: book.url,
title: `${book.series?.position ?? ''} ${book.title}`.trim(),
image: book.cover,
tone: shelfTones[book.shelf]
}))
);
</script> </script>
<section class="series"> <CollectionGroup name={series.name} href={series.url} icon="mdi:bookshelf" fallbackIcon="mdi:book-outline" {covers}>
<header class="series-header"> {#snippet summary()}
<a class="series-name" href={series.url} target="_blank" rel="noopener noreferrer"> {#each shelves.filter((shelf) => shelf.count > 0) as shelf (shelf.id)}
<Icon icon="mdi:bookshelf" width="16" /> <span style:color="var(--{shelfTones[shelf.id]})">{shelf.count} {shelf.label}</span>
{series.name}
</a>
<span class="series-counts">
{#each shelves.filter((shelf) => shelf.count > 0) as shelf (shelf.id)}
<span class="count {shelf.id}">{shelf.count} {shelf.label}</span>
{/each}
</span>
</header>
<div class="progress" role="img" aria-label="{series.counts.read} of {total} read">
{#each shelves as shelf (shelf.id)}
{#if shelf.count}
<span class="segment {shelf.id}" style:flex-grow={shelf.count}></span>
{/if}
{/each} {/each}
</div> {/snippet}
{#snippet details()}
<div class="progress" role="img" aria-label="{series.counts.read} of {total} read">
{#each shelves as shelf (shelf.id)}
{#if shelf.count}
<span class="segment" style:flex-grow={shelf.count} style:background="var(--{shelfTones[shelf.id]})"></span>
{/if}
{/each}
</div>
{/snippet}
<div class="book-grid"> <div class="book-grid">
{#each shownBooks as book (book.goodreadsId)} {#each series.books as book (book.goodreadsId)}
<BookCard {book} showShelf /> <BookCard {book} showShelf />
{/each} {/each}
</div> </div>
</CollectionGroup>
{#if total > collapsedCount}
<button class="toggle" onclick={() => (showAll = !showAll)}>
<Icon icon={showAll ? 'mdi:chevron-up' : 'mdi:chevron-down'} width="16" />
{showAll ? 'Show less' : `Show all ${total}`}
</button>
{/if}
</section>
<style> <style>
.series {
padding: 0.75rem;
border: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
}
.series-header {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 0.4rem 1rem;
}
.series-name {
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: var(--accent);
font-weight: 700;
}
.series-name:hover {
text-decoration: underline;
}
.series-counts {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
font-size: 0.78rem;
}
.count.read {
color: var(--green);
}
.count.reading {
color: var(--blue);
}
.count.want-to-read {
color: var(--magenta);
}
.progress { .progress {
display: flex; display: flex;
gap: 2px; gap: 2px;
height: 4px; height: 4px;
margin: 0.5rem 0 0.85rem; margin-bottom: 0.35rem;
} }
.segment { .segment {
flex-basis: 0; flex-basis: 0;
} }
.segment.read {
background: var(--green);
}
.segment.reading {
background: var(--blue);
}
.segment.want-to-read {
background: var(--magenta);
}
.book-grid { .book-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 0.9rem 0.75rem; gap: 0.9rem 0.75rem;
} padding-top: 0.5rem;
.toggle {
display: inline-flex;
align-items: center;
gap: 0.3rem;
margin-top: 0.75rem;
padding: 0.3rem 0.7rem;
background: none;
border: 1px solid color-mix(in srgb, var(--foreground) 30%, transparent);
color: var(--foreground);
font-size: 0.85rem;
}
.toggle:hover {
border-color: var(--accent);
color: var(--accent);
} }
@container (max-width: 520px) { @container (max-width: 520px) {
@@ -112,7 +112,8 @@
.series-list { .series-list {
display: flex; display: flex;
flex-direction: column; flex-wrap: wrap;
align-items: flex-start;
gap: 0.75rem; gap: 0.75rem;
} }
@@ -0,0 +1,64 @@
<script lang="ts">
import type { MediaCollection } from '#lib/media/collections';
import { isPlanning, isWatching, statusTone } from '#lib/media/collections';
import type { MediaDetails } from '#lib/media/types';
import CollectionGroup from '#lib/components/ui/CollectionGroup.svelte';
import MediaCard from '../MediaCard.svelte';
let {
collection,
mediaDetails,
kind
}: { collection: MediaCollection; mediaDetails: Record<string, MediaDetails>; kind: 'show' | 'movie' } = $props();
const icon = $derived(kind === 'show' ? 'mdi:television-classic' : 'mdi:movie-open-outline');
const watchingCount = $derived(collection.items.filter(isWatching).length);
const planningCount = $derived(collection.items.filter(isPlanning).length);
const rated = $derived(collection.items.filter((item) => item.rating !== undefined));
const average = $derived(rated.length ? rated.reduce((total, item) => total + (item.rating ?? 0), 0) / rated.length : null);
const covers = $derived(
collection.items.map((item) => ({
id: item.url,
href: item.url,
title: item.name,
image: mediaDetails[item.url]?.poster ?? null,
tone: statusTone(item)
}))
);
</script>
<CollectionGroup name={collection.name} {icon} fallbackIcon={icon} {covers}>
{#snippet summary()}
<span class="muted">{collection.items.length} {kind === 'show' ? 'shows' : 'movies'}</span>
{#if watchingCount}
<span style:color="var(--blue)">{watchingCount} watching</span>
{/if}
{#if planningCount}
<span style:color="var(--magenta)">{planningCount} planning</span>
{/if}
{#if average !== null}
<span style:color="var(--yellow)">avg {average.toFixed(1)}/10</span>
{/if}
{/snippet}
<div class="media-grid">
{#each collection.items as item (item.url)}
<MediaCard {item} details={mediaDetails[item.url]} {kind} />
{/each}
</div>
</CollectionGroup>
<style>
.media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1rem 0.85rem;
padding-top: 0.5rem;
}
@container (max-width: 520px) {
.media-grid {
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
}
}
</style>
@@ -0,0 +1,55 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { RatedTitle } from '#lib/content';
import type { MediaDetails } from '#lib/media/types';
import { groupMedia } from '#lib/media/collections';
import MediaGrid from '../MediaGrid.svelte';
import MediaCollectionGroup from './MediaCollectionGroup.svelte';
let {
items,
mediaDetails,
kind
}: { items: RatedTitle[]; mediaDetails: Record<string, MediaDetails>; kind: 'show' | 'movie' } = $props();
const grouped = $derived(groupMedia(items, mediaDetails));
const collectionLabel = $derived(kind === 'show' ? 'Series' : 'Collections');
</script>
{#if grouped.collections.length}
<h3 class="subheading"><Icon icon="mdi:layers-outline" width="16" /> {collectionLabel} <span class="muted">{grouped.collections.length}</span></h3>
<div class="collection-list">
{#each grouped.collections as collection (collection.name)}
<MediaCollectionGroup {collection} {mediaDetails} {kind} />
{/each}
</div>
{/if}
{#if grouped.standalone.length}
{#if grouped.collections.length}
<h3 class="subheading"><Icon icon="mdi:view-grid-outline" width="16" /> All {kind === 'show' ? 'shows' : 'movies'} <span class="muted">{grouped.standalone.length}</span></h3>
{/if}
<MediaGrid items={grouped.standalone} {mediaDetails} {kind} />
{/if}
<style>
.subheading {
display: flex;
align-items: center;
gap: 0.4rem;
margin: 1.25rem 0 0.6rem;
color: var(--foreground);
font-size: 0.9rem;
}
.subheading:first-child {
margin-top: 0;
}
.collection-list {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 0.75rem;
}
</style>
+3 -2
View File
@@ -5,11 +5,12 @@
title, title,
number, number,
color = 'accent', color = 'accent',
tall = false,
children children
}: { title: string; number: number; color?: string; children: Snippet } = $props(); }: { title: string; number: number; color?: string; tall?: boolean; children: Snippet } = $props();
</script> </script>
<section class="btop-box" style:--box-color="var(--{color})"> <section class="btop-box" class:tall style:--box-color="var(--{color})">
<span class="box-title"><sup>{number}</sup>{title}</span> <span class="box-title"><sup>{number}</sup>{title}</span>
{@render children()} {@render children()}
</section> </section>
+2 -1
View File
@@ -52,9 +52,10 @@
.graph { .graph {
display: flex; display: flex;
flex: 1;
align-items: flex-end; align-items: flex-end;
gap: 1px; gap: 1px;
height: 70px; min-height: 70px;
margin-top: 0.4rem; margin-top: 0.4rem;
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent); border-bottom: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
} }
@@ -0,0 +1,50 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { HobbyStats } from '#lib/btop/types';
import ListeningPanel from './hobbies/ListeningPanel.svelte';
import BooksPanel from './hobbies/BooksPanel.svelte';
import ScreenPanel from './hobbies/ScreenPanel.svelte';
import AniListPanel from './hobbies/AniListPanel.svelte';
let stats = $state<HobbyStats | null>(null);
onMount(() => {
fetch('/api/btop/hobbies')
.then((response) => (response.ok ? response.json() : null))
.then((result) => (stats = result))
.catch(() => (stats = null));
});
</script>
{#if stats}
<div class="hobby-panels">
{#if stats.listening}
<ListeningPanel stats={stats.listening} number={5} />
{/if}
{#if stats.books}
<BooksPanel stats={stats.books} number={6} />
{/if}
<ScreenPanel stats={stats.shows} title="tv" number={7} color="yellow" />
<ScreenPanel stats={stats.movies} title="movies" number={8} color="green" />
{#if stats.anime}
<AniListPanel stats={stats.anime} title="anime" number={9} color="magenta" />
{/if}
{#if stats.manga}
<AniListPanel stats={stats.manga} title="manga" number={10} color="blue" />
{/if}
</div>
{/if}
<style>
.hobby-panels {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, max(240px, 25% - 0.75rem)), 1fr));
grid-auto-flow: dense;
gap: 1rem 0.75rem;
padding-top: 0.4rem;
}
.hobby-panels > :global(.btop-box.tall) {
grid-row: span 2;
}
</style>
+24 -10
View File
@@ -5,6 +5,7 @@
import MemoryPanel from './MemoryPanel.svelte'; import MemoryPanel from './MemoryPanel.svelte';
import ContributionPanel from './ContributionPanel.svelte'; import ContributionPanel from './ContributionPanel.svelte';
import ProcessPanel from './ProcessPanel.svelte'; import ProcessPanel from './ProcessPanel.svelte';
import HobbyPanels from './HobbyPanels.svelte';
let data = $state<BtopData | null>(null); let data = $state<BtopData | null>(null);
let clock = $state(new Date()); let clock = $state(new Date());
@@ -35,6 +36,8 @@
<div class="mem"><MemoryPanel {data} /></div> <div class="mem"><MemoryPanel {data} /></div>
<div class="proc"><ProcessPanel {data} /></div> <div class="proc"><ProcessPanel {data} /></div>
</div> </div>
<HobbyPanels />
</div> </div>
<style> <style>
@@ -45,6 +48,7 @@
height: 100%; height: 100%;
min-height: 0; min-height: 0;
padding: 0.5rem 0.75rem 0.75rem; padding: 0.5rem 0.75rem 0.75rem;
overflow-y: auto;
container-type: inline-size; container-type: inline-size;
} }
@@ -62,14 +66,28 @@
.panels { .panels {
display: grid; display: grid;
flex: 1; flex: none;
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
grid-template-rows: auto minmax(0, 1fr); grid-template-rows: auto auto;
grid-template-areas: grid-template-areas:
'cpu net' 'cpu net'
'proc mem'; 'proc mem';
gap: 1rem 0.75rem; gap: 1rem 0.75rem;
min-height: 0; }
.cpu,
.net,
.mem,
.proc {
display: flex;
min-width: 0;
}
.cpu > :global(.btop-box),
.net > :global(.btop-box),
.mem > :global(.btop-box),
.proc > :global(.btop-box) {
flex: 1;
} }
.cpu { .cpu {
@@ -86,12 +104,8 @@
.proc { .proc {
grid-area: proc; grid-area: proc;
display: flex; min-height: 220px;
min-height: 0; contain: size;
}
.proc > :global(.btop-box) {
flex: 1;
} }
@container (max-width: 760px) { @container (max-width: 760px) {
@@ -106,7 +120,7 @@
} }
.proc { .proc {
min-height: 220px; min-height: 260px;
} }
} }
</style> </style>
@@ -0,0 +1,53 @@
<script lang="ts">
let {
values,
color,
height = 56,
labels = [],
grow = false
}: { values: number[]; color: string; height?: number; labels?: string[]; grow?: boolean } = $props();
const minimumScale = 0.02;
const highest = $derived(Math.max(1, ...(values ?? [])));
function barScale(value: number): number {
return Math.max(minimumScale, value / highest);
}
</script>
<div class="bar-graph" class:grow style:min-height="{height}px" style:height={grow ? null : `${height}px`} aria-hidden="true">
{#each values ?? [] as value, index (index)}
<span
class="bar"
class:empty={value === 0}
style:transform="scaleY({barScale(value)})"
style:background="var(--{color})"
title={labels[index] ? `${labels[index]}: ${value}` : `${value}`}
></span>
{/each}
</div>
<style>
.bar-graph {
display: flex;
align-items: flex-end;
gap: 1px;
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
}
.bar-graph.grow {
flex: 1;
}
.bar {
flex: 1;
align-self: stretch;
opacity: 0.85;
transform-origin: bottom;
}
.bar.empty {
opacity: 0.25;
}
</style>
@@ -0,0 +1,46 @@
<script lang="ts">
import type { CountStat } from '#lib/btop/types';
let { items }: { items: CountStat[] } = $props();
const highest = $derived(Math.max(1, ...items.map((item) => item.count)));
</script>
<div class="count-bars">
{#each items as item (item.label)}
<span class="label" title={item.label}>{item.label}</span>
<span class="track"><span style:width="{(item.count / highest) * 100}%" style:background="var(--{item.color})"></span></span>
<span class="count" style:color="var(--{item.color})">{item.count}</span>
{/each}
</div>
<style>
.count-bars {
display: grid;
grid-template-columns: minmax(0, 7.5rem) minmax(0, 1fr) auto;
align-items: center;
gap: 0.25rem 0.5rem;
font-size: 0.75rem;
}
.label {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.track {
display: block;
height: 6px;
background: color-mix(in srgb, var(--foreground) 10%, transparent);
}
.track span {
display: block;
height: 100%;
}
.count {
text-align: right;
}
</style>
@@ -0,0 +1,16 @@
<script lang="ts">
let { text, color }: { text: string; color: string } = $props();
</script>
<span class="section-label" style:color="var(--{color})">{text}</span>
<style>
.section-label {
display: block;
margin: 0.6rem 0 0.3rem;
padding-top: 0.45rem;
border-top: 1px dashed color-mix(in srgb, var(--foreground) 20%, transparent);
font-size: 0.72rem;
font-weight: 700;
}
</style>
@@ -0,0 +1,40 @@
<script lang="ts">
import type { CountStat } from '#lib/btop/types';
let { items }: { items: CountStat[] } = $props();
const shown = $derived(items.filter((item) => item.count > 0));
</script>
<div class="stacked">
<div class="bar" role="img" aria-label={shown.map((item) => `${item.label}: ${item.count}`).join(', ')}>
{#each shown as item (item.label)}
<span style:flex-grow={item.count} style:background="var(--{item.color})"></span>
{/each}
</div>
<div class="legend">
{#each shown as item (item.label)}
<span style:color="var(--{item.color})">{item.count} {item.label}</span>
{/each}
</div>
</div>
<style>
.bar {
display: flex;
gap: 2px;
height: 8px;
}
.bar span {
flex-basis: 0;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 0.2rem 0.75rem;
margin-top: 0.35rem;
font-size: 0.72rem;
}
</style>
@@ -0,0 +1,26 @@
<script lang="ts">
import type { StatRow } from '#lib/btop/types';
let { rows }: { rows: StatRow[] } = $props();
</script>
<div class="stat-rows">
{#each rows as row (row.label)}
<div class="row"><span class="muted">{row.label}</span><span>{row.value}</span></div>
{/each}
</div>
<style>
.stat-rows {
display: flex;
flex-direction: column;
gap: 0.15rem;
font-size: 0.75rem;
}
.row {
display: flex;
justify-content: space-between;
gap: 0.75rem;
}
</style>
@@ -0,0 +1,18 @@
<script lang="ts">
import type { AniListStats } from '#lib/btop/types';
import BtopBox from '../BtopBox.svelte';
import CountBars from '../charts/CountBars.svelte';
import StatRows from '../charts/StatRows.svelte';
import SectionLabel from '../charts/SectionLabel.svelte';
let { stats, title, number, color }: { stats: AniListStats; title: string; number: number; color: string } = $props();
</script>
<BtopBox {title} {number} {color}>
<StatRows rows={stats.rows} />
{#if stats.statuses.length}
<SectionLabel text="lists" {color} />
<CountBars items={stats.statuses} />
{/if}
</BtopBox>
@@ -0,0 +1,37 @@
<script lang="ts">
import type { BookStats } from '#lib/btop/types';
import BtopBox from '../BtopBox.svelte';
import BarGraph from '../charts/BarGraph.svelte';
import CountBars from '../charts/CountBars.svelte';
import StackedBar from '../charts/StackedBar.svelte';
import StatRows from '../charts/StatRows.svelte';
import SectionLabel from '../charts/SectionLabel.svelte';
let { stats, number }: { stats: BookStats; number: number } = $props();
const ratingLabels = Array.from({ length: 11 }, (_, rating) => `${rating}/10`);
</script>
<BtopBox title="books" {number} color="cyan" tall>
<StackedBar items={stats.shelves} />
<SectionLabel text="reading" color="cyan" />
<StatRows rows={stats.rows} />
{#if stats.topGenres.length}
<SectionLabel text="top genres" color="cyan" />
<CountBars items={stats.topGenres} />
{/if}
<SectionLabel text="ratings" color="cyan" />
<BarGraph values={stats.ratingCounts} labels={ratingLabels} color="cyan" height={44} grow />
<div class="axis muted"><span>0</span><span>5</span><span>10</span></div>
</BtopBox>
<style>
.axis {
display: flex;
justify-content: space-between;
font-size: 0.65rem;
}
</style>
@@ -0,0 +1,27 @@
<script lang="ts">
import type { ListeningStats } from '#lib/btop/types';
import BtopBox from '../BtopBox.svelte';
import BarGraph from '../charts/BarGraph.svelte';
import CountBars from '../charts/CountBars.svelte';
import StatRows from '../charts/StatRows.svelte';
import SectionLabel from '../charts/SectionLabel.svelte';
let { stats, number }: { stats: ListeningStats; number: number } = $props();
const hourLabels = Array.from({ length: 24 }, (_, hour) => `${hour % 12 || 12}${hour < 12 ? 'am' : 'pm'}`);
</script>
<BtopBox title="music" {number} color="red" tall>
<StatRows rows={stats.rows} />
<SectionLabel text="scrobbles, last {stats.daily.length} days" color="red" />
<BarGraph values={stats.daily} color="red" grow />
<SectionLabel text="by hour of day" color="red" />
<BarGraph values={stats.hourly} labels={hourLabels} color="magenta" height={36} />
{#if stats.topArtists.length}
<SectionLabel text="top artists" color="red" />
<CountBars items={stats.topArtists} />
{/if}
</BtopBox>
@@ -0,0 +1,49 @@
<script lang="ts">
import type { ScreenStats } from '#lib/btop/types';
import BtopBox from '../BtopBox.svelte';
import BarGraph from '../charts/BarGraph.svelte';
import StatRows from '../charts/StatRows.svelte';
import SectionLabel from '../charts/SectionLabel.svelte';
let { stats, title, number, color }: { stats: ScreenStats; title: string; number: number; color: string } = $props();
const ratingLabels = Array.from({ length: 11 }, (_, rating) => `${rating}/10`);
</script>
<BtopBox {title} {number} {color}>
<StatRows rows={stats.rows} />
<SectionLabel text="ratings" {color} />
<BarGraph values={stats.ratingCounts} labels={ratingLabels} {color} height={44} grow />
<div class="axis muted"><span>0</span><span>5</span><span>10</span></div>
{#if stats.watching.length}
<SectionLabel text="watching now" {color} />
<ul class="watching">
{#each stats.watching as name (name)}
<li><span style:color="var(--blue)">&gt;</span> {name}</li>
{/each}
</ul>
{/if}
</BtopBox>
<style>
.axis {
display: flex;
justify-content: space-between;
font-size: 0.65rem;
}
.watching {
margin: 0;
padding: 0;
list-style: none;
font-size: 0.75rem;
}
.watching li {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
</style>
+27
View File
@@ -12,6 +12,9 @@
import Breakout from '../games/Breakout.svelte'; import Breakout from '../games/Breakout.svelte';
import DesktopPet from '../pet/DesktopPet.svelte'; import DesktopPet from '../pet/DesktopPet.svelte';
import TcgBrowser from '../tcg/TcgBrowser.svelte'; import TcgBrowser from '../tcg/TcgBrowser.svelte';
import LibraryApp from '../library/LibraryApp.svelte';
import FileExplorer from '../files/FileExplorer.svelte';
import { libraryApps } from '#lib/library/apps';
import DesktopIcons from './DesktopIcons.svelte'; import DesktopIcons from './DesktopIcons.svelte';
import Window from './Window.svelte'; import Window from './Window.svelte';
@@ -178,6 +181,30 @@
</Window> </Window>
{/each} {/each}
{#each windowManager.libraryWindows as libraryWindow (libraryWindow.id)}
<Window
title={libraryWindow.app}
path="library"
icon={libraryApps[libraryWindow.app].icon}
{...windowProps(libraryWindow.id)}
onclose={() => windowManager.closeWindow(libraryWindow.id)}
>
<LibraryApp name={libraryWindow.app} />
</Window>
{/each}
{#each windowManager.fileExplorers as explorer (explorer.id)}
<Window
title="thunar"
path={explorer.startPath ? `~/files/${explorer.startPath}` : '~/files'}
icon="mdi:folder-open-outline"
{...windowProps(explorer.id)}
onclose={() => windowManager.closeWindow(explorer.id)}
>
<FileExplorer startPath={explorer.startPath} />
</Window>
{/each}
</div> </div>
</div> </div>
@@ -0,0 +1,137 @@
<script lang="ts">
import { onMount } from 'svelte';
import { ExplorerState } from '#lib/files/explorer.svelte';
import { formatSize } from '#lib/files/types';
import FileToolbar from './FileToolbar.svelte';
import FileSidebar from './FileSidebar.svelte';
import FileGrid from './FileGrid.svelte';
import FileList from './FileList.svelte';
import FilePreview from './FilePreview.svelte';
let { startPath = '' }: { startPath?: string } = $props();
const explorer = new ExplorerState(startPath);
const fileCount = $derived(explorer.shownEntries.filter((entry) => entry.kind !== 'folder').length);
const folderCount = $derived(explorer.shownEntries.length - fileCount);
const totalSize = $derived(explorer.shownEntries.reduce((total, entry) => total + entry.size, 0));
const keyActions: Record<string, () => void> = {
ArrowLeft: () => explorer.moveSelection(-1),
ArrowUp: () => explorer.moveSelection(-1),
ArrowRight: () => explorer.moveSelection(1),
ArrowDown: () => explorer.moveSelection(1),
Enter: () => explorer.selected && explorer.open(explorer.selected),
Backspace: () => explorer.up()
};
function handleKeydown(event: KeyboardEvent) {
if (event.target instanceof HTMLInputElement) return;
const action = keyActions[event.key];
if (!action) return;
event.preventDefault();
action();
}
onMount(() => explorer.start());
</script>
<div class="explorer" role="application" aria-label="File explorer">
<FileToolbar {explorer} />
<div class="body">
<FileSidebar {explorer} />
<div class="files" tabindex="0" role="listbox" aria-label="Files" onkeydown={handleKeydown}>
{#if explorer.state === 'error'}
<p class="message">Could not open this folder.</p>
{:else if explorer.state === 'loading' && !explorer.entries.length}
<p class="message muted">Loading...</p>
{:else if !explorer.shownEntries.length}
<p class="message muted">{explorer.query ? 'No files match.' : 'This folder is empty.'}</p>
{:else if explorer.view === 'grid'}
<FileGrid {explorer} />
{:else}
<FileList {explorer} />
{/if}
</div>
{#if explorer.selected}
<FilePreview entry={explorer.selected} onopen={() => explorer.selected && explorer.open(explorer.selected)} />
{/if}
</div>
<footer class="status muted">
<span>{folderCount} folders, {fileCount} files ({formatSize(totalSize)})</span>
{#if explorer.selected}
<span class="selected-name">{explorer.selected.name}</span>
{/if}
</footer>
</div>
<style>
.explorer {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
container-type: inline-size;
}
.body {
display: flex;
flex: 1;
min-height: 0;
}
.files {
flex: 1;
min-width: 0;
overflow-y: auto;
outline: none;
}
.message {
margin: 0;
padding: 1rem;
}
.status {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 0.25rem 0.75rem;
border-top: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
font-size: 0.75rem;
}
.selected-name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
@container (max-width: 720px) {
.body > :global(.sidebar) {
display: none;
}
}
@container (max-width: 560px) {
.body > :global(.preview) {
position: absolute;
right: 0;
bottom: 1.6rem;
left: 0;
max-height: 55%;
background: var(--background);
width: auto;
border-top: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
border-left: none;
}
.explorer {
position: relative;
}
}
</style>
+96
View File
@@ -0,0 +1,96 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { ExplorerState } from '#lib/files/explorer.svelte';
import { fileUrl, kindIcon } from '#lib/files/types';
let { explorer }: { explorer: ExplorerState } = $props();
</script>
<div class="grid">
{#each explorer.shownEntries as entry (entry.path)}
<button
class="item"
class:selected={entry.path === explorer.selectedPath}
title={entry.name}
onclick={() => explorer.select(entry)}
ondblclick={() => explorer.open(entry)}
>
<span class="thumb" class:folder={entry.kind === 'folder'}>
{#if entry.kind === 'image'}
<img src={fileUrl(entry.path)} alt="" loading="lazy" decoding="async" />
{:else}
<Icon icon={kindIcon(entry.kind)} width="44" />
{/if}
</span>
<span class="name">{entry.name}</span>
</button>
{/each}
</div>
<style>
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 0.5rem;
padding: 0.6rem;
}
.item {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
min-width: 0;
padding: 0.4rem;
background: none;
border: 1px solid transparent;
color: var(--foreground);
}
.item:hover {
background: color-mix(in srgb, var(--foreground) 6%, transparent);
}
.item.selected {
background: color-mix(in srgb, var(--accent) 18%, transparent);
border-color: var(--accent);
}
.thumb {
display: grid;
place-items: center;
width: 100%;
aspect-ratio: 1;
overflow: hidden;
color: var(--muted);
}
.thumb.folder {
color: var(--accent);
}
.thumb img {
width: 100%;
height: 100%;
object-fit: cover;
background: var(--dark-background);
}
.name {
display: -webkit-box;
width: 100%;
overflow: hidden;
font-size: 0.75rem;
text-align: center;
word-break: break-all;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
}
@container (max-width: 560px) {
.grid {
grid-template-columns: repeat(auto-fill, minmax(84px, 1fr));
}
}
</style>
+98
View File
@@ -0,0 +1,98 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { ExplorerState } from '#lib/files/explorer.svelte';
import { formatSize, kindIcon, type FileEntry } from '#lib/files/types';
let { explorer }: { explorer: ExplorerState } = $props();
function sizeText(entry: FileEntry): string {
return entry.kind === 'folder' ? `${entry.itemCount ?? 0} items` : formatSize(entry.size);
}
</script>
<table class="list">
<thead>
<tr>
<th>Name</th>
<th class="size">Size</th>
<th class="modified">Modified</th>
</tr>
</thead>
<tbody>
{#each explorer.shownEntries as entry (entry.path)}
<tr
class:selected={entry.path === explorer.selectedPath}
onclick={() => explorer.select(entry)}
ondblclick={() => explorer.open(entry)}
>
<td class="name">
<Icon icon={kindIcon(entry.kind)} width="16" color={entry.kind === 'folder' ? 'var(--accent)' : undefined} />
<span>{entry.name}</span>
</td>
<td class="size">{sizeText(entry)}</td>
<td class="modified">{entry.modified.slice(0, 10)}</td>
</tr>
{/each}
</tbody>
</table>
<style>
.list {
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
}
th {
position: sticky;
top: 0;
padding: 0.35rem 0.6rem;
background: var(--background);
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 12%, transparent);
color: var(--muted);
font-weight: 400;
text-align: left;
}
td {
padding: 0.25rem 0.6rem;
white-space: nowrap;
}
tr {
cursor: default;
}
tbody tr:hover {
background: color-mix(in srgb, var(--foreground) 6%, transparent);
}
tr.selected {
background: color-mix(in srgb, var(--accent) 18%, transparent);
}
.name {
display: flex;
align-items: center;
gap: 0.5rem;
max-width: 0;
width: 100%;
}
.name span {
overflow: hidden;
text-overflow: ellipsis;
}
.size,
.modified {
color: var(--muted);
text-align: right;
}
@container (max-width: 560px) {
.modified {
display: none;
}
}
</style>
+112
View File
@@ -0,0 +1,112 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { fileUrl, formatSize, kindIcon, type FileEntry } from '#lib/files/types';
let { entry, onopen }: { entry: FileEntry; onopen: () => void } = $props();
const rows = $derived([
['Type', entry.kind],
['Size', entry.kind === 'folder' ? `${entry.itemCount ?? 0} items` : formatSize(entry.size)],
['Modified', entry.modified.slice(0, 10)],
['Path', fileUrl(entry.path)]
]);
</script>
<aside class="preview" aria-label="{entry.name} preview">
<div class="media">
{#if entry.kind === 'image'}
<img src={fileUrl(entry.path)} alt={entry.name} />
{:else if entry.kind === 'video'}
<video src={fileUrl(entry.path)} controls muted preload="metadata"></video>
{:else if entry.kind === 'audio'}
<audio src={fileUrl(entry.path)} controls preload="none"></audio>
{:else}
<Icon icon={kindIcon(entry.kind)} width="64" />
{/if}
</div>
<h3>{entry.name}</h3>
<dl>
{#each rows as [label, value] (label)}
<dt>{label}</dt>
<dd>{value}</dd>
{/each}
</dl>
<button class="open" onclick={onopen}>
<Icon icon={entry.kind === 'folder' ? 'mdi:folder-open-outline' : 'mdi:open-in-new'} width="15" />
{entry.kind === 'folder' ? 'Open folder' : entry.kind === 'image' ? 'Open in viewer' : 'Open file'}
</button>
</aside>
<style>
.preview {
display: flex;
flex-direction: column;
gap: 0.6rem;
width: 240px;
padding: 0.75rem;
overflow-y: auto;
border-left: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
font-size: 0.8rem;
}
.media {
display: grid;
place-items: center;
min-height: 120px;
background: var(--dark-background);
color: var(--muted);
}
.media img,
.media video {
max-width: 100%;
max-height: 220px;
object-fit: contain;
}
.media audio {
width: 100%;
}
h3 {
margin: 0;
color: var(--accent);
font-size: 0.9rem;
word-break: break-all;
}
dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.2rem 0.6rem;
margin: 0;
}
dt {
color: var(--blue);
font-weight: 700;
}
dd {
margin: 0;
word-break: break-all;
}
.open {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
padding: 0.3rem 0.6rem;
background: none;
border: 1px solid color-mix(in srgb, var(--foreground) 30%, transparent);
color: var(--foreground);
}
.open:hover {
border-color: var(--accent);
color: var(--accent);
}
</style>
@@ -0,0 +1,63 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { ExplorerState } from '#lib/files/explorer.svelte';
let { explorer }: { explorer: ExplorerState } = $props();
const topFolder = $derived(explorer.path.split('/')[0]);
</script>
<aside class="sidebar" aria-label="Places">
<span class="heading">Places</span>
<button class:active={explorer.path === ''} onclick={() => explorer.navigate('')}>
<Icon icon="mdi:folder-home-outline" width="16" />files
</button>
{#each explorer.places as place (place.path)}
<button class:active={topFolder === place.path} onclick={() => explorer.navigate(place.path)}>
<Icon icon="mdi:folder-outline" width="16" />{place.name}
</button>
{/each}
</aside>
<style>
.sidebar {
display: flex;
flex-direction: column;
gap: 0.1rem;
width: 170px;
padding: 0.5rem 0.4rem;
overflow-y: auto;
border-right: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
}
.heading {
padding: 0 0.4rem 0.3rem;
color: var(--muted);
font-size: 0.72rem;
text-transform: uppercase;
}
button {
display: flex;
align-items: center;
gap: 0.45rem;
padding: 0.25rem 0.4rem;
overflow: hidden;
background: none;
border: none;
color: var(--foreground);
font-size: 0.82rem;
text-align: left;
white-space: nowrap;
text-overflow: ellipsis;
}
button:hover {
background: color-mix(in srgb, var(--foreground) 8%, transparent);
}
button.active {
background: color-mix(in srgb, var(--foreground) 14%, transparent);
color: var(--accent);
}
</style>
+131
View File
@@ -0,0 +1,131 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { ExplorerState } from '#lib/files/explorer.svelte';
let { explorer }: { explorer: ExplorerState } = $props();
const navButtons = $derived([
{ label: 'Back', icon: 'mdi:arrow-left', enabled: explorer.canGoBack, run: () => explorer.back() },
{ label: 'Forward', icon: 'mdi:arrow-right', enabled: explorer.canGoForward, run: () => explorer.forward() },
{ label: 'Up', icon: 'mdi:arrow-up', enabled: explorer.canGoUp, run: () => explorer.up() },
{ label: 'Reload', icon: 'mdi:refresh', enabled: true, run: () => explorer.reload() }
]);
</script>
<div class="toolbar">
<div class="nav">
{#each navButtons as button (button.label)}
<button title={button.label} aria-label={button.label} disabled={!button.enabled} onclick={button.run}>
<Icon icon={button.icon} width="16" />
</button>
{/each}
</div>
<nav class="crumbs" aria-label="Folder path">
<button onclick={() => explorer.navigate('')}><Icon icon="mdi:folder-home-outline" width="15" />files</button>
{#each explorer.crumbs as crumb (crumb.path)}
<span class="separator">/</span>
<button onclick={() => explorer.navigate(crumb.path)}>{crumb.name}</button>
{/each}
</nav>
<label class="search">
<Icon icon="mdi:magnify" width="15" />
<input bind:value={explorer.query} placeholder="Filter" aria-label="Filter files" />
</label>
<div class="views" role="group" aria-label="View">
<button class:active={explorer.view === 'grid'} title="Grid" aria-label="Grid view" onclick={() => (explorer.view = 'grid')}>
<Icon icon="mdi:view-grid-outline" width="16" />
</button>
<button class:active={explorer.view === 'list'} title="List" aria-label="List view" onclick={() => (explorer.view = 'list')}>
<Icon icon="mdi:view-list-outline" width="16" />
</button>
</div>
</div>
<style>
.toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.6rem;
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
}
.nav,
.views {
display: flex;
flex-shrink: 0;
}
button {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.25rem;
background: none;
border: none;
color: var(--muted);
}
button:hover:not(:disabled),
button.active {
color: var(--accent);
}
button:disabled {
opacity: 0.35;
}
.crumbs {
display: flex;
flex: 1;
align-items: center;
min-width: 0;
padding: 0.1rem 0.4rem;
overflow-x: auto;
border: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
font-size: 0.82rem;
white-space: nowrap;
}
.crumbs button {
color: var(--foreground);
}
.separator {
color: var(--muted);
}
.search {
display: flex;
align-items: center;
gap: 0.3rem;
padding: 0.1rem 0.4rem;
border: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
color: var(--muted);
}
.search:focus-within {
border-color: var(--accent);
}
.search input {
width: 8rem;
padding: 0;
background: none;
border: none;
outline: none;
box-shadow: none;
color: var(--foreground);
font: inherit;
font-size: 0.8rem;
}
@container (max-width: 560px) {
.search {
display: none;
}
}
</style>
@@ -0,0 +1,233 @@
<script lang="ts">
import { onMount } from 'svelte';
import Icon from '@iconify/svelte';
import type { LibraryAppName } from '#lib/content';
import { libraryApps, statusOf } from '#lib/library/apps';
import { averageRating, filterEntries, finishedThisYear, groupEntries, type LibraryFilter } from '#lib/library/filter';
import { libraryStore } from '#lib/library/store.svelte';
import LibraryToolbar from './LibraryToolbar.svelte';
import LibraryCard from './LibraryCard.svelte';
import LibraryDetail from './LibraryDetail.svelte';
import LibraryHistory from './LibraryHistory.svelte';
import LibraryGroup from './LibraryGroup.svelte';
type LibraryTab = 'shelf' | 'history';
let { name }: { name: LibraryAppName } = $props();
const tabs: { id: LibraryTab; label: string; icon: string }[] = [
{ id: 'shelf', label: 'shelf', icon: 'mdi:view-grid-outline' },
{ id: 'history', label: 'history', icon: 'mdi:timeline-clock-outline' }
];
let tab = $state<LibraryTab>('shelf');
let filter = $state<LibraryFilter>({ status: null, query: '', sort: 'recent' });
let selectedId = $state<string | null>(null);
const app = $derived(libraryApps[name]);
const finishedLabel = $derived(app.statuses[1].label);
const entries = $derived(app.entries(libraryStore.data));
const shownEntries = $derived(filterEntries(entries, filter));
const grouped = $derived(groupEntries(shownEntries));
const selected = $derived(entries.find((entry) => entry.id === selectedId) ?? null);
const counts = $derived(
Object.fromEntries(app.statuses.map((status) => [status.id, entries.filter((entry) => entry.status === status.id).length]))
);
function toggleSelected(id: string) {
selectedId = id === selectedId ? null : id;
}
onMount(() => {
libraryStore.load();
});
</script>
<div class="library-app">
<nav class="tabs" aria-label="{app.title} sections">
{#each tabs as item (item.id)}
<button class:active={tab === item.id} onclick={() => (tab = item.id)}>
<Icon icon={item.icon} width="16" />{item.label}
</button>
{/each}
<span class="summary muted">
{entries.length} {app.itemLabel} · avg {averageRating(entries)} · {finishedThisYear(entries)} {finishedLabel} this year
</span>
<button class="reload" title="Reload" aria-label="Reload" onclick={() => libraryStore.reload()}>
<Icon icon="mdi:refresh" width="16" />
</button>
</nav>
<div class="body" class:has-detail={!!selected}>
<div class="content">
{#if libraryStore.state === 'error'}
<p class="message">Could not load the library. Try reloading.</p>
{:else if libraryStore.state !== 'ready' && !entries.length}
<p class="message muted">Loading {app.itemLabel}...</p>
{:else if tab === 'history'}
<LibraryHistory {entries} {finishedLabel} onselect={(entry) => (selectedId = entry.id)} />
{:else}
<LibraryToolbar bind:filter statuses={app.statuses} {counts} total={entries.length} />
{#if grouped.groups.length}
<h3 class="subheading">
<Icon icon="mdi:layers-outline" width="16" />{app.groupLabel}
<span class="muted">{grouped.groups.length}</span>
</h3>
<div class="groups">
{#each grouped.groups as group (group.name)}
<LibraryGroup {group} {app} {selectedId} onselect={toggleSelected} />
{/each}
</div>
{/if}
{#if grouped.standalone.length}
{#if grouped.groups.length}
<h3 class="subheading">
<Icon icon="mdi:view-grid-outline" width="16" />All {app.itemLabel}
<span class="muted">{grouped.standalone.length}</span>
</h3>
{/if}
<div class="grid">
{#each grouped.standalone as entry (entry.id)}
<LibraryCard
{entry}
status={statusOf(app, entry.status)}
fallbackIcon={app.icon}
selected={entry.id === selectedId}
onselect={() => toggleSelected(entry.id)}
/>
{/each}
</div>
{/if}
{#if !shownEntries.length}
<p class="message muted">No {app.itemLabel} match.</p>
{/if}
{/if}
</div>
{#if selected}
<LibraryDetail entry={selected} status={statusOf(app, selected.status)} fallbackIcon={app.icon} onclose={() => (selectedId = null)} />
{/if}
</div>
</div>
<style>
.library-app {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
container-type: inline-size;
}
.tabs {
display: flex;
align-items: stretch;
gap: 0.25rem;
padding: 0 0.75rem;
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
}
.tabs button {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 0.8rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--muted);
font-size: 0.85rem;
}
.tabs button:hover {
color: var(--foreground);
}
.tabs button.active {
border-bottom-color: var(--accent);
color: var(--accent);
}
.summary {
align-self: center;
margin-left: auto;
overflow: hidden;
font-size: 0.75rem;
white-space: nowrap;
text-overflow: ellipsis;
}
.tabs .reload {
padding: 0.5rem 0.3rem;
}
.body {
position: relative;
display: grid;
flex: 1;
grid-template-columns: minmax(0, 1fr);
min-height: 0;
}
.body.has-detail {
grid-template-columns: minmax(0, 1fr) 320px;
}
.content {
min-height: 0;
overflow-y: auto;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 0.9rem 0.75rem;
padding: 0 0.75rem 0.75rem;
}
.subheading {
display: flex;
align-items: center;
gap: 0.4rem;
margin: 0.25rem 0.75rem 0.6rem;
color: var(--foreground);
font-size: 0.85rem;
}
.groups {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 0.75rem;
padding: 0 0.75rem 1rem;
}
.message {
margin: 0;
padding: 1rem 0.75rem;
}
@container (max-width: 640px) {
.summary {
display: none;
}
.tabs .reload {
margin-left: auto;
}
.body.has-detail {
grid-template-columns: minmax(0, 1fr);
}
.body.has-detail > :global(.detail) {
position: absolute;
inset: 0;
border-left: none;
}
.grid {
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
}
}
</style>
@@ -0,0 +1,126 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { LibraryEntry } from '#lib/library/entries';
import type { LibraryStatus } from '#lib/library/apps';
let {
entry,
caption,
status,
fallbackIcon,
selected,
onselect
}: {
entry: LibraryEntry;
caption?: string;
status: LibraryStatus;
fallbackIcon: string;
selected: boolean;
onselect: () => void;
} = $props();
let imageFailed = $state(false);
const subtitle = $derived(caption ?? entry.subtitle);
</script>
<button class="library-card" class:selected title={entry.title} onclick={onselect}>
<span class="cover">
{#if entry.image && !imageFailed}
<img src={entry.image} alt="" loading="lazy" onerror={() => (imageFailed = true)} />
{:else}
<span class="placeholder"><Icon icon={fallbackIcon} width="32" /></span>
{/if}
{#if entry.rating !== null}
<span class="rating"><Icon icon="mdi:star" width="12" />{entry.rating}</span>
{/if}
<span class="status" style:background="var(--{status.color})">{status.label}</span>
</span>
<span class="title">{entry.title}</span>
{#if subtitle}
<span class="subtitle">{subtitle}</span>
{/if}
</button>
<style>
.library-card {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
padding: 0;
background: none;
border: none;
color: inherit;
text-align: left;
cursor: pointer;
}
.cover {
position: relative;
aspect-ratio: 2 / 3;
overflow: hidden;
background: var(--dark-background);
border: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
transition: border-color 0.15s ease;
}
.library-card:hover .cover,
.library-card.selected .cover {
border-color: var(--accent);
}
.cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.placeholder {
display: grid;
place-items: center;
height: 100%;
color: var(--muted);
}
.rating {
position: absolute;
top: 0.3rem;
right: 0.3rem;
display: inline-flex;
align-items: center;
gap: 0.15rem;
padding: 0 0.3rem;
background: var(--background);
color: var(--yellow);
font-size: 0.72rem;
}
.status {
position: absolute;
bottom: 0.3rem;
left: 0.3rem;
padding: 0 0.3rem;
color: var(--background);
font-size: 0.68rem;
font-weight: 700;
}
.title,
.subtitle {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.title {
color: var(--accent);
font-size: 0.82rem;
font-weight: 700;
}
.subtitle {
color: var(--muted);
font-size: 0.72rem;
}
</style>
@@ -0,0 +1,220 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { LibraryEntry } from '#lib/library/entries';
import type { LibraryStatus } from '#lib/library/apps';
import Tag from '#lib/components/ui/Tag.svelte';
import { tagIcon } from '#lib/library/gameProfile';
let {
entry,
status,
fallbackIcon,
onclose
}: { entry: LibraryEntry; status: LibraryStatus; fallbackIcon: string; onclose: () => void } = $props();
const dates = $derived(
[
['Added', entry.added],
['Started', entry.started],
['Finished', entry.finished]
].filter((row): row is [string, string] => !!row[1])
);
</script>
<aside class="detail" aria-label="{entry.title} details">
<button class="close" aria-label="Close details" onclick={onclose}><Icon icon="mdi:close" width="16" /></button>
<div class="head">
<span class="cover">
{#if entry.image}
<img src={entry.image} alt="" />
{:else}
<Icon icon={fallbackIcon} width="36" />
{/if}
</span>
<div class="head-text">
<h3>{entry.title}</h3>
{#if entry.subtitle}<span class="muted">{entry.subtitle}</span>{/if}
<span class="status" style:color="var(--{status.color})">{status.label}</span>
{#if entry.rating !== null}
<span class="rating"><Icon icon="mdi:star" width="14" />{entry.rating}/10</span>
{/if}
</div>
</div>
{#if entry.group}
<div class="row"><Icon icon="mdi:layers-outline" width="15" />{[entry.group, entry.groupPosition].filter(Boolean).join(' ')}</div>
{/if}
{#each entry.facts as fact (fact)}
<div class="row muted">{fact}</div>
{/each}
{#if entry.note}
<p class="note">{entry.note}</p>
{/if}
{#if entry.tags.length}
<div class="genres">
{#each entry.tags as tag (tag)}
<span class="player-tag"><Icon icon={tagIcon(tag)} width="13" />{tag}</span>
{/each}
</div>
{/if}
{#if entry.genres.length}
<div class="genres">
{#each entry.genres as genre, index (genre)}
<Tag label={genre} colorIndex={index} />
{/each}
</div>
{/if}
{#if dates.length}
<dl class="dates">
{#each dates as [label, value] (label)}
<dt>{label}</dt>
<dd>{value}</dd>
{/each}
</dl>
{/if}
{#if entry.description}
<p class="description">{entry.description}</p>
{/if}
<a class="link" href={entry.url} target="_blank" rel="noopener noreferrer">
<Icon icon="mdi:open-in-new" width="14" />{entry.url.includes('imdb') ? 'IMDb' : 'Goodreads'}
</a>
</aside>
<style>
.detail {
position: relative;
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 0.9rem;
overflow-y: auto;
background: var(--background);
border-left: 1px solid color-mix(in srgb, var(--foreground) 12%, transparent);
font-size: 0.85rem;
}
.close {
position: absolute;
top: 0.4rem;
right: 0.4rem;
display: grid;
place-items: center;
padding: 0.2rem;
background: none;
border: none;
color: var(--muted);
}
.close:hover {
color: var(--red);
}
.head {
display: flex;
gap: 0.75rem;
}
.cover {
display: grid;
flex-shrink: 0;
place-items: center;
width: 96px;
aspect-ratio: 2 / 3;
overflow: hidden;
background: var(--dark-background);
color: var(--muted);
}
.cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.head-text {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
padding-right: 1.2rem;
}
h3 {
margin: 0;
color: var(--accent);
font-size: 1rem;
}
.status {
font-weight: 700;
}
.rating {
display: inline-flex;
align-items: center;
gap: 0.2rem;
color: var(--yellow);
}
.row {
display: flex;
align-items: center;
gap: 0.4rem;
}
.genres {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.note {
margin: 0;
font-style: italic;
}
.player-tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.05rem 0.45rem;
border: 1px solid color-mix(in srgb, var(--foreground) 25%, transparent);
font-size: 0.75rem;
}
.dates {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.15rem 0.75rem;
margin: 0;
}
dt {
color: var(--blue);
font-weight: 700;
}
dd {
margin: 0;
}
.description {
margin: 0;
color: var(--muted);
line-height: 1.5;
}
.link {
display: inline-flex;
align-items: center;
gap: 0.3rem;
color: var(--accent);
}
</style>
@@ -0,0 +1,180 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { statusOf, type LibraryApp } from '#lib/library/apps';
import { averageRating, type EntryGroup } from '#lib/library/filter';
import LibraryCard from './LibraryCard.svelte';
let {
group,
app,
selectedId,
onselect
}: { group: EntryGroup; app: LibraryApp; selectedId: string | null; onselect: (id: string) => void } = $props();
let expanded = $state(false);
const total = $derived(group.entries.length);
const average = $derived(averageRating(group.entries));
const statusCounts = $derived(
app.statuses
.map((status) => ({ status, count: group.entries.filter((entry) => entry.status === status.id).length }))
.filter((item) => item.count > 0)
);
</script>
<section class="group" class:expanded>
<button class="header" aria-expanded={expanded} onclick={() => (expanded = !expanded)}>
<Icon icon={expanded ? 'mdi:chevron-down' : 'mdi:chevron-right'} width="16" />
<span class="name">{group.name}</span>
<span class="summary">
<span class="muted">{total} {app.itemLabel}</span>
{#each statusCounts as item (item.status.id)}
<span style:color="var(--{item.status.color})">{item.count} {item.status.label}</span>
{/each}
{#if average !== '-'}
<span class="rating">avg {average}</span>
{/if}
</span>
</button>
{#if expanded}
<div class="grid">
{#each group.entries as entry (entry.id)}
<LibraryCard
{entry}
caption={entry.groupPosition ?? undefined}
status={statusOf(app, entry.status)}
fallbackIcon={app.icon}
selected={entry.id === selectedId}
onselect={() => onselect(entry.id)}
/>
{/each}
</div>
{:else}
<div class="stack">
{#each group.entries as entry, index (entry.id)}
<button
class="stacked"
class:selected={entry.id === selectedId}
style:z-index={total - index}
style:border-bottom-color="var(--{statusOf(app, entry.status).color})"
title={[entry.groupPosition, entry.title].filter(Boolean).join(' ')}
onclick={() => onselect(entry.id)}
>
{#if entry.image}
<img src={entry.image} alt={entry.title} loading="lazy" />
{:else}
<span class="fallback"><Icon icon={app.icon} width="20" /></span>
{/if}
</button>
{/each}
</div>
{/if}
</section>
<style>
.group {
flex: 1 1 auto;
min-width: min(100%, 260px);
max-width: 100%;
padding: 0.6rem;
border: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
}
.group.expanded {
flex-basis: 100%;
}
.header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.3rem 0.5rem;
width: 100%;
padding: 0;
background: none;
border: none;
color: var(--muted);
text-align: left;
}
.name {
color: var(--accent);
font-weight: 700;
}
.summary {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
margin-left: auto;
font-size: 0.75rem;
}
.rating {
color: var(--yellow);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 0.9rem 0.75rem;
padding-top: 0.6rem;
}
.stack {
display: flex;
padding: 0.6rem 0 0.2rem;
overflow-x: auto;
}
.stacked {
position: relative;
flex-shrink: 0;
width: 72px;
height: 108px;
padding: 0;
background: none;
border: none;
border-bottom: 3px solid var(--muted);
box-shadow: 4px 0 10px color-mix(in srgb, black 45%, transparent);
transition: transform 0.15s ease;
}
.stacked + .stacked {
margin-left: -44px;
}
.stacked:hover,
.stacked.selected {
z-index: 1000 !important;
transform: translateY(-6px);
}
.stacked img,
.fallback {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.fallback {
display: grid;
place-items: center;
background: color-mix(in srgb, var(--foreground) 8%, var(--background));
color: var(--muted);
}
@container (max-width: 640px) {
.grid {
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
}
}
@media (prefers-reduced-motion: reduce) {
.stacked {
transition: none;
}
}
</style>
@@ -0,0 +1,115 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { LibraryEntry } from '#lib/library/entries';
import { historyByMonth } from '#lib/library/filter';
let {
entries,
finishedLabel,
onselect
}: { entries: LibraryEntry[]; finishedLabel: string; onselect: (entry: LibraryEntry) => void } = $props();
const months = $derived(historyByMonth(entries));
</script>
{#if months.length}
<ol class="timeline">
{#each months as month (month.key)}
<li class="month">
<h3>{month.label} <span class="muted">{month.events.length}</span></h3>
<ul>
{#each month.events as event (event.kind + event.entry.id)}
<li>
<button class="event" onclick={() => onselect(event.entry)}>
<span class="date">{event.date.slice(5)}</span>
<Icon
icon={event.kind === 'finished' ? 'mdi:check-circle-outline' : 'mdi:play-circle-outline'}
width="15"
color={event.kind === 'finished' ? 'var(--green)' : 'var(--blue)'}
/>
<span class="kind">{event.kind === 'finished' ? finishedLabel : 'started'}</span>
<span class="title">{event.entry.title}</span>
{#if event.kind === 'finished' && event.entry.rating !== null}
<span class="rating">{event.entry.rating}/10</span>
{/if}
</button>
</li>
{/each}
</ul>
</li>
{/each}
</ol>
{:else}
<p class="empty muted">No history yet. Dates are saved when something is started or finished.</p>
{/if}
<style>
.timeline,
ul {
margin: 0;
padding: 0;
list-style: none;
}
.timeline {
padding: 0.25rem 0.75rem 0.75rem;
}
.month + .month {
margin-top: 1rem;
}
h3 {
margin: 0 0 0.35rem;
color: var(--foreground);
font-size: 0.9rem;
}
ul {
border-left: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
}
.event {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.25rem 0.6rem;
background: none;
border: none;
color: inherit;
font-size: 0.82rem;
text-align: left;
}
.event:hover {
background: color-mix(in srgb, var(--foreground) 8%, transparent);
}
.date,
.kind {
flex-shrink: 0;
color: var(--muted);
}
.kind {
width: 4.5rem;
}
.title {
overflow: hidden;
color: var(--accent);
white-space: nowrap;
text-overflow: ellipsis;
}
.rating {
flex-shrink: 0;
margin-left: auto;
color: var(--yellow);
}
.empty {
padding: 1rem 0.75rem;
}
</style>
@@ -0,0 +1,120 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { LibraryStatus } from '#lib/library/apps';
import { sortOptions, type LibraryFilter } from '#lib/library/filter';
let {
filter = $bindable(),
statuses,
counts,
total
}: { filter: LibraryFilter; statuses: LibraryStatus[]; counts: Record<string, number>; total: number } = $props();
</script>
<div class="toolbar">
<div class="statuses" role="group" aria-label="Filter by status">
<button class:active={filter.status === null} onclick={() => (filter.status = null)}>all <span class="count">{total}</span></button>
{#each statuses as status (status.id)}
<button
class:active={filter.status === status.id}
style:--status-color="var(--{status.color})"
onclick={() => (filter.status = filter.status === status.id ? null : status.id)}
>
{status.label} <span class="count">{counts[status.id] ?? 0}</span>
</button>
{/each}
</div>
<label class="search">
<Icon icon="mdi:magnify" width="16" />
<input bind:value={filter.query} placeholder="Search" aria-label="Search" />
</label>
<label class="sort">
<Icon icon="mdi:sort" width="16" />
<select bind:value={filter.sort} aria-label="Sort by">
{#each sortOptions as option (option.id)}
<option value={option.id}>{option.label}</option>
{/each}
</select>
</label>
</div>
<style>
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.75rem;
}
.statuses {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-right: auto;
}
.statuses button {
--status-color: var(--accent);
padding: 0.1rem 0.5rem;
background: none;
border: 1px solid color-mix(in srgb, var(--foreground) 25%, transparent);
color: var(--muted);
font-size: 0.78rem;
}
.statuses button:hover {
color: var(--foreground);
}
.statuses button.active {
border-color: var(--status-color);
color: var(--status-color);
}
.count {
opacity: 0.7;
}
.search,
.sort {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.1rem 0.45rem;
border: 1px solid color-mix(in srgb, var(--foreground) 25%, transparent);
color: var(--muted);
}
.search:focus-within,
.sort:focus-within {
border-color: var(--accent);
}
.search input,
.sort select {
padding: 0;
background: none;
border: none;
outline: none;
box-shadow: none;
color: var(--foreground);
font: inherit;
font-size: 0.8rem;
}
.search input {
width: 9rem;
}
.sort select {
padding-right: 1.4rem;
background-position: right 0 center;
}
.sort option {
background: var(--background);
}
</style>
+2
View File
@@ -4,6 +4,7 @@
import Clock from './Clock.svelte'; import Clock from './Clock.svelte';
import Workspaces from './Workspaces.svelte'; import Workspaces from './Workspaces.svelte';
import ThemeMenu from './ThemeMenu.svelte'; import ThemeMenu from './ThemeMenu.svelte';
import WallpaperMenu from './WallpaperMenu.svelte';
import DiscordStatus from './DiscordStatus.svelte'; import DiscordStatus from './DiscordStatus.svelte';
import Battery from './Battery.svelte'; import Battery from './Battery.svelte';
import Music from './Music.svelte'; import Music from './Music.svelte';
@@ -27,6 +28,7 @@
<DiscordStatus /> <DiscordStatus />
<Battery /> <Battery />
<Donate /> <Donate />
<WallpaperMenu />
<ThemeMenu /> <ThemeMenu />
</div> </div>
</header> </header>
+13 -3
View File
@@ -2,7 +2,12 @@
import { focusTrap } from '#lib/ui/focusTrap'; import { focusTrap } from '#lib/ui/focusTrap';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
let { title, onclose, children }: { title: string; onclose: () => void; children: Snippet } = $props(); let {
title,
wide = false,
onclose,
children
}: { title: string; wide?: boolean; onclose: () => void; children: Snippet } = $props();
function handleKeydown(event: KeyboardEvent) { function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') onclose(); if (event.key === 'Escape') onclose();
@@ -12,7 +17,7 @@
<svelte:window onkeydown={handleKeydown} /> <svelte:window onkeydown={handleKeydown} />
<button class="backdrop" aria-label="Close {title}" onclick={onclose}></button> <button class="backdrop" aria-label="Close {title}" onclick={onclose}></button>
<div class="popup" role="dialog" aria-modal="true" aria-label={title} tabindex="-1" use:focusTrap> <div class="popup" class:wide role="dialog" aria-modal="true" aria-label={title} tabindex="-1" use:focusTrap>
<div class="popup-title">{title}</div> <div class="popup-title">{title}</div>
<div class="popup-body"> <div class="popup-body">
{@render children()} {@render children()}
@@ -42,6 +47,10 @@
animation: window-pop-in 0.15s ease-out; animation: window-pop-in 0.15s ease-out;
} }
.popup.wide {
width: 560px;
}
.popup-title { .popup-title {
padding: 0.4rem 0.6rem; padding: 0.4rem 0.6rem;
background: var(--background); background: var(--background);
@@ -58,7 +67,8 @@
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.popup { .popup,
.popup.wide {
position: fixed; position: fixed;
top: calc(var(--bar-height) + 6px); top: calc(var(--bar-height) + 6px);
right: 8px; right: 8px;
@@ -0,0 +1,166 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { wallpapers } from '#lib/content';
import { wallpaperState } from '#lib/theme/wallpaper.svelte';
import PopupCard from './PopupCard.svelte';
let open = $state(false);
let search = $state('');
const filtered = $derived(
wallpapers.filter((wallpaper) => wallpaper.name.toLowerCase().includes(search.trim().toLowerCase()))
);
</script>
{#if wallpapers.length}
<div class="wallpaper-menu">
<button class="tray-button" title="Wallpaper" aria-expanded={open} onclick={() => (open = !open)}>
<Icon icon="mdi:image-multiple-outline" width="18" />
</button>
{#if open}
<PopupCard title="Wallpapers ({wallpapers.length})" wide onclose={() => (open = false)}>
<div class="toolbar">
<input class="search" type="search" placeholder="search wallpapers" bind:value={search} />
<button class="tool" title="Random wallpaper" onclick={() => wallpaperState.shuffle()}>
<Icon icon="mdi:shuffle-variant" width="16" />
</button>
<button
class="tool"
class:selected={wallpaperState.currentId === null}
title="Use the theme's wallpaper"
onclick={() => wallpaperState.set(null)}
>
<Icon icon="mdi:palette-outline" width="16" />
<span>theme</span>
</button>
</div>
<div class="grid">
{#each filtered as wallpaper (wallpaper.id)}
<button
class="thumb"
class:selected={wallpaper.id === wallpaperState.currentId}
title={wallpaper.name}
onclick={() => wallpaperState.set(wallpaper.id)}
>
<img src={wallpaper.thumb} alt={wallpaper.name} loading="lazy" decoding="async" />
<span class="name">{wallpaper.name}</span>
</button>
{:else}
<span class="empty muted">No wallpapers match "{search}"</span>
{/each}
</div>
</PopupCard>
{/if}
</div>
{/if}
<style>
.wallpaper-menu {
position: relative;
}
.toolbar {
display: flex;
gap: 0.35rem;
margin-bottom: 0.35rem;
}
.search {
flex: 1;
min-width: 0;
padding: 0.3rem 0.5rem;
background: var(--dark-background);
border: 1px solid color-mix(in srgb, var(--foreground) 25%, transparent);
color: var(--foreground);
font: inherit;
font-size: 0.8rem;
}
.search:focus {
border-color: var(--accent);
outline: none;
}
.tool {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0 0.5rem;
background: none;
border: 1px solid color-mix(in srgb, var(--foreground) 25%, transparent);
color: var(--foreground);
font-size: 0.78rem;
}
.tool:hover,
.tool.selected {
border-color: var(--accent);
color: var(--accent);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 0.4rem;
}
.thumb {
position: relative;
aspect-ratio: 16 / 9;
padding: 0;
overflow: hidden;
background: var(--dark-background);
border: 2px solid transparent;
}
.thumb:hover {
border-color: color-mix(in srgb, var(--accent) 60%, transparent);
}
.thumb.selected {
border-color: var(--accent);
}
.thumb img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.name {
position: absolute;
right: 0;
bottom: 0;
left: 0;
overflow: hidden;
padding: 0.1rem 0.35rem;
background: color-mix(in srgb, var(--background) 85%, transparent);
font-size: 0.68rem;
text-align: left;
white-space: nowrap;
text-overflow: ellipsis;
opacity: 0;
transition: opacity 0.15s ease;
}
.thumb:hover .name,
.thumb:focus-visible .name,
.thumb.selected .name {
opacity: 1;
}
.empty {
grid-column: 1 / -1;
padding: 0.5rem;
font-size: 0.8rem;
}
@media (max-width: 640px) {
.grid {
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
}
}
</style>
@@ -0,0 +1,190 @@
<script lang="ts" module>
export interface StackCover {
id: string;
href: string;
title: string;
image: string | null;
tone: string;
}
</script>
<script lang="ts">
import Icon from '@iconify/svelte';
import type { Snippet } from 'svelte';
let {
name,
href,
icon,
fallbackIcon,
covers,
summary,
details,
children
}: {
name: string;
href?: string;
icon: string;
fallbackIcon: string;
covers: StackCover[];
summary?: Snippet;
details?: Snippet;
children: Snippet;
} = $props();
let expanded = $state(false);
const total = $derived(covers.length);
</script>
<section class="collection" class:expanded>
<header class="collection-header">
{#if href}
<a class="collection-name" {href} target="_blank" rel="noopener noreferrer">
<Icon {icon} width="16" />
{name}
</a>
{:else}
<span class="collection-name"><Icon {icon} width="16" />{name}</span>
{/if}
{#if summary}
<span class="collection-summary">{@render summary()}</span>
{/if}
</header>
{@render details?.()}
{#if expanded}
{@render children()}
{:else}
<div class="cover-stack">
{#each covers as cover, index (cover.id)}
<a
class="stacked-cover"
style:z-index={total - index}
style:border-bottom-color="var(--{cover.tone})"
href={cover.href}
target="_blank"
rel="noopener noreferrer"
title={cover.title}
>
{#if cover.image}
<img src={cover.image} alt={cover.title} loading="lazy" />
{:else}
<span class="cover-fallback"><Icon icon={fallbackIcon} width="20" /></span>
{/if}
</a>
{/each}
</div>
{/if}
<button class="toggle" onclick={() => (expanded = !expanded)}>
<Icon icon={expanded ? 'mdi:chevron-up' : 'mdi:chevron-down'} width="16" />
{expanded ? 'Collapse' : `Expand (${total})`}
</button>
</section>
<style>
.collection {
flex: 1 1 auto;
min-width: min(100%, 280px);
max-width: 100%;
padding: 0.75rem;
border: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
}
.collection.expanded {
flex-basis: 100%;
}
.collection-header {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 0.4rem 1rem;
margin-bottom: 0.5rem;
}
.collection-name {
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: var(--accent);
font-weight: 700;
}
a.collection-name:hover {
text-decoration: underline;
}
.collection-summary {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
font-size: 0.78rem;
}
.cover-stack {
display: flex;
padding: 0.5rem 0 0.25rem;
overflow-x: auto;
}
.stacked-cover {
position: relative;
flex-shrink: 0;
width: 84px;
height: 126px;
border-bottom: 3px solid var(--muted);
box-shadow: 4px 0 10px color-mix(in srgb, black 45%, transparent);
transition: transform 0.15s ease;
}
.stacked-cover + .stacked-cover {
margin-left: -52px;
}
.stacked-cover:hover {
z-index: 1000 !important;
transform: translateY(-8px);
}
.stacked-cover img,
.cover-fallback {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.cover-fallback {
display: grid;
place-items: center;
background: color-mix(in srgb, var(--foreground) 8%, var(--background));
color: var(--muted);
}
.toggle {
display: inline-flex;
align-items: center;
gap: 0.3rem;
margin-top: 0.75rem;
padding: 0.3rem 0.7rem;
background: none;
border: 1px solid color-mix(in srgb, var(--foreground) 30%, transparent);
color: var(--foreground);
font-size: 0.85rem;
}
.toggle:hover {
border-color: var(--accent);
color: var(--accent);
}
@media (prefers-reduced-motion: reduce) {
.stacked-cover {
transition: none;
}
}
</style>
+22 -2
View File
@@ -2,14 +2,30 @@
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import Icon from '@iconify/svelte'; import Icon from '@iconify/svelte';
let { title, icon, href, children }: { title?: string; icon?: string; href?: string; children?: Snippet } = $props(); let {
title,
icon,
href,
onclick,
children
}: { title?: string; icon?: string; href?: string; onclick?: () => void; children?: Snippet } = $props();
function handleKeydown(event: KeyboardEvent) {
if (!onclick || (event.key !== 'Enter' && event.key !== ' ')) return;
event.preventDefault();
onclick();
}
</script> </script>
<svelte:element <svelte:element
this={href ? 'a' : 'article'} this={href ? 'a' : 'article'}
class="panel" class="panel"
class:linked={!!href} class:linked={!!href || !!onclick}
{href} {href}
role={onclick ? 'button' : undefined}
tabindex={onclick ? 0 : undefined}
{onclick}
onkeydown={onclick ? handleKeydown : undefined}
target={href?.startsWith('http') ? '_blank' : undefined} target={href?.startsWith('http') ? '_blank' : undefined}
rel={href?.startsWith('http') ? 'noopener noreferrer' : undefined} rel={href?.startsWith('http') ? 'noopener noreferrer' : undefined}
> >
@@ -34,6 +50,10 @@
background: color-mix(in srgb, var(--foreground) 3%, transparent); background: color-mix(in srgb, var(--foreground) 3%, transparent);
} }
.linked {
cursor: pointer;
}
.linked:hover { .linked:hover {
border-color: var(--accent); border-color: var(--accent);
background: color-mix(in srgb, var(--foreground) 7%, transparent); background: color-mix(in srgb, var(--foreground) 7%, transparent);
+5 -5
View File
@@ -11,11 +11,10 @@ import hackathonsData from '#content/projects/hackathons.json';
import researchData from '#content/projects/research.json'; import researchData from '#content/projects/research.json';
import organizationsData from '#content/projects/organizations.json'; import organizationsData from '#content/projects/organizations.json';
import tcgDecksData from '#content/tcgDecks.json'; import tcgDecksData from '#content/tcgDecks.json';
import booksData from '#content/books.json'; import wallpapersData from '#content/wallpapers.json';
import type { import type {
AboutContent, AboutContent,
BlogSettings, BlogSettings,
Book,
DesktopContent, DesktopContent,
Experience, Experience,
Leadership, Leadership,
@@ -25,7 +24,8 @@ import type {
ProjectCategory, ProjectCategory,
ProjectCategoryId, ProjectCategoryId,
ResearchGroup, ResearchGroup,
TcgDeck TcgDeck,
Wallpaper
} from './types'; } from './types';
export type * from './types'; export type * from './types';
@@ -33,13 +33,13 @@ export type * from './types';
export const profile: Profile = profileData; export const profile: Profile = profileData;
export const experience: Experience[] = experienceData; export const experience: Experience[] = experienceData;
export const leadership: Leadership[] = leadershipData; export const leadership: Leadership[] = leadershipData;
export const about: AboutContent = aboutData; export const about: AboutContent = aboutData as AboutContent;
export const desktop: DesktopContent = desktopData; export const desktop: DesktopContent = desktopData;
export const blogSettings: BlogSettings = blogData; export const blogSettings: BlogSettings = blogData;
export const playlist: Playlist = musicData; export const playlist: Playlist = musicData;
export const researchGroups: ResearchGroup[] = researchData; export const researchGroups: ResearchGroup[] = researchData;
export const tcgDecks: TcgDeck[] = tcgDecksData as TcgDeck[]; export const tcgDecks: TcgDeck[] = tcgDecksData as TcgDeck[];
export const books: Book[] = booksData as Book[]; export const wallpapers: Wallpaper[] = wallpapersData as Wallpaper[];
function featuredFirst(projects: Project[]): Project[] { function featuredFirst(projects: Project[]): Project[] {
return [...projects].sort((first, second) => Number(!!second.featured) - Number(!!first.featured)); return [...projects].sort((first, second) => Number(!!second.featured) - Number(!!first.featured));
+45 -7
View File
@@ -125,18 +125,47 @@ export interface Hobby {
name: string; name: string;
icon: string; icon: string;
description: string; description: string;
folder?: string;
} }
export type PlayStatus = 'played' | 'playing' | 'planning' | 'replayable';
export interface Game { export interface Game {
igdbId: string;
name: string; name: string;
image: string; url: string | null;
url: string; igdbUrl: string | null;
image: string | null;
cover: string | null;
status: PlayStatus;
rating?: number;
released: string | null;
platforms: string[];
developers: string[];
publishers: string[];
genres: string[];
summary: string | null;
score: number | null;
favorite: boolean;
liked: boolean;
tags: string[];
note: string | null;
added?: string;
started?: string;
finished?: string;
} }
export type WatchStatus = 'watched' | 'watching' | 'planning';
export interface RatedTitle { export interface RatedTitle {
name: string; name: string;
rating: number; rating?: number;
url: string; url: string;
status?: WatchStatus;
collection?: string;
added?: string;
started?: string;
finished?: string;
} }
export type BookShelf = 'read' | 'reading' | 'want-to-read'; export type BookShelf = 'read' | 'reading' | 'want-to-read';
@@ -147,6 +176,7 @@ export interface Book {
title: string; title: string;
authors: string[]; authors: string[];
cover: string | null; cover: string | null;
coverSource?: string | null;
description: string | null; description: string | null;
pages: number | null; pages: number | null;
published: string | null; published: string | null;
@@ -156,6 +186,8 @@ export interface Book {
shelf: BookShelf; shelf: BookShelf;
rating?: number; rating?: number;
added?: string; added?: string;
started?: string;
finished?: string;
series?: BookSeries; series?: BookSeries;
} }
@@ -184,14 +216,13 @@ export interface AboutContent {
lastfmUsername?: string; lastfmUsername?: string;
setup?: DesktopSetup; setup?: DesktopSetup;
hobbies: Hobby[]; hobbies: Hobby[];
games: Game[];
shows: RatedTitle[];
movies: RatedTitle[];
} }
export type GameName = 'snake' | 'breakout'; export type GameName = 'snake' | 'breakout';
export type DesktopApp = 'btop' | 'tcg' | 'pet' | GameName; export type LibraryAppName = 'movies' | 'shows' | 'books' | 'games';
export type DesktopApp = 'btop' | 'tcg' | 'pet' | 'files' | GameName | LibraryAppName;
export type DesktopAction = { page: string } | { terminal: string } | { url: string } | { app: DesktopApp }; export type DesktopAction = { page: string } | { terminal: string } | { url: string } | { app: DesktopApp };
@@ -246,3 +277,10 @@ export interface TcgDeck {
name: string; name: string;
cards: DeckCard[]; cards: DeckCard[];
} }
export interface Wallpaper {
id: string;
name: string;
image: string;
thumb: string;
}
+10 -2
View File
@@ -1,5 +1,5 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { desktop, type DesktopAction, type DesktopApp, type Workspace } from '#lib/content'; import { desktop, type DesktopAction, type DesktopApp, type LibraryAppName, type Workspace } from '#lib/content';
import { petState } from '#lib/pet/petState.svelte'; import { petState } from '#lib/pet/petState.svelte';
import { pageWindowId, windowManager } from './windows.svelte'; import { pageWindowId, windowManager } from './windows.svelte';
@@ -20,9 +20,17 @@ export function openPage(path: string) {
goto(path); goto(path);
} }
const libraryAppNames: DesktopApp[] = ['movies', 'shows', 'books', 'games'];
function isLibraryApp(app: DesktopApp): app is LibraryAppName {
return libraryAppNames.includes(app);
}
export function openApp(app: DesktopApp) { export function openApp(app: DesktopApp) {
if (app === 'btop') windowManager.openSystemMonitor(); if (isLibraryApp(app)) windowManager.openLibrary(app);
else if (app === 'btop') windowManager.openSystemMonitor();
else if (app === 'tcg') windowManager.openTcgBrowser(); else if (app === 'tcg') windowManager.openTcgBrowser();
else if (app === 'files') windowManager.openFileExplorer();
else if (app === 'pet') petState.toggle(); else if (app === 'pet') petState.toggle();
else windowManager.openGame(app); else windowManager.openGame(app);
} }
+32 -2
View File
@@ -1,4 +1,4 @@
import type { GameName } from '#lib/content'; import type { GameName, LibraryAppName } from '#lib/content';
export const pageWindowId = 'page'; export const pageWindowId = 'page';
@@ -26,6 +26,16 @@ export interface TcgBrowserWindow {
id: string; id: string;
} }
export interface FileExplorerWindow {
id: string;
startPath: string;
}
export interface LibraryWindow {
id: string;
app: LibraryAppName;
}
export interface GameWindow { export interface GameWindow {
id: string; id: string;
game: GameName; game: GameName;
@@ -44,6 +54,8 @@ class WindowManager {
systemMonitors = $state<SystemMonitorWindow[]>([]); systemMonitors = $state<SystemMonitorWindow[]>([]);
games = $state<GameWindow[]>([]); games = $state<GameWindow[]>([]);
tcgBrowsers = $state<TcgBrowserWindow[]>([]); tcgBrowsers = $state<TcgBrowserWindow[]>([]);
libraryWindows = $state<LibraryWindow[]>([]);
fileExplorers = $state<FileExplorerWindow[]>([]);
floatingRects = $state<Record<string, FloatingRect | null>>({}); floatingRects = $state<Record<string, FloatingRect | null>>({});
focusedId = $state<string>(pageWindowId); focusedId = $state<string>(pageWindowId);
maximizedId = $state<string | null>(null); maximizedId = $state<string | null>(null);
@@ -54,6 +66,8 @@ class WindowManager {
private nextSystemMonitorNumber = 1; private nextSystemMonitorNumber = 1;
private nextGameNumber = 1; private nextGameNumber = 1;
private nextTcgBrowserNumber = 1; private nextTcgBrowserNumber = 1;
private nextLibraryNumber = 1;
private nextFileExplorerNumber = 1;
get extraWindowIds(): string[] { get extraWindowIds(): string[] {
return [ return [
@@ -61,7 +75,9 @@ class WindowManager {
...this.imageViewers.map((viewer) => viewer.id), ...this.imageViewers.map((viewer) => viewer.id),
...this.systemMonitors.map((monitor) => monitor.id), ...this.systemMonitors.map((monitor) => monitor.id),
...this.games.map((gameWindow) => gameWindow.id), ...this.games.map((gameWindow) => gameWindow.id),
...this.tcgBrowsers.map((browser) => browser.id) ...this.tcgBrowsers.map((browser) => browser.id),
...this.libraryWindows.map((libraryWindow) => libraryWindow.id),
...this.fileExplorers.map((explorer) => explorer.id)
]; ];
} }
@@ -101,12 +117,26 @@ class WindowManager {
this.focus(browser.id); this.focus(browser.id);
} }
openLibrary(app: LibraryAppName) {
const libraryWindow = { id: `${app}-${this.nextLibraryNumber++}`, app };
this.libraryWindows.push(libraryWindow);
this.focus(libraryWindow.id);
}
openFileExplorer(startPath = '') {
const explorer = { id: `thunar-${this.nextFileExplorerNumber++}`, startPath };
this.fileExplorers.push(explorer);
this.focus(explorer.id);
}
closeWindow(id: string) { closeWindow(id: string) {
this.terminals = this.terminals.filter((terminal) => terminal.id !== id); this.terminals = this.terminals.filter((terminal) => terminal.id !== id);
this.imageViewers = this.imageViewers.filter((viewer) => viewer.id !== id); this.imageViewers = this.imageViewers.filter((viewer) => viewer.id !== id);
this.systemMonitors = this.systemMonitors.filter((monitor) => monitor.id !== id); this.systemMonitors = this.systemMonitors.filter((monitor) => monitor.id !== id);
this.games = this.games.filter((gameWindow) => gameWindow.id !== id); this.games = this.games.filter((gameWindow) => gameWindow.id !== id);
this.tcgBrowsers = this.tcgBrowsers.filter((browser) => browser.id !== id); this.tcgBrowsers = this.tcgBrowsers.filter((browser) => browser.id !== id);
this.libraryWindows = this.libraryWindows.filter((libraryWindow) => libraryWindow.id !== id);
this.fileExplorers = this.fileExplorers.filter((explorer) => explorer.id !== id);
delete this.floatingRects[id]; delete this.floatingRects[id];
if (this.maximizedId === id) this.maximizedId = null; if (this.maximizedId === id) this.maximizedId = null;
if (this.focusedId === id) this.focusedId = this.extraWindowIds.at(-1) ?? pageWindowId; if (this.focusedId === id) this.focusedId = this.extraWindowIds.at(-1) ?? pageWindowId;
+127
View File
@@ -0,0 +1,127 @@
import { windowManager } from '#lib/desktop/windows.svelte';
import { fileUrl, parentPath, type FileEntry, type FolderListing } from './types';
export type ExplorerView = 'grid' | 'list';
type LoadState = 'loading' | 'ready' | 'error';
export class ExplorerState {
path = $state('');
entries = $state<FileEntry[]>([]);
state = $state<LoadState>('loading');
selectedPath = $state<string | null>(null);
view = $state<ExplorerView>('grid');
query = $state('');
places = $state<FileEntry[]>([]);
private history = $state<string[]>([]);
private historyIndex = $state(0);
constructor(startPath = '') {
this.history = [startPath];
this.path = startPath;
}
shownEntries = $derived(
this.query.trim()
? this.entries.filter((entry) => entry.name.toLowerCase().includes(this.query.trim().toLowerCase()))
: this.entries
);
selected = $derived(this.entries.find((entry) => entry.path === this.selectedPath) ?? null);
canGoBack = $derived(this.historyIndex > 0);
canGoForward = $derived(this.historyIndex < this.history.length - 1);
canGoUp = $derived(this.path !== '');
crumbs = $derived(
this.path
.split('/')
.filter(Boolean)
.map((name, index, parts) => ({ name, path: parts.slice(0, index + 1).join('/') }))
);
private async fetchListing(path: string): Promise<FolderListing> {
const response = await fetch(`/api/files?path=${encodeURIComponent(path)}`);
if (!response.ok) throw new Error(`files returned ${response.status}`);
return response.json();
}
private async load(path: string) {
this.state = 'loading';
this.selectedPath = null;
this.query = '';
try {
const listing = await this.fetchListing(path);
this.path = listing.path;
this.entries = listing.entries;
this.state = 'ready';
} catch {
this.state = 'error';
}
}
private async loadPlaces() {
try {
const root = await this.fetchListing('');
this.places = root.entries.filter((entry) => entry.kind === 'folder');
} catch {
this.places = [];
}
}
start() {
this.load(this.path);
this.loadPlaces();
}
navigate(path: string) {
if (path === this.path && this.state === 'ready') return;
this.history = [...this.history.slice(0, this.historyIndex + 1), path];
this.historyIndex = this.history.length - 1;
this.load(path);
}
back() {
if (!this.canGoBack) return;
this.historyIndex--;
this.load(this.history[this.historyIndex]);
}
forward() {
if (!this.canGoForward) return;
this.historyIndex++;
this.load(this.history[this.historyIndex]);
}
up() {
if (this.canGoUp) this.navigate(parentPath(this.path));
}
reload() {
this.load(this.path);
}
select(entry: FileEntry) {
this.selectedPath = entry.path;
}
open(entry: FileEntry) {
if (entry.kind === 'folder') {
this.navigate(entry.path);
return;
}
if (entry.kind === 'image') {
const images = this.shownEntries.filter((item) => item.kind === 'image');
windowManager.openImageViewer(
images.map((image) => ({ src: fileUrl(image.path), alt: image.name })),
Math.max(0, images.indexOf(entry))
);
return;
}
window.open(fileUrl(entry.path), '_blank', 'noopener,noreferrer');
}
moveSelection(step: number) {
if (!this.shownEntries.length) return;
const index = this.shownEntries.findIndex((entry) => entry.path === this.selectedPath);
const nextIndex = index === -1 ? 0 : Math.min(this.shownEntries.length - 1, Math.max(0, index + step));
this.selectedPath = this.shownEntries[nextIndex].path;
}
}
+51
View File
@@ -0,0 +1,51 @@
export type FileKind = 'folder' | 'image' | 'video' | 'audio' | 'text' | 'font' | 'document' | 'other';
export interface FileEntry {
name: string;
path: string;
kind: FileKind;
size: number;
modified: string;
itemCount: number | null;
}
export interface FolderListing {
path: string;
entries: FileEntry[];
}
const kindIcons: Record<FileKind, string> = {
folder: 'mdi:folder',
image: 'mdi:file-image-outline',
video: 'mdi:file-video-outline',
audio: 'mdi:file-music-outline',
text: 'mdi:file-document-outline',
font: 'mdi:format-font',
document: 'mdi:file-pdf-box',
other: 'mdi:file-outline'
};
export function kindIcon(kind: FileKind): string {
return kindIcons[kind];
}
export const filesFolder = 'files';
export function fileUrl(path: string): string {
return `/${filesFolder}/${path.split('/').map(encodeURIComponent).join('/')}`;
}
export function formatSize(bytes: number): string {
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return unitIndex === 0 ? `${size} ${units[0]}` : `${size.toFixed(1)} ${units[unitIndex]}`;
}
export function parentPath(path: string): string {
return path.split('/').slice(0, -1).join('/');
}
+81
View File
@@ -0,0 +1,81 @@
import type { LibraryAppName } from '#lib/content';
import type { LibraryData } from './types';
import { bookEntries, gameEntries, mediaEntries, type LibraryEntry } from './entries';
export interface LibraryStatus {
id: string;
label: string;
color: string;
}
export interface LibraryApp {
name: LibraryAppName;
title: string;
icon: string;
itemLabel: string;
groupLabel: string;
statuses: LibraryStatus[];
entries: (library: LibraryData) => LibraryEntry[];
}
const watchStatuses: LibraryStatus[] = [
{ id: 'watching', label: 'watching', color: 'blue' },
{ id: 'watched', label: 'watched', color: 'green' },
{ id: 'planning', label: 'planning', color: 'magenta' }
];
const playStatuses: LibraryStatus[] = [
{ id: 'playing', label: 'playing', color: 'blue' },
{ id: 'played', label: 'played', color: 'green' },
{ id: 'planning', label: 'planning', color: 'magenta' },
{ id: 'replayable', label: 'replayable', color: 'cyan' }
];
const readStatuses: LibraryStatus[] = [
{ id: 'reading', label: 'reading', color: 'blue' },
{ id: 'read', label: 'read', color: 'green' },
{ id: 'want-to-read', label: 'want to read', color: 'magenta' }
];
export const libraryApps: Record<LibraryAppName, LibraryApp> = {
movies: {
name: 'movies',
title: 'Movies',
icon: 'mdi:movie-open-outline',
itemLabel: 'movies',
groupLabel: 'Collections',
statuses: watchStatuses,
entries: (library) => mediaEntries(library.movies, library.mediaDetails, 'movie')
},
shows: {
name: 'shows',
title: 'TV Shows',
icon: 'mdi:television-classic',
itemLabel: 'shows',
groupLabel: 'Series',
statuses: watchStatuses,
entries: (library) => mediaEntries(library.shows, library.mediaDetails, 'show')
},
books: {
name: 'books',
title: 'Books',
icon: 'mdi:bookshelf',
itemLabel: 'books',
groupLabel: 'Series',
statuses: readStatuses,
entries: (library) => bookEntries(library.books)
},
games: {
name: 'games',
title: 'Games',
icon: 'mdi:gamepad-variant-outline',
itemLabel: 'games',
groupLabel: 'Series',
statuses: playStatuses,
entries: (library) => gameEntries(library.games)
}
};
export function statusOf(app: LibraryApp, id: string): LibraryStatus {
return app.statuses.find((status) => status.id === id) ?? app.statuses[1];
}
+122
View File
@@ -0,0 +1,122 @@
import type { Book, Game, RatedTitle } from '#lib/content';
import type { MediaDetails, MediaKind } from '#lib/media/types';
export interface LibraryEntry {
id: string;
title: string;
subtitle: string;
image: string | null;
url: string;
status: string;
rating: number | null;
year: string | null;
genres: string[];
description: string | null;
group: string | null;
groupPosition: string | null;
groupOrder: number;
facts: string[];
tags: string[];
note: string | null;
added: string | null;
started: string | null;
finished: string | null;
}
const unknownOrder = Number.MAX_SAFE_INTEGER;
const rangeOffset = 1_000_000;
function seriesOrder(position: string | undefined): number {
const match = position?.match(/\d+(?:\.\d+)?/);
if (!match) return unknownOrder;
const isRange = /\d\s*[-–]\s*\d/.test(position ?? '');
return Number(match[0]) + (isRange ? rangeOffset : 0);
}
function releaseOrder(year: string | null | undefined): number {
const parsed = Number.parseInt(year ?? '', 10);
return Number.isNaN(parsed) ? unknownOrder : parsed;
}
function splitGenres(genre: string | null | undefined): string[] {
return genre ? genre.split(',').map((name) => name.trim()).filter(Boolean) : [];
}
export function mediaEntries(items: RatedTitle[], details: Record<string, MediaDetails>, kind: MediaKind): LibraryEntry[] {
return items.map((item) => {
const itemDetails = details[item.url];
return {
id: item.url,
title: item.name,
subtitle: [itemDetails?.year, itemDetails?.runtime].filter(Boolean).join(' · '),
image: itemDetails?.poster ?? null,
url: item.url,
status: item.status ?? 'watched',
rating: item.rating ?? null,
year: itemDetails?.year?.slice(0, 4) ?? null,
genres: splitGenres(itemDetails?.genre),
description: itemDetails?.plot ?? null,
group: item.collection ?? null,
groupPosition: null,
groupOrder: releaseOrder(itemDetails?.year),
facts: [kind === 'show' ? 'TV show' : 'Movie', itemDetails?.runtime ?? ''].filter(Boolean),
tags: [],
note: null,
added: item.added ?? null,
started: item.started ?? null,
finished: item.finished ?? null
};
});
}
export function bookEntries(books: Book[]): LibraryEntry[] {
return books.map((book) => ({
id: book.goodreadsId,
title: book.title,
subtitle: book.authors.join(', '),
image: book.cover,
url: book.url,
status: book.shelf,
rating: book.rating ?? null,
year: book.published?.slice(0, 4) ?? null,
genres: book.genres,
description: book.description,
group: book.series?.name ?? null,
groupPosition: book.series?.position ?? null,
groupOrder: seriesOrder(book.series?.position),
facts: [book.pages ? `${book.pages} pages` : '', book.averageRating ? `Goodreads ${book.averageRating}` : ''].filter(Boolean),
tags: [],
note: null,
added: book.added ?? null,
started: book.started ?? null,
finished: book.finished ?? null
}));
}
function profileFacts(game: Game): string[] {
return [game.favorite ? 'Favorite game' : '', game.liked ? 'Liked' : ''].filter(Boolean);
}
export function gameEntries(games: Game[]): LibraryEntry[] {
return games.map((game) => ({
id: game.igdbId,
title: game.name,
subtitle: game.developers.join(', '),
image: game.cover ?? game.image,
url: game.url ?? game.igdbUrl ?? '',
status: game.status,
rating: game.rating ?? null,
year: game.released?.slice(0, 4) ?? null,
genres: game.genres,
description: game.summary,
group: null,
groupPosition: null,
groupOrder: 0,
facts: [...profileFacts(game), game.platforms.join(', '), game.score !== null ? `IGDB ${game.score}` : ''].filter(Boolean),
tags: game.tags,
note: game.note,
added: game.added ?? null,
started: game.started ?? null,
finished: game.finished ?? null
}));
}
+109
View File
@@ -0,0 +1,109 @@
import type { LibraryEntry } from './entries';
export type LibrarySort = 'recent' | 'rating' | 'title' | 'year';
export interface LibraryFilter {
status: string | null;
query: string;
sort: LibrarySort;
}
export interface HistoryEvent {
date: string;
kind: 'started' | 'finished';
entry: LibraryEntry;
}
export interface HistoryMonth {
key: string;
label: string;
events: HistoryEvent[];
}
export interface EntryGroup {
name: string;
entries: LibraryEntry[];
}
export interface GroupedEntries {
groups: EntryGroup[];
standalone: LibraryEntry[];
}
export const sortOptions: { id: LibrarySort; label: string }[] = [
{ id: 'recent', label: 'recent' },
{ id: 'rating', label: 'rating' },
{ id: 'title', label: 'title' },
{ id: 'year', label: 'year' }
];
export function lastActivity(entry: LibraryEntry): string {
return entry.finished ?? entry.started ?? entry.added ?? '';
}
const sorters: Record<LibrarySort, (first: LibraryEntry, second: LibraryEntry) => number> = {
recent: (first, second) => lastActivity(second).localeCompare(lastActivity(first)),
rating: (first, second) => (second.rating ?? -1) - (first.rating ?? -1),
title: (first, second) => first.title.localeCompare(second.title),
year: (first, second) => (second.year ?? '').localeCompare(first.year ?? '')
};
function matchesQuery(entry: LibraryEntry, query: string): boolean {
const text = [entry.title, entry.subtitle, entry.group ?? '', ...entry.genres].join(' ').toLowerCase();
return text.includes(query.trim().toLowerCase());
}
export function filterEntries(entries: LibraryEntry[], filter: LibraryFilter): LibraryEntry[] {
return entries
.filter((entry) => !filter.status || entry.status === filter.status)
.filter((entry) => !filter.query.trim() || matchesQuery(entry, filter.query))
.sort((first, second) => sorters[filter.sort](first, second) || first.title.localeCompare(second.title));
}
function groupOrder(first: LibraryEntry, second: LibraryEntry): number {
return first.groupOrder - second.groupOrder || first.title.localeCompare(second.title);
}
export function groupEntries(entries: LibraryEntry[]): GroupedEntries {
const members = new Map<string, LibraryEntry[]>();
for (const entry of entries) {
if (entry.group) members.set(entry.group, [...(members.get(entry.group) ?? []), entry]);
}
const groups = [...members.entries()]
.filter(([, groupEntries]) => groupEntries.length > 1)
.map(([name, groupEntries]) => ({ name, entries: [...groupEntries].sort(groupOrder) }));
const groupedIds = new Set(groups.flatMap((group) => group.entries.map((entry) => entry.id)));
return { groups, standalone: entries.filter((entry) => !groupedIds.has(entry.id)) };
}
function monthLabel(key: string): string {
const [year, month] = key.split('-').map(Number);
return new Date(Date.UTC(year, month - 1, 1)).toLocaleString('en-US', { month: 'long', year: 'numeric', timeZone: 'UTC' });
}
export function historyByMonth(entries: LibraryEntry[]): HistoryMonth[] {
const events = entries.flatMap((entry): HistoryEvent[] => [
...(entry.finished ? [{ date: entry.finished, kind: 'finished' as const, entry }] : []),
...(entry.started ? [{ date: entry.started, kind: 'started' as const, entry }] : [])
]);
events.sort((first, second) => second.date.localeCompare(first.date));
const months = new Map<string, HistoryEvent[]>();
for (const event of events) {
const key = event.date.slice(0, 7);
months.set(key, [...(months.get(key) ?? []), event]);
}
return [...months.entries()].map(([key, monthEvents]) => ({ key, label: monthLabel(key), events: monthEvents }));
}
export function averageRating(entries: LibraryEntry[]): string {
const ratings = entries.flatMap((entry) => (entry.rating === null ? [] : [entry.rating]));
return ratings.length ? (ratings.reduce((total, rating) => total + rating, 0) / ratings.length).toFixed(1) : '-';
}
export function finishedThisYear(entries: LibraryEntry[]): number {
const year = `${new Date().getFullYear()}`;
return entries.filter((entry) => entry.finished?.startsWith(year)).length;
}
+13
View File
@@ -0,0 +1,13 @@
export const likedLimit = 20;
export const suggestedTags: { label: string; icon: string }[] = [
{ label: 'Casual', icon: 'mdi:sofa-outline' },
{ label: 'Competitive', icon: 'mdi:sword-cross' },
{ label: 'Love it', icon: 'mdi:thumb-up-outline' },
{ label: 'Open to play', icon: 'mdi:account-multiple-plus-outline' },
{ label: 'Looking to discuss', icon: 'mdi:chat-outline' }
];
export function tagIcon(tag: string): string {
return suggestedTags.find((suggested) => suggested.label.toLowerCase() === tag.toLowerCase())?.icon ?? 'mdi:tag-outline';
}
+35
View File
@@ -0,0 +1,35 @@
import { emptyLibrary, type LibraryData } from './types';
type LoadState = 'idle' | 'loading' | 'ready' | 'error';
class LibraryStore {
data = $state<LibraryData>(emptyLibrary);
state = $state<LoadState>('idle');
private loading: Promise<void> | null = null;
load(): Promise<void> {
this.loading ??= this.fetchLibrary();
return this.loading;
}
reload(): Promise<void> {
this.loading = this.fetchLibrary();
return this.loading;
}
private async fetchLibrary() {
this.state = 'loading';
try {
const response = await fetch('/api/library');
if (!response.ok) throw new Error(`library returned ${response.status}`);
this.data = await response.json();
this.state = 'ready';
} catch {
this.state = 'error';
this.loading = null;
}
}
}
export const libraryStore = new LibraryStore();
+24
View File
@@ -0,0 +1,24 @@
export interface GameStore {
label: string;
icon: string;
}
const knownStores: Record<string, GameStore> = {
'store.steampowered.com': { label: 'Steam', icon: 'mdi:steam' },
'www.xbox.com': { label: 'Xbox', icon: 'mdi:microsoft-xbox' },
'www.nintendo.com': { label: 'Nintendo', icon: 'mdi:nintendo-switch' },
'store.epicgames.com': { label: 'Epic', icon: 'simple-icons:epicgames' },
'www.gog.com': { label: 'GOG', icon: 'simple-icons:gogdotcom' },
'store.playstation.com': { label: 'PlayStation', icon: 'mdi:sony-playstation' },
'www.igdb.com': { label: 'IGDB', icon: 'mdi:database-outline' }
};
export function storeFor(url: string): GameStore {
try {
const host = new URL(url).hostname;
if (host.endsWith('itch.io')) return { label: 'itch.io', icon: 'simple-icons:itchdotio' };
return knownStores[host] ?? { label: host.replace(/^www\./, ''), icon: 'mdi:web' };
} catch {
return { label: 'link', icon: 'mdi:web' };
}
}
+15
View File
@@ -0,0 +1,15 @@
import type { Book, Game, RatedTitle } from '#lib/content';
import type { MediaDetails } from '#lib/media/types';
export interface LibraryMedia {
shows: RatedTitle[];
movies: RatedTitle[];
mediaDetails: Record<string, MediaDetails>;
}
export interface LibraryData extends LibraryMedia {
books: Book[];
games: Game[];
}
export const emptyLibrary: LibraryData = { shows: [], movies: [], mediaDetails: {}, books: [], games: [] };
+72
View File
@@ -0,0 +1,72 @@
import type { RatedTitle } from '#lib/content';
import type { MediaDetails } from './types';
export interface MediaCollection {
name: string;
items: RatedTitle[];
}
export interface GroupedMedia {
collections: MediaCollection[];
standalone: RatedTitle[];
}
const statusOrder = { watching: 0, watched: 1, planning: 2 };
export function isWatching(item: RatedTitle): boolean {
return item.status === 'watching';
}
export function isPlanning(item: RatedTitle): boolean {
return item.status === 'planning';
}
export function statusTone(item: RatedTitle): string {
if (isWatching(item)) return 'blue';
if (isPlanning(item)) return 'magenta';
return 'green';
}
export function ratingOf(item: RatedTitle): number {
return item.rating ?? -1;
}
function standaloneOrder(first: RatedTitle, second: RatedTitle): number {
const statusDifference = statusOrder[first.status ?? 'watched'] - statusOrder[second.status ?? 'watched'];
return statusDifference || ratingOf(second) - ratingOf(first);
}
function releaseYear(item: RatedTitle, details: Record<string, MediaDetails>): number {
const year = Number.parseInt(details[item.url]?.year ?? '', 10);
return Number.isNaN(year) ? Number.MAX_SAFE_INTEGER : year;
}
function averageRating(items: RatedTitle[]): number {
const rated = items.filter((item) => item.rating !== undefined);
return rated.length ? rated.reduce((total, item) => total + ratingOf(item), 0) / rated.length : -1;
}
export function groupMedia(items: RatedTitle[], details: Record<string, MediaDetails>): GroupedMedia {
const groups = new Map<string, RatedTitle[]>();
const standalone: RatedTitle[] = [];
for (const item of items) {
if (item.collection) {
groups.set(item.collection, [...(groups.get(item.collection) ?? []), item]);
} else {
standalone.push(item);
}
}
const collections = [...groups.entries()]
.map(([name, groupItems]) => ({
name,
items: [...groupItems].sort((first, second) => releaseYear(first, details) - releaseYear(second, details))
}))
.sort((first, second) => averageRating(second.items) - averageRating(first.items) || first.name.localeCompare(second.name));
return {
collections,
standalone: [...standalone].sort(standaloneOrder)
};
}
+2
View File
@@ -5,3 +5,5 @@ export interface MediaDetails {
runtime: string | null; runtime: string | null;
plot: string | null; plot: string | null;
} }
export type MediaKind = 'show' | 'movie';
+26
View File
@@ -0,0 +1,26 @@
import { about } from '#lib/content';
import type { HobbyStats } from '#lib/btop/types';
import { aniListStats, bookStats, listeningStats, screenStats } from '#lib/btop/hobbies';
import { getAniListProfile } from './anilist';
import { getListeningProfile } from './lastfm/profile';
import { getLibrary } from './library';
export async function getHobbyStats(): Promise<HobbyStats> {
const anilistUsername = about.anilistUsername;
const [library, listening, anime, manga] = await Promise.all([
getLibrary(),
about.lastfmUsername ? getListeningProfile(about.lastfmUsername).catch(() => null) : null,
anilistUsername ? getAniListProfile(anilistUsername, 'ANIME').catch(() => null) : null,
anilistUsername ? getAniListProfile(anilistUsername, 'MANGA').catch(() => null) : null
]);
const hasManga = !!manga?.statusCounts.some((status) => status.count > 0);
return {
listening: listening ? listeningStats(listening) : null,
books: bookStats(library.books),
shows: screenStats(library.shows),
movies: screenStats(library.movies),
anime: anime ? aniListStats(anime) : null,
manga: manga && hasManga ? aniListStats(manga) : null
};
}
+75
View File
@@ -0,0 +1,75 @@
import { readdir, stat } from 'node:fs/promises';
import path from 'node:path';
import { error } from '@sveltejs/kit';
import { filesFolder, type FileEntry, type FileKind, type FolderListing } from '#lib/files/types';
const filesRoot = path.resolve('static', filesFolder);
const extensionKinds: Record<string, FileKind> = {
'.png': 'image',
'.jpg': 'image',
'.jpeg': 'image',
'.gif': 'image',
'.webp': 'image',
'.avif': 'image',
'.svg': 'image',
'.ico': 'image',
'.mp4': 'video',
'.webm': 'video',
'.mov': 'video',
'.mp3': 'audio',
'.ogg': 'audio',
'.wav': 'audio',
'.flac': 'audio',
'.txt': 'text',
'.md': 'text',
'.json': 'text',
'.csv': 'text',
'.xml': 'text',
'.ttf': 'font',
'.otf': 'font',
'.woff': 'font',
'.woff2': 'font',
'.pdf': 'document'
};
function resolveInsideRoot(relativePath: string): string {
const cleaned = relativePath.replace(/^\/+|\/+$/g, '');
const absolute = path.resolve(filesRoot, cleaned);
if (absolute !== filesRoot && !absolute.startsWith(`${filesRoot}${path.sep}`)) error(400, 'Path is outside the files folder');
return absolute;
}
function isHidden(name: string): boolean {
return name.startsWith('.');
}
async function toEntry(folder: string, relativeFolder: string, name: string): Promise<FileEntry | null> {
const details = await stat(path.join(folder, name)).catch(() => null);
if (!details || (!details.isFile() && !details.isDirectory())) return null;
const isFolder = details.isDirectory();
const children = isFolder ? await readdir(path.join(folder, name)).catch(() => []) : [];
return {
name,
path: relativeFolder ? `${relativeFolder}/${name}` : name,
kind: isFolder ? 'folder' : (extensionKinds[path.extname(name).toLowerCase()] ?? 'other'),
size: isFolder ? 0 : details.size,
modified: details.mtime.toISOString(),
itemCount: isFolder ? children.filter((child) => !isHidden(child)).length : null
};
}
export async function listFolder(relativePath: string): Promise<FolderListing> {
const folder = resolveInsideRoot(relativePath);
const names = await readdir(folder).catch(() => error(404, 'Folder not found'));
const relativeFolder = path.relative(filesRoot, folder).split(path.sep).join('/');
const entries = await Promise.all(names.filter((name) => !isHidden(name)).map((name) => toEntry(folder, relativeFolder, name)));
return {
path: relativeFolder,
entries: entries
.filter((entry): entry is FileEntry => entry !== null)
.sort((first, second) => Number(second.kind === 'folder') - Number(first.kind === 'folder') || first.name.localeCompare(second.name))
};
}
+52 -43
View File
@@ -1,19 +1,20 @@
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'; import { inTransaction, type SqlDatabase } from '../sqlite';
import path from 'node:path';
import { fetchTrackImage } from './api'; import { fetchTrackImage } from './api';
import { lastfmDatabase } from './database';
export interface CoverImage {
contentType: string;
data: Uint8Array;
}
const dataDirectory = path.resolve(process.env.LASTFM_DATA_DIR ?? 'data/lastfm');
const albumCoversPath = path.join(dataDirectory, 'covers.json');
const trackCoversPath = path.join(dataDirectory, 'track-covers.json');
const imageDirectory = path.join(dataDirectory, 'images');
const imageIdPattern = /^[0-9a-f]{16}$/; const imageIdPattern = /^[0-9a-f]{16}$/;
const lookupDelay = 300; const lookupDelay = 300;
const notFound = ''; const notFound = '';
let albumCovers: Record<string, string> = {}; const albumCovers = new Map<string, string>();
let trackCovers: Record<string, string> = {}; const trackCovers = new Map<string, string>();
const savedImages = new Map<string, string>(); const savedImageIds = new Set<string>();
const queuedDownloads = new Set<string>(); const queuedDownloads = new Set<string>();
const queuedLookups = new Map<string, { artist: string; track: string }>(); const queuedLookups = new Map<string, { artist: string; track: string }>();
let loading: Promise<void> | null = null; let loading: Promise<void> | null = null;
@@ -24,25 +25,23 @@ const wait = (milliseconds: number) => new Promise((resolve) => setTimeout(resol
const pairKey = (first: string, second: string) => `${first}\n${second}`; const pairKey = (first: string, second: string) => `${first}\n${second}`;
const imageId = (url: string) => createHash('sha1').update(url).digest('hex').slice(0, 16); const imageId = (url: string) => createHash('sha1').update(url).digest('hex').slice(0, 16);
async function readJson(filePath: string): Promise<Record<string, string>> { function loadPairs(database: SqlDatabase, sql: string, target: Map<string, string>) {
try { for (const row of database.prepare(sql).all() as { first: string; second: string; image: string }[]) {
return JSON.parse(await readFile(filePath, 'utf8')); target.set(pairKey(row.first, row.second), row.image);
} catch {
return {};
} }
} }
async function loadFromDisk() { async function loadFromDatabase() {
await mkdir(imageDirectory, { recursive: true }); const database = await lastfmDatabase();
albumCovers = await readJson(albumCoversPath); loadPairs(database, 'SELECT artist AS first, album AS second, image FROM album_covers', albumCovers);
trackCovers = await readJson(trackCoversPath); loadPairs(database, 'SELECT artist AS first, track AS second, image FROM track_covers', trackCovers);
for (const fileName of await readdir(imageDirectory)) { for (const row of database.prepare('SELECT id FROM cover_images').all() as { id: string }[]) {
savedImages.set(path.parse(fileName).name, fileName); savedImageIds.add(row.id);
} }
} }
export function loadCovers(): Promise<void> { export function loadCovers(): Promise<void> {
loading ??= loadFromDisk(); loading ??= loadFromDatabase();
return loading; return loading;
} }
@@ -52,34 +51,34 @@ export function coversVersion(): number {
function servedImage(url: string): string { function servedImage(url: string): string {
const id = imageId(url); const id = imageId(url);
if (savedImages.has(id)) return `/api/lastfm/covers/${id}`; if (savedImageIds.has(id)) return `/api/lastfm/covers/${id}`;
queuedDownloads.add(url); queuedDownloads.add(url);
runWorker(); runWorker();
return url; return url;
} }
export function coverFor(artist: string, album: string, track?: string): string | null { export function coverFor(artist: string, album: string, track?: string): string | null {
const url = (album && albumCovers[pairKey(artist, album)]) || (track && trackCovers[pairKey(artist, track)]) || null; const url = (album && albumCovers.get(pairKey(artist, album))) || (track && trackCovers.get(pairKey(artist, track))) || null;
return url ? servedImage(url) : null; return url ? servedImage(url) : null;
} }
export async function rememberAlbumCovers(entries: { artist: string; album: string; image: string | null }[]) { export async function rememberAlbumCovers(entries: { artist: string; album: string; image: string | null }[]) {
let changed = false; const changed = entries.filter(({ artist, album, image }) => album && image && albumCovers.get(pairKey(artist, album)) !== image);
for (const { artist, album, image } of entries) { if (!changed.length) return;
const key = pairKey(artist, album);
if (!album || !image || albumCovers[key] === image) continue; const database = await lastfmDatabase();
albumCovers[key] = image; const upsert = database.prepare(
changed = true; 'INSERT INTO album_covers (artist, album, image) VALUES (?, ?, ?) ON CONFLICT(artist, album) DO UPDATE SET image = excluded.image'
} );
if (!changed) return; inTransaction(database, () => changed.forEach(({ artist, album, image }) => upsert.run(artist, album, image)));
changed.forEach(({ artist, album, image }) => albumCovers.set(pairKey(artist, album), image!));
version++; version++;
await writeFile(albumCoversPath, JSON.stringify(albumCovers));
} }
export function requestTrackCovers(tracks: { artist: string; track: string }[]) { export function requestTrackCovers(tracks: { artist: string; track: string }[]) {
for (const { artist, track } of tracks) { for (const { artist, track } of tracks) {
const key = pairKey(artist, track); const key = pairKey(artist, track);
if (key in trackCovers || queuedLookups.has(key)) continue; if (trackCovers.has(key) || queuedLookups.has(key)) continue;
queuedLookups.set(key, { artist, track }); queuedLookups.set(key, { artist, track });
} }
runWorker(); runWorker();
@@ -87,21 +86,26 @@ export function requestTrackCovers(tracks: { artist: string; track: string }[])
async function downloadImage(url: string) { async function downloadImage(url: string) {
const id = imageId(url); const id = imageId(url);
if (savedImages.has(id)) return; if (savedImageIds.has(id)) return;
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) throw new Error(`cover download returned ${response.status}`); if (!response.ok) throw new Error(`cover download returned ${response.status}`);
const contentType = response.headers.get('content-type')?.split(';')[0] ?? 'image/jpeg';
const data = new Uint8Array(await response.arrayBuffer());
const fileName = `${id}${path.extname(new URL(url).pathname) || '.jpg'}`; const database = await lastfmDatabase();
await writeFile(path.join(imageDirectory, fileName), Buffer.from(await response.arrayBuffer())); database.prepare('INSERT OR REPLACE INTO cover_images (id, content_type, data) VALUES (?, ?, ?)').run(id, contentType, data);
savedImages.set(id, fileName); savedImageIds.add(id);
version++; version++;
} }
async function lookUpTrackCover(key: string, artist: string, track: string) { async function lookUpTrackCover(key: string, artist: string, track: string) {
const image = await fetchTrackImage(artist, track); const image = (await fetchTrackImage(artist, track)) ?? notFound;
trackCovers[key] = image ?? notFound; const database = await lastfmDatabase();
await writeFile(trackCoversPath, JSON.stringify(trackCovers)); database
.prepare('INSERT INTO track_covers (artist, track, image) VALUES (?, ?, ?) ON CONFLICT(artist, track) DO UPDATE SET image = excluded.image')
.run(artist, track, image);
trackCovers.set(key, image);
if (image) version++; if (image) version++;
} }
@@ -134,7 +138,12 @@ function runWorker() {
.finally(() => (workerRunning = false)); .finally(() => (workerRunning = false));
} }
export function savedCoverPath(id: string): string | null { export async function readCoverImage(id: string): Promise<CoverImage | null> {
const fileName = imageIdPattern.test(id) ? savedImages.get(id) : undefined; if (!imageIdPattern.test(id)) return null;
return fileName ? path.join(imageDirectory, fileName) : null; const database = await lastfmDatabase();
const row = database.prepare('SELECT content_type, data FROM cover_images WHERE id = ?').get(id) as
| { content_type: string; data: Uint8Array }
| null
| undefined;
return row ? { contentType: row.content_type, data: row.data } : null;
} }
+49
View File
@@ -0,0 +1,49 @@
import path from 'node:path';
import { openSqlite, type SqlDatabase } from '../sqlite';
import { importLegacyFiles } from './legacy';
export type { SqlDatabase };
export const lastfmDataDirectory = path.resolve(process.env.LASTFM_DATA_DIR ?? 'data/lastfm');
const databasePath = path.join(lastfmDataDirectory, 'lastfm.db');
const schema = `
CREATE TABLE IF NOT EXISTS scrobbles (
played_at INTEGER NOT NULL,
artist TEXT NOT NULL,
track TEXT NOT NULL,
album TEXT NOT NULL,
UNIQUE (played_at, artist, track)
);
CREATE INDEX IF NOT EXISTS scrobbles_played_at ON scrobbles (played_at);
CREATE TABLE IF NOT EXISTS album_covers (
artist TEXT NOT NULL,
album TEXT NOT NULL,
image TEXT NOT NULL,
PRIMARY KEY (artist, album)
);
CREATE TABLE IF NOT EXISTS track_covers (
artist TEXT NOT NULL,
track TEXT NOT NULL,
image TEXT NOT NULL,
PRIMARY KEY (artist, track)
);
CREATE TABLE IF NOT EXISTS cover_images (
id TEXT PRIMARY KEY,
content_type TEXT NOT NULL,
data BLOB NOT NULL
);
`;
let opening: Promise<SqlDatabase> | null = null;
async function openLastfmDatabase(): Promise<SqlDatabase> {
const database = await openSqlite(databasePath, schema);
await importLegacyFiles(database, lastfmDataDirectory);
return database;
}
export function lastfmDatabase(): Promise<SqlDatabase> {
opening ??= openLastfmDatabase();
return opening;
}
+84
View File
@@ -0,0 +1,84 @@
import { readdir, readFile } from 'node:fs/promises';
import path from 'node:path';
import { inTransaction, type SqlDatabase } from '../sqlite';
const contentTypes: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp',
'.gif': 'image/gif'
};
async function readText(filePath: string): Promise<string> {
try {
return await readFile(filePath, 'utf8');
} catch {
return '';
}
}
async function readPairs(filePath: string): Promise<[string, string, string][]> {
try {
const covers: Record<string, string> = JSON.parse(await readText(filePath));
return Object.entries(covers).map(([key, image]) => {
const [first, second] = key.split('\n');
return [first, second ?? '', image];
});
} catch {
return [];
}
}
async function readImageFiles(directory: string) {
try {
const fileNames = await readdir(directory);
return Promise.all(
fileNames.map(async (fileName) => ({
id: path.parse(fileName).name,
contentType: contentTypes[path.extname(fileName)] ?? 'image/jpeg',
data: new Uint8Array(await readFile(path.join(directory, fileName)))
}))
);
} catch {
return [];
}
}
function isEmpty(database: SqlDatabase, table: string): boolean {
return !database.prepare(`SELECT 1 FROM ${table} LIMIT 1`).get();
}
async function importScrobbles(database: SqlDatabase, directory: string) {
const lines = (await readText(path.join(directory, 'scrobbles.jsonl'))).split('\n').filter(Boolean);
if (!lines.length) return;
const insert = database.prepare('INSERT OR IGNORE INTO scrobbles (played_at, artist, track, album) VALUES (?, ?, ?, ?)');
inTransaction(database, () => {
for (const line of lines) {
const scrobble = JSON.parse(line);
insert.run(scrobble.playedAt, scrobble.artist, scrobble.track, scrobble.album);
}
});
console.log(`Last.fm moved ${lines.length} scrobbles into SQLite`);
}
async function importCovers(database: SqlDatabase, directory: string, fileName: string, table: string, column: string) {
const pairs = await readPairs(path.join(directory, fileName));
if (!pairs.length) return;
const insert = database.prepare(`INSERT OR IGNORE INTO ${table} (artist, ${column}, image) VALUES (?, ?, ?)`);
inTransaction(database, () => pairs.forEach((pair) => insert.run(...pair)));
}
async function importImages(database: SqlDatabase, directory: string) {
const images = await readImageFiles(path.join(directory, 'images'));
if (!images.length) return;
const insert = database.prepare('INSERT OR IGNORE INTO cover_images (id, content_type, data) VALUES (?, ?, ?)');
inTransaction(database, () => images.forEach((image) => insert.run(image.id, image.contentType, image.data)));
}
export async function importLegacyFiles(database: SqlDatabase, directory: string) {
if (isEmpty(database, 'scrobbles')) await importScrobbles(database, directory);
if (isEmpty(database, 'album_covers')) await importCovers(database, directory, 'covers.json', 'album_covers', 'album');
if (isEmpty(database, 'track_covers')) await importCovers(database, directory, 'track-covers.json', 'track_covers', 'track');
if (isEmpty(database, 'cover_images')) await importImages(database, directory);
}
+12 -22
View File
@@ -1,5 +1,5 @@
import { appendFile, mkdir, readFile } from 'node:fs/promises'; import { inTransaction } from '../sqlite';
import path from 'node:path'; import { lastfmDatabase } from './database';
export interface Scrobble { export interface Scrobble {
playedAt: number; playedAt: number;
@@ -8,30 +8,19 @@ export interface Scrobble {
album: string; album: string;
} }
const dataDirectory = path.resolve(process.env.LASTFM_DATA_DIR ?? 'data/lastfm');
const scrobblesPath = path.join(dataDirectory, 'scrobbles.jsonl');
const scrobbles: Scrobble[] = []; const scrobbles: Scrobble[] = [];
let loading: Promise<void> | null = null; let loading: Promise<void> | null = null;
async function readTextFile(filePath: string): Promise<string> { async function loadFromDatabase() {
try { const database = await lastfmDatabase();
return await readFile(filePath, 'utf8'); const rows = database
} catch { .prepare('SELECT played_at AS playedAt, artist, track, album FROM scrobbles ORDER BY played_at')
return ''; .all() as Scrobble[];
} for (const row of rows) scrobbles.push(row);
}
async function loadFromDisk() {
await mkdir(dataDirectory, { recursive: true });
const lines = (await readTextFile(scrobblesPath)).split('\n').filter(Boolean);
scrobbles.push(...lines.map((line) => JSON.parse(line) as Scrobble));
scrobbles.sort((first, second) => first.playedAt - second.playedAt);
} }
export function loadScrobbleStore(): Promise<void> { export function loadScrobbleStore(): Promise<void> {
loading ??= loadFromDisk(); loading ??= loadFromDatabase();
return loading; return loading;
} }
@@ -50,8 +39,9 @@ export async function appendScrobbles(newScrobbles: Scrobble[]) {
.sort((first, second) => first.playedAt - second.playedAt); .sort((first, second) => first.playedAt - second.playedAt);
if (!fresh.length) return 0; if (!fresh.length) return 0;
const lines = fresh.map((scrobble) => JSON.stringify(scrobble)).join('\n'); const database = await lastfmDatabase();
await appendFile(scrobblesPath, `${lines}\n`); const insert = database.prepare('INSERT OR IGNORE INTO scrobbles (played_at, artist, track, album) VALUES (?, ?, ?, ?)');
inTransaction(database, () => fresh.forEach((scrobble) => insert.run(scrobble.playedAt, scrobble.artist, scrobble.track, scrobble.album)));
scrobbles.push(...fresh); scrobbles.push(...fresh);
return fresh.length; return fresh.length;
} }
+11
View File
@@ -0,0 +1,11 @@
import { timingSafeEqual } from 'node:crypto';
import { error } from '@sveltejs/kit';
import { LIBRARY_API_KEY } from '$app/env/private';
export function requireApiKey(request: Request) {
if (!LIBRARY_API_KEY) error(503, 'LIBRARY_API_KEY is not set on the server');
const given = Buffer.from(request.headers.get('authorization')?.replace(/^Bearer\s+/i, '') ?? '');
const expected = Buffer.from(LIBRARY_API_KEY);
if (given.length !== expected.length || !timingSafeEqual(given, expected)) error(401, 'Invalid API key');
}
+191
View File
@@ -0,0 +1,191 @@
import { error } from '@sveltejs/kit';
import type { Book, BookSeries, BookShelf } from '#lib/content';
import { timestamp, type SqlDatabase } from './database';
import { hasImage, imageUrl, removeImage, syncImage } from './images';
import { parseList, readChoice, readHistory, readNumber, readRating, readText, readTextList, type ExportedEntry, type RequestBody } from './input';
interface BookRow {
goodreads_id: string;
title: string;
authors: string;
description: string | null;
pages: number | null;
published: string | null;
genres: string;
average_rating: number | null;
rating_count: number | null;
cover_source: string | null;
shelf: BookShelf;
rating: number | null;
series_name: string | null;
series_url: string | null;
series_position: string | null;
added: string | null;
started: string | null;
finished: string | null;
}
const bookShelves: BookShelf[] = ['read', 'reading', 'want-to-read'];
const goodreadsIdPattern = /^\d+$/;
export function isGoodreadsId(text: string): boolean {
return goodreadsIdPattern.test(text);
}
function coverImageId(goodreadsId: string): string {
return `book-${goodreadsId}`;
}
function seriesOf(row: BookRow): BookSeries | undefined {
if (!row.series_name) return undefined;
return { name: row.series_name, url: row.series_url ?? '', ...(row.series_position && { position: row.series_position }) };
}
function toBook(row: BookRow, hasCover: boolean): Book {
const series = seriesOf(row);
return {
goodreadsId: row.goodreads_id,
url: `https://www.goodreads.com/book/show/${row.goodreads_id}`,
title: row.title,
authors: parseList(row.authors),
cover: hasCover ? imageUrl(coverImageId(row.goodreads_id)) : row.cover_source,
coverSource: row.cover_source,
description: row.description,
pages: row.pages,
published: row.published,
genres: parseList(row.genres),
averageRating: row.average_rating,
ratingCount: row.rating_count,
shelf: row.shelf,
...(row.rating !== null && { rating: row.rating }),
...(row.added && { added: row.added }),
...(row.started && { started: row.started }),
...(row.finished && { finished: row.finished }),
...(series && { series })
};
}
function findRow(database: SqlDatabase, goodreadsId: string): BookRow | null {
return (database.prepare('SELECT * FROM books WHERE goodreads_id = ?').get(goodreadsId) as BookRow | undefined) ?? null;
}
export function listBooks(database: SqlDatabase, imageIds: Set<string>): Book[] {
const rows = database.prepare('SELECT * FROM books ORDER BY title COLLATE NOCASE').all() as BookRow[];
return rows.map((row) => toBook(row, imageIds.has(coverImageId(row.goodreads_id))));
}
export function exportBooks(database: SqlDatabase): ExportedEntry[] {
const rows = database.prepare('SELECT * FROM books ORDER BY goodreads_id').all() as BookRow[];
return rows.map((row) => ({
id: row.goodreads_id,
fields: {
title: row.title,
authors: parseList(row.authors),
description: row.description,
pages: row.pages,
published: row.published,
genres: parseList(row.genres),
averageRating: row.average_rating,
ratingCount: row.rating_count,
coverSource: row.cover_source,
shelf: row.shelf,
rating: row.rating,
series: row.series_name ? { name: row.series_name, url: row.series_url, position: row.series_position } : null,
added: row.added,
started: row.started,
finished: row.finished
}
}));
}
function readSeries(body: RequestBody, existing: BookRow | null): Pick<BookRow, 'series_name' | 'series_url' | 'series_position'> {
const current = {
series_name: existing?.series_name ?? null,
series_url: existing?.series_url ?? null,
series_position: existing?.series_position ?? null
};
if (!('series' in body)) return current;
if (body.series === null) return { series_name: null, series_url: null, series_position: null };
if (typeof body.series !== 'object' || Array.isArray(body.series)) error(400, 'series must be an object or null');
const series = body.series as RequestBody;
const name = readText(series, 'name', null);
if (!name) error(400, 'series needs a name');
return { series_name: name, series_url: readText(series, 'url', null), series_position: readText(series, 'position', null) };
}
function mergeRow(goodreadsId: string, existing: BookRow | null, body: RequestBody): BookRow {
const title = readText(body, 'title', existing?.title ?? null);
if (!title) error(400, 'title is required');
const shelf = readChoice(body, 'shelf', bookShelves, existing?.shelf ?? 'read');
const pages = readNumber(body, 'pages', existing?.pages ?? null);
return {
goodreads_id: goodreadsId,
title,
authors: JSON.stringify(readTextList(body, 'authors', parseList(existing?.authors ?? '[]'))),
description: readText(body, 'description', existing?.description ?? null),
pages: pages === null ? null : Math.round(pages),
published: readText(body, 'published', existing?.published ?? null),
genres: JSON.stringify(readTextList(body, 'genres', parseList(existing?.genres ?? '[]'))),
average_rating: readNumber(body, 'averageRating', existing?.average_rating ?? null),
rating_count: readNumber(body, 'ratingCount', existing?.rating_count ?? null),
cover_source: readText(body, 'coverSource', existing?.cover_source ?? null),
shelf,
rating: readRating(body, existing?.rating ?? null),
...readSeries(body, existing),
...readHistory(
body,
{ added: existing?.added ?? null, started: existing?.started ?? null, finished: existing?.finished ?? null },
{ isNew: !existing, previousStatus: existing?.shelf ?? null, status: shelf, activeStatus: 'reading', doneStatus: 'read' }
)
};
}
export async function saveBook(database: SqlDatabase, goodreadsId: string, body: RequestBody): Promise<Book> {
const row = mergeRow(goodreadsId, findRow(database, goodreadsId), body);
database
.prepare(
`INSERT INTO books (goodreads_id, title, authors, description, pages, published, genres, average_rating, rating_count, cover_source,
shelf, rating, series_name, series_url, series_position, added, started, finished, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(goodreads_id) DO UPDATE SET title = excluded.title, authors = excluded.authors, description = excluded.description,
pages = excluded.pages, published = excluded.published, genres = excluded.genres, average_rating = excluded.average_rating,
rating_count = excluded.rating_count, cover_source = excluded.cover_source, shelf = excluded.shelf, rating = excluded.rating,
series_name = excluded.series_name, series_url = excluded.series_url, series_position = excluded.series_position,
added = excluded.added, started = excluded.started, finished = excluded.finished, updated_at = excluded.updated_at`
)
.run(
row.goodreads_id,
row.title,
row.authors,
row.description,
row.pages,
row.published,
row.genres,
row.average_rating,
row.rating_count,
row.cover_source,
row.shelf,
row.rating,
row.series_name,
row.series_url,
row.series_position,
row.added,
row.started,
row.finished,
timestamp()
);
if ('coverSource' in body) await syncImage(database, coverImageId(goodreadsId), row.cover_source);
return toBook(row, hasImage(database, coverImageId(goodreadsId)));
}
export function deleteBook(database: SqlDatabase, goodreadsId: string): boolean {
if (!findRow(database, goodreadsId)) return false;
database.prepare('DELETE FROM books WHERE goodreads_id = ?').run(goodreadsId);
removeImage(database, coverImageId(goodreadsId));
return true;
}
+108
View File
@@ -0,0 +1,108 @@
import path from 'node:path';
import { addMissingColumns, openSqlite, type SqlDatabase } from '../sqlite';
export type { SqlDatabase };
const dataDirectory = path.resolve(process.env.LIBRARY_DATA_DIR ?? 'data/library');
const databasePath = path.join(dataDirectory, 'library.db');
const schema = `
CREATE TABLE IF NOT EXISTS media (
imdb_id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'watched',
rating INTEGER,
collection TEXT,
year TEXT,
genre TEXT,
runtime TEXT,
plot TEXT,
poster_source TEXT,
added TEXT,
started TEXT,
finished TEXT,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS books (
goodreads_id TEXT PRIMARY KEY,
title TEXT NOT NULL,
authors TEXT NOT NULL DEFAULT '[]',
description TEXT,
pages INTEGER,
published TEXT,
genres TEXT NOT NULL DEFAULT '[]',
average_rating REAL,
rating_count INTEGER,
cover_source TEXT,
shelf TEXT NOT NULL DEFAULT 'read',
rating INTEGER,
series_name TEXT,
series_url TEXT,
series_position TEXT,
added TEXT,
started TEXT,
finished TEXT,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS games (
igdb_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
store_url TEXT,
igdb_url TEXT,
status TEXT NOT NULL DEFAULT 'played',
rating INTEGER,
released TEXT,
platforms TEXT NOT NULL DEFAULT '[]',
developers TEXT NOT NULL DEFAULT '[]',
publishers TEXT NOT NULL DEFAULT '[]',
genres TEXT NOT NULL DEFAULT '[]',
summary TEXT,
score INTEGER,
image_source TEXT,
cover_source TEXT,
favorite INTEGER NOT NULL DEFAULT 0,
liked INTEGER NOT NULL DEFAULT 0,
tags TEXT NOT NULL DEFAULT '[]',
note TEXT,
added TEXT,
started TEXT,
finished TEXT,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS images (
id TEXT PRIMARY KEY,
source TEXT NOT NULL,
content_type TEXT NOT NULL,
data BLOB NOT NULL,
updated_at TEXT NOT NULL
);
`;
const addedGameColumns = {
favorite: 'INTEGER NOT NULL DEFAULT 0',
liked: 'INTEGER NOT NULL DEFAULT 0',
tags: "TEXT NOT NULL DEFAULT '[]'",
note: 'TEXT'
};
let opening: Promise<SqlDatabase> | null = null;
async function openLibraryDatabase(): Promise<SqlDatabase> {
const database = await openSqlite(databasePath, schema);
addMissingColumns(database, 'games', addedGameColumns);
return database;
}
export function libraryDatabase(): Promise<SqlDatabase> {
opening ??= openLibraryDatabase();
return opening;
}
export function timestamp(): string {
return new Date().toISOString();
}
export function today(): string {
return timestamp().slice(0, 10);
}
+208
View File
@@ -0,0 +1,208 @@
import { error } from '@sveltejs/kit';
import type { Game, PlayStatus } from '#lib/content';
import { likedLimit } from '#lib/library/gameProfile';
import { timestamp, type SqlDatabase } from './database';
import { hasImage, imageUrl, removeImage, syncImage } from './images';
import { parseList, readChoice, readFlag, readHistory, readNumber, readRating, readText, readTextList, type ExportedEntry, type RequestBody } from './input';
interface GameRow {
igdb_id: string;
name: string;
store_url: string | null;
igdb_url: string | null;
status: PlayStatus;
rating: number | null;
released: string | null;
platforms: string;
developers: string;
publishers: string;
genres: string;
summary: string | null;
score: number | null;
image_source: string | null;
cover_source: string | null;
favorite: number;
liked: number;
tags: string;
note: string | null;
added: string | null;
started: string | null;
finished: string | null;
}
const playStatuses: PlayStatus[] = ['played', 'playing', 'planning', 'replayable'];
const igdbIdPattern = /^\d+$/;
export function isIgdbId(text: string): boolean {
return igdbIdPattern.test(text);
}
function gameImageId(igdbId: string): string {
return `game-${igdbId}`;
}
function toGame(row: GameRow, hasStoredImage: boolean): Game {
return {
igdbId: row.igdb_id,
name: row.name,
url: row.store_url,
igdbUrl: row.igdb_url,
image: hasStoredImage ? imageUrl(gameImageId(row.igdb_id)) : row.image_source,
cover: row.cover_source,
status: row.status,
released: row.released,
platforms: parseList(row.platforms),
developers: parseList(row.developers),
publishers: parseList(row.publishers),
genres: parseList(row.genres),
summary: row.summary,
score: row.score,
favorite: !!row.favorite,
liked: !!row.liked,
tags: parseList(row.tags),
note: row.note,
...(row.rating !== null && { rating: row.rating }),
...(row.added && { added: row.added }),
...(row.started && { started: row.started }),
...(row.finished && { finished: row.finished })
};
}
function findRow(database: SqlDatabase, igdbId: string): GameRow | null {
return (database.prepare('SELECT * FROM games WHERE igdb_id = ?').get(igdbId) as GameRow | undefined) ?? null;
}
export function listGames(database: SqlDatabase, imageIds: Set<string>): Game[] {
const rows = database.prepare('SELECT * FROM games ORDER BY name COLLATE NOCASE').all() as GameRow[];
return rows.map((row) => toGame(row, imageIds.has(gameImageId(row.igdb_id))));
}
export function exportGames(database: SqlDatabase): ExportedEntry[] {
const rows = database.prepare('SELECT * FROM games ORDER BY igdb_id').all() as GameRow[];
return rows.map((row) => ({
id: row.igdb_id,
fields: {
name: row.name,
url: row.store_url,
igdbUrl: row.igdb_url,
status: row.status,
rating: row.rating,
released: row.released,
platforms: parseList(row.platforms),
developers: parseList(row.developers),
publishers: parseList(row.publishers),
genres: parseList(row.genres),
summary: row.summary,
score: row.score,
imageSource: row.image_source,
coverSource: row.cover_source,
favorite: !!row.favorite,
liked: !!row.liked,
tags: parseList(row.tags),
note: row.note,
added: row.added,
started: row.started,
finished: row.finished
}
}));
}
function readList(body: RequestBody, key: string, current: string | undefined): string {
return JSON.stringify(readTextList(body, key, parseList(current ?? '[]')));
}
function mergeRow(igdbId: string, existing: GameRow | null, body: RequestBody): GameRow {
const name = readText(body, 'name', existing?.name ?? null);
if (!name) error(400, 'name is required');
const status = readChoice(body, 'status', playStatuses, existing?.status ?? 'played');
const score = readNumber(body, 'score', existing?.score ?? null);
return {
igdb_id: igdbId,
name,
store_url: readText(body, 'url', existing?.store_url ?? null),
igdb_url: readText(body, 'igdbUrl', existing?.igdb_url ?? null),
status,
rating: readRating(body, existing?.rating ?? null),
released: readText(body, 'released', existing?.released ?? null),
platforms: readList(body, 'platforms', existing?.platforms),
developers: readList(body, 'developers', existing?.developers),
publishers: readList(body, 'publishers', existing?.publishers),
genres: readList(body, 'genres', existing?.genres),
summary: readText(body, 'summary', existing?.summary ?? null),
score: score === null ? null : Math.round(score),
image_source: readText(body, 'imageSource', existing?.image_source ?? null),
cover_source: readText(body, 'coverSource', existing?.cover_source ?? null),
favorite: Number(readFlag(body, 'favorite', !!existing?.favorite)),
liked: Number(readFlag(body, 'liked', !!existing?.liked)),
tags: readList(body, 'tags', existing?.tags),
note: readText(body, 'note', existing?.note ?? null),
...readHistory(
body,
{ added: existing?.added ?? null, started: existing?.started ?? null, finished: existing?.finished ?? null },
{ isNew: !existing, previousStatus: existing?.status ?? null, status, activeStatus: 'playing', doneStatus: 'played' }
)
};
}
function checkLikedLimit(database: SqlDatabase, row: GameRow) {
if (!row.liked) return;
const others = database.prepare('SELECT COUNT(*) AS total FROM games WHERE liked = 1 AND igdb_id != ?').get(row.igdb_id) as { total: number };
if (Number(others.total) >= likedLimit) error(400, `Only ${likedLimit} games can be liked`);
}
export async function saveGame(database: SqlDatabase, igdbId: string, body: RequestBody): Promise<Game> {
const row = mergeRow(igdbId, findRow(database, igdbId), body);
checkLikedLimit(database, row);
if (row.favorite) database.prepare('UPDATE games SET favorite = 0 WHERE igdb_id != ?').run(igdbId);
database
.prepare(
`INSERT INTO games (igdb_id, name, store_url, igdb_url, status, rating, released, platforms, developers, publishers, genres, summary,
score, image_source, cover_source, favorite, liked, tags, note, added, started, finished, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(igdb_id) DO UPDATE SET name = excluded.name, store_url = excluded.store_url, igdb_url = excluded.igdb_url,
status = excluded.status, rating = excluded.rating, released = excluded.released, platforms = excluded.platforms,
developers = excluded.developers, publishers = excluded.publishers, genres = excluded.genres, summary = excluded.summary,
score = excluded.score, image_source = excluded.image_source, cover_source = excluded.cover_source,
favorite = excluded.favorite, liked = excluded.liked, tags = excluded.tags,
note = excluded.note, added = excluded.added,
started = excluded.started, finished = excluded.finished, updated_at = excluded.updated_at`
)
.run(
row.igdb_id,
row.name,
row.store_url,
row.igdb_url,
row.status,
row.rating,
row.released,
row.platforms,
row.developers,
row.publishers,
row.genres,
row.summary,
row.score,
row.image_source,
row.cover_source,
row.favorite,
row.liked,
row.tags,
row.note,
row.added,
row.started,
row.finished,
timestamp()
);
if ('imageSource' in body) await syncImage(database, gameImageId(igdbId), row.image_source);
return toGame(row, hasImage(database, gameImageId(igdbId)));
}
export function deleteGame(database: SqlDatabase, igdbId: string): boolean {
if (!findRow(database, igdbId)) return false;
database.prepare('DELETE FROM games WHERE igdb_id = ?').run(igdbId);
removeImage(database, gameImageId(igdbId));
return true;
}
+71
View File
@@ -0,0 +1,71 @@
import { timestamp, type SqlDatabase } from './database';
export interface StoredImage {
contentType: string;
data: Uint8Array;
}
const requestHeaders = { 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0' };
const maxImageBytes = 8 * 1024 * 1024;
export function imageUrl(id: string): string {
return `/api/library/images/${id}`;
}
export function storedImageIds(database: SqlDatabase): Set<string> {
const rows = database.prepare('SELECT id FROM images').all() as { id: string }[];
return new Set(rows.map((row) => row.id));
}
export function hasImage(database: SqlDatabase, id: string): boolean {
return !!database.prepare('SELECT id FROM images WHERE id = ?').get(id);
}
export function readImage(database: SqlDatabase, id: string): StoredImage | null {
const row = database.prepare('SELECT content_type, data FROM images WHERE id = ?').get(id) as
| { content_type: string; data: Uint8Array }
| null
| undefined;
return row ? { contentType: row.content_type, data: row.data } : null;
}
export function removeImage(database: SqlDatabase, id: string) {
database.prepare('DELETE FROM images WHERE id = ?').run(id);
}
function isWebAddress(source: string): boolean {
return /^https?:\/\//.test(source);
}
async function downloadImage(source: string): Promise<StoredImage> {
const response = await fetch(source, { headers: requestHeaders });
if (!response.ok) throw new Error(`image request returned ${response.status}`);
const contentType = response.headers.get('content-type')?.split(';')[0] ?? 'image/jpeg';
if (!contentType.startsWith('image/')) throw new Error(`${source} is not an image`);
const data = new Uint8Array(await response.arrayBuffer());
if (data.byteLength > maxImageBytes) throw new Error(`${source} is larger than 8 MB`);
return { contentType, data };
}
export async function syncImage(database: SqlDatabase, id: string, source: string | null) {
if (!source || !isWebAddress(source)) {
removeImage(database, id);
return;
}
const stored = database.prepare('SELECT source FROM images WHERE id = ?').get(id) as { source: string } | null | undefined;
if (stored?.source === source) return;
try {
const image = await downloadImage(source);
database
.prepare(
`INSERT INTO images (id, source, content_type, data, updated_at) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET source = excluded.source, content_type = excluded.content_type, data = excluded.data, updated_at = excluded.updated_at`
)
.run(id, source, image.contentType, image.data, timestamp());
} catch (downloadError) {
console.error(`Could not save image ${id}:`, downloadError);
}
}
+24
View File
@@ -0,0 +1,24 @@
import type { LibraryData } from '#lib/library/types';
import { libraryDatabase } from './database';
import { storedImageIds } from './images';
import { exportMedia, listMedia } from './media';
import { exportBooks, listBooks } from './books';
import { exportGames, listGames } from './games';
import type { ExportedEntry } from './input';
export async function getLibrary(): Promise<LibraryData> {
const database = await libraryDatabase();
const imageIds = storedImageIds(database);
return { ...listMedia(database, imageIds), books: listBooks(database, imageIds), games: listGames(database, imageIds) };
}
export interface LibraryExport {
media: ExportedEntry[];
books: ExportedEntry[];
games: ExportedEntry[];
}
export async function exportLibrary(): Promise<LibraryExport> {
const database = await libraryDatabase();
return { media: exportMedia(database), books: exportBooks(database), games: exportGames(database) };
}
+106
View File
@@ -0,0 +1,106 @@
import { error } from '@sveltejs/kit';
import { today } from './database';
export type RequestBody = Record<string, unknown>;
export interface ExportedEntry {
id: string;
fields: RequestBody;
}
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
export async function readBody(request: Request): Promise<RequestBody> {
const body = await request.json().catch(() => null);
if (!body || typeof body !== 'object' || Array.isArray(body)) error(400, 'Body must be a JSON object');
return body as RequestBody;
}
export function readText(body: RequestBody, key: string, current: string | null): string | null {
if (!(key in body)) return current;
const value = body[key];
if (value === null || value === undefined) return null;
if (typeof value !== 'string') error(400, `${key} must be text`);
return value.trim() || null;
}
export function readNumber(body: RequestBody, key: string, current: number | null): number | null {
if (!(key in body)) return current;
const value = body[key];
if (value === null || value === undefined || value === '') return null;
const number = Number(value);
if (!Number.isFinite(number)) error(400, `${key} must be a number`);
return number;
}
export function readRating(body: RequestBody, current: number | null): number | null {
const rating = readNumber(body, 'rating', current);
if (rating !== null && (!Number.isInteger(rating) || rating < 0 || rating > 10)) error(400, 'rating must be a whole number from 0 to 10');
return rating;
}
export function readChoice<Choice extends string>(body: RequestBody, key: string, choices: readonly Choice[], current: Choice): Choice {
if (!(key in body)) return current;
const value = body[key];
if (!choices.includes(value as Choice)) error(400, `${key} must be one of ${choices.join(', ')}`);
return value as Choice;
}
export function readTextList(body: RequestBody, key: string, current: string[]): string[] {
if (!(key in body)) return current;
const value = body[key];
if (value === null || value === undefined) return [];
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) error(400, `${key} must be a list of text`);
return (value as string[]).map((item) => item.trim()).filter(Boolean);
}
export function readFlag(body: RequestBody, key: string, current: boolean): boolean {
if (!(key in body)) return current;
if (typeof body[key] !== 'boolean') error(400, `${key} must be true or false`);
return body[key] as boolean;
}
export function parseList(text: string): string[] {
try {
const value = JSON.parse(text);
return Array.isArray(value) ? value : [];
} catch {
return [];
}
}
export function readDate(body: RequestBody, key: string, current: string | null): string | null {
const value = readText(body, key, current);
if (value !== null && !datePattern.test(value)) error(400, `${key} must be a date like 2026-01-31`);
return value;
}
export interface HistoryDates {
added: string | null;
started: string | null;
finished: string | null;
}
export interface StatusChange<Status extends string> {
isNew: boolean;
previousStatus: Status | null;
status: Status;
activeStatus: Status;
doneStatus: Status;
}
export function readHistory<Status extends string>(body: RequestBody, current: HistoryDates, change: StatusChange<Status>): HistoryDates {
const statusChanged = change.status !== change.previousStatus;
const autoDate = (key: keyof HistoryDates, status: Status, value: string | null) =>
!(key in body) && statusChanged && change.status === status && !value ? today() : value;
const added = readDate(body, 'added', current.added);
const started = readDate(body, 'started', current.started);
const finished = readDate(body, 'finished', current.finished);
return {
added: !('added' in body) && change.isNew ? today() : added,
started: autoDate('started', change.activeStatus, started),
finished: autoDate('finished', change.doneStatus, finished)
};
}
+175
View File
@@ -0,0 +1,175 @@
import { error } from '@sveltejs/kit';
import type { RatedTitle, WatchStatus } from '#lib/content';
import type { MediaDetails, MediaKind } from '#lib/media/types';
import type { LibraryMedia } from '#lib/library/types';
import { timestamp, type SqlDatabase } from './database';
import { hasImage, imageUrl, removeImage, syncImage } from './images';
import { readChoice, readHistory, readRating, readText, type ExportedEntry, type RequestBody } from './input';
interface MediaRow {
imdb_id: string;
kind: MediaKind;
name: string;
status: WatchStatus;
rating: number | null;
collection: string | null;
year: string | null;
genre: string | null;
runtime: string | null;
plot: string | null;
poster_source: string | null;
added: string | null;
started: string | null;
finished: string | null;
}
export interface SavedMedia {
kind: MediaKind;
item: RatedTitle;
details: MediaDetails;
}
const mediaKinds: MediaKind[] = ['show', 'movie'];
const watchStatuses: WatchStatus[] = ['watched', 'watching', 'planning'];
const imdbIdPattern = /^tt\d{7,9}$/;
export function isImdbId(text: string): boolean {
return imdbIdPattern.test(text);
}
function posterImageId(imdbId: string): string {
return `media-${imdbId}`;
}
function titleUrl(imdbId: string): string {
return `https://www.imdb.com/title/${imdbId}/`;
}
function toRatedTitle(row: MediaRow): RatedTitle {
return {
name: row.name,
url: titleUrl(row.imdb_id),
...(row.rating !== null && { rating: row.rating }),
...(row.status !== 'watched' && { status: row.status }),
...(row.collection && { collection: row.collection }),
...(row.added && { added: row.added }),
...(row.started && { started: row.started }),
...(row.finished && { finished: row.finished })
};
}
function toMediaDetails(row: MediaRow, hasPoster: boolean): MediaDetails {
return {
poster: hasPoster ? imageUrl(posterImageId(row.imdb_id)) : row.poster_source,
year: row.year,
genre: row.genre,
runtime: row.runtime,
plot: row.plot
};
}
function findRow(database: SqlDatabase, imdbId: string): MediaRow | null {
return (database.prepare('SELECT * FROM media WHERE imdb_id = ?').get(imdbId) as MediaRow | undefined) ?? null;
}
export function listMedia(database: SqlDatabase, imageIds: Set<string>): LibraryMedia {
const rows = database.prepare('SELECT * FROM media ORDER BY name COLLATE NOCASE').all() as MediaRow[];
return {
shows: rows.filter((row) => row.kind === 'show').map(toRatedTitle),
movies: rows.filter((row) => row.kind === 'movie').map(toRatedTitle),
mediaDetails: Object.fromEntries(
rows.map((row) => [titleUrl(row.imdb_id), toMediaDetails(row, imageIds.has(posterImageId(row.imdb_id)))])
)
};
}
export function exportMedia(database: SqlDatabase): ExportedEntry[] {
const rows = database.prepare('SELECT * FROM media ORDER BY imdb_id').all() as MediaRow[];
return rows.map((row) => ({
id: row.imdb_id,
fields: {
kind: row.kind,
name: row.name,
status: row.status,
rating: row.rating,
collection: row.collection,
year: row.year,
genre: row.genre,
runtime: row.runtime,
plot: row.plot,
posterSource: row.poster_source,
added: row.added,
started: row.started,
finished: row.finished
}
}));
}
function mergeRow(imdbId: string, existing: MediaRow | null, body: RequestBody): MediaRow {
const name = readText(body, 'name', existing?.name ?? null);
if (!name) error(400, 'name is required');
if (!existing && !('kind' in body)) error(400, 'kind is required');
const status = readChoice(body, 'status', watchStatuses, existing?.status ?? 'watched');
return {
imdb_id: imdbId,
kind: readChoice(body, 'kind', mediaKinds, existing?.kind ?? 'movie'),
name,
status,
rating: readRating(body, existing?.rating ?? null),
collection: readText(body, 'collection', existing?.collection ?? null),
year: readText(body, 'year', existing?.year ?? null),
genre: readText(body, 'genre', existing?.genre ?? null),
runtime: readText(body, 'runtime', existing?.runtime ?? null),
plot: readText(body, 'plot', existing?.plot ?? null),
poster_source: readText(body, 'posterSource', existing?.poster_source ?? null),
...readHistory(
body,
{ added: existing?.added ?? null, started: existing?.started ?? null, finished: existing?.finished ?? null },
{ isNew: !existing, previousStatus: existing?.status ?? null, status, activeStatus: 'watching', doneStatus: 'watched' }
)
};
}
export async function saveMedia(database: SqlDatabase, imdbId: string, body: RequestBody): Promise<SavedMedia> {
const row = mergeRow(imdbId, findRow(database, imdbId), body);
database
.prepare(
`INSERT INTO media (imdb_id, kind, name, status, rating, collection, year, genre, runtime, plot, poster_source, added, started, finished, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(imdb_id) DO UPDATE SET kind = excluded.kind, name = excluded.name, status = excluded.status, rating = excluded.rating,
collection = excluded.collection, year = excluded.year, genre = excluded.genre, runtime = excluded.runtime, plot = excluded.plot,
poster_source = excluded.poster_source, added = excluded.added, started = excluded.started, finished = excluded.finished,
updated_at = excluded.updated_at`
)
.run(
row.imdb_id,
row.kind,
row.name,
row.status,
row.rating,
row.collection,
row.year,
row.genre,
row.runtime,
row.plot,
row.poster_source,
row.added,
row.started,
row.finished,
timestamp()
);
if ('posterSource' in body) await syncImage(database, posterImageId(imdbId), row.poster_source);
return { kind: row.kind, item: toRatedTitle(row), details: toMediaDetails(row, hasImage(database, posterImageId(imdbId))) };
}
export function deleteMedia(database: SqlDatabase, imdbId: string): boolean {
const existing = findRow(database, imdbId);
if (!existing) return false;
database.prepare('DELETE FROM media WHERE imdb_id = ?').run(imdbId);
removeImage(database, posterImageId(imdbId));
return true;
}
-70
View File
@@ -1,70 +0,0 @@
import { OMDB_API_KEY } from '$app/env/private';
import type { MediaDetails } from '#lib/media/types';
const cacheDuration = 24 * 60 * 60 * 1000;
const imdbIdPattern = /tt\d+/;
const missingValue = 'N/A';
interface OmdbResponse {
Response: 'True' | 'False';
Error?: string;
Year?: string;
Genre?: string;
Runtime?: string;
Plot?: string;
Poster?: string;
}
const detailsCache = new Map<string, { details: MediaDetails | null; fetchedAt: number }>();
function imdbIdFromUrl(url: string): string | null {
return url.match(imdbIdPattern)?.[0] ?? null;
}
function presentValue(value: string | undefined): string | null {
return value && value !== missingValue ? value : null;
}
function toMediaDetails(result: OmdbResponse): MediaDetails {
return {
poster: presentValue(result.Poster),
year: presentValue(result.Year),
genre: presentValue(result.Genre),
runtime: presentValue(result.Runtime),
plot: presentValue(result.Plot)
};
}
async function fetchDetails(imdbId: string): Promise<MediaDetails | null> {
const cached = detailsCache.get(imdbId);
if (cached && Date.now() - cached.fetchedAt < cacheDuration) return cached.details;
try {
const params = new URLSearchParams({ apikey: OMDB_API_KEY ?? '', i: imdbId, plot: 'short' });
const response = await fetch(`https://www.omdbapi.com/?${params}`);
if (!response.ok) throw new Error(`OMDb returned ${response.status}`);
const result: OmdbResponse = await response.json();
if (result.Response === 'False') throw new Error(result.Error ?? 'OMDb found no result');
const details = toMediaDetails(result);
detailsCache.set(imdbId, { details, fetchedAt: Date.now() });
return details;
} catch (error) {
console.error(`OMDb lookup failed for ${imdbId}:`, error);
return cached?.details ?? null;
}
}
export async function getMediaDetails(urls: string[]): Promise<Record<string, MediaDetails>> {
if (!OMDB_API_KEY) return {};
const results = await Promise.all(
urls.map(async (url) => {
const imdbId = imdbIdFromUrl(url);
const details = imdbId ? await fetchDetails(imdbId) : null;
return [url, details] as const;
})
);
return Object.fromEntries(results.filter((result): result is readonly [string, MediaDetails] => result[1] !== null));
}
+47
View File
@@ -0,0 +1,47 @@
import { mkdirSync } from 'node:fs';
import path from 'node:path';
export type SqlValue = string | number | null | Uint8Array;
export interface SqlStatement {
all(...params: SqlValue[]): unknown[];
get(...params: SqlValue[]): unknown;
run(...params: SqlValue[]): unknown;
}
export interface SqlDatabase {
exec(sql: string): void;
prepare(sql: string): SqlStatement;
}
async function importModule(name: string) {
return import(/* @vite-ignore */ name);
}
export async function openSqlite(filePath: string, schema: string): Promise<SqlDatabase> {
mkdirSync(path.dirname(filePath), { recursive: true });
const database: SqlDatabase = process.versions.bun
? new (await importModule('bun:sqlite')).Database(filePath, { create: true })
: new (await importModule('node:sqlite')).DatabaseSync(filePath);
database.exec('PRAGMA journal_mode = WAL;');
database.exec(schema);
return database;
}
export function addMissingColumns(database: SqlDatabase, table: string, columns: Record<string, string>) {
const existing = new Set((database.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]).map((column) => column.name));
for (const [name, definition] of Object.entries(columns)) {
if (!existing.has(name)) database.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`);
}
}
export function inTransaction(database: SqlDatabase, work: () => void) {
database.exec('BEGIN');
try {
work();
database.exec('COMMIT');
} catch (error) {
database.exec('ROLLBACK');
throw error;
}
}
+7 -2
View File
@@ -19,9 +19,13 @@ const launchCommands: Command[] = gameNames.map((game) => ({
const gamesCommand: Command = { const gamesCommand: Command = {
name: 'games', name: 'games',
description: 'List or launch the games', description: 'List or launch the games',
usage: 'games [name]', usage: 'games [name | library]',
argumentOptions: () => gameNames, argumentOptions: () => [...gameNames, 'library'],
run: (args, context) => { run: (args, context) => {
if (args[0]?.toLowerCase() === 'library') {
context.openApp('games');
return;
}
const requestedGame = gameNames.find((game) => game === args[0]?.toLowerCase()); const requestedGame = gameNames.find((game) => game === args[0]?.toLowerCase());
if (requestedGame) { if (requestedGame) {
context.openApp(requestedGame); context.openApp(requestedGame);
@@ -30,6 +34,7 @@ const gamesCommand: Command = {
gameNames.forEach((game) => gameNames.forEach((game) =>
context.print(line(span(game.padEnd(12), 'accent', { bold: true }), gameDescriptions[game])) context.print(line(span(game.padEnd(12), 'accent', { bold: true }), gameDescriptions[game]))
); );
context.print(line(span('library'.padEnd(12), 'accent', { bold: true }), 'Games I play, my favorite and the ones I like'));
context.print(line(span('Run ', 'muted'), span('games <name>', 'accent'), span(' to play', 'muted'))); context.print(line(span('Run ', 'muted'), span('games <name>', 'accent'), span(' to play', 'muted')));
} }
}; };
+23
View File
@@ -140,6 +140,29 @@ export const systemCommands: Command[] = [
description: 'Open the Pokemon TCG card browser', description: 'Open the Pokemon TCG card browser',
run: (_args, context) => context.openApp('tcg') run: (_args, context) => context.openApp('tcg')
}, },
{
name: 'thunar',
aliases: ['files', 'explorer'],
description: 'Open the file explorer',
run: (_args, context) => context.openApp('files')
},
{
name: 'movies',
description: 'Open my movie watch history',
run: (_args, context) => context.openApp('movies')
},
{
name: 'shows',
aliases: ['tv'],
description: 'Open my TV show watch history',
run: (_args, context) => context.openApp('shows')
},
{
name: 'books',
aliases: ['library'],
description: 'Open my reading history',
run: (_args, context) => context.openApp('books')
},
{ {
name: 'pwd', name: 'pwd',
hidden: true, hidden: true,
+52
View File
@@ -0,0 +1,52 @@
import { wallpapers, type Wallpaper } from '#lib/content';
const wallpaperStorageKey = 'wallpaper';
function readStoredWallpaperId(): string | null {
try {
return localStorage.getItem(wallpaperStorageKey);
} catch {
return null;
}
}
function storeWallpaperId(wallpaperId: string | null) {
try {
if (wallpaperId) localStorage.setItem(wallpaperStorageKey, wallpaperId);
else localStorage.removeItem(wallpaperStorageKey);
} catch {}
}
function findWallpaper(wallpaperId: string | null): Wallpaper | null {
return wallpapers.find((wallpaper) => wallpaper.id === wallpaperId) ?? null;
}
function applyWallpaper(wallpaper: Wallpaper | null) {
const rootStyle = document.documentElement.style;
if (wallpaper) rootStyle.setProperty('--wallpaper', `url('${wallpaper.image}')`);
else rootStyle.removeProperty('--wallpaper');
}
class WallpaperState {
currentId = $state<string | null>(null);
restore() {
const stored = findWallpaper(readStoredWallpaperId());
this.currentId = stored?.id ?? null;
applyWallpaper(stored);
}
set(wallpaperId: string | null) {
const wallpaper = findWallpaper(wallpaperId);
this.currentId = wallpaper?.id ?? null;
applyWallpaper(wallpaper);
storeWallpaperId(this.currentId);
}
shuffle() {
const choices = wallpapers.filter((wallpaper) => wallpaper.id !== this.currentId);
if (choices.length) this.set(choices[Math.floor(Math.random() * choices.length)].id);
}
}
export const wallpaperState = new WallpaperState();
+2
View File
@@ -6,6 +6,7 @@
import { desktop, profile } from '#lib/content'; import { desktop, profile } from '#lib/content';
import { buildThemeStylesheet } from '#lib/theme/stylesheet'; import { buildThemeStylesheet } from '#lib/theme/stylesheet';
import { themeState } from '#lib/theme/theme.svelte'; import { themeState } from '#lib/theme/theme.svelte';
import { wallpaperState } from '#lib/theme/wallpaper.svelte';
import { pageWindowId, windowManager } from '#lib/desktop/windows.svelte'; import { pageWindowId, windowManager } from '#lib/desktop/windows.svelte';
import { handleKeybind } from '#lib/desktop/keybinds'; import { handleKeybind } from '#lib/desktop/keybinds';
import { handleImageClick } from '#lib/desktop/imageViewer'; import { handleImageClick } from '#lib/desktop/imageViewer';
@@ -27,6 +28,7 @@
onMount(() => { onMount(() => {
themeState.restore(); themeState.restore();
wallpaperState.restore();
const isWideScreen = window.innerWidth >= 768; const isWideScreen = window.innerWidth >= 768;
if (!pageOpen && isWideScreen && desktop.startupCommand) windowManager.openTerminal(desktop.startupCommand, { floating: true }); if (!pageOpen && isWideScreen && desktop.startupCommand) windowManager.openTerminal(desktop.startupCommand, { floating: true });
window.addEventListener('click', handleImageClick, true); window.addEventListener('click', handleImageClick, true);
+4 -5
View File
@@ -1,21 +1,20 @@
import { about } from '#lib/content'; import { about } from '#lib/content';
import { getAniListProfile } from '#lib/server/anilist'; import { getAniListProfile } from '#lib/server/anilist';
import { getMediaDetails } from '#lib/server/omdb'; import { getLibrary } from '#lib/server/library';
import { getListeningProfile } from '#lib/server/lastfm/profile'; import { getListeningProfile } from '#lib/server/lastfm/profile';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => { export const load: PageServerLoad = async () => {
const mediaUrls = [...about.shows, ...about.movies].map((item) => item.url);
const anilistUsername = about.anilistUsername; const anilistUsername = about.anilistUsername;
const [anime, manga, mediaDetails, listening] = await Promise.all([ const [anime, manga, library, listening] = await Promise.all([
anilistUsername ? getAniListProfile(anilistUsername, 'ANIME') : null, anilistUsername ? getAniListProfile(anilistUsername, 'ANIME') : null,
anilistUsername ? getAniListProfile(anilistUsername, 'MANGA') : null, anilistUsername ? getAniListProfile(anilistUsername, 'MANGA') : null,
getMediaDetails(mediaUrls), getLibrary(),
about.lastfmUsername ? getListeningProfile(about.lastfmUsername) : null about.lastfmUsername ? getListeningProfile(about.lastfmUsername) : null
]); ]);
const hasManga = !!manga?.statusCounts.some((item) => item.count > 0); const hasManga = !!manga?.statusCounts.some((item) => item.count > 0);
return { anime, manga: hasManga ? manga : null, mediaDetails, listening }; return { anime, manga: hasManga ? manga : null, library, listening };
}; };
+46 -19
View File
@@ -1,12 +1,14 @@
<script lang="ts"> <script lang="ts">
import { about, books, profile } from '#lib/content'; import Icon from '@iconify/svelte';
import { about, profile } from '#lib/content';
import { windowManager } from '#lib/desktop/windows.svelte';
import PageHeader from '#lib/components/ui/PageHeader.svelte'; import PageHeader from '#lib/components/ui/PageHeader.svelte';
import ProfileIntro from '#lib/components/ui/ProfileIntro.svelte'; import ProfileIntro from '#lib/components/ui/ProfileIntro.svelte';
import SectionNav from '#lib/components/ui/SectionNav.svelte'; import SectionNav from '#lib/components/ui/SectionNav.svelte';
import Section from '#lib/components/ui/Section.svelte'; import Section from '#lib/components/ui/Section.svelte';
import Panel from '#lib/components/ui/Panel.svelte'; import Panel from '#lib/components/ui/Panel.svelte';
import AniListSection from '#lib/components/about/AniListSection.svelte'; import AniListSection from '#lib/components/about/AniListSection.svelte';
import MediaGrid from '#lib/components/about/MediaGrid.svelte'; import MediaSection from '#lib/components/about/media/MediaSection.svelte';
import GameCard from '#lib/components/about/GameCard.svelte'; import GameCard from '#lib/components/about/GameCard.svelte';
import BooksSection from '#lib/components/about/books/BooksSection.svelte'; import BooksSection from '#lib/components/about/books/BooksSection.svelte';
import SetupSection from '#lib/components/about/setup/SetupSection.svelte'; import SetupSection from '#lib/components/about/setup/SetupSection.svelte';
@@ -15,18 +17,26 @@
let { data }: PageProps = $props(); let { data }: PageProps = $props();
const byRating = (first: { rating: number }, second: { rating: number }) => second.rating - first.rating; const books = $derived(data.library.books);
const shows = [...about.shows].sort(byRating); const playOrder = { playing: 0, replayable: 1, played: 2, planning: 3 };
const movies = [...about.movies].sort(byRating); const starredGames = $derived(data.library.games.filter((game) => game.favorite || game.liked));
const mediaGroups = [ const games = $derived(
{ id: 'shows', title: 'TV Shows', items: shows, kind: 'show' as const }, [...(starredGames.length ? starredGames : data.library.games)].sort(
{ id: 'movies', title: 'Movies', items: movies, kind: 'movie' as const } (first, second) =>
]; Number(second.favorite) - Number(first.favorite) ||
playOrder[first.status] - playOrder[second.status] ||
(second.rating ?? -1) - (first.rating ?? -1)
)
);
const mediaGroups = $derived([
{ id: 'shows', title: 'TV Shows', items: data.library.shows, kind: 'show' as const },
{ id: 'movies', title: 'Movies', items: data.library.movies, kind: 'movie' as const }
]);
const sections = $derived([ const sections = $derived([
{ id: 'hobbies', label: 'Hobbies', color: 'blue' }, { id: 'hobbies', label: 'Hobbies', color: 'blue' },
...(about.setup ? [{ id: 'setup', label: 'Setup', color: 'cyan' }] : []), ...(about.setup ? [{ id: 'setup', label: 'Setup', color: 'cyan' }] : []),
{ id: 'games', label: 'Games', color: 'orange' }, ...(games.length ? [{ id: 'games', label: 'Games', color: 'orange' }] : []),
{ id: 'shows', label: 'Shows', color: 'yellow' }, { id: 'shows', label: 'Shows', color: 'yellow' },
{ id: 'movies', label: 'Movies', color: 'green' }, { id: 'movies', label: 'Movies', color: 'green' },
...(books.length ? [{ id: 'books', label: 'Books', color: 'cyan' }] : []), ...(books.length ? [{ id: 'books', label: 'Books', color: 'cyan' }] : []),
@@ -45,8 +55,15 @@
<Section title="Hobbies" id="hobbies"> <Section title="Hobbies" id="hobbies">
<div class="card-grid"> <div class="card-grid">
{#each about.hobbies as hobby (hobby.name)} {#each about.hobbies as hobby (hobby.name)}
<Panel title={hobby.name} icon={hobby.icon}> <Panel
title={hobby.name}
icon={hobby.icon}
onclick={hobby.folder ? () => windowManager.openFileExplorer(hobby.folder) : undefined}
>
<p class="muted">{hobby.description}</p> <p class="muted">{hobby.description}</p>
{#if hobby.folder}
<span class="hobby-files"><Icon icon="mdi:folder-open-outline" width="14" />Browse ~/files/{hobby.folder}</span>
{/if}
</Panel> </Panel>
{/each} {/each}
</div> </div>
@@ -58,17 +75,19 @@
</Section> </Section>
{/if} {/if}
<Section title="Games" id="games"> {#if games.length}
<div class="game-grid"> <Section title="Games" id="games">
{#each about.games as game (game.name)} <div class="game-grid">
<GameCard {game} /> {#each games as game (game.igdbId)}
{/each} <GameCard {game} />
</div> {/each}
</Section> </div>
</Section>
{/if}
{#each mediaGroups as group (group.id)} {#each mediaGroups as group (group.id)}
<Section title={group.title} id={group.id}> <Section title={group.title} id={group.id}>
<MediaGrid items={group.items} mediaDetails={data.mediaDetails} kind={group.kind} /> <MediaSection items={group.items} mediaDetails={data.library.mediaDetails} kind={group.kind} />
</Section> </Section>
{/each} {/each}
@@ -102,6 +121,14 @@
margin: 0; margin: 0;
} }
.hobby-files {
display: inline-flex;
align-items: center;
gap: 0.3rem;
color: var(--accent);
font-size: 0.8rem;
}
.game-grid { .game-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+7
View File
@@ -0,0 +1,7 @@
import { json } from '@sveltejs/kit';
import { getHobbyStats } from '#lib/server/btopHobbies';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async () => {
return json(await getHobbyStats(), { headers: { 'Cache-Control': 'no-cache' } });
};
+7
View File
@@ -0,0 +1,7 @@
import { json } from '@sveltejs/kit';
import { listFolder } from '#lib/server/files';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url }) => {
return json(await listFolder(url.searchParams.get('path') ?? ''), { headers: { 'Cache-Control': 'no-cache' } });
};
+5 -16
View File
@@ -1,25 +1,14 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import { readFile } from 'node:fs/promises'; import { readCoverImage } from '#lib/server/lastfm/covers';
import path from 'node:path';
import { loadCovers, savedCoverPath } from '#lib/server/lastfm/covers';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
const contentTypes: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp',
'.gif': 'image/gif'
};
export const GET: RequestHandler = async ({ params }) => { export const GET: RequestHandler = async ({ params }) => {
await loadCovers(); const image = await readCoverImage(params.id);
const filePath = savedCoverPath(params.id); if (!image) error(404, 'Cover not found');
if (!filePath) error(404, 'Cover not found');
return new Response(await readFile(filePath), { return new Response(new Uint8Array(image.data), {
headers: { headers: {
'Content-Type': contentTypes[path.extname(filePath)] ?? 'image/jpeg', 'Content-Type': image.contentType,
'Cache-Control': 'public, max-age=31536000, immutable' 'Cache-Control': 'public, max-age=31536000, immutable'
} }
}); });
+7
View File
@@ -0,0 +1,7 @@
import { json } from '@sveltejs/kit';
import { getLibrary } from '#lib/server/library';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async () => {
return json(await getLibrary(), { headers: { 'Cache-Control': 'no-cache' } });
};
@@ -0,0 +1,24 @@
import { error, json } from '@sveltejs/kit';
import { requireApiKey } from '#lib/server/library/auth';
import { libraryDatabase } from '#lib/server/library/database';
import { readBody } from '#lib/server/library/input';
import { deleteBook, isGoodreadsId, saveBook } from '#lib/server/library/books';
import type { RequestHandler } from './$types';
function checkedId(goodreadsId: string): string {
if (!isGoodreadsId(goodreadsId)) error(400, `${goodreadsId} is not a Goodreads ID`);
return goodreadsId;
}
export const PUT: RequestHandler = async ({ params, request }) => {
requireApiKey(request);
const goodreadsId = checkedId(params.goodreadsId);
return json(await saveBook(await libraryDatabase(), goodreadsId, await readBody(request)));
};
export const DELETE: RequestHandler = async ({ params, request }) => {
requireApiKey(request);
const goodreadsId = checkedId(params.goodreadsId);
if (!deleteBook(await libraryDatabase(), goodreadsId)) error(404, `${goodreadsId} is not listed`);
return new Response(null, { status: 204 });
};
+9
View File
@@ -0,0 +1,9 @@
import { json } from '@sveltejs/kit';
import { exportLibrary } from '#lib/server/library';
import { requireApiKey } from '#lib/server/library/auth';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ request }) => {
requireApiKey(request);
return json(await exportLibrary(), { headers: { 'Cache-Control': 'no-store' } });
};
@@ -0,0 +1,24 @@
import { error, json } from '@sveltejs/kit';
import { requireApiKey } from '#lib/server/library/auth';
import { libraryDatabase } from '#lib/server/library/database';
import { readBody } from '#lib/server/library/input';
import { deleteGame, isIgdbId, saveGame } from '#lib/server/library/games';
import type { RequestHandler } from './$types';
function checkedId(igdbId: string): string {
if (!isIgdbId(igdbId)) error(400, `${igdbId} is not an IGDB ID`);
return igdbId;
}
export const PUT: RequestHandler = async ({ params, request }) => {
requireApiKey(request);
const igdbId = checkedId(params.igdbId);
return json(await saveGame(await libraryDatabase(), igdbId, await readBody(request)));
};
export const DELETE: RequestHandler = async ({ params, request }) => {
requireApiKey(request);
const igdbId = checkedId(params.igdbId);
if (!deleteGame(await libraryDatabase(), igdbId)) error(404, `${igdbId} is not listed`);
return new Response(null, { status: 204 });
};
@@ -0,0 +1,16 @@
import { error } from '@sveltejs/kit';
import { libraryDatabase } from '#lib/server/library/database';
import { readImage } from '#lib/server/library/images';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params }) => {
const image = readImage(await libraryDatabase(), params.id);
if (!image) error(404, 'Image not found');
return new Response(new Uint8Array(image.data), {
headers: {
'Content-Type': image.contentType,
'Cache-Control': 'public, max-age=86400'
}
});
};
@@ -0,0 +1,24 @@
import { error, json } from '@sveltejs/kit';
import { requireApiKey } from '#lib/server/library/auth';
import { libraryDatabase } from '#lib/server/library/database';
import { readBody } from '#lib/server/library/input';
import { deleteMedia, isImdbId, saveMedia } from '#lib/server/library/media';
import type { RequestHandler } from './$types';
function checkedId(imdbId: string): string {
if (!isImdbId(imdbId)) error(400, `${imdbId} is not an IMDb ID`);
return imdbId;
}
export const PUT: RequestHandler = async ({ params, request }) => {
requireApiKey(request);
const imdbId = checkedId(params.imdbId);
return json(await saveMedia(await libraryDatabase(), imdbId, await readBody(request)));
};
export const DELETE: RequestHandler = async ({ params, request }) => {
requireApiKey(request);
const imdbId = checkedId(params.imdbId);
if (!deleteMedia(await libraryDatabase(), imdbId)) error(404, `${imdbId} is not listed`);
return new Response(null, { status: 204 });
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Loaded 100 of 439 files, more files were not shown because too many files have changed in this diff. Show more