Compare commits
33
Commits
d993634fbe
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da02748dca | ||
|
|
0f90babb7a | ||
|
|
ec57bd73b3 | ||
|
|
a16444451a | ||
|
|
6674428346 | ||
|
|
1e5e528c0c | ||
|
|
69abd9a90f | ||
|
|
7e23fdcd8e | ||
|
|
dfe3955604 | ||
|
|
4f78883f0b | ||
|
|
1f7e88f117 | ||
|
|
9ce67f7600 | ||
|
|
d473aebb79 | ||
|
|
cd9a8a186a | ||
|
|
8342304354 | ||
|
|
d81ec1b057 | ||
|
|
5f5d24cb2d | ||
|
|
4a52320d4e | ||
|
|
90705bc3e2 | ||
|
|
7ef3a29ece | ||
|
|
af73bb5b26 | ||
|
|
30656cb580 | ||
|
|
bad7b7d3b3 | ||
|
|
2318cdeaac | ||
|
|
737700a9a8 | ||
|
|
0d58ddd430 | ||
|
|
0cfebc385f | ||
|
|
a2ffd45442 | ||
|
|
f5df946e0b | ||
|
|
90d61c48e2 | ||
|
|
87079dcb88 | ||
|
|
c664f573b8 | ||
|
|
812503251b |
No files matched your search
@@ -19,3 +19,4 @@ __pycache__
|
|||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
data/lastfm
|
||||||
@@ -2,4 +2,10 @@ DISCORD_TOKEN=
|
|||||||
DISCORD_USER_ID=
|
DISCORD_USER_ID=
|
||||||
DISCORD_GUILD_ID=
|
DISCORD_GUILD_ID=
|
||||||
OMDB_API_KEY=
|
OMDB_API_KEY=
|
||||||
|
IGDB_CLIENT_ID=
|
||||||
|
IGDB_CLIENT_SECRET=
|
||||||
LASTFM_API_KEY=
|
LASTFM_API_KEY=
|
||||||
|
LIBRARY_API_KEY=
|
||||||
|
LIBRARY_API_URL=http://localhost:5000
|
||||||
|
LIBRARY_PUSH_URL=https://dev.sirblob.co
|
||||||
|
LIBRARY_PUSH_KEY=
|
||||||
+7
-1
@@ -25,7 +25,13 @@ vite.config.ts.timestamp-*
|
|||||||
bun.lock
|
bun.lock
|
||||||
__pycache__/
|
__pycache__/
|
||||||
.cache/
|
.cache/
|
||||||
data/
|
data/lastfm/
|
||||||
|
data/library/
|
||||||
Update.md
|
Update.md
|
||||||
PROJECT.md
|
PROJECT.md
|
||||||
blogs/
|
blogs/
|
||||||
|
|
||||||
|
# Content tool
|
||||||
|
tools/content/.venv/
|
||||||
|
tools/content/.cache/
|
||||||
|
__pycache__/
|
||||||
+4
-3
@@ -18,11 +18,12 @@ COPY package.json ./
|
|||||||
RUN bun install --production --ignore-scripts
|
RUN bun install --production --ignore-scripts
|
||||||
|
|
||||||
COPY --from=build /app/build ./build
|
COPY --from=build /app/build ./build
|
||||||
RUN mkdir -p /app/.cache/music /app/data/lastfm && chown -R bun:bun /app/.cache /app/data
|
COPY --from=build /app/static ./static
|
||||||
|
RUN mkdir -p /app/.cache/music /app/data/lastfm /app/data/library && chown -R bun:bun /app/.cache /app/data
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=3000
|
ENV PORT=5000
|
||||||
EXPOSE 3000
|
EXPOSE 5000
|
||||||
|
|
||||||
USER bun
|
USER bun
|
||||||
CMD ["bun", "./build/index.js"]
|
CMD ["bun", "./build/index.js"]
|
||||||
@@ -17,16 +17,18 @@ The site recreates my [blomarchy](https://git.sirblob.co/SirBlob/blomarchy) setu
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Tiling windows** - every page opens as a window that tiles like Hyprland's dwindle layout, with gradient borders.
|
- **Tiling windows** - every page opens as a window that tiles like Hyprland's dwindle layout, with gradient borders.
|
||||||
- **Quickshell bar** - app launcher, workspaces, clock, music player, live Discord status, battery details and a theme picker.
|
- **Quickshell bar** - app launcher, workspaces, clock, music player, live Discord status, battery details, a theme picker and a wallpaper picker.
|
||||||
- **Music player** - plays a random song from a YouTube playlist, muted until you unmute it, with seek, skip, repeat, volume and a playlist view.
|
- **Music player** - plays a random song from a YouTube playlist, muted until you unmute it, with seek, skip, repeat, volume and a playlist view.
|
||||||
- **Floating windows** - Alt + V or the title bar button pops a window out of the tiling layout so it can be dragged and resized.
|
- **Floating windows** - Alt + V or the title bar button pops a window out of the tiling layout so it can be dragged and resized.
|
||||||
- **btop** - a system monitor window with my real GitHub commit graph, repos as processes and some very scientific CPU and caffeine readings.
|
- **btop** - a system monitor window with my real GitHub commit graph, repos as processes and some very scientific CPU and caffeine readings.
|
||||||
- **Image viewer** - click any image to open it in an imv window with zoom, pan, rotate, pinch and swipe.
|
- **Image viewer** - click any image to open it in an imv window with zoom, pan, rotate, pinch and swipe.
|
||||||
|
- **File explorer** - a thunar window that browses `static/files` with grid and list views, a gallery of image thumbnails, previews for images, video and audio, and the image viewer for opening pictures.
|
||||||
- **Kitty terminals** - a real command line with history and tab completion. Try `fastfetch`, `whoami` or `cowsay`.
|
- **Kitty terminals** - a real command line with history and tab completion. Try `fastfetch`, `whoami` or `cowsay`.
|
||||||
- **Desktop icons** - grouped into pages, terminal scripts and socials (GitHub, LinkedIn, Devpost, Discord).
|
- **Desktop icons** - grouped into pages, terminal scripts, apps (files, tcg, movies, shows, books, games), games and socials (GitHub, LinkedIn, Devpost, Discord).
|
||||||
- **13 themes** - Cerberus, Flats, Tokyo Night, Night Cat, Catppuccin, Arch Linux, Gruvbox, Nord, Kanagawa, Wintry, Rose, Catppuccin Latte and Rose Pine Dawn, each with its own wallpaper.
|
- **13 themes** - Cerberus, Flats, Tokyo Night, Night Cat, Catppuccin, Arch Linux, Gruvbox, Nord, Kanagawa, Wintry, Rose, Catppuccin Latte and Rose Pine Dawn, each with its own wallpaper.
|
||||||
- **Portfolio** - about, portfolio, projects (open source, packages, hackathons, research, organizations), blog with RSS and links with QR codes.
|
- **Portfolio** - about, portfolio, projects (open source, packages, hackathons, research, organizations), blog with RSS and links with QR codes.
|
||||||
- **Live data** - GitHub stars and forks, npm downloads, AniList anime stats and OMDb posters for shows and movies.
|
- **Library apps** - movies, shows, books and games open as their own windows with status filters, search, sorting, details and a monthly watch and read history. Entries in the same series or collection are grouped together. Games can also be marked as a favorite or liked, with tags and a note, and the about page shows those starred games.
|
||||||
|
- **Live data** - GitHub stars and forks, npm downloads, AniList anime stats and Last.fm listening stats collected into SQLite.
|
||||||
- **Mobile friendly** - on small screens windows go fullscreen with tabs to switch between them.
|
- **Mobile friendly** - on small screens windows go fullscreen with tabs to switch between them.
|
||||||
|
|
||||||
## Keybinds
|
## Keybinds
|
||||||
@@ -69,6 +71,8 @@ Alt stands in for Super, since browsers can't use it.
|
|||||||
| `theme [name]` | list or switch themes |
|
| `theme [name]` | list or switch themes |
|
||||||
| `cowsay <text>` | moo |
|
| `cowsay <text>` | moo |
|
||||||
| `btop` | system monitor with my GitHub stats |
|
| `btop` | system monitor with my GitHub stats |
|
||||||
|
| `thunar` / `files` | browse `static/files` |
|
||||||
|
| `movies` / `shows` / `books` / `games library` | open the library apps |
|
||||||
| `pacman -S <pkg>` | install things (not really) |
|
| `pacman -S <pkg>` | install things (not really) |
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
@@ -87,7 +91,7 @@ The music player needs `ffmpeg` installed on the server.
|
|||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
The image builds the site with Bun and includes `ffmpeg`. It reads `.env` and serves on port 3000; set `HOST_PORT` to publish it on a different port.
|
The image builds the site with Bun and includes `ffmpeg`. It reads `.env` and serves on port 5000; set `HOST_PORT` to publish it on a different port. Shows, movies and books live in the `library-data` volume and Last.fm data in the `lastfm-data` volume.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -96,35 +100,108 @@ Copy `.env.example` to `.env` and fill in what you need. Every value is optional
|
|||||||
| Variable | Used for |
|
| Variable | Used for |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `DISCORD_TOKEN`, `DISCORD_USER_ID`, `DISCORD_GUILD_ID` | Live Discord status in the bar |
|
| `DISCORD_TOKEN`, `DISCORD_USER_ID`, `DISCORD_GUILD_ID` | Live Discord status in the bar |
|
||||||
| `OMDB_API_KEY` | Posters and details for shows and movies |
|
| `OMDB_API_KEY` | Looking up shows and movies in the content editor |
|
||||||
|
| `IGDB_CLIENT_ID`, `IGDB_CLIENT_SECRET` | Looking up games on IGDB in the content editor, from a Confidential app in the [Twitch developer console](https://dev.twitch.tv/console) |
|
||||||
|
| `LASTFM_API_KEY` | Collecting Last.fm scrobbles for the listening stats |
|
||||||
|
| `LIBRARY_API_KEY` | Secret the content editor sends to change shows, movies, books and games. Set the same value on the server and locally |
|
||||||
|
| `LIBRARY_API_URL` | Site the content editor talks to, `http://localhost:5000` by default |
|
||||||
|
| `LIBRARY_PUSH_URL` | Live site the Push to website button copies the library to, for example `https://dev.sirblob.co` |
|
||||||
|
| `LIBRARY_PUSH_KEY` | `LIBRARY_API_KEY` of that live site, when it differs from the local one |
|
||||||
|
| `LIBRARY_DATA_DIR` | Folder for `library.db`, `data/library` by default |
|
||||||
|
| `LASTFM_DATA_DIR` | Folder for `lastfm.db`, `data/lastfm` by default |
|
||||||
|
|
||||||
## Content
|
## Content
|
||||||
|
|
||||||
Site content lives in `content/`.
|
Personal content and config live in `content/`. Shows, movies, books and games are the exception: they live in a SQLite database at `data/library/library.db` (set `LIBRARY_DATA_DIR` to move it) that the server owns, so adding one never needs a commit. Last.fm scrobbles and album covers are collected at runtime into `data/lastfm/lastfm.db` (set `LASTFM_DATA_DIR` to move it). On first start an older `scrobbles.jsonl`, `covers.json`, `track-covers.json` and `images/` in that folder are moved into the database, and those files can be deleted afterwards. Both databases stay out of git.
|
||||||
|
|
||||||
### 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
|
```bash
|
||||||
bun run content music list --titles
|
bun run content:setup
|
||||||
bun run content music add https://www.youtube.com/watch?v=VIDEO_ID
|
bun run content
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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 |
|
| File | Holds |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `profile.json` | Name, bio, socials, skills and photos |
|
| `profile.json` | Name, bio, socials, skills and photos |
|
||||||
| `about.json` | Hobbies, games, shows, movies and the AniList username |
|
| `about.json` | Hobbies, setup and the AniList and Last.fm usernames |
|
||||||
| `experience.json` / `leadership.json` | Work experience and leadership roles |
|
| `experience.json` / `leadership.json` | Work experience and leadership roles |
|
||||||
| `projects/*.json` | Open source, packages, hackathons, research groups and organizations |
|
| `projects/*.json` | Open source, packages, hackathons, research groups and organizations |
|
||||||
| `music.json` | YouTube links for the music player |
|
| `music.json` | YouTube links for the music player |
|
||||||
| `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 |
|
| `blog/*.md` | Blog posts |
|
||||||
|
|
||||||
|
| File in `data/` | Holds |
|
||||||
|
| --- | --- |
|
||||||
|
| `library/library.db` | Shows, movies, books, games, their history dates and saved images |
|
||||||
|
| `lastfm/lastfm.db` | Last.fm scrobbles, album and track cover links and saved cover images |
|
||||||
|
|
||||||
|
### Files
|
||||||
|
|
||||||
|
Files you drop into `static/files` (any folders inside it too) show up in the file explorer and are served at `/files/...`. Hidden files are skipped.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun run files
|
||||||
|
```
|
||||||
|
|
||||||
|
Converts every HEIC or HEIF photo in `static/files` to a JPEG, turned the right way up and without metadata, and removes GPS location data from any other photo in there since the folder is public. Converted originals are moved to `tools/content/.cache/heic-originals`; pass `--delete-originals` to delete them instead, or `--keep-location` to leave location data alone. Run `bun run content:setup` again first to install `pillow-heif`.
|
||||||
|
|
||||||
|
A hobby in `about.json` can have a `folder`, such as `"folder": "robotics"`. Clicking that hobby on the about page opens the file explorer in `static/files/robotics`.
|
||||||
|
|
||||||
|
### Wallpapers
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun run wallpapers
|
||||||
|
```
|
||||||
|
|
||||||
|
Converts every image in `~/dotfiles/wallpapers` into a compressed JPEG in `static/wallpapers/gallery`, with a small thumbnail for the picker, and writes the list to `content/wallpapers.json`. Unchanged wallpapers are skipped, so run it again whenever the dotfiles change. Pass a folder to use a different source, or `--force` to convert everything again.
|
||||||
+2
-43
@@ -4,11 +4,7 @@
|
|||||||
"setup": {
|
"setup": {
|
||||||
"user": "blob",
|
"user": "blob",
|
||||||
"host": "blomarchy",
|
"host": "blomarchy",
|
||||||
"steam": [
|
"steam": [" ( ) (", " ) ( ) )", " ( ) ( ("],
|
||||||
" ( ) (",
|
|
||||||
" ) ( ) )",
|
|
||||||
" ( ) ( ("
|
|
||||||
],
|
|
||||||
"art": [
|
"art": [
|
||||||
" ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄",
|
" ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄",
|
||||||
" ██ ██▄▄▄▄▄",
|
" ██ ██▄▄▄▄▄",
|
||||||
@@ -37,43 +33,6 @@
|
|||||||
"hobbies": [
|
"hobbies": [
|
||||||
{ "name": "Swimming", "icon": "proicons:person-swim", "description": "I enjoy swimming as a way to relax and stay active. It is my favorite sport." },
|
{ "name": "Swimming", "icon": "proicons:person-swim", "description": "I enjoy swimming as a way to relax and stay active. It is my favorite sport." },
|
||||||
{ "name": "Baking", "icon": "proicons:cake", "description": "I love baking pastries and experimenting with new dessert recipes." },
|
{ "name": "Baking", "icon": "proicons:cake", "description": "I love baking pastries and experimenting with new dessert recipes." },
|
||||||
{ "name": "Robotics", "icon": "proicons:robot", "description": "Building and programming robots to solve problems is fun." }
|
{ "name": "Robotics", "icon": "proicons:robot", "description": "Building and programming robots to solve problems is fun.", "folder": "robotics" }
|
||||||
],
|
|
||||||
"games": [
|
|
||||||
{ "name": "Minecraft", "image": "/img/minecraft.png", "url": "https://minecraft.net" },
|
|
||||||
{ "name": "Pokemon TCG Online", "image": "/img/pokemontcg.png", "url": "https://www.pokemon.com/us/pokemon-tcg" },
|
|
||||||
{ "name": "Fire Watch", "image": "/img/firewatch.png", "url": "https://firewatchgame.com" },
|
|
||||||
{ "name": "Expedition 33", "image": "/img/e33.png", "url": "https://expedition33.com" },
|
|
||||||
{ "name": "Mini Motorways", "image": "/img/mini.png", "url": "https://store.steampowered.com/app/1127500/Mini_Motorways/" },
|
|
||||||
{ "name": "Risk", "image": "/img/risk.png", "url": "https://store.steampowered.com/app/1128810/RISK_Global_Domination/" },
|
|
||||||
{ "name": "Slay the Spire", "image": "/img/slay_the_spire.png", "url": "https://store.steampowered.com/app/646570/Slay_the_Spire/" },
|
|
||||||
{ "name": "Slay the Spire 2", "image": "/img/slay_the_spire_2.png", "url": "https://store.steampowered.com/app/2868840/Slay_the_Spire_2/" }
|
|
||||||
],
|
|
||||||
"shows": [
|
|
||||||
{ "name": "Silo", "rating": 10, "url": "https://www.imdb.com/title/tt14688458/" },
|
|
||||||
{ "name": "D.P. (KDrama)", "rating": 10, "url": "https://www.imdb.com/title/tt13024974/" },
|
|
||||||
{ "name": "Andor", "rating": 9, "url": "https://www.imdb.com/title/tt9253284/" },
|
|
||||||
{ "name": "Severance", "rating": 9, "url": "https://www.imdb.com/title/tt11280740/" },
|
|
||||||
{ "name": "The Mandalorian", "rating": 7, "url": "https://www.imdb.com/title/tt8111088/" },
|
|
||||||
{ "name": "BEEF", "rating": 9, "url": "https://www.imdb.com/title/tt14403178/" },
|
|
||||||
{ "name": "Loki", "rating": 10, "url": "https://www.imdb.com/title/tt9140554/" }
|
|
||||||
],
|
|
||||||
"movies": [
|
|
||||||
{ "name": "Interstellar", "rating": 10, "url": "https://www.imdb.com/title/tt0816692/" },
|
|
||||||
{ "name": "How to Train Your Dragon", "rating": 10, "url": "https://www.imdb.com/title/tt0892769/" },
|
|
||||||
{ "name": "12 Angry Men", "rating": 10, "url": "https://www.imdb.com/title/tt0050083/" },
|
|
||||||
{ "name": "The Wild Robot", "rating": 10, "url": "https://www.imdb.com/title/tt29623480/" },
|
|
||||||
{ "name": "Whiplash", "rating": 10, "url": "https://www.imdb.com/title/tt2582802/" },
|
|
||||||
{ "name": "Predestination", "rating": 10, "url": "https://www.imdb.com/title/tt2397535/" },
|
|
||||||
{ "name": "Project Hail Mary", "rating": 10, "url": "https://www.imdb.com/title/tt12042730/" },
|
|
||||||
{ "name": "Gravity", "rating": 8, "url": "https://www.imdb.com/title/tt1454468/" },
|
|
||||||
{ "name": "The Shawshank Redemption", "rating": 11, "url": "https://www.imdb.com/title/tt0111161/" },
|
|
||||||
{ "name": "Oldboy", "rating": 9, "url": "https://www.imdb.com/title/tt0364569/" },
|
|
||||||
{ "name": "Spider-Man: Into the Spider-Verse", "rating": 10, "url": "https://www.imdb.com/title/tt4633694/" },
|
|
||||||
{ "name": "Spider-Man: Across the Spider-Verse", "rating": 9, "url": "https://www.imdb.com/title/tt9362722/" },
|
|
||||||
{ "name": "Avatar", "rating": 10, "url": "https://www.imdb.com/title/tt0499549/" },
|
|
||||||
{ "name": "Avatar: The Way of Water", "rating": 10, "url": "https://www.imdb.com/title/tt1630029/" },
|
|
||||||
{ "name": "Avatar: Fire and Ash", "rating": 8, "url": "https://www.imdb.com/title/tt1757678/" },
|
|
||||||
{ "name": "Blade Runner 2049", "rating": 10, "url": "https://www.imdb.com/title/tt1856101/" }
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -33,6 +33,27 @@
|
|||||||
{ "label": "btop", "icon": "ph:chart-line-duotone", "open": { "app": "btop" } }
|
{ "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",
|
"name": "socials",
|
||||||
"icons": [
|
"icons": [
|
||||||
|
|||||||
+3
-1
@@ -8,6 +8,8 @@
|
|||||||
"https://music.youtube.com/watch?v=KfEEm4Zx-EU&si=V0b6oiP4P-fcE6VI",
|
"https://music.youtube.com/watch?v=KfEEm4Zx-EU&si=V0b6oiP4P-fcE6VI",
|
||||||
"https://music.youtube.com/watch?v=fixh0ixT-9U&si=dWLHv9bylwNAu-DD",
|
"https://music.youtube.com/watch?v=fixh0ixT-9U&si=dWLHv9bylwNAu-DD",
|
||||||
"https://music.youtube.com/watch?v=En_6WWOWh8E&si=KSUbzK7M3NFmbAUO",
|
"https://music.youtube.com/watch?v=En_6WWOWh8E&si=KSUbzK7M3NFmbAUO",
|
||||||
"https://music.youtube.com/watch?v=Kf5pXDhx5Vc&si=yUIlI-2md9uYjGu5"
|
"https://music.youtube.com/watch?v=Kf5pXDhx5Vc&si=yUIlI-2md9uYjGu5",
|
||||||
|
"https://music.youtube.com/watch?v=vH3ScuFjCyU",
|
||||||
|
"https://music.youtube.com/watch?v=NengL7n9tR8"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -10,7 +10,8 @@
|
|||||||
"links": {
|
"links": {
|
||||||
"website": "https://gmu.autonomousrobotics.club/",
|
"website": "https://gmu.autonomousrobotics.club/",
|
||||||
"code": "https://github.com/ARC-GMU",
|
"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"
|
"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": [
|
"socials": [
|
||||||
{ "name": "Instagram", "icon": "mdi:instagram", "url": "https://www.instagram.com/arc.gmu/" },
|
{ "name": "Instagram", "icon": "mdi:instagram", "url": "https://www.instagram.com/arc.gmu/" },
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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" }
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
[
|
||||||
|
{ "id": "1-a-slient-voice", "name": "A Slient Voice", "image": "/wallpapers/gallery/1-a-slient-voice.jpg", "thumb": "/wallpapers/gallery/thumbs/1-a-slient-voice.jpg" },
|
||||||
|
{ "id": "7-firewatch", "name": "Firewatch", "image": "/wallpapers/gallery/7-firewatch.jpg", "thumb": "/wallpapers/gallery/thumbs/7-firewatch.jpg" },
|
||||||
|
{ "id": "planetred", "name": "Planetred", "image": "/wallpapers/gallery/planetred.jpg", "thumb": "/wallpapers/gallery/thumbs/planetred.jpg" },
|
||||||
|
{ "id": "a-cat-in-a-cup", "name": "A Cat In A Cup", "image": "/wallpapers/gallery/a-cat-in-a-cup.jpg", "thumb": "/wallpapers/gallery/thumbs/a-cat-in-a-cup.jpg" },
|
||||||
|
{ "id": "anime-cafe-tokyonight", "name": "Anime Cafe Tokyonight", "image": "/wallpapers/gallery/anime-cafe-tokyonight.jpg", "thumb": "/wallpapers/gallery/thumbs/anime-cafe-tokyonight.jpg" },
|
||||||
|
{ "id": "anime-circuit", "name": "Anime Circuit", "image": "/wallpapers/gallery/anime-circuit.jpg", "thumb": "/wallpapers/gallery/thumbs/anime-circuit.jpg" },
|
||||||
|
{ "id": "anime-skyline", "name": "Anime Skyline", "image": "/wallpapers/gallery/anime-skyline.jpg", "thumb": "/wallpapers/gallery/thumbs/anime-skyline.jpg" },
|
||||||
|
{ "id": "astronaut", "name": "Astronaut", "image": "/wallpapers/gallery/astronaut.jpg", "thumb": "/wallpapers/gallery/thumbs/astronaut.jpg" },
|
||||||
|
{ "id": "astronaut2", "name": "Astronaut2", "image": "/wallpapers/gallery/astronaut2.jpg", "thumb": "/wallpapers/gallery/thumbs/astronaut2.jpg" },
|
||||||
|
{ "id": "autumn-forest", "name": "Autumn Forest", "image": "/wallpapers/gallery/autumn-forest.jpg", "thumb": "/wallpapers/gallery/thumbs/autumn-forest.jpg" },
|
||||||
|
{ "id": "awesome", "name": "Awesome", "image": "/wallpapers/gallery/awesome.jpg", "thumb": "/wallpapers/gallery/thumbs/awesome.jpg" },
|
||||||
|
{ "id": "baby-star", "name": "Baby Star", "image": "/wallpapers/gallery/baby-star.jpg", "thumb": "/wallpapers/gallery/thumbs/baby-star.jpg" },
|
||||||
|
{ "id": "be-productive-4k", "name": "Be Productive 4K", "image": "/wallpapers/gallery/be-productive-4k.jpg", "thumb": "/wallpapers/gallery/thumbs/be-productive-4k.jpg" },
|
||||||
|
{ "id": "black-hole", "name": "Black Hole", "image": "/wallpapers/gallery/black-hole.jpg", "thumb": "/wallpapers/gallery/thumbs/black-hole.jpg" },
|
||||||
|
{ "id": "black-car-girl", "name": "Black Car Girl", "image": "/wallpapers/gallery/black-car-girl.jpg", "thumb": "/wallpapers/gallery/thumbs/black-car-girl.jpg" },
|
||||||
|
{ "id": "camille-unknown", "name": "Camille Unknown", "image": "/wallpapers/gallery/camille-unknown.jpg", "thumb": "/wallpapers/gallery/thumbs/camille-unknown.jpg" },
|
||||||
|
{ "id": "cat", "name": "Cat", "image": "/wallpapers/gallery/cat.jpg", "thumb": "/wallpapers/gallery/thumbs/cat.jpg" },
|
||||||
|
{ "id": "chainsaw-denji-cat", "name": "Chainsaw Denji Cat", "image": "/wallpapers/gallery/chainsaw-denji-cat.jpg", "thumb": "/wallpapers/gallery/thumbs/chainsaw-denji-cat.jpg" },
|
||||||
|
{ "id": "cherry-blossoms", "name": "Cherry Blossoms", "image": "/wallpapers/gallery/cherry-blossoms.jpg", "thumb": "/wallpapers/gallery/thumbs/cherry-blossoms.jpg" },
|
||||||
|
{ "id": "clan-connections", "name": "Clan Connections", "image": "/wallpapers/gallery/clan-connections.jpg", "thumb": "/wallpapers/gallery/thumbs/clan-connections.jpg" },
|
||||||
|
{ "id": "cliff-edge", "name": "Cliff Edge", "image": "/wallpapers/gallery/cliff-edge.jpg", "thumb": "/wallpapers/gallery/thumbs/cliff-edge.jpg" },
|
||||||
|
{ "id": "colorful-planets", "name": "Colorful Planets", "image": "/wallpapers/gallery/colorful-planets.jpg", "thumb": "/wallpapers/gallery/thumbs/colorful-planets.jpg" },
|
||||||
|
{ "id": "deer-forest", "name": "Deer Forest", "image": "/wallpapers/gallery/deer-forest.jpg", "thumb": "/wallpapers/gallery/thumbs/deer-forest.jpg" },
|
||||||
|
{ "id": "delorean", "name": "Delorean", "image": "/wallpapers/gallery/delorean.jpg", "thumb": "/wallpapers/gallery/thumbs/delorean.jpg" },
|
||||||
|
{ "id": "delorean-pastel", "name": "Delorean Pastel", "image": "/wallpapers/gallery/delorean-pastel.jpg", "thumb": "/wallpapers/gallery/thumbs/delorean-pastel.jpg" },
|
||||||
|
{ "id": "doom-slayer", "name": "Doom Slayer", "image": "/wallpapers/gallery/doom-slayer.jpg", "thumb": "/wallpapers/gallery/thumbs/doom-slayer.jpg" },
|
||||||
|
{ "id": "ds-giyu", "name": "Ds Giyu", "image": "/wallpapers/gallery/ds-giyu.jpg", "thumb": "/wallpapers/gallery/thumbs/ds-giyu.jpg" },
|
||||||
|
{ "id": "evangelion", "name": "Evangelion", "image": "/wallpapers/gallery/evangelion.jpg", "thumb": "/wallpapers/gallery/thumbs/evangelion.jpg" },
|
||||||
|
{ "id": "everblush-creep", "name": "Everblush Creep", "image": "/wallpapers/gallery/everblush-creep.jpg", "thumb": "/wallpapers/gallery/thumbs/everblush-creep.jpg" },
|
||||||
|
{ "id": "firewatch-purple", "name": "Firewatch Purple", "image": "/wallpapers/gallery/firewatch-purple.jpg", "thumb": "/wallpapers/gallery/thumbs/firewatch-purple.jpg" },
|
||||||
|
{ "id": "fishschool", "name": "Fishschool", "image": "/wallpapers/gallery/fishschool.jpg", "thumb": "/wallpapers/gallery/thumbs/fishschool.jpg" },
|
||||||
|
{ "id": "geisha", "name": "Geisha", "image": "/wallpapers/gallery/geisha.jpg", "thumb": "/wallpapers/gallery/thumbs/geisha.jpg" },
|
||||||
|
{ "id": "ghost-in-the-shell", "name": "Ghost In The Shell", "image": "/wallpapers/gallery/ghost-in-the-shell.jpg", "thumb": "/wallpapers/gallery/thumbs/ghost-in-the-shell.jpg" },
|
||||||
|
{ "id": "goblin-slayer", "name": "Goblin Slayer", "image": "/wallpapers/gallery/goblin-slayer.jpg", "thumb": "/wallpapers/gallery/thumbs/goblin-slayer.jpg" },
|
||||||
|
{ "id": "godzilla-kong-noodles", "name": "Godzilla Kong Noodles", "image": "/wallpapers/gallery/godzilla-kong-noodles.jpg", "thumb": "/wallpapers/gallery/thumbs/godzilla-kong-noodles.jpg" },
|
||||||
|
{ "id": "goldfish", "name": "Goldfish", "image": "/wallpapers/gallery/goldfish.jpg", "thumb": "/wallpapers/gallery/thumbs/goldfish.jpg" },
|
||||||
|
{ "id": "hello-worlds", "name": "Hello Worlds", "image": "/wallpapers/gallery/hello-worlds.jpg", "thumb": "/wallpapers/gallery/thumbs/hello-worlds.jpg" },
|
||||||
|
{ "id": "hollow-knight-2", "name": "Hollow Knight 2", "image": "/wallpapers/gallery/hollow-knight-2.jpg", "thumb": "/wallpapers/gallery/thumbs/hollow-knight-2.jpg" },
|
||||||
|
{ "id": "hollow-knight-3", "name": "Hollow Knight 3", "image": "/wallpapers/gallery/hollow-knight-3.jpg", "thumb": "/wallpapers/gallery/thumbs/hollow-knight-3.jpg" },
|
||||||
|
{ "id": "hollow-knight", "name": "Hollow Knight", "image": "/wallpapers/gallery/hollow-knight.jpg", "thumb": "/wallpapers/gallery/thumbs/hollow-knight.jpg" },
|
||||||
|
{ "id": "home127-dark", "name": "Home127 Dark", "image": "/wallpapers/gallery/home127-dark.jpg", "thumb": "/wallpapers/gallery/thumbs/home127-dark.jpg" },
|
||||||
|
{ "id": "jellyfish", "name": "Jellyfish", "image": "/wallpapers/gallery/jellyfish.jpg", "thumb": "/wallpapers/gallery/thumbs/jellyfish.jpg" },
|
||||||
|
{ "id": "jupiter", "name": "Jupiter", "image": "/wallpapers/gallery/jupiter.jpg", "thumb": "/wallpapers/gallery/thumbs/jupiter.jpg" },
|
||||||
|
{ "id": "kimi-no-nawa", "name": "Kimi No Nawa", "image": "/wallpapers/gallery/kimi-no-nawa.jpg", "thumb": "/wallpapers/gallery/thumbs/kimi-no-nawa.jpg" },
|
||||||
|
{ "id": "kingler-drawing", "name": "Kingler Drawing", "image": "/wallpapers/gallery/kingler-drawing.jpg", "thumb": "/wallpapers/gallery/thumbs/kingler-drawing.jpg" },
|
||||||
|
{ "id": "krita-digiart", "name": "Krita Digiart", "image": "/wallpapers/gallery/krita-digiart.jpg", "thumb": "/wallpapers/gallery/thumbs/krita-digiart.jpg" },
|
||||||
|
{ "id": "large-asf-goodbg", "name": "Large Asf Goodbg", "image": "/wallpapers/gallery/large-asf-goodbg.jpg", "thumb": "/wallpapers/gallery/thumbs/large-asf-goodbg.jpg" },
|
||||||
|
{ "id": "literal-wallpaper", "name": "Literal Wallpaper", "image": "/wallpapers/gallery/literal-wallpaper.jpg", "thumb": "/wallpapers/gallery/thumbs/literal-wallpaper.jpg" },
|
||||||
|
{ "id": "makima-oxo", "name": "Makima Oxo", "image": "/wallpapers/gallery/makima-oxo.jpg", "thumb": "/wallpapers/gallery/thumbs/makima-oxo.jpg" },
|
||||||
|
{ "id": "material-pastel-1", "name": "Material Pastel 1", "image": "/wallpapers/gallery/material-pastel-1.jpg", "thumb": "/wallpapers/gallery/thumbs/material-pastel-1.jpg" },
|
||||||
|
{ "id": "material-pastel-2", "name": "Material Pastel 2", "image": "/wallpapers/gallery/material-pastel-2.jpg", "thumb": "/wallpapers/gallery/thumbs/material-pastel-2.jpg" },
|
||||||
|
{ "id": "material-pastel-3", "name": "Material Pastel 3", "image": "/wallpapers/gallery/material-pastel-3.jpg", "thumb": "/wallpapers/gallery/thumbs/material-pastel-3.jpg" },
|
||||||
|
{ "id": "material-pastel-4", "name": "Material Pastel 4", "image": "/wallpapers/gallery/material-pastel-4.jpg", "thumb": "/wallpapers/gallery/thumbs/material-pastel-4.jpg" },
|
||||||
|
{ "id": "material-pastel-5", "name": "Material Pastel 5", "image": "/wallpapers/gallery/material-pastel-5.jpg", "thumb": "/wallpapers/gallery/thumbs/material-pastel-5.jpg" },
|
||||||
|
{ "id": "material-pastel-6", "name": "Material Pastel 6", "image": "/wallpapers/gallery/material-pastel-6.jpg", "thumb": "/wallpapers/gallery/thumbs/material-pastel-6.jpg" },
|
||||||
|
{ "id": "material-pastel-7", "name": "Material Pastel 7", "image": "/wallpapers/gallery/material-pastel-7.jpg", "thumb": "/wallpapers/gallery/thumbs/material-pastel-7.jpg" },
|
||||||
|
{ "id": "material-pastel-8", "name": "Material Pastel 8", "image": "/wallpapers/gallery/material-pastel-8.jpg", "thumb": "/wallpapers/gallery/thumbs/material-pastel-8.jpg" },
|
||||||
|
{ "id": "melting-astro", "name": "Melting Astro", "image": "/wallpapers/gallery/melting-astro.jpg", "thumb": "/wallpapers/gallery/thumbs/melting-astro.jpg" },
|
||||||
|
{ "id": "monogatari8", "name": "Monogatari8", "image": "/wallpapers/gallery/monogatari8.jpg", "thumb": "/wallpapers/gallery/thumbs/monogatari8.jpg" },
|
||||||
|
{ "id": "montains", "name": "Montains", "image": "/wallpapers/gallery/montains.jpg", "thumb": "/wallpapers/gallery/thumbs/montains.jpg" },
|
||||||
|
{ "id": "mountain-snow-minima", "name": "Mountain Snow Minima", "image": "/wallpapers/gallery/mountain-snow-minima.jpg", "thumb": "/wallpapers/gallery/thumbs/mountain-snow-minima.jpg" },
|
||||||
|
{ "id": "nasa1", "name": "Nasa1", "image": "/wallpapers/gallery/nasa1.jpg", "thumb": "/wallpapers/gallery/thumbs/nasa1.jpg" },
|
||||||
|
{ "id": "neon-singularity", "name": "Neon Singularity", "image": "/wallpapers/gallery/neon-singularity.jpg", "thumb": "/wallpapers/gallery/thumbs/neon-singularity.jpg" },
|
||||||
|
{ "id": "nighttab-backdrop", "name": "Nighttab Backdrop", "image": "/wallpapers/gallery/nighttab-backdrop.jpg", "thumb": "/wallpapers/gallery/thumbs/nighttab-backdrop.jpg" },
|
||||||
|
{ "id": "nord-chainsaw", "name": "Nord Chainsaw", "image": "/wallpapers/gallery/nord-chainsaw.jpg", "thumb": "/wallpapers/gallery/thumbs/nord-chainsaw.jpg" },
|
||||||
|
{ "id": "nord-minimal-cat", "name": "Nord Minimal Cat", "image": "/wallpapers/gallery/nord-minimal-cat.jpg", "thumb": "/wallpapers/gallery/thumbs/nord-minimal-cat.jpg" },
|
||||||
|
{ "id": "nord-sky", "name": "Nord Sky", "image": "/wallpapers/gallery/nord-sky.jpg", "thumb": "/wallpapers/gallery/thumbs/nord-sky.jpg" },
|
||||||
|
{ "id": "nordic-heroin", "name": "Nordic Heroin", "image": "/wallpapers/gallery/nordic-heroin.jpg", "thumb": "/wallpapers/gallery/thumbs/nordic-heroin.jpg" },
|
||||||
|
{ "id": "p3", "name": "P3", "image": "/wallpapers/gallery/p3.jpg", "thumb": "/wallpapers/gallery/thumbs/p3.jpg" },
|
||||||
|
{ "id": "pacman-black", "name": "Pacman Black", "image": "/wallpapers/gallery/pacman-black.jpg", "thumb": "/wallpapers/gallery/thumbs/pacman-black.jpg" },
|
||||||
|
{ "id": "pastel-car-girl", "name": "Pastel Car Girl", "image": "/wallpapers/gallery/pastel-car-girl.jpg", "thumb": "/wallpapers/gallery/thumbs/pastel-car-girl.jpg" },
|
||||||
|
{ "id": "pastel-car", "name": "Pastel Car", "image": "/wallpapers/gallery/pastel-car.jpg", "thumb": "/wallpapers/gallery/thumbs/pastel-car.jpg" },
|
||||||
|
{ "id": "pastel-city", "name": "Pastel City", "image": "/wallpapers/gallery/pastel-city.jpg", "thumb": "/wallpapers/gallery/thumbs/pastel-city.jpg" },
|
||||||
|
{ "id": "peaks", "name": "Peaks", "image": "/wallpapers/gallery/peaks.jpg", "thumb": "/wallpapers/gallery/thumbs/peaks.jpg" },
|
||||||
|
{ "id": "perfect-blue", "name": "Perfect Blue", "image": "/wallpapers/gallery/perfect-blue.jpg", "thumb": "/wallpapers/gallery/thumbs/perfect-blue.jpg" },
|
||||||
|
{ "id": "pixel-city", "name": "Pixel City", "image": "/wallpapers/gallery/pixel-city.jpg", "thumb": "/wallpapers/gallery/thumbs/pixel-city.jpg" },
|
||||||
|
{ "id": "pixel-earth", "name": "Pixel Earth", "image": "/wallpapers/gallery/pixel-earth.jpg", "thumb": "/wallpapers/gallery/thumbs/pixel-earth.jpg" },
|
||||||
|
{ "id": "planet-minimal", "name": "Planet Minimal", "image": "/wallpapers/gallery/planet-minimal.jpg", "thumb": "/wallpapers/gallery/thumbs/planet-minimal.jpg" },
|
||||||
|
{ "id": "pokemon-legends-arceus-wallpapers", "name": "Pokemon Legends Arceus Wallpapers", "image": "/wallpapers/gallery/pokemon-legends-arceus-wallpapers.jpg", "thumb": "/wallpapers/gallery/thumbs/pokemon-legends-arceus-wallpapers.jpg" },
|
||||||
|
{ "id": "rain-world1", "name": "Rain World1", "image": "/wallpapers/gallery/rain-world1.jpg", "thumb": "/wallpapers/gallery/thumbs/rain-world1.jpg" },
|
||||||
|
{ "id": "rain-world2", "name": "Rain World2", "image": "/wallpapers/gallery/rain-world2.jpg", "thumb": "/wallpapers/gallery/thumbs/rain-world2.jpg" },
|
||||||
|
{ "id": "rm-rf", "name": "Rm Rf", "image": "/wallpapers/gallery/rm-rf.jpg", "thumb": "/wallpapers/gallery/thumbs/rm-rf.jpg" },
|
||||||
|
{ "id": "rocket-launch", "name": "Rocket Launch", "image": "/wallpapers/gallery/rocket-launch.jpg", "thumb": "/wallpapers/gallery/thumbs/rocket-launch.jpg" },
|
||||||
|
{ "id": "sakura", "name": "Sakura", "image": "/wallpapers/gallery/sakura.jpg", "thumb": "/wallpapers/gallery/thumbs/sakura.jpg" },
|
||||||
|
{ "id": "shiny-colors", "name": "Shiny Colors", "image": "/wallpapers/gallery/shiny-colors.jpg", "thumb": "/wallpapers/gallery/thumbs/shiny-colors.jpg" },
|
||||||
|
{ "id": "shooting-stars", "name": "Shooting Stars", "image": "/wallpapers/gallery/shooting-stars.jpg", "thumb": "/wallpapers/gallery/thumbs/shooting-stars.jpg" },
|
||||||
|
{ "id": "siri", "name": "Siri", "image": "/wallpapers/gallery/siri.jpg", "thumb": "/wallpapers/gallery/thumbs/siri.jpg" },
|
||||||
|
{ "id": "skeleton-heart", "name": "Skeleton Heart", "image": "/wallpapers/gallery/skeleton-heart.jpg", "thumb": "/wallpapers/gallery/thumbs/skeleton-heart.jpg" },
|
||||||
|
{ "id": "space-juice", "name": "Space Juice", "image": "/wallpapers/gallery/space-juice.jpg", "thumb": "/wallpapers/gallery/thumbs/space-juice.jpg" },
|
||||||
|
{ "id": "space-man", "name": "Space Man", "image": "/wallpapers/gallery/space-man.jpg", "thumb": "/wallpapers/gallery/thumbs/space-man.jpg" },
|
||||||
|
{ "id": "space-piano", "name": "Space Piano", "image": "/wallpapers/gallery/space-piano.jpg", "thumb": "/wallpapers/gallery/thumbs/space-piano.jpg" },
|
||||||
|
{ "id": "sunset-lake", "name": "Sunset Lake", "image": "/wallpapers/gallery/sunset-lake.jpg", "thumb": "/wallpapers/gallery/thumbs/sunset-lake.jpg" },
|
||||||
|
{ "id": "sushi-dark", "name": "Sushi Dark", "image": "/wallpapers/gallery/sushi-dark.jpg", "thumb": "/wallpapers/gallery/thumbs/sushi-dark.jpg" },
|
||||||
|
{ "id": "swords", "name": "Swords", "image": "/wallpapers/gallery/swords.jpg", "thumb": "/wallpapers/gallery/thumbs/swords.jpg" },
|
||||||
|
{ "id": "tabs", "name": "Tabs", "image": "/wallpapers/gallery/tabs.jpg", "thumb": "/wallpapers/gallery/thumbs/tabs.jpg" },
|
||||||
|
{ "id": "think", "name": "Think", "image": "/wallpapers/gallery/think.jpg", "thumb": "/wallpapers/gallery/thumbs/think.jpg" },
|
||||||
|
{ "id": "totoro", "name": "Totoro", "image": "/wallpapers/gallery/totoro.jpg", "thumb": "/wallpapers/gallery/thumbs/totoro.jpg" },
|
||||||
|
{ "id": "trigonometry", "name": "Trigonometry", "image": "/wallpapers/gallery/trigonometry.jpg", "thumb": "/wallpapers/gallery/thumbs/trigonometry.jpg" },
|
||||||
|
{ "id": "two-astronauts", "name": "Two Astronauts", "image": "/wallpapers/gallery/two-astronauts.jpg", "thumb": "/wallpapers/gallery/thumbs/two-astronauts.jpg" },
|
||||||
|
{ "id": "underwater", "name": "Underwater", "image": "/wallpapers/gallery/underwater.jpg", "thumb": "/wallpapers/gallery/thumbs/underwater.jpg" },
|
||||||
|
{ "id": "united-in-severance", "name": "United In Severance", "image": "/wallpapers/gallery/united-in-severance.jpg", "thumb": "/wallpapers/gallery/thumbs/united-in-severance.jpg" },
|
||||||
|
{ "id": "vintage-ascent", "name": "Vintage Ascent", "image": "/wallpapers/gallery/vintage-ascent.jpg", "thumb": "/wallpapers/gallery/thumbs/vintage-ascent.jpg" },
|
||||||
|
{ "id": "windows-black", "name": "Windows Black", "image": "/wallpapers/gallery/windows-black.jpg", "thumb": "/wallpapers/gallery/thumbs/windows-black.jpg" },
|
||||||
|
{ "id": "windows-error", "name": "Windows Error", "image": "/wallpapers/gallery/windows-error.jpg", "thumb": "/wallpapers/gallery/thumbs/windows-error.jpg" },
|
||||||
|
{ "id": "windows-magenta-blue", "name": "Windows Magenta Blue", "image": "/wallpapers/gallery/windows-magenta-blue.jpg", "thumb": "/wallpapers/gallery/thumbs/windows-magenta-blue.jpg" },
|
||||||
|
{ "id": "windows-magenta-pink", "name": "Windows Magenta Pink", "image": "/wallpapers/gallery/windows-magenta-pink.jpg", "thumb": "/wallpapers/gallery/thumbs/windows-magenta-pink.jpg" },
|
||||||
|
{ "id": "windows-xp", "name": "Windows Xp", "image": "/wallpapers/gallery/windows-xp.jpg", "thumb": "/wallpapers/gallery/thumbs/windows-xp.jpg" },
|
||||||
|
{ "id": "xavier-cuenca-w4-3", "name": "Xavier Cuenca W4 3", "image": "/wallpapers/gallery/xavier-cuenca-w4-3.jpg", "thumb": "/wallpapers/gallery/thumbs/xavier-cuenca-w4-3.jpg" },
|
||||||
|
{ "id": "yosemite-color-block", "name": "Yosemite Color Block", "image": "/wallpapers/gallery/yosemite-color-block.jpg", "thumb": "/wallpapers/gallery/thumbs/yosemite-color-block.jpg" },
|
||||||
|
{ "id": "yosemite-lowpoly", "name": "Yosemite Lowpoly", "image": "/wallpapers/gallery/yosemite-lowpoly.jpg", "thumb": "/wallpapers/gallery/thumbs/yosemite-lowpoly.jpg" }
|
||||||
|
]
|
||||||
+4
-2
@@ -6,11 +6,13 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
PORT: 3000
|
PORT: 5000
|
||||||
ports:
|
ports:
|
||||||
- "${HOST_PORT:-3000}:3000"
|
- "${HOST_PORT:-5000}:5000"
|
||||||
volumes:
|
volumes:
|
||||||
- lastfm-data:/app/data/lastfm
|
- lastfm-data:/app/data/lastfm
|
||||||
|
- library-data:/app/data/library
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
lastfm-data:
|
lastfm-data:
|
||||||
|
library-data:
|
||||||
+6
-1
@@ -8,7 +8,12 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"start": "bun --bun vite build && bun ./build/index.js",
|
"start": "bun --bun vite build && bun ./build/index.js",
|
||||||
"content": "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 ''",
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||||
|
|||||||
+5
-1
@@ -17,10 +17,14 @@ export const variables = defineEnvVars({
|
|||||||
},
|
},
|
||||||
OMDB_API_KEY: {
|
OMDB_API_KEY: {
|
||||||
schema: optional,
|
schema: optional,
|
||||||
description: 'OMDb API key used to load posters and details for shows and movies'
|
description: 'OMDb API key the content tool uses to look up shows and movies'
|
||||||
},
|
},
|
||||||
LASTFM_API_KEY: {
|
LASTFM_API_KEY: {
|
||||||
schema: optional,
|
schema: optional,
|
||||||
description: 'Last.fm API key used to collect scrobbles for the listening stats'
|
description: 'Last.fm API key used to collect scrobbles for the listening stats'
|
||||||
|
},
|
||||||
|
LIBRARY_API_KEY: {
|
||||||
|
schema: optional,
|
||||||
|
description: 'Secret key the content tool sends to add and change shows, movies and books'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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' }))
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -38,3 +38,50 @@ export interface BtopData {
|
|||||||
contributions: ContributionCalendar;
|
contributions: ContributionCalendar;
|
||||||
repos: RepoProcess[];
|
repos: RepoProcess[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CountStat {
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatRow {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookStats {
|
||||||
|
shelves: CountStat[];
|
||||||
|
rows: StatRow[];
|
||||||
|
topGenres: CountStat[];
|
||||||
|
ratingCounts: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScreenStats {
|
||||||
|
rows: StatRow[];
|
||||||
|
ratingCounts: number[];
|
||||||
|
watching: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListeningStats {
|
||||||
|
profileUrl: string;
|
||||||
|
rows: StatRow[];
|
||||||
|
daily: number[];
|
||||||
|
hourly: number[];
|
||||||
|
topArtists: CountStat[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AniListStats {
|
||||||
|
profileUrl: string;
|
||||||
|
rows: StatRow[];
|
||||||
|
statuses: CountStat[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HobbyStats {
|
||||||
|
listening: ListeningStats | null;
|
||||||
|
books: BookStats | null;
|
||||||
|
shows: ScreenStats;
|
||||||
|
movies: ScreenStats;
|
||||||
|
anime: AniListStats | null;
|
||||||
|
manga: AniListStats | null;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
+10
-15
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<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';
|
const hostName = 'anilist';
|
||||||
|
|
||||||
@@ -13,23 +13,18 @@
|
|||||||
DROPPED: 'red'
|
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 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>
|
</script>
|
||||||
|
|
||||||
<div class="anime-fetch">
|
<div class="anilist-fetch">
|
||||||
<a class="heading" href={anime.profileUrl} target="_blank" rel="noopener noreferrer">
|
<a class="heading" href={profile.listUrl} target="_blank" rel="noopener noreferrer">
|
||||||
<span class="user">{anime.name.toLowerCase()}</span><span class="at">@</span><span class="host">{hostName}</span>
|
<span class="user">{profile.name.toLowerCase()}</span><span class="at">@</span><span class="host">{hostName}</span>
|
||||||
</a>
|
</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)}
|
{#each profile.stats as stat (stat.label)}
|
||||||
<div class="row"><span class="label">{label}</span>: {value}</div>
|
<div class="row"><span class="label">{stat.label}</span>: {stat.value}</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#each shownStatuses as item (item.status)}
|
{#each shownStatuses as item (item.status)}
|
||||||
@@ -53,7 +48,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.anime-fetch {
|
.anilist-fetch {
|
||||||
line-height: 1.5;
|
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>
|
|
||||||
@@ -1,28 +1,54 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from '@iconify/svelte';
|
import Icon from '@iconify/svelte';
|
||||||
import type { Game } from '#lib/content';
|
import type { Game } from '#lib/content';
|
||||||
|
import { storeFor } from '#lib/library/stores';
|
||||||
|
|
||||||
let { game }: { game: Game } = $props();
|
let { game }: { game: Game } = $props();
|
||||||
|
|
||||||
const storeNames: Record<string, { label: string; icon: string }> = {
|
const link = $derived(game.url ?? game.igdbUrl ?? '');
|
||||||
'store.steampowered.com': { label: 'Steam', icon: 'mdi:steam' }
|
const store = $derived(storeFor(link));
|
||||||
};
|
const releaseYear = $derived(game.released?.slice(0, 4) ?? null);
|
||||||
|
const metaLine = $derived([releaseYear, game.developers[0]].filter(Boolean).join(' · '));
|
||||||
const store = $derived.by(() => {
|
|
||||||
const host = new URL(game.url).hostname;
|
|
||||||
return storeNames[host] ?? { label: host.replace(/^www\./, ''), icon: 'mdi:web' };
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<a class="game-card" href={game.url} target="_blank" rel="noopener noreferrer">
|
<a class="game-card" href={link || undefined} target="_blank" rel="noopener noreferrer" title={game.summary ?? game.name}>
|
||||||
<div class="cover">
|
<div class="cover">
|
||||||
<img src={game.image} alt="" loading="lazy" />
|
{#if game.image}
|
||||||
<span class="play"><Icon icon="mdi:play" width="16" /> Play</span>
|
<img src={game.image} alt="" loading="lazy" />
|
||||||
|
{:else}
|
||||||
|
<span class="placeholder"><Icon icon="mdi:gamepad-variant-outline" width="36" /></span>
|
||||||
|
{/if}
|
||||||
|
{#if game.favorite}
|
||||||
|
<span class="favorite"><Icon icon="mdi:star" width="13" />favorite</span>
|
||||||
|
{/if}
|
||||||
|
{#if game.rating !== undefined}
|
||||||
|
<span class="score"><Icon icon="mdi:star" width="13" />{game.rating}/10</span>
|
||||||
|
{:else if game.score !== null}
|
||||||
|
<span class="score community" title="IGDB rating"><Icon icon="mdi:star-outline" width="13" />{game.score}</span>
|
||||||
|
{/if}
|
||||||
|
{#if game.status === 'playing'}
|
||||||
|
<span class="status playing"><Icon icon="mdi:play" width="13" />playing</span>
|
||||||
|
{:else if game.status === 'planning'}
|
||||||
|
<span class="status planning"><Icon icon="mdi:bookmark-outline" width="13" />planning</span>
|
||||||
|
{:else if game.status === 'replayable'}
|
||||||
|
<span class="status replayable"><Icon icon="mdi:repeat" width="13" />replayable</span>
|
||||||
|
{/if}
|
||||||
|
{#if link}
|
||||||
|
<span class="play"><Icon icon="mdi:play" width="16" /> Play</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="details">
|
<div class="details">
|
||||||
<span class="name">{game.name}</span>
|
<span class="name">{game.name}</span>
|
||||||
<span class="store"><Icon icon={store.icon} width="14" />{store.label}</span>
|
{#if link}
|
||||||
|
<span class="store"><Icon icon={store.icon} width="14" />{store.label}</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{#if metaLine}
|
||||||
|
<span class="meta">{metaLine}</span>
|
||||||
|
{/if}
|
||||||
|
{#if game.genres.length}
|
||||||
|
<span class="meta">{game.genres.slice(0, 3).join(' · ')}</span>
|
||||||
|
{/if}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -57,6 +83,77 @@
|
|||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.score {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.4rem;
|
||||||
|
right: 0.4rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
padding: 0 0.4rem;
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--yellow);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorite {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.4rem;
|
||||||
|
left: 0.4rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
padding: 0 0.4rem;
|
||||||
|
background: var(--yellow);
|
||||||
|
color: var(--background);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score.community {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0.4rem;
|
||||||
|
left: 0.4rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
padding: 0 0.4rem;
|
||||||
|
color: var(--background);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.playing {
|
||||||
|
background: var(--blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.planning {
|
||||||
|
background: var(--magenta);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.replayable {
|
||||||
|
background: var(--cyan);
|
||||||
|
}
|
||||||
|
|
||||||
.play {
|
.play {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0.5rem;
|
right: 0.5rem;
|
||||||
|
|||||||
@@ -17,10 +17,14 @@
|
|||||||
<span class="placeholder"><Icon icon={placeholderIcon} width="36" /></span>
|
<span class="placeholder"><Icon icon={placeholderIcon} width="36" /></span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<span class="rating"><Icon icon="mdi:star" width="13" />{item.rating}/10</span>
|
{#if item.rating !== undefined}
|
||||||
|
<span class="rating"><Icon icon="mdi:star" width="13" />{item.rating}/10</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if details?.plot}
|
{#if item.status === 'watching'}
|
||||||
<p class="plot">{details.plot}</p>
|
<span class="status watching"><Icon icon="mdi:play" width="13" />watching</span>
|
||||||
|
{:else if item.status === 'planning'}
|
||||||
|
<span class="status planning"><Icon icon="mdi:bookmark-outline" width="13" />planning</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -87,24 +91,25 @@
|
|||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plot {
|
.status {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
bottom: 0.4rem;
|
||||||
bottom: 0;
|
left: 0.4rem;
|
||||||
left: 0;
|
display: inline-flex;
|
||||||
margin: 0;
|
align-items: center;
|
||||||
padding: 0.6rem;
|
gap: 0.2rem;
|
||||||
background: color-mix(in srgb, var(--background) 92%, transparent);
|
padding: 0 0.4rem;
|
||||||
border-top: 1px solid var(--accent);
|
color: var(--background);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
line-height: 1.45;
|
font-weight: 700;
|
||||||
transform: translateY(100%);
|
|
||||||
transition: transform 0.25s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-card:hover .plot,
|
.status.watching {
|
||||||
.media-card:focus-visible .plot {
|
background: var(--blue);
|
||||||
transform: translateY(0);
|
}
|
||||||
|
|
||||||
|
.status.planning {
|
||||||
|
background: var(--magenta);
|
||||||
}
|
}
|
||||||
|
|
||||||
.text {
|
.text {
|
||||||
@@ -132,8 +137,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.poster img,
|
.poster img {
|
||||||
.plot {
|
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -5,7 +5,6 @@
|
|||||||
import Panel from '../../ui/Panel.svelte';
|
import Panel from '../../ui/Panel.svelte';
|
||||||
import ActivityPanel from './ActivityPanel.svelte';
|
import ActivityPanel from './ActivityPanel.svelte';
|
||||||
import ListeningFetch from './ListeningFetch.svelte';
|
import ListeningFetch from './ListeningFetch.svelte';
|
||||||
import RecentTracks from './RecentTracks.svelte';
|
|
||||||
import TopCharts from './TopCharts.svelte';
|
import TopCharts from './TopCharts.svelte';
|
||||||
import TrendingArtists from './TrendingArtists.svelte';
|
import TrendingArtists from './TrendingArtists.svelte';
|
||||||
|
|
||||||
@@ -16,18 +15,13 @@
|
|||||||
<Panel title="last.fm" icon="simple-icons:lastdotfm">
|
<Panel title="last.fm" icon="simple-icons:lastdotfm">
|
||||||
<ListeningFetch {listening} />
|
<ListeningFetch {listening} />
|
||||||
</Panel>
|
</Panel>
|
||||||
<Panel title={listening.nowPlaying ? 'Now playing' : 'Recently played'} icon="mdi:headphones">
|
|
||||||
<RecentTracks nowPlaying={listening.nowPlaying} recentTracks={listening.recentTracks} />
|
|
||||||
</Panel>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="overview">
|
|
||||||
<ActivityPanel {listening} />
|
|
||||||
<Panel title="Trending this week" icon="mdi:trending-up">
|
<Panel title="Trending this week" icon="mdi:trending-up">
|
||||||
<TrendingArtists artists={listening.trending} />
|
<TrendingArtists artists={listening.trending} />
|
||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ActivityPanel {listening} />
|
||||||
|
|
||||||
<h3 class="subheading"><Icon icon="mdi:trophy-outline" width="16" /> Top charts</h3>
|
<h3 class="subheading"><Icon icon="mdi:trophy-outline" width="16" /> Top charts</h3>
|
||||||
<TopCharts top={listening.top} />
|
<TopCharts top={listening.top} />
|
||||||
|
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Icon from '@iconify/svelte';
|
|
||||||
import type { PlayedTrack } from '#lib/lastfm/types';
|
|
||||||
|
|
||||||
let { nowPlaying, recentTracks }: { nowPlaying: PlayedTrack | null; recentTracks: PlayedTrack[] } = $props();
|
|
||||||
|
|
||||||
const shownCount = 5;
|
|
||||||
|
|
||||||
const tracks = $derived(
|
|
||||||
[...(nowPlaying ? [nowPlaying] : []), ...recentTracks].slice(0, shownCount)
|
|
||||||
);
|
|
||||||
|
|
||||||
function timeAgo(playedAt: number | null): string {
|
|
||||||
if (playedAt === null) return 'now';
|
|
||||||
const minutes = Math.floor((Date.now() / 1000 - playedAt) / 60);
|
|
||||||
if (minutes < 60) return `${Math.max(1, minutes)}m ago`;
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
if (hours < 24) return `${hours}h ago`;
|
|
||||||
return `${Math.floor(hours / 24)}d ago`;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if tracks.length}
|
|
||||||
<ul class="tracks">
|
|
||||||
{#each tracks as track, index (`${track.playedAt}-${index}`)}
|
|
||||||
<li>
|
|
||||||
<a class="track" class:playing={track.playedAt === null} href={track.url} target="_blank" rel="noopener noreferrer">
|
|
||||||
{#if track.image}
|
|
||||||
<img src={track.image} alt={track.album} loading="lazy" />
|
|
||||||
{:else}
|
|
||||||
<span class="cover-fallback"><Icon icon="mdi:music-note" width="18" /></span>
|
|
||||||
{/if}
|
|
||||||
<span class="text">
|
|
||||||
<span class="name">{track.track}</span>
|
|
||||||
<span class="muted artist">{track.artist}</span>
|
|
||||||
</span>
|
|
||||||
<span class="when">
|
|
||||||
{#if track.playedAt === null}
|
|
||||||
<span class="equalizer" aria-label="Now playing"><span></span><span></span><span></span></span>
|
|
||||||
{:else}
|
|
||||||
<span class="muted">{timeAgo(track.playedAt)}</span>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{:else}
|
|
||||||
<p class="muted">No scrobbles yet.</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.tracks {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.3rem;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.65rem;
|
|
||||||
padding: 0.3rem;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track:hover {
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.track.playing {
|
|
||||||
border-color: color-mix(in srgb, var(--green) 50%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
img,
|
|
||||||
.cover-fallback {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cover-fallback {
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
background: color-mix(in srgb, var(--foreground) 8%, transparent);
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.text {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
flex-direction: column;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.name,
|
|
||||||
.artist {
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.name {
|
|
||||||
font-size: 0.88rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.artist,
|
|
||||||
.when {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.when {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.equalizer {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 2px;
|
|
||||||
height: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.equalizer span {
|
|
||||||
width: 3px;
|
|
||||||
background: var(--green);
|
|
||||||
animation: equalize 0.9s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.equalizer span:nth-child(2) {
|
|
||||||
animation-delay: 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.equalizer span:nth-child(3) {
|
|
||||||
animation-delay: 0.4s;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes equalize {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
height: 30%;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.equalizer span {
|
|
||||||
animation: none;
|
|
||||||
height: 70%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</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>
|
||||||
@@ -5,11 +5,12 @@
|
|||||||
title,
|
title,
|
||||||
number,
|
number,
|
||||||
color = 'accent',
|
color = 'accent',
|
||||||
|
tall = false,
|
||||||
children
|
children
|
||||||
}: { title: string; number: number; color?: string; children: Snippet } = $props();
|
}: { title: string; number: number; color?: string; tall?: boolean; children: Snippet } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="btop-box" style:--box-color="var(--{color})">
|
<section class="btop-box" class:tall style:--box-color="var(--{color})">
|
||||||
<span class="box-title"><sup>{number}</sup>{title}</span>
|
<span class="box-title"><sup>{number}</sup>{title}</span>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -52,9 +52,10 @@
|
|||||||
|
|
||||||
.graph {
|
.graph {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
height: 70px;
|
min-height: 70px;
|
||||||
margin-top: 0.4rem;
|
margin-top: 0.4rem;
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
|
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import type { HobbyStats } from '#lib/btop/types';
|
||||||
|
import ListeningPanel from './hobbies/ListeningPanel.svelte';
|
||||||
|
import BooksPanel from './hobbies/BooksPanel.svelte';
|
||||||
|
import ScreenPanel from './hobbies/ScreenPanel.svelte';
|
||||||
|
import AniListPanel from './hobbies/AniListPanel.svelte';
|
||||||
|
|
||||||
|
let stats = $state<HobbyStats | null>(null);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
fetch('/api/btop/hobbies')
|
||||||
|
.then((response) => (response.ok ? response.json() : null))
|
||||||
|
.then((result) => (stats = result))
|
||||||
|
.catch(() => (stats = null));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if stats}
|
||||||
|
<div class="hobby-panels">
|
||||||
|
{#if stats.listening}
|
||||||
|
<ListeningPanel stats={stats.listening} number={5} />
|
||||||
|
{/if}
|
||||||
|
{#if stats.books}
|
||||||
|
<BooksPanel stats={stats.books} number={6} />
|
||||||
|
{/if}
|
||||||
|
<ScreenPanel stats={stats.shows} title="tv" number={7} color="yellow" />
|
||||||
|
<ScreenPanel stats={stats.movies} title="movies" number={8} color="green" />
|
||||||
|
{#if stats.anime}
|
||||||
|
<AniListPanel stats={stats.anime} title="anime" number={9} color="magenta" />
|
||||||
|
{/if}
|
||||||
|
{#if stats.manga}
|
||||||
|
<AniListPanel stats={stats.manga} title="manga" number={10} color="blue" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.hobby-panels {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, max(240px, 25% - 0.75rem)), 1fr));
|
||||||
|
grid-auto-flow: dense;
|
||||||
|
gap: 1rem 0.75rem;
|
||||||
|
padding-top: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hobby-panels > :global(.btop-box.tall) {
|
||||||
|
grid-row: span 2;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
import MemoryPanel from './MemoryPanel.svelte';
|
import MemoryPanel from './MemoryPanel.svelte';
|
||||||
import ContributionPanel from './ContributionPanel.svelte';
|
import ContributionPanel from './ContributionPanel.svelte';
|
||||||
import ProcessPanel from './ProcessPanel.svelte';
|
import ProcessPanel from './ProcessPanel.svelte';
|
||||||
|
import HobbyPanels from './HobbyPanels.svelte';
|
||||||
|
|
||||||
let data = $state<BtopData | null>(null);
|
let data = $state<BtopData | null>(null);
|
||||||
let clock = $state(new Date());
|
let clock = $state(new Date());
|
||||||
@@ -35,6 +36,8 @@
|
|||||||
<div class="mem"><MemoryPanel {data} /></div>
|
<div class="mem"><MemoryPanel {data} /></div>
|
||||||
<div class="proc"><ProcessPanel {data} /></div>
|
<div class="proc"><ProcessPanel {data} /></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<HobbyPanels />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -45,6 +48,7 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 0.5rem 0.75rem 0.75rem;
|
padding: 0.5rem 0.75rem 0.75rem;
|
||||||
|
overflow-y: auto;
|
||||||
container-type: inline-size;
|
container-type: inline-size;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,14 +66,28 @@
|
|||||||
|
|
||||||
.panels {
|
.panels {
|
||||||
display: grid;
|
display: grid;
|
||||||
flex: 1;
|
flex: none;
|
||||||
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
|
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
|
||||||
grid-template-rows: auto minmax(0, 1fr);
|
grid-template-rows: auto auto;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
'cpu net'
|
'cpu net'
|
||||||
'proc mem';
|
'proc mem';
|
||||||
gap: 1rem 0.75rem;
|
gap: 1rem 0.75rem;
|
||||||
min-height: 0;
|
}
|
||||||
|
|
||||||
|
.cpu,
|
||||||
|
.net,
|
||||||
|
.mem,
|
||||||
|
.proc {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cpu > :global(.btop-box),
|
||||||
|
.net > :global(.btop-box),
|
||||||
|
.mem > :global(.btop-box),
|
||||||
|
.proc > :global(.btop-box) {
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cpu {
|
.cpu {
|
||||||
@@ -86,12 +104,8 @@
|
|||||||
|
|
||||||
.proc {
|
.proc {
|
||||||
grid-area: proc;
|
grid-area: proc;
|
||||||
display: flex;
|
min-height: 220px;
|
||||||
min-height: 0;
|
contain: size;
|
||||||
}
|
|
||||||
|
|
||||||
.proc > :global(.btop-box) {
|
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@container (max-width: 760px) {
|
@container (max-width: 760px) {
|
||||||
@@ -106,7 +120,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.proc {
|
.proc {
|
||||||
min-height: 220px;
|
min-height: 260px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
values,
|
||||||
|
color,
|
||||||
|
height = 56,
|
||||||
|
labels = [],
|
||||||
|
grow = false
|
||||||
|
}: { values: number[]; color: string; height?: number; labels?: string[]; grow?: boolean } = $props();
|
||||||
|
|
||||||
|
const minimumScale = 0.02;
|
||||||
|
|
||||||
|
const highest = $derived(Math.max(1, ...(values ?? [])));
|
||||||
|
|
||||||
|
function barScale(value: number): number {
|
||||||
|
return Math.max(minimumScale, value / highest);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="bar-graph" class:grow style:min-height="{height}px" style:height={grow ? null : `${height}px`} aria-hidden="true">
|
||||||
|
{#each values ?? [] as value, index (index)}
|
||||||
|
<span
|
||||||
|
class="bar"
|
||||||
|
class:empty={value === 0}
|
||||||
|
style:transform="scaleY({barScale(value)})"
|
||||||
|
style:background="var(--{color})"
|
||||||
|
title={labels[index] ? `${labels[index]}: ${value}` : `${value}`}
|
||||||
|
></span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bar-graph {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 1px;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-graph.grow {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
flex: 1;
|
||||||
|
align-self: stretch;
|
||||||
|
opacity: 0.85;
|
||||||
|
transform-origin: bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar.empty {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { CountStat } from '#lib/btop/types';
|
||||||
|
|
||||||
|
let { items }: { items: CountStat[] } = $props();
|
||||||
|
|
||||||
|
const highest = $derived(Math.max(1, ...items.map((item) => item.count)));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="count-bars">
|
||||||
|
{#each items as item (item.label)}
|
||||||
|
<span class="label" title={item.label}>{item.label}</span>
|
||||||
|
<span class="track"><span style:width="{(item.count / highest) * 100}%" style:background="var(--{item.color})"></span></span>
|
||||||
|
<span class="count" style:color="var(--{item.color})">{item.count}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.count-bars {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 7.5rem) minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track {
|
||||||
|
display: block;
|
||||||
|
height: 6px;
|
||||||
|
background: color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { text, color }: { text: string; color: string } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="section-label" style:color="var(--{color})">{text}</span>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.section-label {
|
||||||
|
display: block;
|
||||||
|
margin: 0.6rem 0 0.3rem;
|
||||||
|
padding-top: 0.45rem;
|
||||||
|
border-top: 1px dashed color-mix(in srgb, var(--foreground) 20%, transparent);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { CountStat } from '#lib/btop/types';
|
||||||
|
|
||||||
|
let { items }: { items: CountStat[] } = $props();
|
||||||
|
|
||||||
|
const shown = $derived(items.filter((item) => item.count > 0));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="stacked">
|
||||||
|
<div class="bar" role="img" aria-label={shown.map((item) => `${item.label}: ${item.count}`).join(', ')}>
|
||||||
|
{#each shown as item (item.label)}
|
||||||
|
<span style:flex-grow={item.count} style:background="var(--{item.color})"></span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="legend">
|
||||||
|
{#each shown as item (item.label)}
|
||||||
|
<span style:color="var(--{item.color})">{item.count} {item.label}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar span {
|
||||||
|
flex-basis: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.2rem 0.75rem;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { StatRow } from '#lib/btop/types';
|
||||||
|
|
||||||
|
let { rows }: { rows: StatRow[] } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="stat-rows">
|
||||||
|
{#each rows as row (row.label)}
|
||||||
|
<div class="row"><span class="muted">{row.label}</span><span>{row.value}</span></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.stat-rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { AniListStats } from '#lib/btop/types';
|
||||||
|
import BtopBox from '../BtopBox.svelte';
|
||||||
|
import CountBars from '../charts/CountBars.svelte';
|
||||||
|
import StatRows from '../charts/StatRows.svelte';
|
||||||
|
import SectionLabel from '../charts/SectionLabel.svelte';
|
||||||
|
|
||||||
|
let { stats, title, number, color }: { stats: AniListStats; title: string; number: number; color: string } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<BtopBox {title} {number} {color}>
|
||||||
|
<StatRows rows={stats.rows} />
|
||||||
|
|
||||||
|
{#if stats.statuses.length}
|
||||||
|
<SectionLabel text="lists" {color} />
|
||||||
|
<CountBars items={stats.statuses} />
|
||||||
|
{/if}
|
||||||
|
</BtopBox>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { BookStats } from '#lib/btop/types';
|
||||||
|
import BtopBox from '../BtopBox.svelte';
|
||||||
|
import BarGraph from '../charts/BarGraph.svelte';
|
||||||
|
import CountBars from '../charts/CountBars.svelte';
|
||||||
|
import StackedBar from '../charts/StackedBar.svelte';
|
||||||
|
import StatRows from '../charts/StatRows.svelte';
|
||||||
|
import SectionLabel from '../charts/SectionLabel.svelte';
|
||||||
|
|
||||||
|
let { stats, number }: { stats: BookStats; number: number } = $props();
|
||||||
|
|
||||||
|
const ratingLabels = Array.from({ length: 11 }, (_, rating) => `${rating}/10`);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<BtopBox title="books" {number} color="cyan" tall>
|
||||||
|
<StackedBar items={stats.shelves} />
|
||||||
|
|
||||||
|
<SectionLabel text="reading" color="cyan" />
|
||||||
|
<StatRows rows={stats.rows} />
|
||||||
|
|
||||||
|
{#if stats.topGenres.length}
|
||||||
|
<SectionLabel text="top genres" color="cyan" />
|
||||||
|
<CountBars items={stats.topGenres} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<SectionLabel text="ratings" color="cyan" />
|
||||||
|
<BarGraph values={stats.ratingCounts} labels={ratingLabels} color="cyan" height={44} grow />
|
||||||
|
<div class="axis muted"><span>0</span><span>5</span><span>10</span></div>
|
||||||
|
</BtopBox>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.axis {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ListeningStats } from '#lib/btop/types';
|
||||||
|
import BtopBox from '../BtopBox.svelte';
|
||||||
|
import BarGraph from '../charts/BarGraph.svelte';
|
||||||
|
import CountBars from '../charts/CountBars.svelte';
|
||||||
|
import StatRows from '../charts/StatRows.svelte';
|
||||||
|
import SectionLabel from '../charts/SectionLabel.svelte';
|
||||||
|
|
||||||
|
let { stats, number }: { stats: ListeningStats; number: number } = $props();
|
||||||
|
|
||||||
|
const hourLabels = Array.from({ length: 24 }, (_, hour) => `${hour % 12 || 12}${hour < 12 ? 'am' : 'pm'}`);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<BtopBox title="music" {number} color="red" tall>
|
||||||
|
<StatRows rows={stats.rows} />
|
||||||
|
|
||||||
|
<SectionLabel text="scrobbles, last {stats.daily.length} days" color="red" />
|
||||||
|
<BarGraph values={stats.daily} color="red" grow />
|
||||||
|
|
||||||
|
<SectionLabel text="by hour of day" color="red" />
|
||||||
|
<BarGraph values={stats.hourly} labels={hourLabels} color="magenta" height={36} />
|
||||||
|
|
||||||
|
{#if stats.topArtists.length}
|
||||||
|
<SectionLabel text="top artists" color="red" />
|
||||||
|
<CountBars items={stats.topArtists} />
|
||||||
|
{/if}
|
||||||
|
</BtopBox>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ScreenStats } from '#lib/btop/types';
|
||||||
|
import BtopBox from '../BtopBox.svelte';
|
||||||
|
import BarGraph from '../charts/BarGraph.svelte';
|
||||||
|
import StatRows from '../charts/StatRows.svelte';
|
||||||
|
import SectionLabel from '../charts/SectionLabel.svelte';
|
||||||
|
|
||||||
|
let { stats, title, number, color }: { stats: ScreenStats; title: string; number: number; color: string } = $props();
|
||||||
|
|
||||||
|
const ratingLabels = Array.from({ length: 11 }, (_, rating) => `${rating}/10`);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<BtopBox {title} {number} {color}>
|
||||||
|
<StatRows rows={stats.rows} />
|
||||||
|
|
||||||
|
<SectionLabel text="ratings" {color} />
|
||||||
|
<BarGraph values={stats.ratingCounts} labels={ratingLabels} {color} height={44} grow />
|
||||||
|
<div class="axis muted"><span>0</span><span>5</span><span>10</span></div>
|
||||||
|
|
||||||
|
{#if stats.watching.length}
|
||||||
|
<SectionLabel text="watching now" {color} />
|
||||||
|
<ul class="watching">
|
||||||
|
{#each stats.watching as name (name)}
|
||||||
|
<li><span style:color="var(--blue)">></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>
|
||||||
@@ -8,6 +8,13 @@
|
|||||||
import { imageFileName } from '#lib/desktop/imageViewer';
|
import { imageFileName } from '#lib/desktop/imageViewer';
|
||||||
import { defaultFloatingRect, floatingStyle, moveRect, resizeRect } from '#lib/desktop/floating';
|
import { defaultFloatingRect, floatingStyle, moveRect, resizeRect } from '#lib/desktop/floating';
|
||||||
import SystemMonitor from '../btop/SystemMonitor.svelte';
|
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 DesktopIcons from './DesktopIcons.svelte';
|
||||||
import Window from './Window.svelte';
|
import Window from './Window.svelte';
|
||||||
|
|
||||||
@@ -98,6 +105,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tiling-area" bind:clientWidth={areaWidth} bind:clientHeight={areaHeight}>
|
<div class="tiling-area" bind:clientWidth={areaWidth} bind:clientHeight={areaHeight}>
|
||||||
|
{#if !isNarrow}
|
||||||
|
<DesktopPet {areaWidth} {areaHeight} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if pageOpen}
|
{#if pageOpen}
|
||||||
<Window title="kitty" path={pageTitle} icon={pageIcon} scrollKey={pageKey} {...windowProps(pageWindowId)} onclose={closePage}>
|
<Window title="kitty" path={pageTitle} icon={pageIcon} scrollKey={pageKey} {...windowProps(pageWindowId)} onclose={closePage}>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
@@ -141,6 +152,59 @@
|
|||||||
<SystemMonitor />
|
<SystemMonitor />
|
||||||
</Window>
|
</Window>
|
||||||
{/each}
|
{/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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,43 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from '@iconify/svelte';
|
import Icon from '@iconify/svelte';
|
||||||
import { desktop } from '#lib/content';
|
import { desktop, type DesktopSide } from '#lib/content';
|
||||||
import { runDesktopAction } from '#lib/desktop/actions';
|
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>
|
</script>
|
||||||
|
|
||||||
<nav class="desktop-icons" aria-label="Desktop">
|
<div class="desktop-icon-sides">
|
||||||
{#each icons as item (item.label)}
|
{#each iconSides as iconSide (iconSide.side)}
|
||||||
<button class="desktop-icon" title={item.label} onclick={() => runDesktopAction(item.open)}>
|
{#if iconSide.icons.length}
|
||||||
<span class="icon-box">
|
<nav class="desktop-icons {iconSide.side}" aria-label={iconSide.label}>
|
||||||
<Icon icon={item.icon} width="36" />
|
{#each iconSide.icons as item (item.label)}
|
||||||
</span>
|
<button class="desktop-icon" title={item.label} onclick={() => runDesktopAction(item.open)}>
|
||||||
<span class="icon-label">{item.label}</span>
|
<span class="icon-box">
|
||||||
</button>
|
<Icon icon={item.icon} width="36" />
|
||||||
|
</span>
|
||||||
|
<span class="icon-label">{item.label}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.desktop-icon-sides {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 4px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.desktop-icons {
|
.desktop-icons {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-auto-flow: column;
|
grid-auto-flow: column;
|
||||||
@@ -29,6 +49,10 @@
|
|||||||
align-content: start;
|
align-content: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.desktop-icons.right {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
.desktop-icon {
|
.desktop-icon {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -38,6 +62,7 @@
|
|||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
|
direction: ltr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.desktop-icon:focus-visible {
|
.desktop-icon:focus-visible {
|
||||||
@@ -86,6 +111,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
|
.desktop-icon-sides {
|
||||||
|
flex-direction: column;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.desktop-icons {
|
.desktop-icons {
|
||||||
grid-auto-flow: row;
|
grid-auto-flow: row;
|
||||||
grid-template-columns: repeat(auto-fill, 104px);
|
grid-template-columns: repeat(auto-fill, 104px);
|
||||||
@@ -94,5 +124,9 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.desktop-icons.right {
|
||||||
|
direction: ltr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
onfocusin={onfocus}
|
onfocusin={onfocus}
|
||||||
>
|
>
|
||||||
<div class="window-surface">
|
<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="title">
|
||||||
<span class="app-name"><Icon {icon} width="15" />{title}</span>
|
<span class="app-name"><Icon {icon} width="15" />{title}</span>
|
||||||
{#if path}
|
{#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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from '@iconify/svelte';
|
||||||
|
import type { ExplorerState } from '#lib/files/explorer.svelte';
|
||||||
|
|
||||||
|
let { explorer }: { explorer: ExplorerState } = $props();
|
||||||
|
|
||||||
|
const navButtons = $derived([
|
||||||
|
{ label: 'Back', icon: 'mdi:arrow-left', enabled: explorer.canGoBack, run: () => explorer.back() },
|
||||||
|
{ label: 'Forward', icon: 'mdi:arrow-right', enabled: explorer.canGoForward, run: () => explorer.forward() },
|
||||||
|
{ label: 'Up', icon: 'mdi:arrow-up', enabled: explorer.canGoUp, run: () => explorer.up() },
|
||||||
|
{ label: 'Reload', icon: 'mdi:refresh', enabled: true, run: () => explorer.reload() }
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="nav">
|
||||||
|
{#each navButtons as button (button.label)}
|
||||||
|
<button title={button.label} aria-label={button.label} disabled={!button.enabled} onclick={button.run}>
|
||||||
|
<Icon icon={button.icon} width="16" />
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="crumbs" aria-label="Folder path">
|
||||||
|
<button onclick={() => explorer.navigate('')}><Icon icon="mdi:folder-home-outline" width="15" />files</button>
|
||||||
|
{#each explorer.crumbs as crumb (crumb.path)}
|
||||||
|
<span class="separator">/</span>
|
||||||
|
<button onclick={() => explorer.navigate(crumb.path)}>{crumb.name}</button>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<label class="search">
|
||||||
|
<Icon icon="mdi:magnify" width="15" />
|
||||||
|
<input bind:value={explorer.query} placeholder="Filter" aria-label="Filter files" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="views" role="group" aria-label="View">
|
||||||
|
<button class:active={explorer.view === 'grid'} title="Grid" aria-label="Grid view" onclick={() => (explorer.view = 'grid')}>
|
||||||
|
<Icon icon="mdi:view-grid-outline" width="16" />
|
||||||
|
</button>
|
||||||
|
<button class:active={explorer.view === 'list'} title="List" aria-label="List view" onclick={() => (explorer.view = 'list')}>
|
||||||
|
<Icon icon="mdi:view-list-outline" width="16" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav,
|
||||||
|
.views {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled),
|
||||||
|
button.active {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crumbs {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crumbs button {
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search:focus-within {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search input {
|
||||||
|
width: 8rem;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
box-shadow: none;
|
||||||
|
color: var(--foreground);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container (max-width: 560px) {
|
||||||
|
.search {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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.code, label: 'View Code', icon: 'mdi:github' },
|
||||||
{ href: project.links.docs, label: 'Docs', icon: 'mdi:book-open-page-variant-outline' },
|
{ 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.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)
|
].filter((button) => button.href)
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import Clock from './Clock.svelte';
|
import Clock from './Clock.svelte';
|
||||||
import Workspaces from './Workspaces.svelte';
|
import Workspaces from './Workspaces.svelte';
|
||||||
import ThemeMenu from './ThemeMenu.svelte';
|
import ThemeMenu from './ThemeMenu.svelte';
|
||||||
|
import WallpaperMenu from './WallpaperMenu.svelte';
|
||||||
import DiscordStatus from './DiscordStatus.svelte';
|
import DiscordStatus from './DiscordStatus.svelte';
|
||||||
import Battery from './Battery.svelte';
|
import Battery from './Battery.svelte';
|
||||||
import Music from './Music.svelte';
|
import Music from './Music.svelte';
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
<DiscordStatus />
|
<DiscordStatus />
|
||||||
<Battery />
|
<Battery />
|
||||||
<Donate />
|
<Donate />
|
||||||
|
<WallpaperMenu />
|
||||||
<ThemeMenu />
|
<ThemeMenu />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -2,7 +2,12 @@
|
|||||||
import { focusTrap } from '#lib/ui/focusTrap';
|
import { focusTrap } from '#lib/ui/focusTrap';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
let { title, onclose, children }: { title: string; onclose: () => void; children: Snippet } = $props();
|
let {
|
||||||
|
title,
|
||||||
|
wide = false,
|
||||||
|
onclose,
|
||||||
|
children
|
||||||
|
}: { title: string; wide?: boolean; onclose: () => void; children: Snippet } = $props();
|
||||||
|
|
||||||
function handleKeydown(event: KeyboardEvent) {
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
if (event.key === 'Escape') onclose();
|
if (event.key === 'Escape') onclose();
|
||||||
@@ -12,7 +17,7 @@
|
|||||||
<svelte:window onkeydown={handleKeydown} />
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
<button class="backdrop" aria-label="Close {title}" onclick={onclose}></button>
|
<button class="backdrop" aria-label="Close {title}" onclick={onclose}></button>
|
||||||
<div class="popup" role="dialog" aria-modal="true" aria-label={title} tabindex="-1" use:focusTrap>
|
<div class="popup" class:wide role="dialog" aria-modal="true" aria-label={title} tabindex="-1" use:focusTrap>
|
||||||
<div class="popup-title">{title}</div>
|
<div class="popup-title">{title}</div>
|
||||||
<div class="popup-body">
|
<div class="popup-body">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
@@ -42,6 +47,10 @@
|
|||||||
animation: window-pop-in 0.15s ease-out;
|
animation: window-pop-in 0.15s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.popup.wide {
|
||||||
|
width: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
.popup-title {
|
.popup-title {
|
||||||
padding: 0.4rem 0.6rem;
|
padding: 0.4rem 0.6rem;
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
@@ -58,7 +67,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.popup {
|
.popup,
|
||||||
|
.popup.wide {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: calc(var(--bar-height) + 6px);
|
top: calc(var(--bar-height) + 6px);
|
||||||
right: 8px;
|
right: 8px;
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from '@iconify/svelte';
|
||||||
|
import { wallpapers } from '#lib/content';
|
||||||
|
import { wallpaperState } from '#lib/theme/wallpaper.svelte';
|
||||||
|
import PopupCard from './PopupCard.svelte';
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
let search = $state('');
|
||||||
|
|
||||||
|
const filtered = $derived(
|
||||||
|
wallpapers.filter((wallpaper) => wallpaper.name.toLowerCase().includes(search.trim().toLowerCase()))
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if wallpapers.length}
|
||||||
|
<div class="wallpaper-menu">
|
||||||
|
<button class="tray-button" title="Wallpaper" aria-expanded={open} onclick={() => (open = !open)}>
|
||||||
|
<Icon icon="mdi:image-multiple-outline" width="18" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<PopupCard title="Wallpapers ({wallpapers.length})" wide onclose={() => (open = false)}>
|
||||||
|
<div class="toolbar">
|
||||||
|
<input class="search" type="search" placeholder="search wallpapers" bind:value={search} />
|
||||||
|
<button class="tool" title="Random wallpaper" onclick={() => wallpaperState.shuffle()}>
|
||||||
|
<Icon icon="mdi:shuffle-variant" width="16" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tool"
|
||||||
|
class:selected={wallpaperState.currentId === null}
|
||||||
|
title="Use the theme's wallpaper"
|
||||||
|
onclick={() => wallpaperState.set(null)}
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:palette-outline" width="16" />
|
||||||
|
<span>theme</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
{#each filtered as wallpaper (wallpaper.id)}
|
||||||
|
<button
|
||||||
|
class="thumb"
|
||||||
|
class:selected={wallpaper.id === wallpaperState.currentId}
|
||||||
|
title={wallpaper.name}
|
||||||
|
onclick={() => wallpaperState.set(wallpaper.id)}
|
||||||
|
>
|
||||||
|
<img src={wallpaper.thumb} alt={wallpaper.name} loading="lazy" decoding="async" />
|
||||||
|
<span class="name">{wallpaper.name}</span>
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<span class="empty muted">No wallpapers match "{search}"</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</PopupCard>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.wallpaper-menu {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
background: var(--dark-background);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--foreground) 25%, transparent);
|
||||||
|
color: var(--foreground);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--foreground) 25%, transparent);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool:hover,
|
||||||
|
.tool.selected {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--dark-background);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 60%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb.selected {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0.1rem 0.35rem;
|
||||||
|
background: color-mix(in srgb, var(--background) 85%, transparent);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb:hover .name,
|
||||||
|
.thumb:focus-visible .name,
|
||||||
|
.thumb.selected .name {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
padding: 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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></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>
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import type { TcgDeck } from '#lib/content';
|
||||||
|
import { deckCardKey, deckSections, deckTotal, lookUpDeckCard } from '#lib/tcg/decks';
|
||||||
|
import type { TcgCard } from '#lib/tcg/types';
|
||||||
|
import DeckCardTile from './DeckCardTile.svelte';
|
||||||
|
|
||||||
|
let { deck, onopen }: { deck: TcgDeck; onopen: (card: TcgCard) => void } = $props();
|
||||||
|
|
||||||
|
const lookupConcurrency = 4;
|
||||||
|
|
||||||
|
let foundCards = $state<Record<string, TcgCard | null>>({});
|
||||||
|
|
||||||
|
const sections = $derived(deckSections(deck));
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const currentDeck = deck;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
untrack(() => {
|
||||||
|
const queue = currentDeck.cards.filter((card) => !(deckCardKey(card) in foundCards));
|
||||||
|
const lookUpNext = async () => {
|
||||||
|
while (!cancelled && queue.length > 0) {
|
||||||
|
const card = queue.shift()!;
|
||||||
|
foundCards[deckCardKey(card)] = await lookUpDeckCard(card).catch(() => null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (let worker = 0; worker < lookupConcurrency; worker++) lookUpNext();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => (cancelled = true);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="deck-view">
|
||||||
|
<header class="deck-header">
|
||||||
|
<h2>{deck.name}</h2>
|
||||||
|
<div class="summary">
|
||||||
|
<span class="total">{deckTotal(deck)} cards</span>
|
||||||
|
{#each sections as section (section.category)}
|
||||||
|
<span class="summary-item {section.category}">{section.title} {section.total}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#each sections as section (section.category)}
|
||||||
|
<section class="deck-section">
|
||||||
|
<h3 class={section.category}>{section.title} <span class="muted">{section.total}</span></h3>
|
||||||
|
<ul class="tile-grid">
|
||||||
|
{#each section.cards as deckCard (deckCardKey(deckCard))}
|
||||||
|
<li>
|
||||||
|
<DeckCardTile {deckCard} card={foundCards[deckCardKey(deckCard)]} {onopen} />
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.deck-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem 1rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total {
|
||||||
|
color: var(--foreground);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 0.6rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pokemon {
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trainer {
|
||||||
|
color: var(--blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.energy {
|
||||||
|
color: var(--yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||||
|
gap: 0.9rem 0.75rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { energyColor } from '#lib/tcg/energy';
|
||||||
|
|
||||||
|
let { type }: { type: string } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="energy-tag" style="--energy-color: {energyColor(type)}">{type}</span>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.energy-tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0 0.4rem;
|
||||||
|
border: 1px solid var(--energy-color);
|
||||||
|
color: var(--energy-color);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { fetchEnergyTypes, fetchSupertypes } from '#lib/tcg/api';
|
||||||
|
import type { TcgBrowserState } from '#lib/tcg/browserState.svelte';
|
||||||
|
import { energyColor } from '#lib/tcg/energy';
|
||||||
|
import BtopBox from '../btop/BtopBox.svelte';
|
||||||
|
import SetFilter from './SetFilter.svelte';
|
||||||
|
|
||||||
|
let { browser }: { browser: TcgBrowserState } = $props();
|
||||||
|
|
||||||
|
let supertypes = $state<string[]>([]);
|
||||||
|
let energyTypes = $state<string[]>([]);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
fetchSupertypes(controller.signal)
|
||||||
|
.then((values) => (supertypes = values))
|
||||||
|
.catch(() => (supertypes = []));
|
||||||
|
fetchEnergyTypes(controller.signal)
|
||||||
|
.then((values) => (energyTypes = values))
|
||||||
|
.catch(() => (energyTypes = []));
|
||||||
|
return () => controller.abort();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<aside class="filters">
|
||||||
|
<BtopBox title="kind" number={1}>
|
||||||
|
<div class="chips">
|
||||||
|
{#each supertypes as supertype (supertype)}
|
||||||
|
<button class="chip" class:active={browser.supertype === supertype} onclick={() => browser.toggleSupertype(supertype)}>
|
||||||
|
{supertype}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</BtopBox>
|
||||||
|
|
||||||
|
<BtopBox title="energy" number={2} color="magenta">
|
||||||
|
<div class="chips">
|
||||||
|
{#each energyTypes as energyType (energyType)}
|
||||||
|
<button
|
||||||
|
class="chip energy"
|
||||||
|
class:active={browser.energyType === energyType}
|
||||||
|
style="--chip-color: {energyColor(energyType)}"
|
||||||
|
onclick={() => browser.toggleEnergyType(energyType)}
|
||||||
|
>
|
||||||
|
<span class="dot"></span>{energyType}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</BtopBox>
|
||||||
|
|
||||||
|
<SetFilter {browser} />
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.1rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
--chip-color: var(--accent);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border-inactive);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip:hover {
|
||||||
|
border-color: var(--chip-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip.active {
|
||||||
|
background: var(--chip-color);
|
||||||
|
border-color: var(--chip-color);
|
||||||
|
color: var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
background: var(--chip-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip.active .dot {
|
||||||
|
background: var(--background);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from '@iconify/svelte';
|
||||||
|
import { tcgDecks } from '#lib/content';
|
||||||
|
import type { TcgBrowserState } from '#lib/tcg/browserState.svelte';
|
||||||
|
import { deckBuilder } from '#lib/tcg/deckBuilder.svelte';
|
||||||
|
import { deckBuilderEntries, deckTotal } from '#lib/tcg/decks';
|
||||||
|
import BtopBox from '../btop/BtopBox.svelte';
|
||||||
|
import DeckView from './DeckView.svelte';
|
||||||
|
|
||||||
|
let { browser }: { browser: TcgBrowserState } = $props();
|
||||||
|
|
||||||
|
let isCopying = $state(false);
|
||||||
|
|
||||||
|
const selectedDeck = $derived(tcgDecks.find((deck) => deck.id === browser.deckId));
|
||||||
|
|
||||||
|
async function openInBuilder() {
|
||||||
|
if (!selectedDeck || isCopying) return;
|
||||||
|
deckBuilder.load();
|
||||||
|
if (deckBuilder.total > 0 && !window.confirm(`Replace ${deckBuilder.name} in the deck builder with ${selectedDeck.name}?`)) return;
|
||||||
|
isCopying = true;
|
||||||
|
const entries = await deckBuilderEntries(selectedDeck);
|
||||||
|
deckBuilder.replace(selectedDeck.name, entries);
|
||||||
|
isCopying = false;
|
||||||
|
browser.tab = 'builder';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="my-decks">
|
||||||
|
<aside class="deck-list">
|
||||||
|
<BtopBox title="decks" number={1}>
|
||||||
|
{#if tcgDecks.length === 0}
|
||||||
|
<p class="muted">No decks yet.</p>
|
||||||
|
{:else}
|
||||||
|
<ul>
|
||||||
|
{#each tcgDecks as deck (deck.id)}
|
||||||
|
<li>
|
||||||
|
<button class:active={deck.id === browser.deckId} onclick={() => (browser.deckId = deck.id)}>
|
||||||
|
<span class="deck-name">{deck.name}</span>
|
||||||
|
<span class="muted">{deckTotal(deck)}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</BtopBox>
|
||||||
|
|
||||||
|
{#if selectedDeck}
|
||||||
|
<BtopBox title="actions" number={2} color="yellow">
|
||||||
|
<button class="action" disabled={isCopying} onclick={openInBuilder}>
|
||||||
|
<Icon icon="mdi:hammer-wrench" width="15" />
|
||||||
|
{isCopying ? 'loading cards...' : 'open in deck builder'}
|
||||||
|
</button>
|
||||||
|
</BtopBox>
|
||||||
|
{/if}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="deck-main">
|
||||||
|
{#if selectedDeck}
|
||||||
|
<DeckView deck={selectedDeck} onopen={(card) => browser.openCard(card)} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.my-decks {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 230px minmax(0, 1fr);
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-list,
|
||||||
|
.deck-main {
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.1rem;
|
||||||
|
padding-top: 1.25rem;
|
||||||
|
border-right: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-main {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
li button {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.25rem 0.4rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
li button:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
li button.active {
|
||||||
|
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-name {
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border-inactive);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action:hover:not(:disabled) {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container (max-width: 640px) {
|
||||||
|
.my-decks {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-list {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import Icon from '@iconify/svelte';
|
||||||
|
import { fetchSets } from '#lib/tcg/api';
|
||||||
|
import type { TcgBrowserState } from '#lib/tcg/browserState.svelte';
|
||||||
|
import type { TcgSet } from '#lib/tcg/types';
|
||||||
|
import BtopBox from '../btop/BtopBox.svelte';
|
||||||
|
|
||||||
|
let { browser }: { browser: TcgBrowserState } = $props();
|
||||||
|
|
||||||
|
let sets = $state<TcgSet[]>([]);
|
||||||
|
let setQuery = $state('');
|
||||||
|
let isLoading = $state(true);
|
||||||
|
let hasFailed = $state(false);
|
||||||
|
|
||||||
|
const seriesGroups = $derived.by(() => {
|
||||||
|
const query = setQuery.trim().toLowerCase();
|
||||||
|
const groups: { series: string; sets: TcgSet[] }[] = [];
|
||||||
|
sets
|
||||||
|
.filter((set) => !query || `${set.name} ${set.series} ${set.ptcgoCode ?? ''} ${set.id}`.toLowerCase().includes(query))
|
||||||
|
.forEach((set) => {
|
||||||
|
const group = groups.find((item) => item.series === set.series);
|
||||||
|
if (group) group.sets.push(set);
|
||||||
|
else groups.push({ series: set.series, sets: [set] });
|
||||||
|
});
|
||||||
|
return groups;
|
||||||
|
});
|
||||||
|
|
||||||
|
function releaseYear(set: TcgSet): string {
|
||||||
|
return set.releaseDate?.slice(0, 4) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
fetchSets(controller.signal)
|
||||||
|
.then((result) => (sets = result))
|
||||||
|
.catch(() => (hasFailed = !controller.signal.aborted))
|
||||||
|
.finally(() => (isLoading = false));
|
||||||
|
return () => controller.abort();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<BtopBox title="sets" number={3} color="cyan">
|
||||||
|
<label class="set-search">
|
||||||
|
<Icon icon="mdi:magnify" width="15" />
|
||||||
|
<input bind:value={setQuery} placeholder="filter sets" spellcheck="false" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="set-list">
|
||||||
|
{#if isLoading}
|
||||||
|
<p class="muted">loading sets...</p>
|
||||||
|
{:else if hasFailed}
|
||||||
|
<p class="error">could not load sets</p>
|
||||||
|
{:else if seriesGroups.length === 0}
|
||||||
|
<p class="muted">no sets match</p>
|
||||||
|
{:else}
|
||||||
|
{#each seriesGroups as group (group.series)}
|
||||||
|
<section>
|
||||||
|
<h4>{group.series}</h4>
|
||||||
|
<ul>
|
||||||
|
{#each group.sets as set (set.id)}
|
||||||
|
<li>
|
||||||
|
<button class="set-row" class:active={browser.setId === set.id} title="{set.name} ({set.id})" onclick={() => browser.toggleSet(set)}>
|
||||||
|
<span class="symbol">
|
||||||
|
{#if set.images?.symbol}
|
||||||
|
<img src={set.images.symbol} alt="" loading="lazy" data-no-viewer />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="set-name">{set.name}</span>
|
||||||
|
<span class="set-code muted">{set.ptcgoCode ?? releaseYear(set)}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</BtopBox>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.set-search {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
padding-bottom: 0.25rem;
|
||||||
|
border-bottom: 1px dashed var(--border-inactive);
|
||||||
|
color: var(--cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.set-search input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
box-shadow: none;
|
||||||
|
color: var(--foreground);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.set-list {
|
||||||
|
max-height: 340px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
margin: 0.4rem 0 0.15rem;
|
||||||
|
padding: 0.1rem 0;
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--orange);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.set-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 18px minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.15rem 0.3rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.set-row:hover {
|
||||||
|
color: var(--cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.set-row.active {
|
||||||
|
background: color-mix(in srgb, var(--cyan) 20%, transparent);
|
||||||
|
color: var(--cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.symbol {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.symbol img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.set-name {
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.set-code {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import Icon from '@iconify/svelte';
|
||||||
|
import { TcgBrowserState, type BrowserTab } from '#lib/tcg/browserState.svelte';
|
||||||
|
import { deckBuilder } from '#lib/tcg/deckBuilder.svelte';
|
||||||
|
import MyDecksTab from './MyDecksTab.svelte';
|
||||||
|
import DeckBuilderTab from './DeckBuilderTab.svelte';
|
||||||
|
import CardModal from './CardModal.svelte';
|
||||||
|
|
||||||
|
const browser = new TcgBrowserState();
|
||||||
|
|
||||||
|
const tabs: { id: BrowserTab; label: string; icon: string }[] = [
|
||||||
|
{ id: 'decks', label: 'my decks', icon: 'ph:cards-duotone' },
|
||||||
|
{ id: 'builder', label: 'deck builder', icon: 'mdi:hammer-wrench' }
|
||||||
|
];
|
||||||
|
|
||||||
|
onMount(() => deckBuilder.load());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="tcg-browser">
|
||||||
|
<nav class="tabs" aria-label="Card browser sections">
|
||||||
|
{#each tabs as tab (tab.id)}
|
||||||
|
<button class:active={browser.tab === tab.id} onclick={() => (browser.tab = tab.id)}>
|
||||||
|
<Icon icon={tab.icon} width="16" />
|
||||||
|
{tab.label}
|
||||||
|
{#if tab.id === 'builder' && deckBuilder.total > 0}
|
||||||
|
<span class="badge">{deckBuilder.total}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
<span class="source muted">tcg.sirblob.co</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="tab-content">
|
||||||
|
{#if browser.tab === 'decks'}
|
||||||
|
<MyDecksTab {browser} />
|
||||||
|
{:else}
|
||||||
|
<DeckBuilderTab {browser} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if browser.openedCard}
|
||||||
|
<CardModal card={browser.openedCard} canBuild={browser.tab === 'builder'} onclose={() => browser.openCard(null)} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.tcg-browser {
|
||||||
|
position: relative;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
padding: 0 0.35rem;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--background);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source {
|
||||||
|
align-self: center;
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container (max-width: 640px) {
|
||||||
|
.source {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from '@iconify/svelte';
|
||||||
|
|
||||||
|
let { page = $bindable(), totalPages }: { page: number; totalPages: number } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav class="pager" aria-label="Pages">
|
||||||
|
<button disabled={page <= 1} aria-label="Previous page" onclick={() => page--}>
|
||||||
|
<Icon icon="mdi:chevron-left" width="18" />
|
||||||
|
</button>
|
||||||
|
<span class="position"><span class="current">{page}</span><span class="muted">/{totalPages}</span></span>
|
||||||
|
<button disabled={page >= totalPages} aria-label="Next page" onclick={() => page++}>
|
||||||
|
<Icon icon="mdi:chevron-right" width="18" />
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.pager {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border-inactive);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position {
|
||||||
|
min-width: 4.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
const context: CommandContext = {
|
const context: CommandContext = {
|
||||||
print: (...newBlocks) => blocks.push(...newBlocks),
|
print: (...newBlocks) => blocks.push(...newBlocks),
|
||||||
clear: () => (blocks = []),
|
clear: () => (blocks = []),
|
||||||
exit: onexit,
|
exit: () => onexit(),
|
||||||
openPage,
|
openPage,
|
||||||
openUrl,
|
openUrl,
|
||||||
setTheme: (themeId) => themeState.set(themeId),
|
setTheme: (themeId) => themeState.set(themeId),
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
export interface StackCover {
|
||||||
|
id: string;
|
||||||
|
href: string;
|
||||||
|
title: string;
|
||||||
|
image: string | null;
|
||||||
|
tone: string;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import Icon from '@iconify/svelte';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
name,
|
||||||
|
href,
|
||||||
|
icon,
|
||||||
|
fallbackIcon,
|
||||||
|
covers,
|
||||||
|
summary,
|
||||||
|
details,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
href?: string;
|
||||||
|
icon: string;
|
||||||
|
fallbackIcon: string;
|
||||||
|
covers: StackCover[];
|
||||||
|
summary?: Snippet;
|
||||||
|
details?: Snippet;
|
||||||
|
children: Snippet;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let expanded = $state(false);
|
||||||
|
|
||||||
|
const total = $derived(covers.length);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="collection" class:expanded>
|
||||||
|
<header class="collection-header">
|
||||||
|
{#if href}
|
||||||
|
<a class="collection-name" {href} target="_blank" rel="noopener noreferrer">
|
||||||
|
<Icon {icon} width="16" />
|
||||||
|
{name}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<span class="collection-name"><Icon {icon} width="16" />{name}</span>
|
||||||
|
{/if}
|
||||||
|
{#if summary}
|
||||||
|
<span class="collection-summary">{@render summary()}</span>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{@render details?.()}
|
||||||
|
|
||||||
|
{#if expanded}
|
||||||
|
{@render children()}
|
||||||
|
{:else}
|
||||||
|
<div class="cover-stack">
|
||||||
|
{#each covers as cover, index (cover.id)}
|
||||||
|
<a
|
||||||
|
class="stacked-cover"
|
||||||
|
style:z-index={total - index}
|
||||||
|
style:border-bottom-color="var(--{cover.tone})"
|
||||||
|
href={cover.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title={cover.title}
|
||||||
|
>
|
||||||
|
{#if cover.image}
|
||||||
|
<img src={cover.image} alt={cover.title} loading="lazy" />
|
||||||
|
{:else}
|
||||||
|
<span class="cover-fallback"><Icon icon={fallbackIcon} width="20" /></span>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button class="toggle" onclick={() => (expanded = !expanded)}>
|
||||||
|
<Icon icon={expanded ? 'mdi:chevron-up' : 'mdi:chevron-down'} width="16" />
|
||||||
|
{expanded ? 'Collapse' : `Expand (${total})`}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.collection {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: min(100%, 280px);
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection.expanded {
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.4rem 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-name {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.collection-name:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-stack {
|
||||||
|
display: flex;
|
||||||
|
padding: 0.5rem 0 0.25rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stacked-cover {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 84px;
|
||||||
|
height: 126px;
|
||||||
|
border-bottom: 3px solid var(--muted);
|
||||||
|
box-shadow: 4px 0 10px color-mix(in srgb, black 45%, transparent);
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stacked-cover + .stacked-cover {
|
||||||
|
margin-left: -52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stacked-cover:hover {
|
||||||
|
z-index: 1000 !important;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stacked-cover img,
|
||||||
|
.cover-fallback {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-fallback {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: color-mix(in srgb, var(--foreground) 8%, var(--background));
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--foreground) 30%, transparent);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.stacked-cover {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,14 +2,30 @@
|
|||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import Icon from '@iconify/svelte';
|
import Icon from '@iconify/svelte';
|
||||||
|
|
||||||
let { title, icon, href, children }: { title?: string; icon?: string; href?: string; children?: Snippet } = $props();
|
let {
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
href,
|
||||||
|
onclick,
|
||||||
|
children
|
||||||
|
}: { title?: string; icon?: string; href?: string; onclick?: () => void; children?: Snippet } = $props();
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (!onclick || (event.key !== 'Enter' && event.key !== ' ')) return;
|
||||||
|
event.preventDefault();
|
||||||
|
onclick();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:element
|
<svelte:element
|
||||||
this={href ? 'a' : 'article'}
|
this={href ? 'a' : 'article'}
|
||||||
class="panel"
|
class="panel"
|
||||||
class:linked={!!href}
|
class:linked={!!href || !!onclick}
|
||||||
{href}
|
{href}
|
||||||
|
role={onclick ? 'button' : undefined}
|
||||||
|
tabindex={onclick ? 0 : undefined}
|
||||||
|
{onclick}
|
||||||
|
onkeydown={onclick ? handleKeydown : undefined}
|
||||||
target={href?.startsWith('http') ? '_blank' : undefined}
|
target={href?.startsWith('http') ? '_blank' : undefined}
|
||||||
rel={href?.startsWith('http') ? 'noopener noreferrer' : undefined}
|
rel={href?.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||||
>
|
>
|
||||||
@@ -34,6 +50,10 @@
|
|||||||
background: color-mix(in srgb, var(--foreground) 3%, transparent);
|
background: color-mix(in srgb, var(--foreground) 3%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.linked {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.linked:hover {
|
.linked:hover {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
background: color-mix(in srgb, var(--foreground) 7%, transparent);
|
background: color-mix(in srgb, var(--foreground) 7%, transparent);
|
||||||
|
|||||||
@@ -121,7 +121,7 @@
|
|||||||
'0': controls.fit,
|
'0': controls.fit,
|
||||||
r: controls.rotateRight,
|
r: controls.rotateRight,
|
||||||
R: controls.rotateLeft,
|
R: controls.rotateLeft,
|
||||||
Escape: onclose
|
Escape: () => onclose()
|
||||||
};
|
};
|
||||||
|
|
||||||
function handleKeydown(event: KeyboardEvent) {
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import packagesData from '#content/projects/packages.json';
|
|||||||
import hackathonsData from '#content/projects/hackathons.json';
|
import hackathonsData from '#content/projects/hackathons.json';
|
||||||
import researchData from '#content/projects/research.json';
|
import researchData from '#content/projects/research.json';
|
||||||
import organizationsData from '#content/projects/organizations.json';
|
import organizationsData from '#content/projects/organizations.json';
|
||||||
|
import tcgDecksData from '#content/tcgDecks.json';
|
||||||
|
import wallpapersData from '#content/wallpapers.json';
|
||||||
import type {
|
import type {
|
||||||
AboutContent,
|
AboutContent,
|
||||||
BlogSettings,
|
BlogSettings,
|
||||||
@@ -21,7 +23,9 @@ import type {
|
|||||||
Project,
|
Project,
|
||||||
ProjectCategory,
|
ProjectCategory,
|
||||||
ProjectCategoryId,
|
ProjectCategoryId,
|
||||||
ResearchGroup
|
ResearchGroup,
|
||||||
|
TcgDeck,
|
||||||
|
Wallpaper
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export type * from './types';
|
export type * from './types';
|
||||||
@@ -29,11 +33,13 @@ export type * from './types';
|
|||||||
export const profile: Profile = profileData;
|
export const profile: Profile = profileData;
|
||||||
export const experience: Experience[] = experienceData;
|
export const experience: Experience[] = experienceData;
|
||||||
export const leadership: Leadership[] = leadershipData;
|
export const leadership: Leadership[] = leadershipData;
|
||||||
export const about: AboutContent = aboutData;
|
export const about: AboutContent = aboutData as AboutContent;
|
||||||
export const desktop: DesktopContent = desktopData;
|
export const desktop: DesktopContent = desktopData;
|
||||||
export const blogSettings: BlogSettings = blogData;
|
export const blogSettings: BlogSettings = blogData;
|
||||||
export const playlist: Playlist = musicData;
|
export const playlist: Playlist = musicData;
|
||||||
export const researchGroups: ResearchGroup[] = researchData;
|
export const researchGroups: ResearchGroup[] = researchData;
|
||||||
|
export const tcgDecks: TcgDeck[] = tcgDecksData as TcgDeck[];
|
||||||
|
export const wallpapers: Wallpaper[] = wallpapersData as Wallpaper[];
|
||||||
|
|
||||||
function featuredFirst(projects: Project[]): Project[] {
|
function featuredFirst(projects: Project[]): Project[] {
|
||||||
return [...projects].sort((first, second) => Number(!!second.featured) - Number(!!first.featured));
|
return [...projects].sort((first, second) => Number(!!second.featured) - Number(!!first.featured));
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export interface ProjectLinks {
|
|||||||
devpost?: string;
|
devpost?: string;
|
||||||
package?: string;
|
package?: string;
|
||||||
article?: string;
|
article?: string;
|
||||||
|
video?: string;
|
||||||
docs?: string;
|
docs?: string;
|
||||||
website?: string;
|
website?: string;
|
||||||
}
|
}
|
||||||
@@ -124,18 +125,76 @@ export interface Hobby {
|
|||||||
name: string;
|
name: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
folder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PlayStatus = 'played' | 'playing' | 'planning' | 'replayable';
|
||||||
|
|
||||||
export interface Game {
|
export interface Game {
|
||||||
|
igdbId: string;
|
||||||
name: string;
|
name: string;
|
||||||
image: string;
|
url: string | null;
|
||||||
url: string;
|
igdbUrl: string | null;
|
||||||
|
image: string | null;
|
||||||
|
cover: string | null;
|
||||||
|
status: PlayStatus;
|
||||||
|
rating?: number;
|
||||||
|
released: string | null;
|
||||||
|
platforms: string[];
|
||||||
|
developers: string[];
|
||||||
|
publishers: string[];
|
||||||
|
genres: string[];
|
||||||
|
summary: string | null;
|
||||||
|
score: number | null;
|
||||||
|
favorite: boolean;
|
||||||
|
liked: boolean;
|
||||||
|
tags: string[];
|
||||||
|
note: string | null;
|
||||||
|
added?: string;
|
||||||
|
started?: string;
|
||||||
|
finished?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WatchStatus = 'watched' | 'watching' | 'planning';
|
||||||
|
|
||||||
export interface RatedTitle {
|
export interface RatedTitle {
|
||||||
name: string;
|
name: string;
|
||||||
rating: number;
|
rating?: number;
|
||||||
url: string;
|
url: string;
|
||||||
|
status?: WatchStatus;
|
||||||
|
collection?: string;
|
||||||
|
added?: string;
|
||||||
|
started?: string;
|
||||||
|
finished?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BookShelf = 'read' | 'reading' | 'want-to-read';
|
||||||
|
|
||||||
|
export interface Book {
|
||||||
|
goodreadsId: string;
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
authors: string[];
|
||||||
|
cover: string | null;
|
||||||
|
coverSource?: string | null;
|
||||||
|
description: string | null;
|
||||||
|
pages: number | null;
|
||||||
|
published: string | null;
|
||||||
|
genres: string[];
|
||||||
|
averageRating: number | null;
|
||||||
|
ratingCount: number | null;
|
||||||
|
shelf: BookShelf;
|
||||||
|
rating?: number;
|
||||||
|
added?: string;
|
||||||
|
started?: string;
|
||||||
|
finished?: string;
|
||||||
|
series?: BookSeries;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookSeries {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
position?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LabeledValue {
|
export interface LabeledValue {
|
||||||
@@ -157,17 +216,21 @@ export interface AboutContent {
|
|||||||
lastfmUsername?: string;
|
lastfmUsername?: string;
|
||||||
setup?: DesktopSetup;
|
setup?: DesktopSetup;
|
||||||
hobbies: Hobby[];
|
hobbies: Hobby[];
|
||||||
games: Game[];
|
|
||||||
shows: RatedTitle[];
|
|
||||||
movies: RatedTitle[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DesktopApp = 'btop';
|
export type GameName = 'snake' | 'breakout';
|
||||||
|
|
||||||
|
export type LibraryAppName = 'movies' | 'shows' | 'books' | 'games';
|
||||||
|
|
||||||
|
export type DesktopApp = 'btop' | 'tcg' | 'pet' | 'files' | GameName | LibraryAppName;
|
||||||
|
|
||||||
export type DesktopAction = { page: string } | { terminal: string } | { url: string } | { app: DesktopApp };
|
export type DesktopAction = { page: string } | { terminal: string } | { url: string } | { app: DesktopApp };
|
||||||
|
|
||||||
|
export type DesktopSide = 'left' | 'right';
|
||||||
|
|
||||||
export interface DesktopIconGroup {
|
export interface DesktopIconGroup {
|
||||||
name: string;
|
name: string;
|
||||||
|
side?: DesktopSide;
|
||||||
icons: DesktopIcon[];
|
icons: DesktopIcon[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,3 +262,25 @@ export interface BlogSettings {
|
|||||||
tagline: string;
|
tagline: string;
|
||||||
postsPerPage: number;
|
postsPerPage: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DeckCategory = 'pokemon' | 'trainer' | 'energy';
|
||||||
|
|
||||||
|
export interface DeckCard {
|
||||||
|
count: number;
|
||||||
|
name: string;
|
||||||
|
set?: string;
|
||||||
|
category: DeckCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TcgDeck {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
cards: DeckCard[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Wallpaper {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
image: string;
|
||||||
|
thumb: string;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { desktop, type DesktopAction, type DesktopApp, type Workspace } from '#lib/content';
|
import { desktop, type DesktopAction, type DesktopApp, type LibraryAppName, type Workspace } from '#lib/content';
|
||||||
|
import { petState } from '#lib/pet/petState.svelte';
|
||||||
import { pageWindowId, windowManager } from './windows.svelte';
|
import { pageWindowId, windowManager } from './windows.svelte';
|
||||||
|
|
||||||
export function isExternalUrl(url: string): boolean {
|
export function isExternalUrl(url: string): boolean {
|
||||||
@@ -19,8 +20,19 @@ export function openPage(path: string) {
|
|||||||
goto(path);
|
goto(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const libraryAppNames: DesktopApp[] = ['movies', 'shows', 'books', 'games'];
|
||||||
|
|
||||||
|
function isLibraryApp(app: DesktopApp): app is LibraryAppName {
|
||||||
|
return libraryAppNames.includes(app);
|
||||||
|
}
|
||||||
|
|
||||||
export function openApp(app: DesktopApp) {
|
export function openApp(app: DesktopApp) {
|
||||||
if (app === 'btop') windowManager.openSystemMonitor();
|
if (isLibraryApp(app)) windowManager.openLibrary(app);
|
||||||
|
else if (app === 'btop') windowManager.openSystemMonitor();
|
||||||
|
else if (app === 'tcg') windowManager.openTcgBrowser();
|
||||||
|
else if (app === 'files') windowManager.openFileExplorer();
|
||||||
|
else if (app === 'pet') petState.toggle();
|
||||||
|
else windowManager.openGame(app);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runDesktopAction(action: DesktopAction) {
|
export function runDesktopAction(action: DesktopAction) {
|
||||||
|
|||||||
@@ -38,6 +38,24 @@ export const imageViewerKeybinds: KeybindEntry[] = [
|
|||||||
{ keys: ['Esc'], action: 'Close the viewer' }
|
{ keys: ['Esc'], action: 'Close the viewer' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const snakeKeybinds: KeybindEntry[] = [
|
||||||
|
{ keys: ['Arrows'], action: 'Steer the snake' },
|
||||||
|
{ keys: ['WASD'], action: 'Steer the snake' },
|
||||||
|
{ keys: ['HJKL'], action: 'Steer the snake (vim style)' },
|
||||||
|
{ keys: ['Space'], action: 'Start or pause' },
|
||||||
|
{ keys: ['R'], action: 'Restart' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const breakoutKeybinds: KeybindEntry[] = [
|
||||||
|
{ keys: ['Left'], action: 'Move the paddle left' },
|
||||||
|
{ keys: ['Right'], action: 'Move the paddle right' },
|
||||||
|
{ keys: ['AD'], action: 'Move the paddle' },
|
||||||
|
{ keys: ['HL'], action: 'Move the paddle (vim style)' },
|
||||||
|
{ keys: ['Mouse'], action: 'Move the paddle' },
|
||||||
|
{ keys: ['Space'], action: 'Launch or pause' },
|
||||||
|
{ keys: ['R'], action: 'Restart' }
|
||||||
|
];
|
||||||
|
|
||||||
export const terminalKeybinds: KeybindEntry[] = [
|
export const terminalKeybinds: KeybindEntry[] = [
|
||||||
{ keys: ['Tab'], action: 'Complete a command or page name' },
|
{ keys: ['Tab'], action: 'Complete a command or page name' },
|
||||||
{ keys: ['Up'], action: 'Previous command in history' },
|
{ keys: ['Up'], action: 'Previous command in history' },
|
||||||
@@ -52,5 +70,6 @@ export const navigationTips: NavigationTip[] = [
|
|||||||
{ icon: 'ph:terminal-window-duotone', title: 'Terminal', description: 'Click the clock or press Alt + Enter. Type help to see every command, or cd <page> to open one.' },
|
{ icon: 'ph:terminal-window-duotone', title: 'Terminal', description: 'Click the clock or press Alt + Enter. Type help to see every command, or cd <page> to open one.' },
|
||||||
{ icon: 'ph:music-notes-duotone', title: 'Bar widgets', description: 'The music, Discord, battery and theme widgets on the right of the bar open more details when clicked.' },
|
{ icon: 'ph:music-notes-duotone', title: 'Bar widgets', description: 'The music, Discord, battery and theme widgets on the right of the bar open more details when clicked.' },
|
||||||
{ icon: 'ph:image-duotone', title: 'Images', description: 'Click any image to open it in the image viewer. Scroll or pinch to zoom and drag to pan.' },
|
{ icon: 'ph:image-duotone', title: 'Images', description: 'Click any image to open it in the image viewer. Scroll or pinch to zoom and drag to pan.' },
|
||||||
|
{ icon: 'ph:robot-duotone', title: 'Desktop robot', description: 'Right click the desktop to send the robot there, click it to say hi or drag it around. The robot icon or the pet command turns it on and off.' },
|
||||||
{ icon: 'ph:device-mobile-duotone', title: 'Mobile', description: 'Windows open fullscreen. Use the tabs above the window to switch between them.' }
|
{ icon: 'ph:device-mobile-duotone', title: 'Mobile', description: 'Windows open fullscreen. Use the tabs above the window to switch between them.' }
|
||||||
];
|
];
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { GameName, LibraryAppName } from '#lib/content';
|
||||||
|
|
||||||
export const pageWindowId = 'page';
|
export const pageWindowId = 'page';
|
||||||
|
|
||||||
export interface TerminalWindow {
|
export interface TerminalWindow {
|
||||||
@@ -20,6 +22,25 @@ export interface SystemMonitorWindow {
|
|||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TcgBrowserWindow {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileExplorerWindow {
|
||||||
|
id: string;
|
||||||
|
startPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LibraryWindow {
|
||||||
|
id: string;
|
||||||
|
app: LibraryAppName;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameWindow {
|
||||||
|
id: string;
|
||||||
|
game: GameName;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FloatingRect {
|
export interface FloatingRect {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
@@ -31,6 +52,10 @@ class WindowManager {
|
|||||||
terminals = $state<TerminalWindow[]>([]);
|
terminals = $state<TerminalWindow[]>([]);
|
||||||
imageViewers = $state<ImageViewerWindow[]>([]);
|
imageViewers = $state<ImageViewerWindow[]>([]);
|
||||||
systemMonitors = $state<SystemMonitorWindow[]>([]);
|
systemMonitors = $state<SystemMonitorWindow[]>([]);
|
||||||
|
games = $state<GameWindow[]>([]);
|
||||||
|
tcgBrowsers = $state<TcgBrowserWindow[]>([]);
|
||||||
|
libraryWindows = $state<LibraryWindow[]>([]);
|
||||||
|
fileExplorers = $state<FileExplorerWindow[]>([]);
|
||||||
floatingRects = $state<Record<string, FloatingRect | null>>({});
|
floatingRects = $state<Record<string, FloatingRect | null>>({});
|
||||||
focusedId = $state<string>(pageWindowId);
|
focusedId = $state<string>(pageWindowId);
|
||||||
maximizedId = $state<string | null>(null);
|
maximizedId = $state<string | null>(null);
|
||||||
@@ -39,12 +64,20 @@ class WindowManager {
|
|||||||
private nextTerminalNumber = 1;
|
private nextTerminalNumber = 1;
|
||||||
private nextImageViewerNumber = 1;
|
private nextImageViewerNumber = 1;
|
||||||
private nextSystemMonitorNumber = 1;
|
private nextSystemMonitorNumber = 1;
|
||||||
|
private nextGameNumber = 1;
|
||||||
|
private nextTcgBrowserNumber = 1;
|
||||||
|
private nextLibraryNumber = 1;
|
||||||
|
private nextFileExplorerNumber = 1;
|
||||||
|
|
||||||
get extraWindowIds(): string[] {
|
get extraWindowIds(): string[] {
|
||||||
return [
|
return [
|
||||||
...this.terminals.map((terminal) => terminal.id),
|
...this.terminals.map((terminal) => terminal.id),
|
||||||
...this.imageViewers.map((viewer) => viewer.id),
|
...this.imageViewers.map((viewer) => viewer.id),
|
||||||
...this.systemMonitors.map((monitor) => monitor.id)
|
...this.systemMonitors.map((monitor) => monitor.id),
|
||||||
|
...this.games.map((gameWindow) => gameWindow.id),
|
||||||
|
...this.tcgBrowsers.map((browser) => browser.id),
|
||||||
|
...this.libraryWindows.map((libraryWindow) => libraryWindow.id),
|
||||||
|
...this.fileExplorers.map((explorer) => explorer.id)
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,10 +104,39 @@ class WindowManager {
|
|||||||
this.focus(monitor.id);
|
this.focus(monitor.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openGame(game: GameName) {
|
||||||
|
const gameWindow = { id: `${game}-${this.nextGameNumber++}`, game };
|
||||||
|
this.games.push(gameWindow);
|
||||||
|
this.floatingRects[gameWindow.id] = null;
|
||||||
|
this.focus(gameWindow.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
openTcgBrowser() {
|
||||||
|
const browser = { id: `tcg-${this.nextTcgBrowserNumber++}` };
|
||||||
|
this.tcgBrowsers.push(browser);
|
||||||
|
this.focus(browser.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
openLibrary(app: LibraryAppName) {
|
||||||
|
const libraryWindow = { id: `${app}-${this.nextLibraryNumber++}`, app };
|
||||||
|
this.libraryWindows.push(libraryWindow);
|
||||||
|
this.focus(libraryWindow.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
openFileExplorer(startPath = '') {
|
||||||
|
const explorer = { id: `thunar-${this.nextFileExplorerNumber++}`, startPath };
|
||||||
|
this.fileExplorers.push(explorer);
|
||||||
|
this.focus(explorer.id);
|
||||||
|
}
|
||||||
|
|
||||||
closeWindow(id: string) {
|
closeWindow(id: string) {
|
||||||
this.terminals = this.terminals.filter((terminal) => terminal.id !== id);
|
this.terminals = this.terminals.filter((terminal) => terminal.id !== id);
|
||||||
this.imageViewers = this.imageViewers.filter((viewer) => viewer.id !== id);
|
this.imageViewers = this.imageViewers.filter((viewer) => viewer.id !== id);
|
||||||
this.systemMonitors = this.systemMonitors.filter((monitor) => monitor.id !== id);
|
this.systemMonitors = this.systemMonitors.filter((monitor) => monitor.id !== id);
|
||||||
|
this.games = this.games.filter((gameWindow) => gameWindow.id !== id);
|
||||||
|
this.tcgBrowsers = this.tcgBrowsers.filter((browser) => browser.id !== id);
|
||||||
|
this.libraryWindows = this.libraryWindows.filter((libraryWindow) => libraryWindow.id !== id);
|
||||||
|
this.fileExplorers = this.fileExplorers.filter((explorer) => explorer.id !== id);
|
||||||
delete this.floatingRects[id];
|
delete this.floatingRects[id];
|
||||||
if (this.maximizedId === id) this.maximizedId = null;
|
if (this.maximizedId === id) this.maximizedId = null;
|
||||||
if (this.focusedId === id) this.focusedId = this.extraWindowIds.at(-1) ?? pageWindowId;
|
if (this.focusedId === id) this.focusedId = this.extraWindowIds.at(-1) ?? pageWindowId;
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { windowManager } from '#lib/desktop/windows.svelte';
|
||||||
|
import { fileUrl, parentPath, type FileEntry, type FolderListing } from './types';
|
||||||
|
|
||||||
|
export type ExplorerView = 'grid' | 'list';
|
||||||
|
type LoadState = 'loading' | 'ready' | 'error';
|
||||||
|
|
||||||
|
export class ExplorerState {
|
||||||
|
path = $state('');
|
||||||
|
entries = $state<FileEntry[]>([]);
|
||||||
|
state = $state<LoadState>('loading');
|
||||||
|
selectedPath = $state<string | null>(null);
|
||||||
|
view = $state<ExplorerView>('grid');
|
||||||
|
query = $state('');
|
||||||
|
places = $state<FileEntry[]>([]);
|
||||||
|
|
||||||
|
private history = $state<string[]>([]);
|
||||||
|
private historyIndex = $state(0);
|
||||||
|
|
||||||
|
constructor(startPath = '') {
|
||||||
|
this.history = [startPath];
|
||||||
|
this.path = startPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
shownEntries = $derived(
|
||||||
|
this.query.trim()
|
||||||
|
? this.entries.filter((entry) => entry.name.toLowerCase().includes(this.query.trim().toLowerCase()))
|
||||||
|
: this.entries
|
||||||
|
);
|
||||||
|
selected = $derived(this.entries.find((entry) => entry.path === this.selectedPath) ?? null);
|
||||||
|
canGoBack = $derived(this.historyIndex > 0);
|
||||||
|
canGoForward = $derived(this.historyIndex < this.history.length - 1);
|
||||||
|
canGoUp = $derived(this.path !== '');
|
||||||
|
crumbs = $derived(
|
||||||
|
this.path
|
||||||
|
.split('/')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((name, index, parts) => ({ name, path: parts.slice(0, index + 1).join('/') }))
|
||||||
|
);
|
||||||
|
|
||||||
|
private async fetchListing(path: string): Promise<FolderListing> {
|
||||||
|
const response = await fetch(`/api/files?path=${encodeURIComponent(path)}`);
|
||||||
|
if (!response.ok) throw new Error(`files returned ${response.status}`);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async load(path: string) {
|
||||||
|
this.state = 'loading';
|
||||||
|
this.selectedPath = null;
|
||||||
|
this.query = '';
|
||||||
|
try {
|
||||||
|
const listing = await this.fetchListing(path);
|
||||||
|
this.path = listing.path;
|
||||||
|
this.entries = listing.entries;
|
||||||
|
this.state = 'ready';
|
||||||
|
} catch {
|
||||||
|
this.state = 'error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadPlaces() {
|
||||||
|
try {
|
||||||
|
const root = await this.fetchListing('');
|
||||||
|
this.places = root.entries.filter((entry) => entry.kind === 'folder');
|
||||||
|
} catch {
|
||||||
|
this.places = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
this.load(this.path);
|
||||||
|
this.loadPlaces();
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate(path: string) {
|
||||||
|
if (path === this.path && this.state === 'ready') return;
|
||||||
|
this.history = [...this.history.slice(0, this.historyIndex + 1), path];
|
||||||
|
this.historyIndex = this.history.length - 1;
|
||||||
|
this.load(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
back() {
|
||||||
|
if (!this.canGoBack) return;
|
||||||
|
this.historyIndex--;
|
||||||
|
this.load(this.history[this.historyIndex]);
|
||||||
|
}
|
||||||
|
|
||||||
|
forward() {
|
||||||
|
if (!this.canGoForward) return;
|
||||||
|
this.historyIndex++;
|
||||||
|
this.load(this.history[this.historyIndex]);
|
||||||
|
}
|
||||||
|
|
||||||
|
up() {
|
||||||
|
if (this.canGoUp) this.navigate(parentPath(this.path));
|
||||||
|
}
|
||||||
|
|
||||||
|
reload() {
|
||||||
|
this.load(this.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
select(entry: FileEntry) {
|
||||||
|
this.selectedPath = entry.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
open(entry: FileEntry) {
|
||||||
|
if (entry.kind === 'folder') {
|
||||||
|
this.navigate(entry.path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (entry.kind === 'image') {
|
||||||
|
const images = this.shownEntries.filter((item) => item.kind === 'image');
|
||||||
|
windowManager.openImageViewer(
|
||||||
|
images.map((image) => ({ src: fileUrl(image.path), alt: image.name })),
|
||||||
|
Math.max(0, images.indexOf(entry))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.open(fileUrl(entry.path), '_blank', 'noopener,noreferrer');
|
||||||
|
}
|
||||||
|
|
||||||
|
moveSelection(step: number) {
|
||||||
|
if (!this.shownEntries.length) return;
|
||||||
|
const index = this.shownEntries.findIndex((entry) => entry.path === this.selectedPath);
|
||||||
|
const nextIndex = index === -1 ? 0 : Math.min(this.shownEntries.length - 1, Math.max(0, index + step));
|
||||||
|
this.selectedPath = this.shownEntries[nextIndex].path;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 100 of 524 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user