Compare commits

..
45 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
SirBlob bad7b7d3b3 Add Animal Farm and 1984 2026-11-01 20:57:37 -05:00
SirBlob 2318cdeaac Add Goodreads series and group them on the about page 2026-11-01 20:57:37 -05:00
SirBlob 737700a9a8 Replace content CLI with Textual TUI 2026-11-01 20:46:34 -05:00
SirBlob 0d58ddd430 Move themes to JSON 2026-11-01 20:46:34 -05:00
SirBlob 0cfebc385f Show Goodreads books on about page 2026-11-01 20:46:34 -05:00
SirBlob a2ffd45442 Hide unknown chapter totals 2026-11-01 19:33:37 -05:00
SirBlob f5df946e0b Remove recently played from music 2026-11-01 18:42:28 -05:00
SirBlob 90d61c48e2 Add books and manga to about page 2026-11-01 18:42:28 -05:00
SirBlob 87079dcb88 Fix svelte warnings 2026-11-01 18:42:28 -05:00
SirBlob c664f573b8 Add desktop games, robot pet and TCG app 2026-11-01 18:42:28 -05:00
SirBlob 812503251b Link RAVC video on ARC entry 2026-11-01 01:58:39 -05:00
SirBlob d993634fbe Link ARC GitHub as code 2026-11-01 01:38:51 -05:00
SirBlob c21160711b Mark PokemonTCGAPI as deprecated 2026-11-01 01:38:51 -05:00
SirBlob 5d7e9857ca Open startup terminal as a floating window 2026-11-01 01:38:51 -05:00
SirBlob 722e182076 Add setup section to about page 2026-11-01 01:38:51 -05:00
SirBlob 2d84d68c46 Add Last.fm music stats to about page 2026-11-01 01:54:15 -04:00
SirBlob 3122bc4bf9 Skip already cached songs on startup 2026-11-01 01:54:15 -04:00
SirBlob fe60a4443d Add page meta tags for search and link previews 2026-11-01 01:54:15 -04:00
SirBlob 411fb03c22 Trap keyboard focus in popovers 2026-11-01 01:27:47 -04:00
SirBlob 2432113c7f Remove offline hackathon demo links 2026-11-01 01:27:47 -04:00
SirBlob 33c9daa58b Cache music as mp3 files on startup 2026-11-01 01:27:47 -04:00
SirBlob c819436481 Remove language cores from btop cpu panel 2026-11-01 01:27:47 -04:00
SirBlob 0659b82e6f Overlay Gitea contributions on btop commit graph 2026-11-01 01:27:47 -04:00
559 changed files with 18074 additions and 1195 deletions

No files matched your search

+2
View File
@@ -15,6 +15,8 @@ UPDATE.md
blogs
tools
__pycache__
.cache
*.log
.DS_Store
Thumbs.db
data/lastfm
+7
View File
@@ -2,3 +2,10 @@ DISCORD_TOKEN=
DISCORD_USER_ID=
DISCORD_GUILD_ID=
OMDB_API_KEY=
IGDB_CLIENT_ID=
IGDB_CLIENT_SECRET=
LASTFM_API_KEY=
LIBRARY_API_KEY=
LIBRARY_API_URL=http://localhost:5000
LIBRARY_PUSH_URL=https://dev.sirblob.co
LIBRARY_PUSH_KEY=
+8
View File
@@ -24,6 +24,14 @@ vite.config.ts.timestamp-*
bun.lock
__pycache__/
.cache/
data/lastfm/
data/library/
Update.md
PROJECT.md
blogs/
# Content tool
tools/content/.venv/
tools/content/.cache/
__pycache__/
+4 -2
View File
@@ -18,10 +18,12 @@ COPY package.json ./
RUN bun install --production --ignore-scripts
COPY --from=build /app/build ./build
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 PORT=3000
EXPOSE 3000
ENV PORT=5000
EXPOSE 5000
USER bun
CMD ["bun", "./build/index.js"]
+96 -19
View File
@@ -17,16 +17,18 @@ The site recreates my [blomarchy](https://git.sirblob.co/SirBlob/blomarchy) setu
## Features
- **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.
- **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.
- **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`.
- **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.
- **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.
## Keybinds
@@ -69,6 +71,8 @@ Alt stands in for Super, since browsers can't use it.
| `theme [name]` | list or switch themes |
| `cowsay <text>` | moo |
| `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) |
## Running
@@ -87,7 +91,7 @@ The music player needs `ffmpeg` installed on the server.
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
@@ -96,35 +100,108 @@ Copy `.env.example` to `.env` and fill in what you need. Every value is optional
| Variable | Used for |
| --- | --- |
| `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
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.
### Content CLI
### Library API
A small Python tool (standard library only) edits the playlist, shows and movies without touching the JSON by hand.
| 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
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
bun run content music list --titles
bun run content music add https://www.youtube.com/watch?v=VIDEO_ID
bun run content music remove VIDEO_ID
bun run content movie add tt0816692 --rating 10
bun run content show add https://www.imdb.com/title/tt14688458/ --rating 10 --name "Silo"
bun run content show rate Silo 9
bun run content movie remove Interstellar
bun run content show list
bun run content:setup
bun run content
```
Titles are looked up on OMDb with `OMDB_API_KEY` from `.env`; pass `--name` to skip the lookup. Run `bun run content --help` for every option.
`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 |
| --- | --- |
| `1` to `9`, `0` | Switch between Music, Shows, Movies, Books, Games, Profile, Experience, Leadership, Projects and Themes |
| `a` | Add (YouTube link, IMDb link, Goodreads link, or a store link, IGDB link or name for a game) |
| `S` | Add books from a Goodreads series |
| `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 |
| `t` | Switch the editor to the next theme |
| `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 |
| `K` / `J` | Move a song or entry up or down |
| `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 |
| `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 |
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 |
| --- | --- |
| `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 |
| `projects/*.json` | Open source, packages, hackathons, research groups and organizations |
| `music.json` | YouTube links for the music player |
| `desktop.json` | Workspaces, desktop icons and the startup command |
| `wallpapers.json` | Wallpapers for the picker, written by `bun run wallpapers` |
| `desktop.json` | Workspaces, desktop icons, the startup command and the default theme |
| `themes.json` | Color themes for the site and the content editor |
| `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.
+31 -38
View File
@@ -1,45 +1,38 @@
{
"anilistUsername": "SirBlob",
"lastfmUsername": "Sir_Blob_",
"setup": {
"user": "blob",
"host": "blomarchy",
"steam": [" ( ) (", " ) ( ) )", " ( ) ( ("],
"art": [
" ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄",
" ██ ██▄▄▄▄▄",
" ██ ██ ██",
" ██ ██ ██",
" ██ ██▀▀▀▀▀",
" ██ ██",
" ▀█▄▄▄▄▄▄▄▄▄▄▄█▀",
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄",
" ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀"
],
"specs": [
{ "label": "host", "value": "Lenovo IdeaPad Flex 5i" },
{ "label": "os", "value": "Arch Linux x86_64" },
{ "label": "kernel", "value": "Linux (arch)" },
{ "label": "shell", "value": "bash" },
{ "label": "wm", "value": "Hyprland (Wayland)" },
{ "label": "term", "value": "foot" },
{ "label": "display", "value": "2x 1920x1080 24\", 60 Hz + 75 Hz" },
{ "label": "cpu", "value": "Intel Core i5-1335U (4+8) @ 4.60 GHz" },
{ "label": "gpu", "value": "Intel Iris Xe Graphics" },
{ "label": "memory", "value": "16 GB" }
],
"dotfilesUrl": "https://github.com/SirBlobby/dotfiles"
},
"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": "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." }
],
"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/" }
{ "name": "Robotics", "icon": "proicons:robot", "description": "Building and programming robots to solve problems is fun.", "folder": "robotics" }
]
}
+21
View File
@@ -33,6 +33,27 @@
{ "label": "btop", "icon": "ph:chart-line-duotone", "open": { "app": "btop" } }
]
},
{
"name": "apps",
"side": "right",
"icons": [
{ "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" } }
]
},
{
"name": "games",
"side": "right",
"icons": [
{ "label": "snake", "icon": "mdi:snake", "open": { "app": "snake" } },
{ "label": "breakout", "icon": "mdi:wall", "open": { "app": "breakout" } },
{ "label": "robot", "icon": "ph:robot-duotone", "open": { "app": "pet" } }
]
},
{
"name": "socials",
"icons": [
+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=fixh0ixT-9U&si=dWLHv9bylwNAu-DD",
"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"
]
}
+1
View File
@@ -6,6 +6,7 @@
"title": "Computer Science Student",
"email": "sirblob0@gmail.com",
"donateUrl": "https://ko-fi.com/sirblob",
"giteaUrl": "https://git.sirblob.co/SirBlob",
"avatar": "/blob_nerd.png",
"photo": "/portfolio-photo.jpg",
"bio": "An engineer who loves making things. I build fun coding projects, compete in hackathons and robotics competitions, and enjoy games like Minecraft and Pokémon TCG Live. I'm interested in Open Source, Robotics, Embedded Systems, and AI/ML.",
+4 -6
View File
@@ -7,7 +7,7 @@
"featured": true,
"event": { "name": "Bitcamp 2026", "university": "University of Maryland", "location": "College Park, MD", "year": "2026" },
"awards": [{ "track": "Best UI/UX Hack", "place": "Winner" }],
"links": { "demo": "https://hushmap.study/", "devpost": "https://devpost.com/software/hushmap-193nia" }
"links": { "devpost": "https://devpost.com/software/hushmap-193nia" }
},
{
"name": "LearningBuddy",
@@ -53,9 +53,8 @@
"description": "Ever felt the indecisiveness of choosing what to eat? Don't fret! Decide your next bite with Food Decisive.",
"image": "/hacks/fooddecisive.png",
"tags": ["AI", "Llama3.1", "Svelte", "Node.js"],
"demoMayBeOffline": true,
"event": { "name": "VTHacks 12", "university": "Virginia Tech", "location": "Blacksburg, VA", "year": "2024" },
"links": { "demo": "https://fooddecisive.sirblob.co/", "code": "https://github.com/SirBlobby/VTHacks-12", "devpost": "https://devpost.com/software/food-decisive" }
"links": { "code": "https://github.com/SirBlobby/VTHacks-12", "devpost": "https://devpost.com/software/food-decisive" }
},
{
"name": "PatSafe",
@@ -79,15 +78,14 @@
"image": "/hacks/drinkhappy.png",
"tags": ["API", "Auth0", "Gemini", "JavaScript", "MongoDB", "Next.js", "React", "Tailwind", "Vercel"],
"event": { "name": "Bitcamp 2025", "university": "University of Maryland", "location": "College Park, MD", "year": "2025" },
"links": { "demo": "https://drinkhappy.tech", "code": "https://github.com/SirBlobby/Bitcamp-2025", "devpost": "https://devpost.com/software/drink-happy" }
"links": { "code": "https://github.com/SirBlobby/Bitcamp-2025", "devpost": "https://devpost.com/software/drink-happy" }
},
{
"name": "Roadcast",
"description": "Roadcast provides drivers with crash data and weather insights to choose safer routes home. The project combines historical crash data, weather feeds, and spatial analysis to surface hazardous areas and route-level safety recommendations.",
"image": "/hacks/roadcast.png",
"tags": ["React", "Node.js", "Python", "MongoDB", "Geospatial", "Mapbox"],
"demoMayBeOffline": true,
"event": { "name": "VTHacks 13", "university": "Virginia Tech", "location": "Blacksburg, VA", "year": "2025" },
"links": { "demo": "https://roadcast.sirblob.co/", "code": "https://github.com/SirBlobby/roadcast", "devpost": "https://devpost.com/software/roadcast" }
"links": { "code": "https://github.com/SirBlobby/roadcast", "devpost": "https://devpost.com/software/roadcast" }
}
]
+3 -2
View File
@@ -9,10 +9,11 @@
"gallery": ["/organizations/arc-1.jpg", "/organizations/arc-2.jpg", "/organizations/arc-3.jpg"],
"links": {
"website": "https://gmu.autonomousrobotics.club/",
"article": "https://www.gmu.edu/news/2026-05/george-mason-students-fly-first-place-no-gps-no-grade-and-almost-no-problems"
"code": "https://github.com/ARC-GMU",
"article": "https://www.gmu.edu/news/2026-05/george-mason-students-fly-first-place-no-gps-no-grade-and-almost-no-problems",
"video": "https://www.youtube.com/watch?v=J9QkS8nLYy0"
},
"socials": [
{ "name": "GitHub", "icon": "mdi:github", "url": "https://github.com/ARC-GMU" },
{ "name": "Instagram", "icon": "mdi:instagram", "url": "https://www.instagram.com/arc.gmu/" },
{ "name": "LinkedIn", "icon": "mdi:linkedin", "url": "https://www.linkedin.com/company/arcgmu/" },
{ "name": "CREATE Lab", "icon": "ph:flask-duotone", "url": "https://create.autonomousrobotics.club/projects/autonomous-robotics-club" }
+1
View File
@@ -10,6 +10,7 @@
"name": "PokemonTCGAPI",
"description": "Unofficial NPM package wrapper for the PokemonTCG API.",
"tags": ["TypeScript", "Node.js"],
"deprecated": true,
"links": { "package": "https://www.npmjs.com/package/@bosstop/pokemontcgapi", "code": "https://github.com/SirBlobby/PokemonTCGAPI" }
},
{
+35
View File
@@ -0,0 +1,35 @@
[
{
"id": "cynthias-garchomp",
"name": "Cynthia's Garchomp",
"cards": [
{ "count": 4, "name": "Cynthia's Gible", "set": "DRI", "category": "pokemon" },
{ "count": 4, "name": "Cynthia's Gabite", "set": "DRI", "category": "pokemon" },
{ "count": 3, "name": "Cynthia's Garchomp ex", "set": "DRI", "category": "pokemon" },
{ "count": 2, "name": "Cynthia's Roselia", "set": "DRI", "category": "pokemon" },
{ "count": 2, "name": "Cynthia's Roserade", "set": "DRI", "category": "pokemon" },
{ "count": 1, "name": "Munkidori", "set": "TWM", "category": "pokemon" },
{ "count": 1, "name": "Cynthia's Spiritomb", "set": "DRI", "category": "pokemon" },
{ "count": 4, "name": "Arven", "category": "trainer" },
{ "count": 4, "name": "Iono", "category": "trainer" },
{ "count": 4, "name": "Buddy-Buddy Poffin", "category": "trainer" },
{ "count": 4, "name": "Cynthia's Power Weight", "category": "trainer" },
{ "count": 3, "name": "Boss's Orders", "category": "trainer" },
{ "count": 2, "name": "Technical Machine: Evolution", "category": "trainer" },
{ "count": 2, "name": "Team Rocket's Watchtower", "category": "trainer" },
{ "count": 2, "name": "Counter Catcher", "category": "trainer" },
{ "count": 2, "name": "Earthen Vessel", "category": "trainer" },
{ "count": 1, "name": "Night Stretcher", "category": "trainer" },
{ "count": 1, "name": "Crispin", "category": "trainer" },
{ "count": 1, "name": "Super Rod", "category": "trainer" },
{ "count": 1, "name": "Team Rocket's Petrel", "category": "trainer" },
{ "count": 1, "name": "Artazon", "category": "trainer" },
{ "count": 1, "name": "Unfair Stamp", "category": "trainer" },
{ "count": 1, "name": "Professor's Research", "category": "trainer" },
{ "count": 1, "name": "Rare Candy", "category": "trainer" },
{ "count": 6, "name": "Fighting Energy", "category": "energy" },
{ "count": 1, "name": "Luminous Energy", "category": "energy" },
{ "count": 1, "name": "Darkness Energy", "category": "energy" }
]
}
]
+96
View File
@@ -0,0 +1,96 @@
[
{
"id": "cerberus",
"name": "Cerberus",
"mode": "dark",
"wallpaper": "/wallpapers/astronaut2.jpg",
"activeBorder": ["#818cf8", "#e879f9"],
"colors": { "accent": "#818cf8", "selection": "#404040", "muted": "#a3a3a3", "background": "#171717", "dark_background": "#0f0f0f", "lighter_background": "#262626", "foreground": "#f5f5f5", "red": "#ef4444", "yellow": "#fbbf24", "orange": "#fb923c", "green": "#2dd4bf", "cyan": "#67e8f9", "blue": "#818cf8", "magenta": "#e879f9" }
},
{
"id": "flats",
"name": "Flats",
"mode": "dark",
"wallpaper": "/wallpapers/pastel-car.jpg",
"colors": { "accent": "#5fa3e8", "selection": "#1e2228", "muted": "#78828c", "background": "#0f1012", "dark_background": "#0a0b0c", "lighter_background": "#191c20", "foreground": "#cbd2d9", "red": "#d53838", "yellow": "#d58638", "orange": "#d56338", "green": "#38d586", "cyan": "#38d4d5", "blue": "#3886d5", "magenta": "#d53886" }
},
{
"id": "tokyo-night",
"name": "Tokyo Night",
"mode": "dark",
"wallpaper": "/wallpapers/sunset-lake.png",
"colors": { "accent": "#7aa2f7", "selection": "#292e42", "muted": "#565f89", "background": "#1a1b26", "dark_background": "#13141c", "lighter_background": "#24283b", "foreground": "#a9b1d6", "red": "#f7768e", "yellow": "#e0af68", "orange": "#eb927b", "green": "#9ece6a", "cyan": "#449dab", "blue": "#7aa2f7", "magenta": "#ad8ee6" }
},
{
"id": "night-cat",
"name": "Night Cat",
"mode": "dark",
"wallpaper": "/wallpapers/cat.jpg",
"activeBorder": ["#ebe68f", "#2c5a78"],
"colors": { "accent": "#ebe68f", "selection": "#24495f", "muted": "#557386", "background": "#0a161e", "dark_background": "#060e14", "lighter_background": "#13252f", "foreground": "#c8d6df", "red": "#e06c75", "yellow": "#ebe68f", "orange": "#e5a46e", "green": "#8ccf9e", "cyan": "#6fc3d4", "blue": "#5a9cc4", "magenta": "#b392d4" }
},
{
"id": "catppuccin",
"name": "Catppuccin",
"mode": "dark",
"wallpaper": "/wallpapers/totoro.png",
"colors": { "accent": "#89b4fa", "selection": "#45475a", "muted": "#6c7086", "background": "#1e1e2e", "dark_background": "#161622", "lighter_background": "#313244", "foreground": "#cdd6f4", "red": "#f38ba8", "yellow": "#f9e2af", "orange": "#fab387", "green": "#a6e3a1", "cyan": "#94e2d5", "blue": "#89b4fa", "magenta": "#cba6f7" }
},
{
"id": "arch",
"name": "Arch Linux",
"mode": "dark",
"wallpaper": "/wallpapers/pacman-black.png",
"activeBorder": ["#33ccff", "#00ff99"],
"colors": { "accent": "#1793d1", "selection": "#21262d", "muted": "#8b949e", "background": "#0d1117", "dark_background": "#090c10", "lighter_background": "#161b22", "foreground": "#c9d1d9", "red": "#ff7b72", "yellow": "#d29922", "orange": "#ffa657", "green": "#3fb950", "cyan": "#39c5cf", "blue": "#58a6ff", "magenta": "#bc8cff" }
},
{
"id": "gruvbox",
"name": "Gruvbox",
"mode": "dark",
"wallpaper": "/wallpapers/awesome.png",
"colors": { "accent": "#7daea3", "selection": "#504945", "muted": "#928374", "background": "#282828", "dark_background": "#1e1e1e", "lighter_background": "#3c3836", "foreground": "#d4be98", "red": "#ea6962", "yellow": "#d8a657", "orange": "#e1875c", "green": "#a9b665", "cyan": "#89b482", "blue": "#7daea3", "magenta": "#d3869b" }
},
{
"id": "nord",
"name": "Nord",
"mode": "dark",
"wallpaper": "/wallpapers/nord_minimal_cat.png",
"colors": { "accent": "#81a1c1", "selection": "#434c5e", "muted": "#7b88a1", "background": "#2e3440", "dark_background": "#222730", "lighter_background": "#3b4252", "foreground": "#d8dee9", "red": "#bf616a", "yellow": "#ebcb8b", "orange": "#d5967a", "green": "#a3be8c", "cyan": "#88c0d0", "blue": "#81a1c1", "magenta": "#b48ead" }
},
{
"id": "kanagawa",
"name": "Kanagawa",
"mode": "dark",
"wallpaper": "/wallpapers/pixel-earth.png",
"colors": { "accent": "#dcd7ba", "selection": "#363646", "muted": "#727169", "background": "#1f1f28", "dark_background": "#17171e", "lighter_background": "#2a2a37", "foreground": "#dcd7ba", "red": "#c34043", "yellow": "#c0a36e", "orange": "#c17158", "green": "#76946a", "cyan": "#6a9589", "blue": "#7e9cd8", "magenta": "#957fb8" }
},
{
"id": "wintry",
"name": "Wintry",
"mode": "dark",
"wallpaper": "/wallpapers/perfect-blue.jpg",
"colors": { "accent": "#7dd3fc", "selection": "#334155", "muted": "#94a3b8", "background": "#0f172a", "dark_background": "#0a1020", "lighter_background": "#1e293b", "foreground": "#f1f5f9", "red": "#f43f5e", "yellow": "#fde047", "orange": "#fb923c", "green": "#4ade80", "cyan": "#67e8f9", "blue": "#60a5fa", "magenta": "#c084fc" }
},
{
"id": "rose",
"name": "Rose",
"mode": "dark",
"wallpaper": "/wallpapers/cherry_blossoms.jpg",
"colors": { "accent": "#fb7185", "selection": "#5f484f", "muted": "#c48b95", "background": "#1f181b", "dark_background": "#171214", "lighter_background": "#2e2327", "foreground": "#ffe4e6", "red": "#f43f5e", "yellow": "#fde047", "orange": "#fb923c", "green": "#34d399", "cyan": "#67e8f9", "blue": "#60a5fa", "magenta": "#e879f9" }
},
{
"id": "catppuccin-latte",
"name": "Catppuccin Latte",
"mode": "light",
"wallpaper": "/wallpapers/yosemite-color-block.jpg",
"colors": { "accent": "#1e66f5", "selection": "#ccd0da", "muted": "#8c8fa1", "background": "#eff1f5", "dark_background": "#e3e4e8", "lighter_background": "#dce0e8", "foreground": "#4c4f69", "red": "#d20f39", "yellow": "#df8e1d", "orange": "#d84e2b", "green": "#40a02b", "cyan": "#179299", "blue": "#1e66f5", "magenta": "#ea76cb" }
},
{
"id": "rose-pine",
"name": "Rose Pine Dawn",
"mode": "light",
"wallpaper": "/wallpapers/material-pastel.jpg",
"colors": { "accent": "#56949f", "selection": "#dfdad9", "muted": "#9893a5", "background": "#faf4ed", "dark_background": "#ede7e1", "lighter_background": "#f2e9e1", "foreground": "#575279", "red": "#b4637a", "yellow": "#ea9d34", "orange": "#cf8057", "green": "#286983", "cyan": "#d7827e", "blue": "#56949f", "magenta": "#907aa9" }
}
]
+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" }
]
+9 -2
View File
@@ -6,6 +6,13 @@ services:
env_file:
- .env
environment:
PORT: 3000
PORT: 5000
ports:
- "${HOST_PORT:-3000}:3000"
- "${HOST_PORT:-5000}:5000"
volumes:
- lastfm-data:/app/data/lastfm
- library-data:/app/data/library
volumes:
lastfm-data:
library-data:
+6 -1
View File
@@ -8,7 +8,12 @@
"build": "vite build",
"preview": "vite preview",
"start": "bun --bun vite build && bun ./build/index.js",
"content": "python3 tools/content/cli.py",
"content": "tools/content/.venv/bin/python tools/content/app.py",
"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 ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
+9 -1
View File
@@ -17,6 +17,14 @@ export const variables = defineEnvVars({
},
OMDB_API_KEY: {
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: {
schema: optional,
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'
}
});
+5
View File
@@ -1,6 +1,11 @@
import type { ServerInit } from '@sveltejs/kit';
import { getDiscordClient } from '#lib/server/discord';
import { refreshMusicCache } from '#lib/server/musicCache';
import { startLastfmSync } from '#lib/server/lastfm/sync';
import { about } from '#lib/content';
export const init: ServerInit = () => {
getDiscordClient();
refreshMusicCache();
startLastfmSync(about.lastfmUsername);
};
+38
View File
@@ -0,0 +1,38 @@
export type AniListMediaType = 'ANIME' | 'MANGA';
export interface AniListEntry {
id: number;
title: string;
url: string;
cover: string;
score: number;
progress: number;
total: number | null;
}
export interface AniListStat {
label: string;
value: string;
}
export interface AniListStatusCount {
status: string;
label: string;
count: number;
}
export interface AniListProfile {
mediaType: AniListMediaType;
name: string;
profileUrl: string;
listUrl: string;
stats: AniListStat[];
statusCounts: AniListStatusCount[];
current: AniListEntry[];
currentLabel: string;
progressUnit: string;
totalUnit: string;
topRated: AniListEntry[];
planning: AniListEntry[];
completedCount: number;
}
-31
View File
@@ -1,31 +0,0 @@
export interface AnimeEntry {
id: number;
title: string;
url: string;
cover: string;
score: number;
progress: number;
episodes: number | null;
}
export interface AnimeStats {
count: number;
episodesWatched: number;
daysWatched: number;
}
export interface AnimeStatusCount {
status: string;
label: string;
count: number;
}
export interface AnimeProfile {
name: string;
profileUrl: string;
stats: AnimeStats;
statusCounts: AnimeStatusCount[];
watching: AnimeEntry[];
topRated: AnimeEntry[];
completedCount: number;
}
+60
View File
@@ -0,0 +1,60 @@
import type { Book, BookShelf } from '#lib/content';
export interface BookSeriesGroup {
name: string;
url: string;
books: Book[];
counts: Record<BookShelf, number>;
}
export interface GroupedBooks {
series: BookSeriesGroup[];
standalone: Book[];
}
const positionGroups = { single: 0, range: 1, none: 2 };
function seriesOrder(first: Book, second: Book): number {
const [firstGroup, firstNumber] = positionKey(first);
const [secondGroup, secondNumber] = positionKey(second);
return firstGroup - secondGroup || firstNumber - secondNumber;
}
function positionKey(book: Book): [number, number] {
const position = book.series?.position ?? '';
const match = position.match(/\d+(?:\.\d+)?/);
if (!match) return [positionGroups.none, 0];
const isRange = /\d\s*[-–]\s*\d/.test(position);
return [isRange ? positionGroups.range : positionGroups.single, Number(match[0])];
}
function shelfCounts(books: Book[]): Record<BookShelf, number> {
return {
read: books.filter((book) => book.shelf === 'read').length,
reading: books.filter((book) => book.shelf === 'reading').length,
'want-to-read': books.filter((book) => book.shelf === 'want-to-read').length
};
}
export function groupBySeries(books: Book[]): GroupedBooks {
const groups = new Map<string, Book[]>();
const standalone: Book[] = [];
for (const book of books) {
if (!book.series) {
standalone.push(book);
continue;
}
const key = book.series.url || book.series.name;
groups.set(key, [...(groups.get(key) ?? []), book]);
}
const series = [...groups.values()]
.map((seriesBooks) => {
const ordered = [...seriesBooks].sort(seriesOrder);
return { name: ordered[0].series!.name, url: ordered[0].series!.url, books: ordered, counts: shelfCounts(ordered) };
})
.sort((first, second) => second.counts.read - first.counts.read || first.name.localeCompare(second.name));
return { series, standalone };
}
+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' }))
};
}
+52
View File
@@ -2,10 +2,15 @@ export interface ContributionDay {
date: string;
level: number;
count: number;
githubCount: number;
giteaCount: number;
}
export interface ContributionCalendar {
total: number;
githubTotal: number;
giteaTotal: number;
giteaProfileUrl: string | null;
weeks: ContributionDay[][];
currentStreak: number;
bestDay: ContributionDay | null;
@@ -33,3 +38,50 @@ export interface BtopData {
contributions: ContributionCalendar;
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;
}
@@ -0,0 +1,150 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { AniListEntry } from '#lib/anilist/types';
let {
title,
icon,
entries,
badge,
totalUnit = ''
}: { title: string; icon: string; entries: AniListEntry[]; badge: 'score' | 'total'; totalUnit?: string } = $props();
const collapsedCount = 12;
let showAll = $state(false);
const shownEntries = $derived(showAll ? entries : entries.slice(0, collapsedCount));
</script>
<h3 class="subheading"><Icon {icon} width="16" /> {title} <span class="muted">{entries.length}</span></h3>
<div class="cover-grid">
{#each shownEntries as entry (entry.id)}
<a class="cover-card" href={entry.url} target="_blank" rel="noopener noreferrer" title={entry.title}>
<div class="cover">
<img src={entry.cover} alt={entry.title} loading="lazy" />
{#if badge === 'score'}
<span class="badge score"><Icon icon="mdi:star" width="13" />{entry.score}</span>
{:else if entry.total}
<span class="badge">{entry.total} {totalUnit}</span>
{/if}
</div>
<span class="cover-title">{entry.title}</span>
</a>
{/each}
</div>
{#if entries.length > collapsedCount}
<button class="toggle" onclick={() => (showAll = !showAll)}>
<Icon icon={showAll ? 'mdi:chevron-up' : 'mdi:chevron-down'} width="16" />
{showAll ? 'Show less' : `Show all ${entries.length}`}
</button>
{/if}
<style>
.subheading {
display: flex;
align-items: center;
gap: 0.4rem;
margin: 1.25rem 0 0.6rem;
color: var(--foreground);
font-size: 0.9rem;
}
.cover-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 0.9rem 0.75rem;
}
.cover-card {
display: flex;
flex-direction: column;
gap: 0.35rem;
min-width: 0;
}
.cover {
position: relative;
aspect-ratio: 2 / 3;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
transition: border-color 0.15s ease;
}
.cover-card:hover .cover {
border-color: var(--accent);
}
.cover img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.cover-card:hover img {
transform: scale(1.05);
}
.badge {
position: absolute;
top: 0.35rem;
right: 0.35rem;
display: inline-flex;
align-items: center;
gap: 0.15rem;
padding: 0 0.35rem;
background: var(--background);
color: var(--muted);
font-size: 0.75rem;
}
.badge.score {
color: var(--yellow);
}
.cover-title {
display: -webkit-box;
overflow: hidden;
font-size: 0.8rem;
line-height: 1.3;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
}
.cover-card:hover .cover-title {
color: var(--accent);
}
.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) {
.cover-grid {
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
}
}
@media (prefers-reduced-motion: reduce) {
.cover,
.cover img {
transition: none;
}
}
</style>
@@ -1,7 +1,7 @@
<script lang="ts">
import type { AnimeProfile } from '#lib/anime/types';
import type { AniListProfile } from '#lib/anilist/types';
let { anime }: { anime: AnimeProfile } = $props();
let { profile }: { profile: AniListProfile } = $props();
const hostName = 'anilist';
@@ -13,23 +13,18 @@
DROPPED: 'red'
};
const shownStatuses = $derived(anime.statusCounts.filter((item) => item.count > 0));
const shownStatuses = $derived(profile.statusCounts.filter((item) => item.count > 0));
const listTotal = $derived(shownStatuses.reduce((total, item) => total + item.count, 0));
const rows = $derived([
['Anime', `${anime.stats.count}`],
['Episodes', `${anime.stats.episodesWatched}`],
['Watch time', `${anime.stats.daysWatched} days`]
]);
</script>
<div class="anime-fetch">
<a class="heading" href={anime.profileUrl} target="_blank" rel="noopener noreferrer">
<span class="user">{anime.name.toLowerCase()}</span><span class="at">@</span><span class="host">{hostName}</span>
<div class="anilist-fetch">
<a class="heading" href={profile.listUrl} target="_blank" rel="noopener noreferrer">
<span class="user">{profile.name.toLowerCase()}</span><span class="at">@</span><span class="host">{hostName}</span>
</a>
<div class="separator">{'-'.repeat(anime.name.length + hostName.length + 1)}</div>
<div class="separator">{'-'.repeat(profile.name.length + hostName.length + 1)}</div>
{#each rows as [label, value] (label)}
<div class="row"><span class="label">{label}</span>: {value}</div>
{#each profile.stats as stat (stat.label)}
<div class="row"><span class="label">{stat.label}</span>: {stat.value}</div>
{/each}
{#each shownStatuses as item (item.status)}
@@ -53,7 +48,7 @@
</div>
<style>
.anime-fetch {
.anilist-fetch {
line-height: 1.5;
}
@@ -0,0 +1,142 @@
<script lang="ts">
import type { AniListProfile } from '#lib/anilist/types';
import LinkButton from '#lib/components/ui/LinkButton.svelte';
import Panel from '#lib/components/ui/Panel.svelte';
import AniListProfileHeader from './AniListProfileHeader.svelte';
import AniListCoverGrid from './AniListCoverGrid.svelte';
let { profile }: { profile: AniListProfile } = $props();
const currentIcon = $derived(profile.mediaType === 'MANGA' ? 'mdi:book-open-variant' : 'mdi:play-circle-outline');
function progressPercent(progress: number, total: number | null): number {
return total ? Math.min(100, (progress / total) * 100) : 0;
}
</script>
<div class="overview" class:single={!profile.current.length}>
<Panel title="anilist" icon="simple-icons:anilist">
<AniListProfileHeader {profile} />
</Panel>
{#if profile.current.length}
<Panel title={profile.currentLabel} icon={currentIcon}>
<div class="current">
{#each profile.current as entry (entry.id)}
<a class="current-row" href={entry.url} target="_blank" rel="noopener noreferrer">
<img src={entry.cover} alt={entry.title} loading="lazy" />
<div class="current-text">
<span class="title">{entry.title}</span>
{#if entry.total}
<div class="progress-track">
<div class="progress-fill" style:width="{progressPercent(entry.progress, entry.total)}%"></div>
</div>
{/if}
<span class="muted progress-label">
{profile.progressUnit} {entry.progress}{entry.total ? ` / ${entry.total}` : ''}
</span>
</div>
</a>
{/each}
</div>
</Panel>
{/if}
</div>
{#if profile.topRated.length}
<AniListCoverGrid title="Top rated" icon="mdi:star" entries={profile.topRated} badge="score" />
{/if}
{#if profile.planning.length}
<AniListCoverGrid
title="Planning"
icon="mdi:calendar-clock"
entries={profile.planning}
badge="total"
totalUnit={profile.totalUnit}
/>
{/if}
<div class="actions">
<LinkButton href={profile.listUrl} icon="simple-icons:anilist">Full list on AniList ({profile.completedCount} completed)</LinkButton>
</div>
<style>
.overview {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 0.75rem;
}
.overview.single {
grid-template-columns: minmax(0, 1fr);
}
.current {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.current-row {
display: flex;
gap: 0.75rem;
padding: 0.4rem;
border: 1px solid transparent;
}
.current-row:hover {
border-color: var(--accent);
}
.current-row img {
flex-shrink: 0;
width: 52px;
height: 74px;
object-fit: cover;
}
.current-text {
display: flex;
flex: 1;
flex-direction: column;
justify-content: center;
gap: 0.4rem;
min-width: 0;
}
.title {
overflow: hidden;
font-size: 0.9rem;
white-space: nowrap;
text-overflow: ellipsis;
}
.progress-track {
height: 4px;
background: color-mix(in srgb, var(--foreground) 15%, transparent);
}
.progress-fill {
height: 100%;
background: var(--accent);
}
.progress-label {
font-size: 0.75rem;
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
margin-top: 1.25rem;
}
@container (max-width: 800px) {
.overview {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
@@ -1,238 +0,0 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { AnimeProfile } from '#lib/anime/types';
import LinkButton from '#lib/components/ui/LinkButton.svelte';
import Panel from '#lib/components/ui/Panel.svelte';
import AnimeProfileHeader from './AnimeProfileHeader.svelte';
let { anime }: { anime: AnimeProfile } = $props();
const collapsedCount = 12;
let showAll = $state(false);
const shownTopRated = $derived(showAll ? anime.topRated : anime.topRated.slice(0, collapsedCount));
function progressPercent(progress: number, episodes: number | null): number {
return episodes ? Math.min(100, (progress / episodes) * 100) : 0;
}
</script>
<div class="overview" class:single={!anime.watching.length}>
<Panel title="anilist" icon="simple-icons:anilist">
<AnimeProfileHeader {anime} />
</Panel>
{#if anime.watching.length}
<Panel title="Currently watching" icon="mdi:play-circle-outline">
<div class="watching">
{#each anime.watching as entry (entry.id)}
<a class="watching-row" href={entry.url} target="_blank" rel="noopener noreferrer">
<img src={entry.cover} alt={entry.title} loading="lazy" />
<div class="watching-text">
<span class="title">{entry.title}</span>
<div class="progress-track">
<div class="progress-fill" style:width="{progressPercent(entry.progress, entry.episodes)}%"></div>
</div>
<span class="muted episode">Episode {entry.progress} / {entry.episodes ?? '?'}</span>
</div>
</a>
{/each}
</div>
</Panel>
{/if}
</div>
<h3 class="subheading"><Icon icon="mdi:star" width="16" /> Top rated</h3>
<div class="cover-grid">
{#each shownTopRated as entry (entry.id)}
<a class="cover-card" href={entry.url} target="_blank" rel="noopener noreferrer" title={entry.title}>
<div class="cover">
<img src={entry.cover} alt={entry.title} loading="lazy" />
<span class="score"><Icon icon="mdi:star" width="13" />{entry.score}</span>
</div>
<span class="cover-title">{entry.title}</span>
</a>
{/each}
</div>
<div class="actions">
{#if anime.topRated.length > collapsedCount}
<button class="toggle" onclick={() => (showAll = !showAll)}>
<Icon icon={showAll ? 'mdi:chevron-up' : 'mdi:chevron-down'} width="16" />
{showAll ? 'Show less' : `Show all ${anime.topRated.length}`}
</button>
{/if}
<LinkButton href={anime.profileUrl} icon="simple-icons:anilist">Full list on AniList ({anime.completedCount} completed)</LinkButton>
</div>
<style>
.subheading {
display: flex;
align-items: center;
gap: 0.4rem;
margin: 1.25rem 0 0.6rem;
color: var(--foreground);
font-size: 0.9rem;
}
.overview {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 0.75rem;
}
.overview.single {
grid-template-columns: minmax(0, 1fr);
}
.watching {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.watching-row {
display: flex;
gap: 0.75rem;
padding: 0.4rem;
border: 1px solid transparent;
}
.watching-row:hover,
.cover-card:hover .cover {
border-color: var(--accent);
}
.watching-row img {
flex-shrink: 0;
width: 52px;
height: 74px;
object-fit: cover;
}
.watching-text {
display: flex;
flex: 1;
flex-direction: column;
justify-content: center;
gap: 0.4rem;
min-width: 0;
}
.title {
overflow: hidden;
font-size: 0.9rem;
white-space: nowrap;
text-overflow: ellipsis;
}
.progress-track {
height: 4px;
background: color-mix(in srgb, var(--foreground) 15%, transparent);
}
.progress-fill {
height: 100%;
background: var(--accent);
}
.episode {
font-size: 0.75rem;
}
.cover-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 0.9rem 0.75rem;
}
.cover-card {
display: flex;
flex-direction: column;
gap: 0.35rem;
min-width: 0;
}
.cover {
position: relative;
aspect-ratio: 2 / 3;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
transition: border-color 0.15s ease;
}
.cover img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.cover-card:hover img {
transform: scale(1.05);
}
.score {
position: absolute;
top: 0.35rem;
right: 0.35rem;
display: inline-flex;
align-items: center;
gap: 0.15rem;
padding: 0 0.35rem;
background: var(--background);
color: var(--yellow);
font-size: 0.75rem;
}
.cover-title {
display: -webkit-box;
overflow: hidden;
font-size: 0.8rem;
line-height: 1.3;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
}
.cover-card:hover .cover-title {
color: var(--accent);
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
margin-top: 1rem;
}
.toggle {
display: inline-flex;
align-items: center;
gap: 0.3rem;
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: 800px) {
.overview {
grid-template-columns: minmax(0, 1fr);
}
}
@container (max-width: 520px) {
.cover-grid {
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
}
}
</style>
+109 -12
View File
@@ -1,28 +1,54 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { Game } from '#lib/content';
import { storeFor } from '#lib/library/stores';
let { game }: { game: Game } = $props();
const storeNames: Record<string, { label: string; icon: string }> = {
'store.steampowered.com': { label: 'Steam', icon: 'mdi:steam' }
};
const store = $derived.by(() => {
const host = new URL(game.url).hostname;
return storeNames[host] ?? { label: host.replace(/^www\./, ''), icon: 'mdi:web' };
});
const link = $derived(game.url ?? game.igdbUrl ?? '');
const store = $derived(storeFor(link));
const releaseYear = $derived(game.released?.slice(0, 4) ?? null);
const metaLine = $derived([releaseYear, game.developers[0]].filter(Boolean).join(' · '));
</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">
<img src={game.image} alt="" loading="lazy" />
<span class="play"><Icon icon="mdi:play" width="16" /> Play</span>
{#if game.image}
<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 class="details">
<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>
{#if metaLine}
<span class="meta">{metaLine}</span>
{/if}
{#if game.genres.length}
<span class="meta">{game.genres.slice(0, 3).join(' · ')}</span>
{/if}
</a>
<style>
@@ -57,6 +83,77 @@
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 {
position: absolute;
right: 0.5rem;
+23 -19
View File
@@ -17,10 +17,14 @@
<span class="placeholder"><Icon icon={placeholderIcon} width="36" /></span>
{/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}
<p class="plot">{details.plot}</p>
{#if item.status === 'watching'}
<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}
</div>
@@ -87,24 +91,25 @@
font-size: 0.78rem;
}
.plot {
.status {
position: absolute;
right: 0;
bottom: 0;
left: 0;
margin: 0;
padding: 0.6rem;
background: color-mix(in srgb, var(--background) 92%, transparent);
border-top: 1px solid var(--accent);
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;
line-height: 1.45;
transform: translateY(100%);
transition: transform 0.25s ease;
font-weight: 700;
}
.media-card:hover .plot,
.media-card:focus-visible .plot {
transform: translateY(0);
.status.watching {
background: var(--blue);
}
.status.planning {
background: var(--magenta);
}
.text {
@@ -132,8 +137,7 @@
}
@media (prefers-reduced-motion: reduce) {
.poster img,
.plot {
.poster img {
transition: none;
}
}
@@ -0,0 +1,169 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { Book } from '#lib/content';
let { book, showShelf = false }: { book: Book; showShelf?: boolean } = $props();
const shelfLabels = { read: 'read', reading: 'reading', 'want-to-read': 'want to read' };
let coverFailed = $state(false);
const hasOwnRating = $derived(book.rating !== undefined);
</script>
<a class="book-card" href={book.url} target="_blank" rel="noopener noreferrer" title="{book.title} by {book.authors.join(', ')}">
<div class="cover">
{#if book.cover && !coverFailed}
<img src={book.cover} alt="" loading="lazy" onerror={() => (coverFailed = true)} />
{:else}
<span class="placeholder">
<Icon icon="mdi:book-open-page-variant-outline" width="32" />
<span class="placeholder-title">{book.title}</span>
</span>
{/if}
{#if hasOwnRating}
<span class="rating"><Icon icon="mdi:star" width="13" />{book.rating}/10</span>
{:else if book.averageRating}
<span class="rating community" title="Goodreads average"><Icon icon="mdi:star-outline" width="13" />{book.averageRating}</span>
{/if}
{#if showShelf}
<span class="shelf {book.shelf}">{shelfLabels[book.shelf]}</span>
{/if}
</div>
<span class="title">{book.title}</span>
{#if book.series?.position}
<span class="meta position">{book.series.position}</span>
{:else}
<span class="meta">{book.authors.join(', ')}</span>
{/if}
{#if book.genres.length && !showShelf}
<span class="meta">{book.genres.slice(0, 3).join(' · ')}</span>
{/if}
</a>
<style>
.book-card {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.cover {
position: relative;
margin-bottom: 0.15rem;
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;
}
.book-card:hover .cover {
border-color: var(--accent);
}
.cover img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.book-card:hover img {
transform: scale(1.05);
}
.placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
height: 100%;
padding: 0.6rem;
color: var(--muted);
text-align: center;
}
.placeholder-title {
font-size: 0.75rem;
}
.rating {
position: absolute;
top: 0.35rem;
right: 0.35rem;
display: inline-flex;
align-items: center;
gap: 0.15rem;
padding: 0 0.35rem;
background: var(--background);
color: var(--yellow);
font-size: 0.75rem;
}
.rating.community {
color: var(--muted);
}
.shelf {
position: absolute;
bottom: 0.35rem;
left: 0.35rem;
padding: 0 0.35rem;
color: var(--background);
font-size: 0.7rem;
font-weight: 700;
}
.shelf.read {
background: var(--green);
}
.shelf.reading {
background: var(--blue);
}
.shelf.want-to-read {
background: var(--magenta);
}
.book-card:hover .shelf {
opacity: 0;
}
.position {
color: var(--foreground);
}
.title {
display: -webkit-box;
overflow: hidden;
color: var(--accent);
font-size: 0.82rem;
font-weight: 700;
line-height: 1.3;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
}
.meta {
overflow: hidden;
color: var(--muted);
font-size: 0.72rem;
white-space: nowrap;
text-overflow: ellipsis;
}
@media (prefers-reduced-motion: reduce) {
.cover,
.cover img {
transition: none;
}
}
</style>
@@ -0,0 +1,75 @@
<script lang="ts">
import type { BookSeriesGroup } from '#lib/books/series';
import CollectionGroup from '#lib/components/ui/CollectionGroup.svelte';
import BookCard from './BookCard.svelte';
let { series }: { series: BookSeriesGroup } = $props();
const shelfTones = { read: 'green', reading: 'blue', 'want-to-read': 'magenta' };
const total = $derived(series.books.length);
const shelves = $derived([
{ id: 'read', label: 'read', count: series.counts.read },
{ id: 'reading', label: 'reading', count: series.counts.reading },
{ 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>
<CollectionGroup name={series.name} href={series.url} icon="mdi:bookshelf" fallbackIcon="mdi:book-outline" {covers}>
{#snippet summary()}
{#each shelves.filter((shelf) => shelf.count > 0) as shelf (shelf.id)}
<span style:color="var(--{shelfTones[shelf.id]})">{shelf.count} {shelf.label}</span>
{/each}
{/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">
{#each series.books as book (book.goodreadsId)}
<BookCard {book} showShelf />
{/each}
</div>
</CollectionGroup>
<style>
.progress {
display: flex;
gap: 2px;
height: 4px;
margin-bottom: 0.35rem;
}
.segment {
flex-basis: 0;
}
.book-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 0.9rem 0.75rem;
padding-top: 0.5rem;
}
@container (max-width: 520px) {
.book-grid {
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
}
}
</style>
@@ -0,0 +1,67 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { Book } from '#lib/content';
import BookCard from './BookCard.svelte';
let { title, icon, books }: { title: string; icon: string; books: Book[] } = $props();
const collapsedCount = 12;
let showAll = $state(false);
const shownBooks = $derived(showAll ? books : books.slice(0, collapsedCount));
</script>
<h3 class="subheading"><Icon {icon} width="16" /> {title} <span class="muted">{books.length}</span></h3>
<div class="book-grid">
{#each shownBooks as book (book.goodreadsId)}
<BookCard {book} />
{/each}
</div>
{#if books.length > collapsedCount}
<button class="toggle" onclick={() => (showAll = !showAll)}>
<Icon icon={showAll ? 'mdi:chevron-up' : 'mdi:chevron-down'} width="16" />
{showAll ? 'Show less' : `Show all ${books.length}`}
</button>
{/if}
<style>
.subheading {
display: flex;
align-items: center;
gap: 0.4rem;
margin: 1.25rem 0 0.6rem;
color: var(--foreground);
font-size: 0.9rem;
}
.book-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.9rem 0.75rem;
}
.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) {
.book-grid {
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
}
}
</style>
@@ -0,0 +1,210 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { Book } from '#lib/content';
import { groupBySeries } from '#lib/books/series';
import Panel from '#lib/components/ui/Panel.svelte';
import BookShelfGrid from './BookShelfGrid.svelte';
import BookSeriesGroup from './BookSeriesGroup.svelte';
let { books }: { books: Book[] } = $props();
const shelfColors = { read: 'green', reading: 'blue', 'want-to-read': 'magenta' };
const byRating = (first: Book, second: Book) => (second.rating ?? -1) - (first.rating ?? -1);
const read = $derived(books.filter((book) => book.shelf === 'read').sort(byRating));
const reading = $derived(books.filter((book) => book.shelf === 'reading'));
const wantToRead = $derived(books.filter((book) => book.shelf === 'want-to-read'));
const shelfCounts = $derived([
{ label: 'Read', count: read.length, color: shelfColors.read },
{ label: 'Reading', count: reading.length, color: shelfColors.reading },
{ label: 'Want to read', count: wantToRead.length, color: shelfColors['want-to-read'] }
]);
const shownCounts = $derived(shelfCounts.filter((shelf) => shelf.count > 0));
const grouped = $derived(groupBySeries(books));
const standaloneRead = $derived(read.filter((book) => !book.series));
const standaloneWantToRead = $derived(wantToRead.filter((book) => !book.series));
const pagesRead = $derived(read.reduce((total, book) => total + (book.pages ?? 0), 0));
const authorCount = $derived(new Set(read.flatMap((book) => book.authors)).size);
const topGenres = $derived.by(() => {
const counts = new Map<string, number>();
read.flatMap((book) => book.genres).forEach((genre) => counts.set(genre, (counts.get(genre) ?? 0) + 1));
return [...counts.entries()].sort((first, second) => second[1] - first[1]).slice(0, 3).map(([genre]) => genre);
});
const rows = $derived([
['Pages read', pagesRead.toLocaleString('en-US')],
['Authors', `${authorCount}`],
...(topGenres.length ? [['Top genres', topGenres.join(', ')]] : [])
]);
</script>
<div class="overview" class:single={!reading.length}>
<Panel title="bookshelf" icon="mdi:bookshelf">
<div class="fetch">
{#each shelfCounts as shelf (shelf.label)}
<div><span class="label">{shelf.label}</span>: <span style:color="var(--{shelf.color})">{shelf.count}</span></div>
{/each}
{#each rows as [label, value] (label)}
<div><span class="label">{label}</span>: {value}</div>
{/each}
{#if shownCounts.length}
<div class="breakdown" role="img" aria-label="Books by shelf">
{#each shownCounts as shelf (shelf.label)}
<span class="block" style:flex-grow={shelf.count} style:background="var(--{shelf.color})" title="{shelf.label}: {shelf.count}"></span>
{/each}
</div>
{/if}
</div>
</Panel>
{#if reading.length}
<Panel title="Currently reading" icon="mdi:book-open-variant">
<div class="reading">
{#each reading as book (book.goodreadsId)}
<a class="reading-row" href={book.url} target="_blank" rel="noopener noreferrer">
{#if book.cover}
<img src={book.cover} alt={book.title} loading="lazy" />
{:else}
<span class="cover-fallback"><Icon icon="mdi:book-outline" width="22" /></span>
{/if}
<span class="reading-text">
<span class="title">{book.title}</span>
<span class="muted detail">{book.authors.join(', ')}</span>
<span class="muted detail">
{[book.pages ? `${book.pages} pages` : '', book.published?.slice(0, 4) ?? ''].filter(Boolean).join(' · ')}
</span>
</span>
</a>
{/each}
</div>
</Panel>
{/if}
</div>
{#if grouped.series.length}
<h3 class="subheading"><Icon icon="mdi:bookshelf" width="16" /> Series <span class="muted">{grouped.series.length}</span></h3>
<div class="series-list">
{#each grouped.series as series (series.url || series.name)}
<BookSeriesGroup {series} />
{/each}
</div>
{/if}
{#if standaloneRead.length}
<BookShelfGrid title="Read" icon="mdi:check-circle-outline" books={standaloneRead} />
{/if}
{#if standaloneWantToRead.length}
<BookShelfGrid title="Want to read" icon="mdi:bookmark-outline" books={standaloneWantToRead} />
{/if}
<style>
.subheading {
display: flex;
align-items: center;
gap: 0.4rem;
margin: 1.25rem 0 0.6rem;
color: var(--foreground);
font-size: 0.9rem;
}
.series-list {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 0.75rem;
}
.overview {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 0.75rem;
}
.overview.single {
grid-template-columns: minmax(0, 1fr);
}
.fetch {
line-height: 1.5;
}
.label {
color: var(--blue);
font-weight: 700;
}
.breakdown {
display: flex;
margin-top: 0.75rem;
}
.block {
flex-basis: 0;
min-width: 0.5rem;
height: 1rem;
}
.reading {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.reading-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.4rem;
border: 1px solid transparent;
}
.reading-row:hover {
border-color: var(--accent);
}
.reading-row img,
.cover-fallback {
flex-shrink: 0;
width: 52px;
height: 78px;
object-fit: cover;
}
.cover-fallback {
display: grid;
place-items: center;
background: color-mix(in srgb, var(--foreground) 8%, transparent);
color: var(--muted);
}
.reading-text {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
}
.title,
.detail {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.title {
font-size: 0.9rem;
}
.detail {
font-size: 0.75rem;
}
@container (max-width: 800px) {
.overview {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
@@ -0,0 +1,24 @@
<script lang="ts">
import type { ListeningProfile } from '#lib/lastfm/types';
import Panel from '../../ui/Panel.svelte';
import BarChart from './BarChart.svelte';
import TabRow from './TabRow.svelte';
let { listening }: { listening: ListeningProfile } = $props();
const views = [
{ id: 'daily', label: '30 days', color: 'accent' },
{ id: 'monthly', label: '12 months', color: 'blue' },
{ id: 'hourly', label: 'By hour', color: 'magenta' }
] as const;
let selectedView = $state<(typeof views)[number]['id']>('daily');
const selected = $derived(views.find((view) => view.id === selectedView)!);
const buckets = $derived(listening[selectedView]);
</script>
<Panel title="Listening activity" icon="mdi:chart-bar">
<TabRow tabs={views} bind:selected={selectedView} label="Activity range" />
<BarChart {buckets} color={selected.color} />
</Panel>
@@ -0,0 +1,71 @@
<script lang="ts">
import type { CountBucket } from '#lib/lastfm/types';
let { buckets, color = 'accent', unit = 'plays' }: { buckets: CountBucket[]; color?: string; unit?: string } = $props();
const highest = $derived(Math.max(1, ...buckets.map((bucket) => bucket.count)));
const peak = $derived(buckets.reduce((best, bucket) => (bucket.count > best.count ? bucket : best), buckets[0]));
</script>
<div class="chart" role="img" aria-label="Bar chart, peak {peak?.title} with {peak?.count} {unit}">
<div class="bars">
{#each buckets as bucket, index (index)}
<div class="column" title="{bucket.title}: {bucket.count} {unit}">
<div class="bar" style:height="{(bucket.count / highest) * 100}%" style:background="var(--{color})"></div>
</div>
{/each}
</div>
<div class="labels">
{#each buckets as bucket, index (index)}
<span>{bucket.label}</span>
{/each}
</div>
{#if peak?.count}
<p class="muted peak">peak: {peak.title} ({peak.count} {unit})</p>
{/if}
</div>
<style>
.bars,
.labels {
display: grid;
grid-auto-columns: minmax(0, 1fr);
grid-auto-flow: column;
gap: 2px;
}
.bars {
height: 110px;
border-bottom: 1px dashed color-mix(in srgb, var(--foreground) 30%, transparent);
}
.column {
display: flex;
align-items: flex-end;
height: 100%;
}
.column:hover .bar {
filter: brightness(1.3);
}
.bar {
width: 100%;
min-height: 1px;
opacity: 0.85;
transition: height 0.3s ease;
}
.labels {
margin-top: 0.25rem;
color: var(--muted);
font-size: 0.65rem;
text-align: center;
white-space: nowrap;
}
.peak {
margin: 0.4rem 0 0;
font-size: 0.75rem;
}
</style>
@@ -0,0 +1,64 @@
<script lang="ts">
import type { ListeningProfile } from '#lib/lastfm/types';
let { listening }: { listening: ListeningProfile } = $props();
const hostName = 'last.fm';
const numberFormat = new Intl.NumberFormat('en-US');
const memberSince = $derived(
listening.registeredAt
? new Date(listening.registeredAt * 1000).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })
: null
);
const rows = $derived(
[
['Scrobbles', numberFormat.format(listening.totals.scrobbles)],
['Artists', listening.totals.artists ? numberFormat.format(listening.totals.artists) : null],
['Albums', listening.totals.albums ? numberFormat.format(listening.totals.albums) : null],
['Tracks', listening.totals.tracks ? numberFormat.format(listening.totals.tracks) : null],
['Daily avg', `${listening.averagePerDay} plays (30 days)`],
['Streak', `${listening.currentStreak} days (best ${listening.longestStreak})`],
['Since', memberSince]
].filter((row): row is [string, string] => row[1] !== null)
);
</script>
<div class="listening-fetch">
<a class="heading" href={listening.profileUrl} target="_blank" rel="noopener noreferrer">
<span class="user">{listening.userName.toLowerCase()}</span><span class="at">@</span><span class="host">{hostName}</span>
</a>
<div class="separator">{'-'.repeat(listening.userName.length + hostName.length + 1)}</div>
{#each rows as [label, value] (label)}
<div class="row"><span class="label">{label}</span>: {value}</div>
{/each}
</div>
<style>
.listening-fetch {
line-height: 1.5;
}
.user,
.host {
color: var(--accent);
font-weight: 700;
}
.heading:hover .user,
.heading:hover .host {
text-decoration: underline;
}
.at,
.separator {
color: var(--muted);
}
.label {
color: var(--blue);
font-weight: 700;
}
</style>
@@ -0,0 +1,67 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { ListeningProfile } from '#lib/lastfm/types';
import LinkButton from '../../ui/LinkButton.svelte';
import Panel from '../../ui/Panel.svelte';
import ActivityPanel from './ActivityPanel.svelte';
import ListeningFetch from './ListeningFetch.svelte';
import TopCharts from './TopCharts.svelte';
import TrendingArtists from './TrendingArtists.svelte';
let { listening }: { listening: ListeningProfile } = $props();
</script>
<div class="overview">
<Panel title="last.fm" icon="simple-icons:lastdotfm">
<ListeningFetch {listening} />
</Panel>
<Panel title="Trending this week" icon="mdi:trending-up">
<TrendingArtists artists={listening.trending} />
</Panel>
</div>
<ActivityPanel {listening} />
<h3 class="subheading"><Icon icon="mdi:trophy-outline" width="16" /> Top charts</h3>
<TopCharts top={listening.top} />
<div class="actions">
<LinkButton href={listening.profileUrl} icon="simple-icons:lastdotfm">Full history on Last.fm</LinkButton>
<span class="muted stored">{listening.storedScrobbles.toLocaleString('en-US')} scrobbles collected</span>
</div>
<style>
.overview {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.subheading {
display: flex;
align-items: center;
gap: 0.4rem;
margin: 1.25rem 0 0.6rem;
color: var(--foreground);
font-size: 0.9rem;
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
margin-top: 1rem;
}
.stored {
font-size: 0.8rem;
}
@container (max-width: 800px) {
.overview {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
@@ -0,0 +1,41 @@
<script lang="ts" generics="TabId extends string">
let {
tabs,
selected = $bindable(),
label
}: { tabs: readonly { id: TabId; label: string }[]; selected: TabId; label: string } = $props();
</script>
<div class="tabs" role="tablist" aria-label={label}>
{#each tabs as tab (tab.id)}
<button role="tab" aria-selected={selected === tab.id} class:active={selected === tab.id} onclick={() => (selected = tab.id)}>
{tab.label}
</button>
{/each}
</div>
<style>
.tabs {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
button {
padding: 0.15rem 0.6rem;
background: none;
border: 1px dashed color-mix(in srgb, var(--foreground) 30%, transparent);
color: var(--muted);
font-size: 0.8rem;
}
button:hover {
color: var(--foreground);
}
button.active {
border-style: solid;
border-color: var(--accent);
color: var(--accent);
}
</style>
@@ -0,0 +1,236 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { ChartItem, ListeningPeriod, TopCharts } from '#lib/lastfm/types';
import Panel from '../../ui/Panel.svelte';
import TabRow from './TabRow.svelte';
let { top }: { top: Record<ListeningPeriod, TopCharts> } = $props();
const periods: { id: ListeningPeriod; label: string }[] = [
{ id: 'week', label: '7 days' },
{ id: 'month', label: '30 days' },
{ id: 'year', label: '12 months' },
{ id: 'all', label: 'All time' }
];
let selectedPeriod = $state<ListeningPeriod>('month');
const charts = $derived(top[selectedPeriod]);
const lists = $derived([
{ title: 'Top artists', icon: 'mdi:account-music', items: charts.artists, showCovers: false },
{ title: 'Top tracks', icon: 'mdi:music-note', items: charts.tracks, showCovers: true }
]);
</script>
<div class="top-charts">
<TabRow tabs={periods} bind:selected={selectedPeriod} label="Chart period" />
<div class="album-grid">
{#each charts.albums as album (album.url)}
<a class="album" href={album.url} target="_blank" rel="noopener noreferrer" title="{album.name} by {album.artist}">
<div class="cover">
{#if album.image}
<img src={album.image} alt={album.name} loading="lazy" />
{:else}
<span class="cover-fallback"><Icon icon="mdi:album" width="32" /></span>
{/if}
<span class="plays">{album.count}</span>
</div>
<span class="album-name">{album.name}</span>
<span class="muted album-artist">{album.artist}</span>
</a>
{:else}
<p class="muted">No albums in this period.</p>
{/each}
</div>
<div class="lists">
{#each lists as list (list.title)}
<Panel title={list.title} icon={list.icon}>
{@render chartList(list.items, list.showCovers)}
</Panel>
{/each}
</div>
</div>
{#snippet chartList(items: ChartItem[], showCovers: boolean)}
{#if items.length}
<ol class="chart-list">
{#each items as item, index (item.url)}
<li>
<span class="rank">{index + 1}</span>
{#if showCovers}
{#if item.image}
<img class="thumb" src={item.image} alt="" loading="lazy" />
{:else}
<span class="thumb thumb-fallback"><Icon icon="mdi:music-note" width="16" /></span>
{/if}
{/if}
<a class="item" href={item.url} target="_blank" rel="noopener noreferrer">
<span class="item-name">{item.name}</span>
{#if item.artist}
<span class="muted item-artist">{item.artist}</span>
{/if}
</a>
<span class="muted">{item.count}</span>
</li>
{/each}
</ol>
{:else}
<p class="muted">Nothing here yet.</p>
{/if}
{/snippet}
<style>
.top-charts {
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.album-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 0.9rem 0.75rem;
}
.album {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
}
.cover {
position: relative;
aspect-ratio: 1;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
transition: border-color 0.15s ease;
}
.album:hover .cover {
border-color: var(--accent);
}
.cover img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.album:hover img {
transform: scale(1.05);
}
.cover-fallback {
display: grid;
place-items: center;
height: 100%;
background: color-mix(in srgb, var(--foreground) 8%, transparent);
color: var(--muted);
}
.plays {
position: absolute;
top: 0.3rem;
right: 0.3rem;
padding: 0 0.35rem;
background: var(--background);
color: var(--accent);
font-size: 0.72rem;
}
.album-name,
.album-artist,
.item-name,
.item-artist {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.album-name {
margin-top: 0.15rem;
font-size: 0.8rem;
}
.album:hover .album-name {
color: var(--accent);
}
.album-artist {
font-size: 0.72rem;
}
.lists {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 0.75rem;
}
.chart-list {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin: 0;
padding: 0;
list-style: none;
}
li {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
}
.rank {
flex-shrink: 0;
width: 1.4rem;
color: var(--muted);
text-align: right;
}
.thumb {
flex-shrink: 0;
width: 36px;
height: 36px;
object-fit: cover;
}
.thumb-fallback {
display: grid;
place-items: center;
background: color-mix(in srgb, var(--foreground) 8%, transparent);
color: var(--muted);
}
.item {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.item:hover .item-name {
color: var(--accent);
}
.item-artist {
font-size: 0.72rem;
}
@container (max-width: 800px) {
.lists {
grid-template-columns: minmax(0, 1fr);
}
}
@container (max-width: 520px) {
.album-grid {
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
}
}
</style>
@@ -0,0 +1,103 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { TrendingArtist } from '#lib/lastfm/types';
let { artists }: { artists: TrendingArtist[] } = $props();
const highest = $derived(Math.max(1, ...artists.map((artist) => artist.count)));
function changeOf(artist: TrendingArtist) {
if (artist.previousCount === 0) return null;
const percent = Math.round(((artist.count - artist.previousCount) / artist.previousCount) * 100);
if (percent > 0) return { icon: 'mdi:arrow-up', color: 'green', text: `${percent}%` };
if (percent < 0) return { icon: 'mdi:arrow-down', color: 'red', text: `${Math.abs(percent)}%` };
return { icon: 'mdi:minus', color: 'muted', text: 'same' };
}
</script>
{#if artists.length}
<ol class="trending">
{#each artists as artist, index (artist.name)}
{@const change = changeOf(artist)}
<li>
<span class="rank">{index + 1}</span>
<a class="name" href={artist.url} target="_blank" rel="noopener noreferrer">{artist.name}</a>
<span class="meter"><span class="fill" style:width="{(artist.count / highest) * 100}%"></span></span>
<span class="count">{artist.count}</span>
<span class="change" style:color={change ? `var(--${change.color})` : undefined} title="{artist.previousCount} plays the week before">
{#if change}
<Icon icon={change.icon} width="14" />{change.text}
{/if}
</span>
</li>
{/each}
</ol>
{:else}
<p class="muted">Nothing played this week.</p>
{/if}
<style>
.trending {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin: 0;
padding: 0;
list-style: none;
}
li {
display: grid;
grid-template-columns: 1.4rem minmax(0, 1.2fr) minmax(0, 1fr) 2.5rem 4rem;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
}
.rank {
color: var(--muted);
text-align: right;
}
.name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.name:hover {
color: var(--accent);
}
.meter {
height: 6px;
background: color-mix(in srgb, var(--foreground) 12%, transparent);
}
.fill {
display: block;
height: 100%;
background: var(--accent);
}
.count {
text-align: right;
}
.change {
display: inline-flex;
align-items: center;
gap: 0.15rem;
font-size: 0.75rem;
}
@container (max-width: 520px) {
li {
grid-template-columns: 1.4rem minmax(0, 1fr) 2.5rem 4rem;
}
.meter {
display: none;
}
}
</style>
@@ -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>
@@ -0,0 +1,141 @@
<script lang="ts">
import type { DesktopSetup } from '#lib/content/types';
let { setup }: { setup: DesktopSetup } = $props();
const title = $derived(`${setup.user}@${setup.host}`);
const dotfilesLabel = $derived(setup.dotfilesUrl.replace(/^https?:\/\//, ''));
const paletteColors = ['red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'foreground'];
</script>
<div class="fetch">
<div class="art" aria-hidden="true">
<pre class="steam">{#each setup.steam as line, index (index)}<span style:animation-delay="{index * 0.4}s">{line}</span>
{/each}</pre>
<pre class="cup">{setup.art.join('\n')}</pre>
</div>
<div class="info">
<div class="heading">
<span class="accent">{setup.user}</span><span class="muted">@</span><span class="accent">{setup.host}</span>
</div>
<div class="muted">{'-'.repeat(title.length)}</div>
{#each setup.specs as spec (spec.label)}
<div class="row"><span class="label">{spec.label}</span><span>{spec.value}</span></div>
{/each}
<div class="row">
<span class="label">dotfiles</span>
<a class="link" href={setup.dotfilesUrl} target="_blank" rel="noopener noreferrer">{dotfilesLabel}</a>
</div>
<div class="palette" aria-hidden="true">
{#each paletteColors as color (color)}
<span style:background="var(--{color})"></span>
{/each}
</div>
</div>
</div>
<style>
.fetch {
display: flex;
align-items: flex-start;
gap: 1.5rem;
}
.art {
flex-shrink: 0;
align-self: center;
}
.art pre {
margin: 0;
font-size: 1.2rem;
line-height: 1.2;
}
.steam {
color: var(--muted);
}
.steam span {
display: inline-block;
animation: steam-rise 2.4s ease-in-out infinite;
}
.steam {
font-weight: 700;
}
.cup {
color: var(--accent);
line-height: 1;
}
@keyframes steam-rise {
0%,
100% {
opacity: 0.35;
transform: translateY(2px);
}
50% {
opacity: 1;
transform: translateY(-2px);
}
}
@media (prefers-reduced-motion: reduce) {
.steam span {
animation: none;
}
}
.info {
display: flex;
flex-direction: column;
min-width: 0;
line-height: 1.5;
}
.accent {
color: var(--accent);
font-weight: 700;
}
.row {
display: grid;
grid-template-columns: 5.5rem minmax(0, 1fr);
gap: 0.5rem;
}
.label {
color: var(--blue);
font-weight: 700;
}
.link {
overflow: hidden;
color: var(--green);
white-space: nowrap;
text-overflow: ellipsis;
}
.link:hover {
text-decoration: underline;
}
.palette {
display: flex;
margin-top: 0.6rem;
}
.palette span {
width: 1.5rem;
height: 0.9rem;
}
@container (max-width: 700px) {
.art {
display: none;
}
}
</style>
@@ -0,0 +1,11 @@
<script lang="ts">
import type { DesktopSetup } from '#lib/content/types';
import Panel from '../../ui/Panel.svelte';
import SetupFetch from './SetupFetch.svelte';
let { setup }: { setup: DesktopSetup } = $props();
</script>
<Panel title="fastfetch" icon="mdi:console">
<SetupFetch {setup} />
</Panel>
+3 -2
View File
@@ -5,11 +5,12 @@
title,
number,
color = 'accent',
tall = false,
children
}: { title: string; number: number; color?: string; children: Snippet } = $props();
}: { title: string; number: number; color?: string; tall?: boolean; children: Snippet } = $props();
</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>
{@render children()}
</section>
@@ -1,12 +1,23 @@
<script lang="ts">
import type { BtopData } from '#lib/btop/types';
import type { BtopData, ContributionDay } from '#lib/btop/types';
import BtopBox from './BtopBox.svelte';
let { data }: { data: BtopData | null } = $props();
const levelOpacity = [0, 0.3, 0.55, 0.8, 1];
const levelOpacity = [1, 0.35, 0.6, 0.8, 1];
const calendar = $derived(data?.contributions ?? null);
function dayBackground({ githubCount, giteaCount }: ContributionDay): string | undefined {
if (githubCount > 0 && giteaCount > 0) return 'linear-gradient(135deg, var(--green) 50%, var(--orange) 50%)';
if (giteaCount > 0) return 'var(--orange)';
if (githubCount > 0) return 'var(--green)';
return undefined;
}
function dayTitle(day: ContributionDay): string {
return `${day.count} contributions on ${day.date} (GitHub ${day.githubCount}, Gitea ${day.giteaCount})`;
}
</script>
<BtopBox title="net · commits" number={3} color="blue">
@@ -19,27 +30,38 @@
{/if}
</div>
<a class="heatmap" href={data.github.profileUrl} target="_blank" rel="noopener noreferrer" aria-label="GitHub contribution graph">
<div class="heatmap" aria-label="Contribution graph">
{#each calendar.weeks as week, weekIndex (weekIndex)}
<span class="week">
{#each week as day (day.date)}
<span
class="day"
style:opacity={day.level === 0 ? 1 : levelOpacity[day.level]}
class:empty={day.level === 0}
title="{day.count} contributions on {day.date}"
class:empty={day.count === 0}
style:background={dayBackground(day)}
style:opacity={levelOpacity[day.level]}
title={dayTitle(day)}
></span>
{/each}
</span>
{/each}
</a>
</div>
<div class="legend muted">
less
{#each levelOpacity as opacity, level (level)}
<span class="day" class:empty={level === 0} style:opacity={level === 0 ? 1 : opacity}></span>
{/each}
more
<div class="legend">
<a class="source" href={data.github.profileUrl} target="_blank" rel="noopener noreferrer">
<span class="swatch github"></span>GitHub <b>{calendar.githubTotal.toLocaleString('en-US')}</b>
</a>
{#if calendar.giteaProfileUrl}
<a class="source" href={calendar.giteaProfileUrl} target="_blank" rel="noopener noreferrer">
<span class="swatch gitea"></span>Gitea <b>{calendar.giteaTotal.toLocaleString('en-US')}</b>
</a>
{/if}
<span class="scale muted">
less
{#each levelOpacity as opacity, level (level)}
<span class="swatch" class:empty={level === 0} style:opacity></span>
{/each}
more
</span>
</div>
{:else}
<p class="muted loading">connecting to github...</p>
@@ -76,25 +98,57 @@
display: block;
aspect-ratio: 1;
min-width: 4px;
background: var(--green);
border-radius: 1px;
}
.day.empty {
.empty {
background: color-mix(in srgb, var(--foreground) 10%, transparent);
}
.legend {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 3px;
margin-top: 0.4rem;
font-size: 0.7rem;
gap: 0.3rem 0.9rem;
margin-top: 0.5rem;
font-size: 0.72rem;
}
.legend .day {
.source {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.source:hover {
color: var(--accent);
}
.source b {
color: var(--blue);
}
.scale {
display: inline-flex;
align-items: center;
gap: 3px;
margin-left: auto;
}
.swatch {
display: inline-block;
width: 9px;
height: 9px;
border-radius: 1px;
background: var(--green);
}
.swatch.gitea {
background: var(--orange);
}
.swatch.empty {
background: color-mix(in srgb, var(--foreground) 10%, transparent);
}
.loading {
+3 -52
View File
@@ -1,22 +1,18 @@
<script lang="ts">
import { onMount } from 'svelte';
import { profile } from '#lib/content';
import { randomWalk, usageTone } from '#lib/btop/simulate';
import BtopBox from './BtopBox.svelte';
const historyLength = 48;
const updateInterval = 1000;
const coreNames = (profile.skills.Languages ?? []).slice(0, 6);
let history = $state<number[]>(Array.from({ length: historyLength }, () => 20 + Math.random() * 30));
let cores = $state(coreNames.map((name) => ({ name, usage: 20 + Math.random() * 60 })));
const totalUsage = $derived(Math.round(history.at(-1) ?? 0));
onMount(() => {
const timer = setInterval(() => {
history = [...history.slice(1), randomWalk(history.at(-1) ?? 40, 18, 5, 98)];
cores = cores.map((core) => ({ ...core, usage: randomWalk(core.usage, 15, 3, 100) }));
}, updateInterval);
return () => clearInterval(timer);
});
@@ -33,16 +29,6 @@
<span class="bar" style:height="{value}%" style:background="var(--{usageTone(value)})"></span>
{/each}
</div>
<div class="cores">
{#each cores as core (core.name)}
<div class="core">
<span class="core-name">{core.name}</span>
<span class="core-meter"><span style:width="{core.usage}%" style:background="var(--{usageTone(core.usage)})"></span></span>
<span class="core-value">{Math.round(core.usage)}%</span>
</div>
{/each}
</div>
</BtopBox>
<style>
@@ -66,10 +52,11 @@
.graph {
display: flex;
flex: 1;
align-items: flex-end;
gap: 1px;
height: 70px;
margin: 0.4rem 0 0.6rem;
min-height: 70px;
margin-top: 0.4rem;
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
}
@@ -79,40 +66,4 @@
opacity: 0.85;
transition: height 0.4s ease;
}
.cores {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
gap: 0.2rem 1rem;
font-size: 0.72rem;
}
.core {
display: grid;
grid-template-columns: 6.5rem 1fr 2.5rem;
align-items: center;
gap: 0.4rem;
}
.core-name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.core-meter {
height: 6px;
background: color-mix(in srgb, var(--foreground) 10%, transparent);
}
.core-meter span {
display: block;
height: 100%;
transition: width 0.4s ease;
}
.core-value {
color: var(--muted);
text-align: right;
}
</style>
@@ -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 ContributionPanel from './ContributionPanel.svelte';
import ProcessPanel from './ProcessPanel.svelte';
import HobbyPanels from './HobbyPanels.svelte';
let data = $state<BtopData | null>(null);
let clock = $state(new Date());
@@ -35,6 +36,8 @@
<div class="mem"><MemoryPanel {data} /></div>
<div class="proc"><ProcessPanel {data} /></div>
</div>
<HobbyPanels />
</div>
<style>
@@ -45,6 +48,7 @@
height: 100%;
min-height: 0;
padding: 0.5rem 0.75rem 0.75rem;
overflow-y: auto;
container-type: inline-size;
}
@@ -62,14 +66,28 @@
.panels {
display: grid;
flex: 1;
flex: none;
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
grid-template-rows: auto minmax(0, 1fr);
grid-template-rows: auto auto;
grid-template-areas:
'cpu net'
'proc mem';
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 {
@@ -86,12 +104,8 @@
.proc {
grid-area: proc;
display: flex;
min-height: 0;
}
.proc > :global(.btop-box) {
flex: 1;
min-height: 220px;
contain: size;
}
@container (max-width: 760px) {
@@ -106,7 +120,7 @@
}
.proc {
min-height: 220px;
min-height: 260px;
}
}
</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>
+64
View File
@@ -8,6 +8,13 @@
import { imageFileName } from '#lib/desktop/imageViewer';
import { defaultFloatingRect, floatingStyle, moveRect, resizeRect } from '#lib/desktop/floating';
import SystemMonitor from '../btop/SystemMonitor.svelte';
import Snake from '../games/Snake.svelte';
import Breakout from '../games/Breakout.svelte';
import DesktopPet from '../pet/DesktopPet.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 Window from './Window.svelte';
@@ -98,6 +105,10 @@
</div>
<div class="tiling-area" bind:clientWidth={areaWidth} bind:clientHeight={areaHeight}>
{#if !isNarrow}
<DesktopPet {areaWidth} {areaHeight} />
{/if}
{#if pageOpen}
<Window title="kitty" path={pageTitle} icon={pageIcon} scrollKey={pageKey} {...windowProps(pageWindowId)} onclose={closePage}>
{@render children()}
@@ -141,6 +152,59 @@
<SystemMonitor />
</Window>
{/each}
{#each windowManager.games as gameWindow (gameWindow.id)}
<Window
title={gameWindow.game}
path="games"
icon="ph:game-controller-duotone"
{...windowProps(gameWindow.id)}
onclose={() => windowManager.closeWindow(gameWindow.id)}
>
{#if gameWindow.game === 'snake'}
<Snake focused={windowManager.focusedId === gameWindow.id} />
{:else if gameWindow.game === 'breakout'}
<Breakout focused={windowManager.focusedId === gameWindow.id} />
{/if}
</Window>
{/each}
{#each windowManager.tcgBrowsers as browser (browser.id)}
<Window
title="tcg"
path="card browser"
icon="mdi:pokeball"
{...windowProps(browser.id)}
onclose={() => windowManager.closeWindow(browser.id)}
>
<TcgBrowser />
</Window>
{/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>
+45 -11
View File
@@ -1,23 +1,43 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { desktop } from '#lib/content';
import { desktop, type DesktopSide } from '#lib/content';
import { runDesktopAction } from '#lib/desktop/actions';
const icons = desktop.iconGroups.flatMap((group) => group.icons);
function iconsOnSide(side: DesktopSide) {
return desktop.iconGroups.filter((group) => (group.side ?? 'left') === side).flatMap((group) => group.icons);
}
const iconSides = [
{ side: 'left', label: 'Desktop', icons: iconsOnSide('left') },
{ side: 'right', label: 'Desktop games', icons: iconsOnSide('right') }
];
</script>
<nav class="desktop-icons" aria-label="Desktop">
{#each icons as item (item.label)}
<button class="desktop-icon" title={item.label} onclick={() => runDesktopAction(item.open)}>
<span class="icon-box">
<Icon icon={item.icon} width="36" />
</span>
<span class="icon-label">{item.label}</span>
</button>
<div class="desktop-icon-sides">
{#each iconSides as iconSide (iconSide.side)}
{#if iconSide.icons.length}
<nav class="desktop-icons {iconSide.side}" aria-label={iconSide.label}>
{#each iconSide.icons as item (item.label)}
<button class="desktop-icon" title={item.label} onclick={() => runDesktopAction(item.open)}>
<span class="icon-box">
<Icon icon={item.icon} width="36" />
</span>
<span class="icon-label">{item.label}</span>
</button>
{/each}
</nav>
{/if}
{/each}
</nav>
</div>
<style>
.desktop-icon-sides {
display: flex;
justify-content: space-between;
gap: 4px;
height: 100%;
}
.desktop-icons {
display: grid;
grid-auto-flow: column;
@@ -29,6 +49,10 @@
align-content: start;
}
.desktop-icons.right {
direction: rtl;
}
.desktop-icon {
display: flex;
flex-direction: column;
@@ -38,6 +62,7 @@
background: none;
border: none;
color: var(--foreground);
direction: ltr;
}
.desktop-icon:focus-visible {
@@ -86,6 +111,11 @@
}
@media (max-width: 767px) {
.desktop-icon-sides {
flex-direction: column;
height: auto;
}
.desktop-icons {
grid-auto-flow: row;
grid-template-columns: repeat(auto-fill, 104px);
@@ -94,5 +124,9 @@
justify-content: center;
height: auto;
}
.desktop-icons.right {
direction: ltr;
}
}
</style>
+1 -1
View File
@@ -80,7 +80,7 @@
onfocusin={onfocus}
>
<div class="window-surface">
<header class="title-bar" onpointerdown={(event) => trackPointer(event, ondrag)}>
<header class="title-bar" role="presentation" onpointerdown={(event) => trackPointer(event, ondrag)}>
<span class="title">
<span class="app-name"><Icon {icon} width="15" />{title}</span>
{#if path}
@@ -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>
+216
View File
@@ -0,0 +1,216 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
advanceBreakout,
createBreakoutGame,
fieldHeight,
fieldWidth,
launchBall,
movePaddleTo,
type BreakoutStatus
} from '#lib/games/breakout';
import { loadHighScore, saveHighScore } from '#lib/games/highScore';
import GameShell from './GameShell.svelte';
import BreakoutCanvas from './BreakoutCanvas.svelte';
let { focused }: { focused: boolean } = $props();
const gameName = 'breakout';
const boardFrameSize = 6;
const longestFrameSeconds = 0.05;
const steeringKeys: Record<string, number> = {
ArrowLeft: -1,
a: -1,
h: -1,
ArrowRight: 1,
d: 1,
l: 1
};
const launchKeys = ['ArrowUp', 'w', 'k', 'Enter'];
const statusMessages: Record<BreakoutStatus, string> = {
serving: 'press space or click to launch',
running: '',
paused: 'paused, press space to resume',
over: 'game over, press r to restart'
};
let game = $state(createBreakoutGame());
let highScore = $state(0);
let boardElement = $state<HTMLDivElement>();
let areaWidth = $state(0);
let areaHeight = $state(0);
const heldKeys = new Set<string>();
const canvasWidth = $derived(
Math.max(0, Math.floor(Math.min(areaWidth - boardFrameSize, ((areaHeight - boardFrameSize) * fieldWidth) / fieldHeight)))
);
const canvasHeight = $derived(Math.round((canvasWidth * fieldHeight) / fieldWidth));
const isActive = $derived(game.status === 'serving' || game.status === 'running');
function normalizedKey(event: KeyboardEvent): string {
return event.key.length === 1 ? event.key.toLowerCase() : event.key;
}
function steering(): number {
let direction = 0;
heldKeys.forEach((key) => (direction += steeringKeys[key] ?? 0));
return Math.sign(direction);
}
function restart() {
game = createBreakoutGame();
}
function primaryAction() {
if (game.status === 'serving') launchBall(game);
else if (game.status === 'paused') game.status = 'running';
else if (game.status === 'over') restart();
}
function togglePause() {
if (game.status === 'running') game.status = 'paused';
else primaryAction();
}
function handleKeydown(event: KeyboardEvent) {
if (event.altKey || event.ctrlKey || event.metaKey) return;
const key = normalizedKey(event);
if (key in steeringKeys) heldKeys.add(key);
else if (key === ' ' || key === 'p') togglePause();
else if (launchKeys.includes(key)) primaryAction();
else if (key === 'r') restart();
else return;
event.preventDefault();
}
function handleKeyup(event: KeyboardEvent) {
heldKeys.delete(normalizedKey(event));
}
function handlePointerMove(event: PointerEvent) {
if (!isActive || !boardElement || canvasWidth === 0) return;
const boardLeft = boardElement.getBoundingClientRect().left + boardFrameSize / 2;
movePaddleTo(game, ((event.clientX - boardLeft) / canvasWidth) * fieldWidth);
}
function handleBoardClick() {
primaryAction();
boardElement?.focus({ preventScroll: true });
}
function updateHighScore() {
if (game.score <= highScore) return;
highScore = game.score;
saveHighScore(gameName, highScore);
}
$effect(() => {
if (!isActive) return;
let frameId = 0;
let lastTime = performance.now();
const frame = (time: number) => {
const seconds = Math.min(longestFrameSeconds, (time - lastTime) / 1000);
lastTime = time;
advanceBreakout(game, seconds, steering());
updateHighScore();
frameId = requestAnimationFrame(frame);
};
frameId = requestAnimationFrame(frame);
return () => cancelAnimationFrame(frameId);
});
$effect(() => {
if (focused) {
boardElement?.focus({ preventScroll: true });
return;
}
heldKeys.clear();
if (game.status === 'running') game.status = 'paused';
});
onMount(() => {
highScore = loadHighScore(gameName);
});
</script>
<GameShell
title="breakout"
stats={[
{ label: 'score', value: game.score },
{ label: 'lives', value: game.lives },
{ label: 'level', value: game.level },
{ label: 'best', value: highScore }
]}
hint="arrows / ad / hl or mouse to move, space to launch or pause, r to restart"
touchHint="drag to move the paddle, tap to launch"
>
<div class="board-area" bind:clientWidth={areaWidth} bind:clientHeight={areaHeight}>
<div
class="board"
bind:this={boardElement}
tabindex="-1"
role="presentation"
onkeydown={handleKeydown}
onkeyup={handleKeyup}
onpointermove={handlePointerMove}
onpointerdown={handlePointerMove}
onclick={handleBoardClick}
>
<BreakoutCanvas paddleX={game.paddleX} ball={game.ball} bricks={game.bricks} width={canvasWidth} height={canvasHeight} />
{#if statusMessages[game.status]}
<div class="overlay {game.status}">{statusMessages[game.status]}</div>
{/if}
</div>
</div>
</GameShell>
<style>
.board-area {
display: grid;
flex: 1;
min-height: 0;
place-items: center;
overflow: hidden;
}
.board {
position: relative;
padding: 2px;
border: 1px solid var(--border-inactive);
outline: none;
cursor: none;
touch-action: none;
}
.board:focus-visible {
border-color: var(--accent);
}
.overlay {
position: absolute;
inset: 0;
display: grid;
place-items: center;
padding: 1rem;
background: color-mix(in srgb, var(--background) 70%, transparent);
color: var(--foreground);
text-align: center;
pointer-events: none;
}
.overlay.serving {
align-items: end;
padding-bottom: 22%;
background: none;
color: var(--muted);
}
</style>
@@ -0,0 +1,73 @@
<script lang="ts">
import {
ballRadius,
brickHeight,
brickWidth,
fieldWidth,
paddleHeight,
paddleWidth,
paddleY,
type Ball,
type Brick
} from '#lib/games/breakout';
import { themeState } from '#lib/theme/theme.svelte';
let {
paddleX,
ball,
bricks,
width,
height
}: { paddleX: number; ball: Ball; bricks: Brick[]; width: number; height: number } = $props();
const brickRowColors = ['--red', '--orange', '--yellow', '--green', '--cyan', '--blue'];
let canvasElement = $state<HTMLCanvasElement>();
let pixelRatio = $state(1);
const canvasWidth = $derived(Math.round(width * pixelRatio));
const canvasHeight = $derived(Math.round(height * pixelRatio));
function drawField(context: CanvasRenderingContext2D) {
const style = getComputedStyle(canvasElement!);
const themeColor = (name: string) => style.getPropertyValue(name).trim();
const scale = canvasWidth / fieldWidth;
context.setTransform(1, 0, 0, 1, 0, 0);
context.clearRect(0, 0, canvasWidth, canvasHeight);
context.setTransform(scale, 0, 0, scale, 0, 0);
bricks.forEach((brick) => {
if (brick.broken) return;
context.fillStyle = themeColor(brickRowColors[brick.row % brickRowColors.length]);
context.fillRect(brick.x, brick.y, brickWidth, brickHeight);
});
context.fillStyle = themeColor('--accent');
context.fillRect(paddleX, paddleY, paddleWidth, paddleHeight);
context.fillStyle = themeColor('--foreground');
context.beginPath();
context.arc(ball.x, ball.y, ballRadius, 0, Math.PI * 2);
context.fill();
}
$effect(() => {
width;
pixelRatio = window.devicePixelRatio || 1;
});
$effect(() => {
themeState.currentId;
const context = canvasElement?.getContext('2d');
if (context && canvasWidth > 0) drawField(context);
});
</script>
<canvas bind:this={canvasElement} width={canvasWidth} height={canvasHeight} style="width: {width}px; height: {height}px"></canvas>
<style>
canvas {
display: block;
}
</style>
@@ -0,0 +1,65 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { Direction } from '#lib/games/snake';
let { onsteer }: { onsteer: (direction: Direction) => void } = $props();
const buttons: { direction: Direction; icon: string }[] = [
{ direction: 'up', icon: 'mdi:chevron-up' },
{ direction: 'left', icon: 'mdi:chevron-left' },
{ direction: 'down', icon: 'mdi:chevron-down' },
{ direction: 'right', icon: 'mdi:chevron-right' }
];
</script>
<div class="direction-pad">
{#each buttons as button (button.direction)}
<button class={button.direction} aria-label="Move {button.direction}" onpointerdown={() => onsteer(button.direction)}>
<Icon icon={button.icon} width="26" />
</button>
{/each}
</div>
<style>
.direction-pad {
display: grid;
grid-template-columns: repeat(3, 52px);
grid-template-rows: repeat(2, 52px);
grid-template-areas:
'. up .'
'left down right';
gap: 6px;
justify-content: center;
touch-action: manipulation;
}
button {
display: grid;
place-items: center;
padding: 0;
background: var(--background);
border: 2px solid var(--accent);
color: var(--accent);
}
button:active {
background: var(--accent);
color: var(--background);
}
.up {
grid-area: up;
}
.left {
grid-area: left;
}
.down {
grid-area: down;
}
.right {
grid-area: right;
}
</style>
+83
View File
@@ -0,0 +1,83 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface GameStat {
label: string;
value: number;
}
let {
title,
stats,
hint,
touchHint = '',
children
}: { title: string; stats: GameStat[]; hint: string; touchHint?: string; children: Snippet } = $props();
</script>
<div class="game">
<header class="score-line">
<span class="title">{title}</span>
{#each stats as stat (stat.label)}
<span>{stat.label} <span class="value">{stat.value}</span></span>
{/each}
</header>
{@render children()}
<footer class="hint muted">
<span class="keyboard-hint">{hint}</span>
{#if touchHint}
<span class="touch-hint">{touchHint}</span>
{/if}
</footer>
</div>
<style>
.game {
display: flex;
flex-direction: column;
gap: 0.5rem;
height: 100%;
min-height: 0;
padding: 0.5rem 0.75rem 0.75rem;
}
.score-line {
display: flex;
flex-shrink: 0;
justify-content: space-between;
gap: 1rem;
font-size: 0.85rem;
}
.title {
color: var(--accent);
font-weight: 700;
}
.value {
color: var(--yellow);
font-weight: 700;
}
.hint {
flex-shrink: 0;
font-size: 0.75rem;
text-align: center;
}
.touch-hint {
display: none;
}
@media (pointer: coarse) {
.keyboard-hint {
display: none;
}
.touch-hint {
display: inline;
}
}
</style>
+182
View File
@@ -0,0 +1,182 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
advanceSnake,
createSnakeGame,
queueDirection,
tickDelay,
type Direction,
type SnakeStatus
} from '#lib/games/snake';
import { loadHighScore, saveHighScore } from '#lib/games/highScore';
import GameShell from './GameShell.svelte';
import DirectionPad from './DirectionPad.svelte';
import SnakeCanvas from './SnakeCanvas.svelte';
let { focused }: { focused: boolean } = $props();
const gameName = 'snake';
const boardFrameSize = 6;
const keyDirections: Record<string, Direction> = {
ArrowUp: 'up',
ArrowDown: 'down',
ArrowLeft: 'left',
ArrowRight: 'right',
w: 'up',
s: 'down',
a: 'left',
d: 'right',
k: 'up',
j: 'down',
h: 'left',
l: 'right'
};
const statusMessages: Record<SnakeStatus, string> = {
ready: 'press space or an arrow key to start',
running: '',
paused: 'paused, press space to resume',
over: 'game over, press r to restart',
won: 'you win, press r to play again'
};
let game = $state(createSnakeGame());
let highScore = $state(0);
let boardElement = $state<HTMLDivElement>();
let areaWidth = $state(0);
let areaHeight = $state(0);
const canvasSize = $derived(Math.max(0, Math.floor(Math.min(areaWidth, areaHeight)) - boardFrameSize));
const isFinished = $derived(game.status === 'over' || game.status === 'won');
function restart() {
game = createSnakeGame();
game.status = 'running';
}
function steer(direction: Direction) {
if (isFinished) return;
queueDirection(game, direction);
game.status = 'running';
}
function togglePause() {
if (isFinished) restart();
else game.status = game.status === 'running' ? 'paused' : 'running';
}
function handleOverlayClick() {
togglePause();
boardElement?.focus({ preventScroll: true });
}
function tick() {
advanceSnake(game);
if (game.score <= highScore) return;
highScore = game.score;
saveHighScore(gameName, highScore);
}
function handleKeydown(event: KeyboardEvent) {
if (event.altKey || event.ctrlKey || event.metaKey) return;
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
const direction = keyDirections[key];
if (direction) steer(direction);
else if (key === ' ' || key === 'p') togglePause();
else if (key === 'r' || key === 'Enter') restart();
else return;
event.preventDefault();
}
$effect(() => {
if (game.status !== 'running') return;
const timer = setInterval(tick, tickDelay(game.score));
return () => clearInterval(timer);
});
$effect(() => {
if (focused) boardElement?.focus({ preventScroll: true });
else if (game.status === 'running') game.status = 'paused';
});
onMount(() => {
highScore = loadHighScore(gameName);
});
</script>
<GameShell
title="snake"
stats={[
{ label: 'score', value: game.score },
{ label: 'best', value: highScore }
]}
hint="arrows / wasd / hjkl to move, space to pause, r to restart"
>
<div class="board-area" bind:clientWidth={areaWidth} bind:clientHeight={areaHeight}>
<div
class="board"
bind:this={boardElement}
tabindex="-1"
role="presentation"
onkeydown={handleKeydown}
>
<SnakeCanvas snake={game.snake} food={game.food} size={canvasSize} />
{#if statusMessages[game.status]}
<button class="overlay" tabindex="-1" onclick={handleOverlayClick}>{statusMessages[game.status]}</button>
{/if}
</div>
</div>
<div class="touch-controls">
<DirectionPad onsteer={steer} />
</div>
</GameShell>
<style>
.board-area {
display: grid;
flex: 1;
min-height: 0;
place-items: center;
overflow: hidden;
}
.board {
position: relative;
padding: 2px;
border: 1px solid var(--border-inactive);
outline: none;
}
.board:focus-visible {
border-color: var(--accent);
}
.overlay {
position: absolute;
inset: 0;
display: grid;
place-items: center;
padding: 1rem;
background: color-mix(in srgb, var(--background) 70%, transparent);
border: none;
color: var(--foreground);
font: inherit;
text-align: center;
cursor: pointer;
}
.touch-controls {
display: none;
}
@media (pointer: coarse) {
.touch-controls {
display: block;
}
}
</style>
@@ -0,0 +1,69 @@
<script lang="ts">
import { boardSize, type Point } from '#lib/games/snake';
import { themeState } from '#lib/theme/theme.svelte';
let { snake, food, size }: { snake: Point[]; food: Point | null; size: number } = $props();
const emptyCellOpacity = 0.06;
let canvasElement = $state<HTMLCanvasElement>();
let pixelRatio = $state(1);
const canvasSize = $derived(Math.round(size * pixelRatio));
function themeColor(name: string): string {
return getComputedStyle(canvasElement!).getPropertyValue(name).trim();
}
function drawBoard(context: CanvasRenderingContext2D) {
const gap = Math.max(1, Math.round(pixelRatio));
const cellSpan = (canvasSize - gap) / boardSize;
const fillCell = (point: Point) => {
const left = gap + Math.round(point.x * cellSpan);
const top = gap + Math.round(point.y * cellSpan);
const right = Math.round((point.x + 1) * cellSpan);
const bottom = Math.round((point.y + 1) * cellSpan);
context.fillRect(left, top, right - left, bottom - top);
};
context.clearRect(0, 0, canvasSize, canvasSize);
context.globalAlpha = emptyCellOpacity;
context.fillStyle = themeColor('--foreground');
for (let y = 0; y < boardSize; y++) {
for (let x = 0; x < boardSize; x++) fillCell({ x, y });
}
context.globalAlpha = 1;
if (food) {
context.fillStyle = themeColor('--red');
fillCell(food);
}
context.fillStyle = themeColor('--green');
snake.slice(1).forEach(fillCell);
context.fillStyle = themeColor('--accent');
if (snake[0]) fillCell(snake[0]);
}
$effect(() => {
size;
pixelRatio = window.devicePixelRatio || 1;
});
$effect(() => {
themeState.currentId;
const context = canvasElement?.getContext('2d');
if (context && canvasSize > 0) drawBoard(context);
});
</script>
<canvas bind:this={canvasElement} width={canvasSize} height={canvasSize} style="width: {size}px; height: {size}px"></canvas>
<style>
canvas {
display: block;
}
</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 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { focusTrap } from '#lib/ui/focusTrap';
import Icon from '@iconify/svelte';
import { musicPlayer } from '#lib/music/player.svelte';
import MusicNowPlaying from './MusicNowPlaying.svelte';
@@ -19,7 +20,7 @@
<svelte:window onkeydown={handleKeydown} />
<button class="backdrop" aria-label="Close music player" onclick={onclose}></button>
<div class="popover" role="dialog" aria-label="Music player">
<div class="popover" role="dialog" aria-modal="true" aria-label="Music player" tabindex="-1" use:focusTrap>
<div class="popover-surface">
{#if track}
<MusicNowPlaying {track} />
+485
View File
@@ -0,0 +1,485 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
commandPet,
createPet,
dropPet,
holdPet,
hopPet,
petExpression,
placePet,
stepPet,
type Pet,
type PetAction
} from '#lib/pet/behavior';
import { petState } from '#lib/pet/petState.svelte';
import { randomVoiceLine, type VoiceMoment } from '#lib/pet/voiceLines';
import type { Rect } from '#lib/pet/world';
import RobotSprite from './RobotSprite.svelte';
import MoveMarker from './MoveMarker.svelte';
import PixelIcon from './PixelIcon.svelte';
let { areaWidth, areaHeight }: { areaWidth: number; areaHeight: number } = $props();
interface SpeechBubble {
id: number;
text: string;
}
interface Marker {
id: number;
x: number;
y: number;
}
const dragThreshold = 4;
const longestFrameSeconds = 0.05;
const bubbleMilliseconds = 1800;
const markerMilliseconds = 600;
const commandReplyChance = 0.4;
const arriveReplyChance = 0.5;
const activityReplyChance = 0.5;
const chatterChancePerSecond = 0.03;
let pet = $state<Pet | null>(null);
let bubble = $state<SpeechBubble | null>(null);
let markers = $state<Marker[]>([]);
let obstacles = $state<Rect[]>([]);
let layerElement = $state<HTMLDivElement>();
let petElement = $state<HTMLButtonElement>();
let nextId = 0;
let bubbleTimer: ReturnType<typeof setTimeout> | undefined;
let dragStart: { x: number; y: number } | null = null;
let grabOffset = { x: 0, y: 0 };
const area = $derived({ width: areaWidth, height: areaHeight, obstacles });
const expression = $derived(pet ? petExpression(pet) : 'awake');
const noteRows = ['..XX', '..X.', '..X.', 'XXX.', 'XX..'];
const boltRows = ['..XX', '.XX.', 'XXXX', '.XX.', 'XX..'];
function say(moment: VoiceMoment) {
clearTimeout(bubbleTimer);
bubble = { id: nextId++, text: randomVoiceLine(moment) };
bubbleTimer = setTimeout(() => (bubble = null), bubbleMilliseconds);
}
function reactToActionChange(previousAction: PetAction, currentPet: Pet) {
if (previousAction === currentPet.action) return;
if (currentPet.activity && Math.random() < activityReplyChance) say(currentPet.activity);
else if (currentPet.action === 'sleep') say('sleep');
else if (previousAction === 'sleep') say('wake');
else if (previousAction === 'walk' && currentPet.commanded && Math.random() < arriveReplyChance) say('arrive');
}
function maybeChatter(currentPet: Pet, seconds: number) {
if (bubble || currentPet.action !== 'idle') return;
if (Math.random() < chatterChancePerSecond * seconds) say('chatter');
}
function measureObstacles() {
const desktopElement = layerElement?.closest('.desktop');
if (!layerElement || !desktopElement) return;
const layerRect = layerElement.getBoundingClientRect();
obstacles = [...desktopElement.querySelectorAll('.desktop-icon')].map((iconElement) => {
const iconRect = iconElement.getBoundingClientRect();
return {
left: iconRect.left - layerRect.left,
top: iconRect.top - layerRect.top,
right: iconRect.right - layerRect.left,
bottom: iconRect.bottom - layerRect.top
};
});
}
$effect(() => {
areaWidth;
areaHeight;
measureObstacles();
});
function showMarker(x: number, y: number) {
const marker = { id: nextId++, x, y };
markers.push(marker);
setTimeout(() => (markers = markers.filter((item) => item.id !== marker.id)), markerMilliseconds);
}
function isDesktopBackground(target: EventTarget | null): boolean {
if (!(target instanceof Element) || !layerElement) return false;
const desktopElement = layerElement.closest('.desktop');
return !!desktopElement?.contains(target) && !target.closest('.window');
}
function handleContextMenu(event: MouseEvent) {
if (!pet || !petState.enabled || !layerElement || !isDesktopBackground(event.target)) return;
event.preventDefault();
const layerRect = layerElement.getBoundingClientRect();
const pointX = event.clientX - layerRect.left;
const pointY = event.clientY - layerRect.top;
commandPet(pet, pointX, pointY, area);
showMarker(pointX, pointY);
if (Math.random() < commandReplyChance) say('command');
}
function handlePointerDown(event: PointerEvent) {
if (event.button !== 0 || !petElement) return;
event.preventDefault();
petElement.setPointerCapture(event.pointerId);
const petRect = petElement.getBoundingClientRect();
dragStart = { x: event.clientX, y: event.clientY };
grabOffset = { x: event.clientX - petRect.left, y: event.clientY - petRect.top };
}
function handlePointerMove(event: PointerEvent) {
if (!pet || !dragStart || !layerElement) return;
const movedDistance = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
if (pet.action !== 'held' && movedDistance < dragThreshold) return;
if (pet.action !== 'held') {
holdPet(pet);
say('pickup');
}
const layerRect = layerElement.getBoundingClientRect();
placePet(pet, event.clientX - layerRect.left - grabOffset.x, event.clientY - layerRect.top - grabOffset.y, area);
}
function handlePointerUp() {
if (!pet || !dragStart) return;
dragStart = null;
if (pet.action === 'held') {
dropPet(pet);
say('drop');
return;
}
hopPet(pet);
say('click');
}
onMount(() => {
petState.load();
let frameId = 0;
let lastTime = performance.now();
const frame = (time: number) => {
const seconds = Math.min(longestFrameSeconds, (time - lastTime) / 1000);
lastTime = time;
if (!pet && areaWidth > 0) pet = createPet(area);
if (pet && petState.enabled) {
const previousAction = pet.action;
stepPet(pet, seconds, area);
reactToActionChange(previousAction, pet);
maybeChatter(pet, seconds);
}
frameId = requestAnimationFrame(frame);
};
frameId = requestAnimationFrame(frame);
return () => cancelAnimationFrame(frameId);
});
</script>
<svelte:window oncontextmenu={handleContextMenu} />
<div class="pet-layer" bind:this={layerElement}>
{#if pet && petState.enabled}
{#each markers as marker (marker.id)}
<MoveMarker x={marker.x} y={marker.y} />
{/each}
<button
class="pet {pet.action} {pet.activity ?? ''}"
style="left: {pet.x}px; top: {pet.y}px"
bind:this={petElement}
aria-label="Desktop robot, click to say hi, drag to move or right click the desktop to send it somewhere"
onmousedown={(event) => event.preventDefault()}
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
onpointercancel={handlePointerUp}
>
<span class="shadow"></span>
<span class="facing" style="transform: scaleX({pet.facing})">
<span class="motion">
<RobotSprite {expression} activity={pet.activity} />
</span>
</span>
{#if pet.action === 'sleep'}
<span class="snore">zZ</span>
{:else if pet.activity === 'dance'}
<span class="floating-icon note"><PixelIcon rows={noteRows} pixelSize={4} /></span>
{:else if pet.activity === 'charge'}
<span class="floating-icon bolt"><PixelIcon rows={boltRows} pixelSize={4} /></span>
{/if}
{#if bubble}
{#key bubble.id}
<span class="bubble">{bubble.text}</span>
{/key}
{/if}
</button>
{/if}
</div>
<style>
.pet-layer {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
}
.pet {
position: absolute;
padding: 0;
background: none;
border: none;
cursor: grab;
pointer-events: auto;
touch-action: none;
user-select: none;
}
.pet.held {
cursor: grabbing;
}
.shadow {
position: absolute;
bottom: -3px;
left: 50%;
width: 52px;
height: 12px;
background: rgb(0 0 0 / 0.35);
border-radius: 50%;
transform: translateX(-50%);
}
.held .shadow {
bottom: -18px;
width: 36px;
opacity: 0.6;
}
.facing,
.motion {
position: relative;
display: block;
}
.motion {
transform-origin: bottom center;
}
.walk .motion {
animation: robot-step 0.3s steps(2) infinite;
}
.idle .motion {
animation: robot-hum 2.4s ease-in-out infinite;
}
.sleep .motion {
transform: translateY(2px) scaleY(0.94);
}
.hop .motion {
animation: robot-hop 0.4s ease-out;
}
.held .motion {
animation: robot-wiggle 0.3s ease-in-out infinite;
}
.wave .motion,
.scan .motion,
.compute .motion {
animation: robot-hum 2.4s ease-in-out infinite;
}
.dance .motion {
animation: robot-dance 0.5s infinite;
}
.spin .motion {
animation: robot-spin 0.8s infinite;
}
.charge .motion {
animation: robot-buzz 0.15s steps(2) infinite;
}
.floating-icon {
position: absolute;
bottom: calc(100% - 6px);
left: 70%;
pointer-events: none;
animation: icon-float 1.2s ease-out infinite;
}
.note {
color: var(--magenta);
}
.bolt {
color: var(--yellow);
}
@keyframes robot-dance {
0% {
transform: translateY(0) rotate(-8deg);
animation-timing-function: steps(1);
}
50% {
transform: translateY(-4px) rotate(8deg);
animation-timing-function: steps(1);
}
}
@keyframes robot-spin {
0% {
transform: scaleX(1);
animation-timing-function: steps(1);
}
25% {
transform: scaleX(0.3);
animation-timing-function: steps(1);
}
50% {
transform: scaleX(-1);
animation-timing-function: steps(1);
}
75% {
transform: scaleX(-0.3);
animation-timing-function: steps(1);
}
}
@keyframes robot-buzz {
from {
transform: translateX(-1px);
}
to {
transform: translateX(1px);
}
}
@keyframes icon-float {
from {
opacity: 1;
transform: translate(0, 0);
}
to {
opacity: 0;
transform: translate(8px, -18px);
}
}
.snore,
.bubble {
position: absolute;
bottom: calc(100% + 2px);
left: 50%;
white-space: nowrap;
pointer-events: none;
}
.snore {
color: var(--muted);
font-size: 12px;
animation: snore-float 2s ease-out infinite;
}
.bubble {
padding: 1px 6px;
background: var(--background);
border: 1px solid var(--accent);
color: var(--foreground);
font-size: 12px;
transform: translateX(-50%);
animation: bubble-rise 1.8s ease-out forwards;
}
@keyframes robot-step {
from {
transform: translateY(0);
}
to {
transform: translateY(-2px);
}
}
@keyframes robot-hum {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-1px);
}
}
@keyframes robot-hop {
0%,
100% {
transform: translateY(0);
}
40% {
transform: translateY(-14px);
}
}
@keyframes robot-wiggle {
0%,
100% {
transform: rotate(-6deg);
}
50% {
transform: rotate(6deg);
}
}
@keyframes snore-float {
from {
opacity: 1;
transform: translate(0, 0);
}
to {
opacity: 0;
transform: translate(10px, -14px);
}
}
@keyframes bubble-rise {
0% {
opacity: 0;
transform: translate(-50%, 4px);
}
15% {
opacity: 1;
transform: translate(-50%, 0);
}
80% {
opacity: 1;
}
100% {
opacity: 0;
transform: translate(-50%, -10px);
}
}
</style>
+77
View File
@@ -0,0 +1,77 @@
<script lang="ts">
import { pixelRuns } from '#lib/pet/pixelArt';
let { x, y }: { x: number; y: number } = $props();
const pixelSize = 3;
const frameSeconds = 0.2;
const frameRows = [
[
'...XXXXX...',
'.XX.....XX.',
'X.........X',
'X.........X',
'X.........X',
'.XX.....XX.',
'...XXXXX...'
],
['..XXX..', '.X...X.', 'X.....X', '.X...X.', '..XXX..'],
['.X.', 'X.X', '.X.']
];
const frames = frameRows.map((rows, index) => ({
index,
width: rows[0].length,
height: rows.length,
runs: pixelRuns(rows)
}));
</script>
<span class="move-marker" style="left: {x}px; top: {y}px">
{#each frames as frame (frame.index)}
<svg
class="frame"
viewBox="0 0 {frame.width} {frame.height}"
width={frame.width * pixelSize}
height={frame.height * pixelSize}
shape-rendering="crispEdges"
style="animation-delay: {frame.index * frameSeconds}s; animation-duration: {frameSeconds}s"
aria-hidden="true"
>
{#each frame.runs as run (`${run.x}-${run.y}`)}
<rect x={run.x} y={run.y} width={run.width} height="1" />
{/each}
</svg>
{/each}
</span>
<style>
.move-marker {
position: absolute;
width: 0;
height: 0;
pointer-events: none;
}
.frame {
position: absolute;
top: 0;
left: 0;
fill: var(--green);
opacity: 0;
transform: translate(-50%, -50%);
animation-name: show-frame;
animation-timing-function: steps(1, end);
}
@keyframes show-frame {
from {
opacity: 1;
}
to {
opacity: 1;
}
}
</style>
+29
View File
@@ -0,0 +1,29 @@
<script lang="ts">
import { pixelRuns } from '#lib/pet/pixelArt';
let { rows, pixelSize = 2 }: { rows: string[]; pixelSize?: number } = $props();
const width = $derived(rows[0]?.length ?? 0);
const height = $derived(rows.length);
const runs = $derived(pixelRuns(rows));
</script>
<svg
class="pixel-icon"
viewBox="0 0 {width} {height}"
width={width * pixelSize}
height={height * pixelSize}
shape-rendering="crispEdges"
aria-hidden="true"
>
{#each runs as run (`${run.x}-${run.y}`)}
<rect x={run.x} y={run.y} width={run.width} height="1" />
{/each}
</svg>
<style>
.pixel-icon {
display: block;
fill: currentColor;
}
</style>
+266
View File
@@ -0,0 +1,266 @@
<script lang="ts">
import { pixelRuns } from '#lib/pet/pixelArt';
import { petHeight, petWidth, robotGridSize, type Point } from '#lib/pet/world';
import type { PetActivity, PetExpression } from '#lib/pet/behavior';
let { expression, activity }: { expression: PetExpression; activity: PetActivity | null } = $props();
const bodyRows = [
'............',
'.....XX.....',
'.XXXXXXXXXX.',
'.X........X.',
'.X........X.',
'.X........X.',
'.XXXXXXXXXX.',
'...XXXXXX...',
'...XXXXXX...',
'...XXXXXX...',
'...X....X...',
'..XX....XX..'
];
const bodyRuns = pixelRuns(bodyRows);
const leftArmDown: Point[] = [{ x: 0, y: 8 }, { x: 1, y: 8 }];
const rightArmDown: Point[] = [{ x: 10, y: 8 }, { x: 11, y: 8 }];
const leftArmUp: Point[] = [{ x: 1, y: 7 }, { x: 0, y: 6 }];
const rightArmUp: Point[] = [{ x: 10, y: 7 }, { x: 11, y: 6 }, { x: 11, y: 5 }];
const rightArmOut: Point[] = [{ x: 10, y: 7 }, { x: 11, y: 7 }, { x: 11, y: 6 }];
const armPoses: Partial<Record<PetActivity, Point[][]>> = {
wave: [
[...leftArmDown, ...rightArmUp],
[...leftArmDown, ...rightArmOut]
],
dance: [
[...leftArmUp, ...rightArmDown],
[...leftArmDown, ...rightArmUp]
]
};
const happyEyePixels: Point[] = [
{ x: 2, y: 4 },
{ x: 3, y: 3 },
{ x: 4, y: 4 },
{ x: 7, y: 4 },
{ x: 8, y: 3 },
{ x: 9, y: 4 }
];
const codeFrames = [
[
{ x: 3, y: 3, width: 4 },
{ x: 3, y: 4, width: 2 },
{ x: 4, y: 5, width: 4 }
],
[
{ x: 3, y: 3, width: 2 },
{ x: 4, y: 4, width: 4 },
{ x: 3, y: 5, width: 3 }
]
];
const armFrames = $derived((activity && armPoses[activity]) || [[...leftArmDown, ...rightArmDown]]);
</script>
<svg
class="robot activity-{activity ?? 'none'}"
viewBox="0 0 {robotGridSize} {robotGridSize}"
width={petWidth}
height={petHeight}
shape-rendering="crispEdges"
aria-hidden="true"
>
<rect class="antenna-light" class:powered-down={expression === 'sleeping'} x="5" y="0" width="2" height="1" />
{#each bodyRuns as run (`${run.x}-${run.y}`)}
<rect class="frame" x={run.x} y={run.y} width={run.width} height="1" />
{/each}
{#each armFrames as armPixels, frameIndex (frameIndex)}
<g class="frame arm-frame" class:alternating={armFrames.length > 1} class:second-frame={frameIndex === 1}>
{#each armPixels as pixel (`${pixel.x}-${pixel.y}`)}
<rect x={pixel.x} y={pixel.y} width="1" height="1" />
{/each}
</g>
{/each}
<rect class="screen" x="2" y="3" width="8" height="3" />
<rect class="chest-light" x="5" y="8" width="2" height="1" />
{#if activity === 'compute'}
{#each codeFrames as lines, frameIndex (frameIndex)}
<g class="code alternating" class:second-frame={frameIndex === 1}>
{#each lines as codeLine (`${codeLine.x}-${codeLine.y}`)}
<rect x={codeLine.x} y={codeLine.y} width={codeLine.width} height="1" />
{/each}
</g>
{/each}
{:else if activity === 'charge'}
<rect class="battery-track" x="3" y="4" width="6" height="1" />
<rect class="battery-fill" x="3" y="4" width="6" height="1" />
{:else if expression === 'awake'}
<g class="eyes" class:blinking={activity !== 'scan'} class:scanning={activity === 'scan'}>
<rect x="3" y="3" width="2" height="2" />
<rect x="7" y="3" width="2" height="2" />
</g>
{:else if expression === 'sleeping'}
<g class="eyes powered-down">
<rect x="3" y="4" width="2" height="1" />
<rect x="7" y="4" width="2" height="1" />
</g>
{:else}
<g class="eyes">
{#each happyEyePixels as pixel (`${pixel.x}-${pixel.y}`)}
<rect x={pixel.x} y={pixel.y} width="1" height="1" />
{/each}
</g>
{/if}
</svg>
<style>
.robot {
display: block;
}
.frame {
fill: var(--accent);
}
.screen {
fill: var(--dark-background, var(--background));
}
.eyes {
fill: var(--cyan);
}
.code {
fill: var(--green);
}
.battery-track {
fill: var(--muted);
opacity: 0.4;
}
.battery-fill {
fill: var(--green);
transform-box: fill-box;
transform-origin: left center;
animation: battery-charge 1.8s steps(6, jump-start) infinite;
}
.chest-light {
fill: var(--yellow);
}
.activity-charge .chest-light {
fill: var(--green);
animation: light-flash 0.5s steps(1) infinite;
}
.antenna-light {
fill: var(--red);
animation: light-flash 1.6s steps(1) infinite;
}
.activity-scan .antenna-light {
fill: var(--green);
animation-duration: 0.4s;
}
.powered-down {
opacity: 0.35;
animation: none;
}
.blinking {
transform-box: fill-box;
transform-origin: center;
animation: blink 4s infinite;
}
.scanning {
animation: scan-eyes 1.4s steps(1) infinite;
}
.alternating {
animation: show-first-frame 0.5s steps(1) infinite;
}
.alternating.second-frame {
animation-name: show-second-frame;
}
@keyframes show-first-frame {
0% {
opacity: 1;
}
50% {
opacity: 0;
}
}
@keyframes show-second-frame {
0% {
opacity: 0;
}
50% {
opacity: 1;
}
}
@keyframes light-flash {
0% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
@keyframes scan-eyes {
0% {
transform: translateX(0);
}
25% {
transform: translateX(-1px);
}
50% {
transform: translateX(0);
}
75% {
transform: translateX(1px);
}
}
@keyframes battery-charge {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
@keyframes blink {
0%,
94%,
100% {
transform: scaleY(1);
}
97% {
transform: scaleY(0.1);
}
}
</style>
@@ -24,7 +24,8 @@
{ href: project.links.code, label: 'View Code', icon: 'mdi:github' },
{ href: project.links.docs, label: 'Docs', icon: 'mdi:book-open-page-variant-outline' },
{ href: project.links.devpost, label: 'Devpost', icon: 'simple-icons:devpost' },
{ href: project.links.article, label: 'Article', icon: 'mdi:newspaper-variant-outline' }
{ href: project.links.article, label: 'Article', icon: 'mdi:newspaper-variant-outline' },
{ href: project.links.video, label: 'Video', icon: 'mdi:youtube' }
].filter((button) => button.href)
);
</script>
@@ -42,6 +43,9 @@
{#if project.featured}
<span class="badge featured">Featured</span>
{/if}
{#if project.deprecated}
<span class="badge deprecated">Deprecated</span>
{/if}
{#if project.version}
<span class="badge version">v{project.version}</span>
{/if}
@@ -255,6 +259,10 @@
background: var(--orange);
}
.deprecated {
background: var(--red);
}
.version {
background: var(--blue);
}
+2
View File
@@ -4,6 +4,7 @@
import Clock from './Clock.svelte';
import Workspaces from './Workspaces.svelte';
import ThemeMenu from './ThemeMenu.svelte';
import WallpaperMenu from './WallpaperMenu.svelte';
import DiscordStatus from './DiscordStatus.svelte';
import Battery from './Battery.svelte';
import Music from './Music.svelte';
@@ -27,6 +28,7 @@
<DiscordStatus />
<Battery />
<Donate />
<WallpaperMenu />
<ThemeMenu />
</div>
</header>
@@ -1,4 +1,5 @@
<script lang="ts">
import { focusTrap } from '#lib/ui/focusTrap';
import Icon from '@iconify/svelte';
import { slide } from 'svelte/transition';
import {
@@ -41,7 +42,7 @@
<svelte:window onkeydown={handleKeydown} />
<button class="backdrop" aria-label="Close battery details" onclick={onclose}></button>
<div class="popover" role="dialog" aria-label="Battery details">
<div class="popover" role="dialog" aria-modal="true" aria-label="Battery details" tabindex="-1" use:focusTrap>
<div class="popover-surface" class:charging={battery.charging} class:low={isLow}>
<header class="summary">
<span class="battery-icon">
@@ -1,4 +1,5 @@
<script lang="ts">
import { focusTrap } from '#lib/ui/focusTrap';
import Icon from '@iconify/svelte';
import { activityLabels, type DiscordPresence } from '#lib/discord/types';
@@ -12,7 +13,7 @@
<svelte:window onkeydown={handleKeydown} />
<button class="backdrop" aria-label="Close Discord status" onclick={onclose}></button>
<div class="popover" role="dialog" aria-label="Discord status">
<div class="popover" role="dialog" aria-modal="true" aria-label="Discord status" tabindex="-1" use:focusTrap>
<div class="popover-surface">
<header class="profile">
<div class="avatar">
@@ -1,4 +1,5 @@
<script lang="ts">
import { focusTrap } from '#lib/ui/focusTrap';
import Icon from '@iconify/svelte';
let { donateUrl, discordUrl, onclose }: { donateUrl: string; discordUrl: string; onclose: () => void } = $props();
@@ -11,7 +12,7 @@
<svelte:window onkeydown={handleKeydown} />
<button class="backdrop" aria-label="Close donate card" onclick={onclose}></button>
<div class="popover" role="dialog" aria-label="Support me">
<div class="popover" role="dialog" aria-modal="true" aria-label="Support me" tabindex="-1" use:focusTrap>
<div class="popover-surface">
<header class="header">
<Icon icon="simple-icons:kofi" width="22" />
+1
View File
@@ -18,6 +18,7 @@
{#if track}
{#key musicPlayer.streamKey}
<audio
bind:this={musicPlayer.audioElement}
src={musicPlayer.streamUrl}
autoplay
preload="auto"
+14 -3
View File
@@ -1,7 +1,13 @@
<script lang="ts">
import { focusTrap } from '#lib/ui/focusTrap';
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) {
if (event.key === 'Escape') onclose();
@@ -11,7 +17,7 @@
<svelte:window onkeydown={handleKeydown} />
<button class="backdrop" aria-label="Close {title}" onclick={onclose}></button>
<div class="popup" role="dialog" aria-label={title}>
<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-body">
{@render children()}
@@ -41,6 +47,10 @@
animation: window-pop-in 0.15s ease-out;
}
.popup.wide {
width: 560px;
}
.popup-title {
padding: 0.4rem 0.6rem;
background: var(--background);
@@ -57,7 +67,8 @@
}
@media (max-width: 640px) {
.popup {
.popup,
.popup.wide {
position: fixed;
top: calc(var(--bar-height) + 6px);
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,251 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { deckSizeLimit } from '#lib/tcg/cardKind';
import { deckBuilder } from '#lib/tcg/deckBuilder.svelte';
import { copyText, downloadDeckJson, downloadDeckText } from '#lib/tcg/deckExport';
import BtopBox from '../btop/BtopBox.svelte';
const statusMilliseconds = 1600;
let exportStatus = $state('');
let statusTimer: ReturnType<typeof setTimeout> | undefined;
const fillPercent = $derived(Math.min(100, (deckBuilder.total / deckSizeLimit) * 100));
const isEmpty = $derived(deckBuilder.total === 0);
function showStatus(message: string) {
clearTimeout(statusTimer);
exportStatus = message;
statusTimer = setTimeout(() => (exportStatus = ''), statusMilliseconds);
}
async function copyDeck() {
const copied = await copyText(deckBuilder.exportText());
showStatus(copied ? 'copied for PTCG Live' : 'copy failed');
}
function clearDeck() {
if (window.confirm(`Remove all ${deckBuilder.total} cards from ${deckBuilder.name}?`)) deckBuilder.clear();
}
</script>
<aside class="deck-panel">
<BtopBox title="deck" number={3} color="green">
<input
class="deck-name"
value={deckBuilder.name}
oninput={(event) => deckBuilder.rename(event.currentTarget.value)}
aria-label="Deck name"
spellcheck="false"
/>
<div class="progress" class:full={deckBuilder.total === deckSizeLimit}>
<span class="progress-label">{deckBuilder.total}/{deckSizeLimit} cards</span>
<span class="progress-track"><span class="progress-fill" style="width: {fillPercent}%"></span></span>
</div>
{#if isEmpty}
<p class="empty muted">Hover a card and press + to add it. Up to 4 copies of a card, any number of basic energy.</p>
{:else}
<div class="sections">
{#each deckBuilder.sections as section (section.category)}
<section>
<h4>{section.title} <span class="muted">{section.total}</span></h4>
<ul>
{#each section.entries as entry (entry.card.id)}
<li class="entry">
<img src={entry.card.image} alt="" data-no-viewer />
<span class="entry-count">{entry.count}</span>
<span class="entry-name" title={entry.card.name}>{entry.card.name}</span>
<span class="entry-buttons">
<button aria-label="Remove one {entry.card.name}" onclick={() => deckBuilder.remove(entry.card.id)}>
<Icon icon="mdi:minus" width="14" />
</button>
<button
aria-label="Add one {entry.card.name}"
disabled={!deckBuilder.canAdd(entry.card)}
onclick={() => deckBuilder.increase(entry.card.id)}
>
<Icon icon="mdi:plus" width="14" />
</button>
</span>
</li>
{/each}
</ul>
</section>
{/each}
</div>
{/if}
</BtopBox>
<BtopBox title="export" number={4} color="yellow">
<div class="actions">
<button disabled={isEmpty} onclick={copyDeck}><Icon icon="mdi:content-copy" width="15" /> copy list</button>
<button disabled={isEmpty} onclick={() => downloadDeckText(deckBuilder.name, deckBuilder.exportText())}>
<Icon icon="mdi:file-document-outline" width="15" /> .txt
</button>
<button disabled={isEmpty} onclick={() => downloadDeckJson(deckBuilder.name, deckBuilder.entries)}>
<Icon icon="mdi:code-json" width="15" /> .json
</button>
<button class="danger" disabled={isEmpty} onclick={clearDeck}><Icon icon="mdi:delete-outline" width="15" /> clear</button>
</div>
{#if exportStatus}
<p class="status">{exportStatus}</p>
{/if}
</BtopBox>
</aside>
<style>
.deck-panel {
display: flex;
flex-direction: column;
gap: 1.1rem;
padding-top: 0.5rem;
}
.deck-name {
width: 100%;
margin-bottom: 0.5rem;
padding: 0.2rem 0;
background: none;
border: none;
border-bottom: 1px dashed var(--border-inactive);
outline: none;
color: var(--accent);
font: inherit;
font-weight: 700;
}
.deck-name:focus {
border-bottom-color: var(--accent);
}
.progress {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 0.6rem;
font-size: 0.78rem;
}
.progress-track {
height: 6px;
background: color-mix(in srgb, var(--foreground) 10%, transparent);
}
.progress-fill {
display: block;
height: 100%;
background: var(--yellow);
transition: width 0.15s;
}
.progress.full .progress-fill {
background: var(--green);
}
.empty {
margin: 0;
font-size: 0.78rem;
}
.sections {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
h4 {
margin: 0 0 0.3rem;
color: var(--orange);
font-size: 0.8rem;
}
ul {
margin: 0;
padding: 0;
list-style: none;
}
.entry {
display: grid;
grid-template-columns: 22px 1.4rem minmax(0, 1fr) auto;
align-items: center;
gap: 0.4rem;
padding: 0.15rem 0;
font-size: 0.78rem;
}
.entry img {
width: 22px;
aspect-ratio: 63 / 88;
object-fit: cover;
}
.entry-count {
color: var(--accent);
font-weight: 700;
text-align: right;
}
.entry-name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.entry-buttons {
display: flex;
gap: 2px;
}
.entry-buttons button,
.actions button {
display: inline-flex;
align-items: center;
justify-content: center;
background: none;
border: 1px solid var(--border-inactive);
color: var(--foreground);
}
.entry-buttons button {
width: 20px;
height: 20px;
padding: 0;
}
.entry-buttons button:hover:not(:disabled),
.actions button:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
button:disabled {
opacity: 0.35;
cursor: default;
}
.actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.35rem;
}
.actions button {
gap: 0.3rem;
padding: 0.25rem 0.4rem;
font-size: 0.78rem;
}
.actions .danger:hover:not(:disabled) {
border-color: var(--red);
color: var(--red);
}
.status {
margin: 0.5rem 0 0;
color: var(--green);
font-size: 0.78rem;
}
</style>
+194
View File
@@ -0,0 +1,194 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { cardApiUrl } from '#lib/tcg/api';
import type { TcgCard } from '#lib/tcg/types';
import BtopBox from '../btop/BtopBox.svelte';
import EnergyTag from './EnergyTag.svelte';
let { card }: { card: TcgCard } = $props();
const cardKind = $derived([card.supertype, ...(card.subtypes ?? [])].filter(Boolean).join(' / '));
const setNumber = $derived(card.set.printedTotal ? `${card.number}/${card.set.printedTotal}` : card.number);
const hasMoves = $derived((card.abilities?.length ?? 0) + (card.attacks?.length ?? 0) > 0);
</script>
<article class="card-detail">
<div class="image-column">
<img class="card-image" src={card.images.large} alt={card.name} />
<p class="muted zoom-hint">click the card to zoom</p>
</div>
<div class="info-column">
<BtopBox title="info" number={1}>
<dl class="facts">
{#if cardKind}<dt>type</dt><dd>{cardKind}</dd>{/if}
{#if card.hp}<dt>hp</dt><dd class="hp">{card.hp}</dd>{/if}
{#if card.types?.length}
<dt>energy</dt>
<dd class="tags">{#each card.types as type (type)}<EnergyTag {type} />{/each}</dd>
{/if}
<dt>set</dt><dd>{card.set.name} <span class="muted">{card.set.series}</span></dd>
{#if setNumber}<dt>number</dt><dd>{setNumber}</dd>{/if}
{#if card.rarity}<dt>rarity</dt><dd>{card.rarity}</dd>{/if}
{#if card.weaknesses?.length}
<dt>weakness</dt>
<dd>{card.weaknesses.map((weakness) => `${weakness.type} ${weakness.value}`).join(', ')}</dd>
{/if}
{#if card.resistances?.length}
<dt>resistance</dt>
<dd>{card.resistances.map((resistance) => `${resistance.type} ${resistance.value}`).join(', ')}</dd>
{/if}
{#if card.artist}<dt>artist</dt><dd>{card.artist}</dd>{/if}
</dl>
</BtopBox>
{#if hasMoves}
<BtopBox title="moves" number={2} color="red">
<div class="moves">
{#each card.abilities ?? [] as ability (ability.name)}
<section class="move">
<h3><span class="ability-label">{ability.type ?? 'Ability'}</span> {ability.name}</h3>
{#if ability.text}<p>{ability.text}</p>{/if}
</section>
{/each}
{#each card.attacks ?? [] as attack (attack.name)}
<section class="move">
<h3>
<span class="tags">{#each attack.cost ?? [] as cost, index (index)}<EnergyTag type={cost} />{/each}</span>
{attack.name}
{#if attack.damage}<span class="damage">{attack.damage}</span>{/if}
</h3>
{#if attack.text}<p>{attack.text}</p>{/if}
</section>
{/each}
</div>
</BtopBox>
{/if}
{#if card.flavorText}
<p class="flavor muted">"{card.flavorText}"</p>
{/if}
<a class="api-link" href={cardApiUrl(card.id)} target="_blank" rel="noopener noreferrer">
<Icon icon="mdi:code-json" width="16" /> {card.id}.json
</a>
</div>
</article>
<style>
.card-detail {
display: grid;
grid-template-columns: minmax(0, 300px) minmax(0, 1fr);
gap: 1.5rem;
align-items: start;
}
.image-column {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
}
.card-image {
width: 100%;
cursor: zoom-in;
}
.zoom-hint {
margin: 0;
font-size: 0.72rem;
}
.info-column {
display: flex;
flex-direction: column;
gap: 1.1rem;
padding-top: 0.6rem;
}
.facts {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.3rem 1rem;
margin: 0;
font-size: 0.82rem;
}
dt {
color: var(--muted);
}
dd {
margin: 0;
}
.hp {
color: var(--red);
font-weight: 700;
}
.tags {
display: inline-flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.moves {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.move + .move {
padding-top: 0.6rem;
border-top: 1px dashed color-mix(in srgb, var(--foreground) 15%, transparent);
}
h3 {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
margin: 0 0 0.25rem;
font-size: 0.88rem;
}
.ability-label {
color: var(--magenta);
}
.damage {
margin-left: auto;
color: var(--yellow);
font-weight: 700;
}
p {
margin: 0;
font-size: 0.8rem;
line-height: 1.5;
}
.flavor {
font-style: italic;
}
.api-link {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.8rem;
}
@container (max-width: 640px) {
.card-detail {
grid-template-columns: minmax(0, 1fr);
}
.card-image {
max-width: 260px;
}
}
</style>
+149
View File
@@ -0,0 +1,149 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { TcgCard } from '#lib/tcg/types';
let {
cards,
countOf,
canAdd,
onopen,
onadd
}: {
cards: TcgCard[];
countOf: (card: TcgCard) => number;
canAdd: (card: TcgCard) => boolean;
onopen: (card: TcgCard) => void;
onadd: (card: TcgCard) => void;
} = $props();
</script>
<ul class="card-grid">
{#each cards as card (card.id)}
{@const count = countOf(card)}
<li class="card" class:in-deck={count > 0}>
<button class="art" title="{card.name} ({card.set.name})" onclick={() => onopen(card)}>
<img src={card.images.small} alt={card.name} loading="lazy" data-no-viewer />
</button>
{#if count > 0}
<span class="count">x{count}</span>
{/if}
<button class="add" disabled={!canAdd(card)} aria-label="Add {card.name} to deck" onclick={() => onadd(card)}>
<Icon icon="mdi:plus" width="18" />
</button>
<span class="card-name">{card.name}</span>
<span class="card-set muted">{card.set.name}</span>
</li>
{/each}
</ul>
<style>
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 1rem 0.75rem;
margin: 0;
padding: 0;
list-style: none;
}
.card {
position: relative;
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
.art {
display: block;
padding: 0;
background: color-mix(in srgb, var(--foreground) 5%, transparent);
border: 2px solid transparent;
transition:
transform 0.12s,
border-color 0.12s;
}
.art:hover,
.art:focus-visible {
border-color: var(--accent);
outline: none;
transform: translateY(-3px);
}
.in-deck .art {
border-color: var(--green);
}
img {
display: block;
width: 100%;
aspect-ratio: 63 / 88;
object-fit: contain;
}
.count {
position: absolute;
top: 6px;
left: 6px;
padding: 0 0.35rem;
background: var(--green);
color: var(--background);
font-size: 0.8rem;
font-weight: 700;
}
.add {
position: absolute;
top: 6px;
right: 6px;
display: grid;
place-items: center;
width: 28px;
height: 28px;
padding: 0;
background: var(--background);
border: 1px solid var(--accent);
color: var(--accent);
opacity: 0;
transition: opacity 0.12s;
}
.card:hover .add,
.add:focus-visible {
opacity: 1;
}
.add:hover:not(:disabled) {
background: var(--accent);
color: var(--background);
}
.add:disabled {
border-color: var(--border-inactive);
color: var(--muted);
cursor: not-allowed;
}
.card-name,
.card-set {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.card-name {
margin-top: 0.3rem;
font-size: 0.8rem;
}
.card-set {
font-size: 0.7rem;
}
@media (pointer: coarse) {
.add {
opacity: 1;
}
}
</style>
+155
View File
@@ -0,0 +1,155 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { deckBuilder } from '#lib/tcg/deckBuilder.svelte';
import type { TcgCard } from '#lib/tcg/types';
import CardDetail from './CardDetail.svelte';
let { card, canBuild, onclose }: { card: TcgCard; canBuild: boolean; onclose: () => void } = $props();
let dialogElement = $state<HTMLDivElement>();
const countInDeck = $derived(deckBuilder.countOf(card.id));
$effect(() => {
dialogElement?.focus({ preventScroll: true });
});
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.stopPropagation();
onclose();
}
}
</script>
<div class="modal-layer">
<button class="backdrop" aria-label="Close card" onclick={onclose}></button>
<div class="dialog" role="dialog" aria-label={card.name} tabindex="-1" bind:this={dialogElement} onkeydown={handleKeydown}>
<header class="dialog-header">
<h2>{card.name}</h2>
<div class="header-actions">
{#if canBuild}
<span class="in-deck muted">{countInDeck} in deck</span>
<button class="deck-button" disabled={countInDeck === 0} onclick={() => deckBuilder.remove(card.id)} aria-label="Remove one from deck">
<Icon icon="mdi:minus" width="16" />
</button>
<button class="deck-button add" disabled={!deckBuilder.canAdd(card)} onclick={() => deckBuilder.add(card)}>
<Icon icon="mdi:plus" width="16" /> add to deck
</button>
{/if}
<button class="close-button" aria-label="Close card" onclick={onclose}>
<Icon icon="mdi:close" width="18" />
</button>
</div>
</header>
<div class="dialog-body">
<CardDetail {card} />
</div>
</div>
</div>
<style>
.modal-layer {
position: absolute;
inset: 0;
z-index: 5;
display: grid;
place-items: center;
padding: 1rem;
}
.backdrop {
position: absolute;
inset: 0;
background: color-mix(in srgb, var(--dark-background, var(--background)) 75%, transparent);
border: none;
cursor: default;
}
.dialog {
position: relative;
display: flex;
flex-direction: column;
width: min(880px, 100%);
max-height: 100%;
background: var(--background);
border: 1px solid var(--accent);
box-shadow: 0 16px 40px rgb(0 0 0 / 0.5);
outline: none;
}
.dialog-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem 1rem;
padding: 0.6rem 0.9rem;
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
}
h2 {
margin: 0;
color: var(--accent);
font-size: 1.05rem;
}
.header-actions {
display: flex;
align-items: center;
gap: 0.4rem;
}
.in-deck {
margin-right: 0.25rem;
font-size: 0.8rem;
}
.deck-button {
display: inline-flex;
align-items: center;
gap: 0.3rem;
height: 28px;
padding: 0 0.5rem;
background: none;
border: 1px solid var(--border-inactive);
font-size: 0.8rem;
}
.deck-button.add {
border-color: var(--accent);
color: var(--accent);
}
.deck-button:hover:not(:disabled) {
background: var(--accent);
border-color: var(--accent);
color: var(--background);
}
.deck-button:disabled {
opacity: 0.35;
cursor: default;
}
.close-button {
display: grid;
place-items: center;
width: 28px;
height: 28px;
padding: 0;
background: none;
border: none;
color: var(--muted);
}
.close-button:hover {
color: var(--red);
}
.dialog-body {
overflow-y: auto;
overscroll-behavior: contain;
padding: 1rem 1.1rem 1.25rem;
}
</style>
@@ -0,0 +1,244 @@
<script lang="ts">
import { untrack } from 'svelte';
import { buildCardQuery, cardsPerPage, searchCards } from '#lib/tcg/api';
import type { TcgBrowserState } from '#lib/tcg/browserState.svelte';
import { deckBuilder } from '#lib/tcg/deckBuilder.svelte';
import type { TcgCard } from '#lib/tcg/types';
import CardGrid from './CardGrid.svelte';
import TcgPager from './TcgPager.svelte';
let { browser }: { browser: TcgBrowserState } = $props();
const searchDelayMilliseconds = 350;
let nameInput = $state(untrack(() => browser.searchedName));
let cards = $state<TcgCard[]>([]);
let totalCount = $state(0);
let isLoading = $state(true);
let errorMessage = $state('');
let resultsElement = $state<HTMLDivElement>();
const search = $derived({
name: browser.searchedName,
supertype: browser.supertype,
energyType: browser.energyType,
setId: browser.setId,
page: browser.page
});
const queryPreview = $derived(buildCardQuery(search) || '*');
const totalPages = $derived(Math.max(1, Math.ceil(totalCount / cardsPerPage)));
function handleSearchKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') browser.setSearchedName(nameInput);
}
$effect(() => {
const name = nameInput;
const timer = setTimeout(() => browser.setSearchedName(name), searchDelayMilliseconds);
return () => clearTimeout(timer);
});
$effect(() => {
const currentSearch = search;
const controller = new AbortController();
isLoading = true;
errorMessage = '';
searchCards(currentSearch, controller.signal)
.then((result) => {
cards = result.data;
totalCount = result.totalCount;
isLoading = false;
resultsElement?.scrollTo({ top: 0 });
})
.catch((error: Error) => {
if (controller.signal.aborted) return;
errorMessage = error.message;
isLoading = false;
});
return () => controller.abort();
});
</script>
<div class="card-search">
<header class="toolbar">
<label class="prompt">
<span class="prompt-symbol">tcg&gt;</span>
<input bind:value={nameInput} onkeydown={handleSearchKeydown} placeholder="search cards by name" spellcheck="false" />
</label>
<div class="status">
<code class="query muted" title="Query sent to the API">q={queryPreview}</code>
<span class="count muted">{isLoading ? 'loading...' : `${totalCount.toLocaleString()} cards`}</span>
<TcgPager bind:page={browser.page} {totalPages} />
</div>
</header>
<div class="results" bind:this={resultsElement}>
{#if browser.selectedSet}
{@const set = browser.selectedSet}
<div class="set-banner">
{#if set.images?.logo}
<img class="set-logo" src={set.images.logo} alt={set.name} data-no-viewer />
{/if}
<div class="set-info">
<span class="set-name">{set.name}</span>
<span class="muted">
{set.series}{set.releaseDate ? `, released ${set.releaseDate}` : ''}{set.printedTotal ? `, ${set.printedTotal} cards` : ''}
</span>
</div>
<button class="clear-set" onclick={() => browser.toggleSet(null)}>clear set</button>
</div>
{/if}
{#if errorMessage}
<p class="message error">error: {errorMessage}</p>
{:else if !isLoading && cards.length === 0}
<p class="message muted">No cards match that search.</p>
{:else}
<div class:faded={isLoading}>
<CardGrid
{cards}
countOf={(card) => deckBuilder.countOf(card.id)}
canAdd={(card) => deckBuilder.canAdd(card)}
onopen={(card) => browser.openCard(card)}
onadd={(card) => deckBuilder.add(card)}
/>
</div>
<div class="bottom-pager">
<TcgPager bind:page={browser.page} {totalPages} />
</div>
{/if}
</div>
</div>
<style>
.card-search {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
min-width: 0;
}
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem 1rem;
padding: 0.6rem 0.75rem;
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
}
.prompt {
display: flex;
flex: 1;
align-items: center;
gap: 0.5rem;
min-width: 200px;
}
.prompt-symbol {
color: var(--green);
font-weight: 700;
}
.prompt input {
flex: 1;
min-width: 0;
padding: 0.2rem 0;
background: none;
border: none;
outline: none;
box-shadow: none;
color: var(--foreground);
font: inherit;
caret-color: var(--accent);
}
.status {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 1rem;
font-size: 0.8rem;
}
.query {
max-width: 260px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.results {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding: 0.9rem 0.75rem;
}
.set-banner {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem 1rem;
margin-bottom: 1rem;
padding: 0.6rem 0.75rem;
border: 1px solid var(--cyan);
}
.set-logo {
max-width: 140px;
max-height: 48px;
object-fit: contain;
}
.set-info {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
font-size: 0.8rem;
}
.set-name {
color: var(--cyan);
font-size: 0.95rem;
font-weight: 700;
}
.clear-set {
padding: 0.2rem 0.6rem;
background: none;
border: 1px solid var(--border-inactive);
font-size: 0.78rem;
}
.clear-set:hover {
border-color: var(--red);
color: var(--red);
}
.bottom-pager {
display: flex;
justify-content: center;
margin-top: 1.25rem;
}
.faded {
opacity: 0.5;
transition: opacity 0.15s;
}
.message {
margin: 1rem 0;
text-align: center;
}
.error {
color: var(--red);
}
</style>
@@ -0,0 +1,88 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { TcgBrowserState } from '#lib/tcg/browserState.svelte';
import { deckBuilder } from '#lib/tcg/deckBuilder.svelte';
import FilterSidebar from './FilterSidebar.svelte';
import CardSearchView from './CardSearchView.svelte';
import BuilderDeckPanel from './BuilderDeckPanel.svelte';
let { browser }: { browser: TcgBrowserState } = $props();
onMount(() => deckBuilder.load());
</script>
<div class="deck-builder">
<div class="side filters-column">
<FilterSidebar {browser} />
</div>
<div class="search-column">
<CardSearchView {browser} />
</div>
<div class="side deck-column">
<BuilderDeckPanel />
</div>
</div>
<style>
.deck-builder {
display: grid;
grid-template-columns: 250px minmax(0, 1fr) 290px;
height: 100%;
min-height: 0;
}
.side {
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding: 0.75rem;
}
.filters-column {
border-right: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
}
.deck-column {
border-left: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
}
.search-column {
min-height: 0;
}
@container (max-width: 980px) {
.deck-builder {
grid-template-columns: minmax(0, 1fr) 270px;
grid-template-rows: auto minmax(0, 1fr);
}
.filters-column {
grid-column: 1 / -1;
max-height: 240px;
border-right: none;
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
}
}
@container (max-width: 640px) {
.deck-builder {
display: flex;
flex-direction: column;
height: auto;
min-height: 100%;
}
.filters-column {
max-height: none;
}
.search-column {
height: 70vh;
}
.deck-column {
border-left: none;
border-top: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
}
}
</style>
@@ -0,0 +1,96 @@
<script lang="ts">
import type { DeckCard } from '#lib/content';
import type { TcgCard } from '#lib/tcg/types';
let {
deckCard,
card,
onopen
}: {
deckCard: DeckCard;
card: TcgCard | null | undefined;
onopen: (card: TcgCard) => void;
} = $props();
</script>
<button class="tile" disabled={!card} title={deckCard.name} onclick={() => card && onopen(card)}>
<span class="art">
{#if card}
<img src={card.images.small} alt={deckCard.name} loading="lazy" data-no-viewer />
{:else}
<span class="placeholder muted">{card === null ? 'not found' : 'loading...'}</span>
{/if}
<span class="count">x{deckCard.count}</span>
</span>
<span class="name">{deckCard.name}</span>
</button>
<style>
.tile {
display: flex;
flex-direction: column;
gap: 0.3rem;
width: 100%;
padding: 0;
background: none;
border: none;
text-align: left;
}
.tile:disabled {
cursor: default;
}
.art {
position: relative;
display: grid;
place-items: center;
width: 100%;
aspect-ratio: 63 / 88;
background: color-mix(in srgb, var(--foreground) 5%, transparent);
border: 2px solid transparent;
transition:
transform 0.12s,
border-color 0.12s;
}
.tile:hover:not(:disabled) .art,
.tile:focus-visible .art {
border-color: var(--accent);
transform: translateY(-3px);
}
.tile:focus-visible {
outline: none;
}
img {
width: 100%;
height: 100%;
object-fit: contain;
}
.placeholder {
padding: 0.5rem;
font-size: 0.7rem;
text-align: center;
}
.count {
position: absolute;
right: 6px;
bottom: 6px;
padding: 0 0.4rem;
background: var(--accent);
color: var(--background);
font-size: 0.85rem;
font-weight: 700;
}
.name {
overflow: hidden;
font-size: 0.78rem;
white-space: nowrap;
text-overflow: ellipsis;
}
</style>
Loaded 100 of 559 files, more files were not shown because too many files have changed in this diff. Show more