diff --git a/.env.example b/.env.example index 27079f2..9677e41 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,8 @@ DISCORD_TOKEN= DISCORD_USER_ID= DISCORD_GUILD_ID= OMDB_API_KEY= +IGDB_CLIENT_ID= +IGDB_CLIENT_SECRET= LASTFM_API_KEY= LIBRARY_API_KEY= LIBRARY_API_URL=http://localhost:5000 diff --git a/Dockerfile b/Dockerfile index e957867..47d7bd4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,7 @@ COPY package.json ./ RUN bun install --production --ignore-scripts COPY --from=build /app/build ./build +COPY --from=build /app/static ./static RUN mkdir -p /app/.cache/music /app/data/lastfm /app/data/library && chown -R bun:bun /app/.cache /app/data ENV NODE_ENV=production diff --git a/README.md b/README.md index c936083..68c6a6b 100644 --- a/README.md +++ b/README.md @@ -22,11 +22,12 @@ The site recreates my [blomarchy](https://git.sirblob.co/SirBlob/blomarchy) setu - **Floating windows** - Alt + V or the title bar button pops a window out of the tiling layout so it can be dragged and resized. - **btop** - a system monitor window with my real GitHub commit graph, repos as processes and some very scientific CPU and caffeine readings. - **Image viewer** - click any image to open it in an imv window with zoom, pan, rotate, pinch and swipe. +- **File explorer** - a thunar window that browses `static/files` with grid and list views, a gallery of image thumbnails, previews for images, video and audio, and the image viewer for opening pictures. - **Kitty terminals** - a real command line with history and tab completion. Try `fastfetch`, `whoami` or `cowsay`. -- **Desktop icons** - grouped into pages, terminal scripts, apps (tcg, movies, shows, books), games and socials (GitHub, LinkedIn, Devpost, Discord). +- **Desktop icons** - grouped into pages, terminal scripts, apps (files, tcg, movies, shows, books, games), games and socials (GitHub, LinkedIn, Devpost, Discord). - **13 themes** - Cerberus, Flats, Tokyo Night, Night Cat, Catppuccin, Arch Linux, Gruvbox, Nord, Kanagawa, Wintry, Rose, Catppuccin Latte and Rose Pine Dawn, each with its own wallpaper. - **Portfolio** - about, portfolio, projects (open source, packages, hackathons, research, organizations), blog with RSS and links with QR codes. -- **Library apps** - movies, shows and books 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. +- **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. @@ -70,7 +71,8 @@ Alt stands in for Super, since browsers can't use it. | `theme [name]` | list or switch themes | | `cowsay ` | moo | | `btop` | system monitor with my GitHub stats | -| `movies` / `shows` / `books` | open the library apps | +| `thunar` / `files` | browse `static/files` | +| `movies` / `shows` / `books` / `games library` | open the library apps | | `pacman -S ` | install things (not really) | ## Running @@ -99,67 +101,74 @@ Copy `.env.example` to `.env` and fill in what you need. Every value is optional | --- | --- | | `DISCORD_TOKEN`, `DISCORD_USER_ID`, `DISCORD_GUILD_ID` | Live Discord status in the bar | | `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 and books. Set the same value on the server and locally | +| `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_DATA_DIR` | Folder for `library.db`, `data/library` by default | | `LASTFM_DATA_DIR` | Folder for `lastfm.db`, `data/lastfm` by default | ## Content -Personal content and config live in `content/`. Shows, movies and books 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. +Personal content and config live in `content/`. Shows, movies, books and games are the exception: they live in a SQLite database at `data/library/library.db` (set `LIBRARY_DATA_DIR` to move it) that the server owns, so adding one never needs a commit. Last.fm scrobbles and album covers are collected at runtime into `data/lastfm/lastfm.db` (set `LASTFM_DATA_DIR` to move it). On first start an older `scrobbles.jsonl`, `covers.json`, `track-covers.json` and `images/` in that folder are moved into the database, and those files can be deleted afterwards. Both databases stay out of git. ### Library API | Request | Does | | --- | --- | -| `GET /api/library` | Every show, movie and book, public | +| `GET /api/library` | Every show, movie, book and game, public | | `PUT /api/library/media/` | Add or change a show or movie | | `DELETE /api/library/media/` | Remove a show or movie | | `PUT /api/library/books/` | Add or change a book | | `DELETE /api/library/books/` | Remove a book | -| `GET /api/library/images/` | A saved poster or cover | +| `PUT /api/library/games/` | Add or change a game | +| `DELETE /api/library/games/` | Remove a game | +| `GET /api/library/images/` | A saved poster, cover or game image | -Changes need `Authorization: Bearer `. A `PUT` only changes the fields it sends, and `null` clears one. When a status changes to watching or reading the start date is set, and to watched or read the finish date, unless the request sends its own. Posters and covers are downloaded by the server and kept in the database. +Changes need `Authorization: Bearer `. 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, portfolio, projects and themes without touching the JSON by hand. It uses the site's own themes. +A terminal app built with [Textual](https://textual.textualize.io/) edits the playlist, shows, movies, books, games, portfolio, projects and themes without touching the JSON by hand. It uses the site's own themes. ```bash bun run content:setup bun run content ``` -`content:setup` creates a virtual environment in `tools/content/.venv` and installs Textual and Pillow. Run it once. +`content:setup` creates a virtual environment in `tools/content/.venv` and installs Textual, Pillow and pillow-heif. Run it once, and again when `requirements.txt` changes. | Key | Action | | --- | --- | -| `1` to `9` | Switch between Music, Shows, Movies, Books, Profile, Experience, Leadership, Projects and Themes | -| `a` | Add (YouTube link, IMDb link or Goodreads link) | +| `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 and books) | -| `g` | Put a show or movie in a group, which the library apps and about page show together | -| `h` | Set the started and finished dates for a show, movie or book | -| `f` | Toggle featured on a project | +| `e` / `Enter` | Edit the selected entry (or change the rating for shows, movies, books and games) | +| `g` | Put a show or movie in a group, which the library apps and about page show together, or set a game's tags and note | +| `h` | Set the started and finished dates for a show, movie, book or game | +| `l` | Change a game's store link or image | +| `f` | Toggle featured on a project, or make a game your favorite | +| `k` | Add a game to the games you like (up to 20) | | `p` | Preview a theme in the editor | | `t` | Switch the editor to the next theme | -| `s` | Move a book to another shelf, set a show or movie to watched, watching or planning, or make a theme the site default | +| `s` | Move a book to another shelf, set a show, movie or game status (a game can also be replayable), or make a theme the site default | | `d` | Delete | | `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 | +| `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 | | `q` | Quit | -The Shows, Movies and Books 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. 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 `. +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. 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 `. -A game in `about.json` can have a `mobygames` link. Run `python3 tools/content/mobygames.py --save` to scrape its release date, platforms, developers, genres, scores, description and images into `data/gameDetails.json`, which the game card shows. Leave out `--save` to only print the result, or use `--debug` if MobyGames blocks the request. +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 `. + +To move the games that used to be listed in `about.json` into the library once, run `bun run games:import`. It removes them from `about.json` only when every game was imported. | File | Holds | | --- | --- | | `profile.json` | Name, bio, socials, skills and photos | -| `about.json` | Hobbies, games and the AniList username | +| `about.json` | Hobbies, setup and the AniList and Last.fm usernames | | `experience.json` / `leadership.json` | Work experience and leadership roles | | `projects/*.json` | Open source, packages, hackathons, research groups and organizations | | `music.json` | YouTube links for the music player | @@ -170,9 +179,20 @@ A game in `about.json` can have a `mobygames` link. Run `python3 tools/content/m | File in `data/` | Holds | | --- | --- | -| `library/library.db` | Shows, movies, books, their history dates and saved images | +| `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 | -| `gameDetails.json` | Video game details scraped from MobyGames | + +### 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 diff --git a/content/about.json b/content/about.json index d4be88a..da28f82 100644 --- a/content/about.json +++ b/content/about.json @@ -33,17 +33,6 @@ "hobbies": [ { "name": "Swimming", "icon": "proicons:person-swim", "description": "I enjoy swimming as a way to relax and stay active. It is my favorite sport." }, { "name": "Baking", "icon": "proicons:cake", "description": "I love baking pastries and experimenting with new dessert recipes." }, - { "name": "Robotics", "icon": "proicons:robot", "description": "Building and programming robots to solve problems is fun." } - ], - "games": [ - { "name": "Minecraft", "image": "/img/minecraft.png", "url": "https://minecraft.net" }, - { "name": "Pokemon TCG Online", "image": "/img/pokemontcg.png", "url": "https://www.pokemon.com/us/pokemon-tcg" }, - { "name": "Fire Watch", "image": "/img/firewatch.png", "url": "https://firewatchgame.com" }, - { "name": "Expedition 33", "image": "/img/e33.png", "url": "https://expedition33.com" }, - { "name": "Mini Motorways", "image": "/img/mini.png", "url": "https://store.steampowered.com/app/1127500/Mini_Motorways/" }, - { "name": "Risk", "image": "/img/risk.png", "url": "https://store.steampowered.com/app/1128810/RISK_Global_Domination/" }, - { "name": "Slay the Spire", "image": "/img/slay_the_spire.png", "url": "https://store.steampowered.com/app/646570/Slay_the_Spire/" }, - { "name": "Slay the Spire 2", "image": "/img/slay_the_spire_2.png", "url": "https://store.steampowered.com/app/2868840/Slay_the_Spire_2/" }, - { "name": "Hades II", "url": "https://store.steampowered.com/app/1145350/Hades_II/", "mobygames": "https://www.mobygames.com/game/223472/hades-ii/" } + { "name": "Robotics", "icon": "proicons:robot", "description": "Building and programming robots to solve problems is fun.", "folder": "robotics" } ] } diff --git a/content/desktop.json b/content/desktop.json index 94cbb87..489093f 100644 --- a/content/desktop.json +++ b/content/desktop.json @@ -37,10 +37,12 @@ "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": "books", "icon": "mdi:bookshelf", "open": { "app": "books" } }, + { "label": "games", "icon": "mdi:gamepad-variant-outline", "open": { "app": "games" } } ] }, { diff --git a/data/gameDetails.json b/data/gameDetails.json deleted file mode 100644 index 0967ef4..0000000 --- a/data/gameDetails.json +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/package.json b/package.json index bdf76f1..df4b021 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "start": "bun --bun vite build && bun ./build/index.js", "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", "content:setup":"python3 -m venv tools/content/.venv && tools/content/.venv/bin/pip install -r tools/content/requirements.txt", "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", @@ -38,7 +40,6 @@ "imports": { "#lib": "./src/lib/index.js", "#lib/*": "./src/lib/*", - "#content/*": "./content/*", - "#data/*": "./data/*" + "#content/*": "./content/*" } } diff --git a/src/lib/components/about/GameCard.svelte b/src/lib/components/about/GameCard.svelte index 831ebe2..13702d4 100644 --- a/src/lib/components/about/GameCard.svelte +++ b/src/lib/components/about/GameCard.svelte @@ -1,51 +1,53 @@ - +
- {#if image} - + {#if game.image} + {:else} {/if} - {#if score} - {score} + {#if game.favorite} + favorite + {/if} + {#if game.rating !== undefined} + {game.rating}/10 + {:else if game.score !== null} + {game.score} + {/if} + {#if game.status === 'playing'} + playing + {:else if game.status === 'planning'} + planning + {:else if game.status === 'replayable'} + replayable + {/if} + {#if link} + Play {/if} - Play
{game.name} - {store.label} + {#if link} + {store.label} + {/if}
{#if metaLine} {metaLine} {/if} - {#if details?.genres.length} - {details.genres.slice(0, 3).join(' · ')} - {/if} - {#if game.mobygames} - MobyGames + {#if game.genres.length} + {game.genres.slice(0, 3).join(' · ')} {/if}
@@ -109,10 +111,47 @@ text-overflow: ellipsis; } - .source { + .favorite { + position: absolute; + top: 0.4rem; + left: 0.4rem; display: inline-flex; align-items: center; - gap: 0.25rem; + 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 { diff --git a/src/lib/components/desktop/Desktop.svelte b/src/lib/components/desktop/Desktop.svelte index a11019d..53ef92e 100644 --- a/src/lib/components/desktop/Desktop.svelte +++ b/src/lib/components/desktop/Desktop.svelte @@ -13,6 +13,7 @@ import DesktopPet from '../pet/DesktopPet.svelte'; import TcgBrowser from '../tcg/TcgBrowser.svelte'; import LibraryApp from '../library/LibraryApp.svelte'; + import FileExplorer from '../files/FileExplorer.svelte'; import { libraryApps } from '#lib/library/apps'; import DesktopIcons from './DesktopIcons.svelte'; import Window from './Window.svelte'; @@ -192,6 +193,18 @@ {/each} + {#each windowManager.fileExplorers as explorer (explorer.id)} + windowManager.closeWindow(explorer.id)} + > + + + {/each} + diff --git a/src/lib/components/files/FileExplorer.svelte b/src/lib/components/files/FileExplorer.svelte new file mode 100644 index 0000000..4745a61 --- /dev/null +++ b/src/lib/components/files/FileExplorer.svelte @@ -0,0 +1,137 @@ + + +
+ + +
+ + +
+ {#if explorer.state === 'error'} +

Could not open this folder.

+ {:else if explorer.state === 'loading' && !explorer.entries.length} +

Loading...

+ {:else if !explorer.shownEntries.length} +

{explorer.query ? 'No files match.' : 'This folder is empty.'}

+ {:else if explorer.view === 'grid'} + + {:else} + + {/if} +
+ + {#if explorer.selected} + explorer.selected && explorer.open(explorer.selected)} /> + {/if} +
+ +
+ {folderCount} folders, {fileCount} files ({formatSize(totalSize)}) + {#if explorer.selected} + {explorer.selected.name} + {/if} +
+
+ + diff --git a/src/lib/components/files/FileGrid.svelte b/src/lib/components/files/FileGrid.svelte new file mode 100644 index 0000000..c6acea2 --- /dev/null +++ b/src/lib/components/files/FileGrid.svelte @@ -0,0 +1,96 @@ + + +
+ {#each explorer.shownEntries as entry (entry.path)} + + {/each} +
+ + diff --git a/src/lib/components/files/FileList.svelte b/src/lib/components/files/FileList.svelte new file mode 100644 index 0000000..6d65305 --- /dev/null +++ b/src/lib/components/files/FileList.svelte @@ -0,0 +1,98 @@ + + + + + + + + + + + + {#each explorer.shownEntries as entry (entry.path)} + explorer.select(entry)} + ondblclick={() => explorer.open(entry)} + > + + + + + {/each} + +
NameSizeModified
+ + {entry.name} + {sizeText(entry)}{entry.modified.slice(0, 10)}
+ + diff --git a/src/lib/components/files/FilePreview.svelte b/src/lib/components/files/FilePreview.svelte new file mode 100644 index 0000000..4f08ab2 --- /dev/null +++ b/src/lib/components/files/FilePreview.svelte @@ -0,0 +1,112 @@ + + + + + diff --git a/src/lib/components/files/FileSidebar.svelte b/src/lib/components/files/FileSidebar.svelte new file mode 100644 index 0000000..38cfe12 --- /dev/null +++ b/src/lib/components/files/FileSidebar.svelte @@ -0,0 +1,63 @@ + + + + + diff --git a/src/lib/components/files/FileToolbar.svelte b/src/lib/components/files/FileToolbar.svelte new file mode 100644 index 0000000..a22aa24 --- /dev/null +++ b/src/lib/components/files/FileToolbar.svelte @@ -0,0 +1,131 @@ + + +
+ + + + + + +
+ + +
+
+ + diff --git a/src/lib/components/library/LibraryDetail.svelte b/src/lib/components/library/LibraryDetail.svelte index 1161256..02745f5 100644 --- a/src/lib/components/library/LibraryDetail.svelte +++ b/src/lib/components/library/LibraryDetail.svelte @@ -3,6 +3,7 @@ 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, @@ -48,6 +49,18 @@
{fact}
{/each} + {#if entry.note} +

{entry.note}

+ {/if} + + {#if entry.tags.length} +
+ {#each entry.tags as tag (tag)} + {tag} + {/each} +
+ {/if} + {#if entry.genres.length}
{#each entry.genres as genre, index (genre)} @@ -162,6 +175,20 @@ 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; diff --git a/src/lib/components/ui/Panel.svelte b/src/lib/components/ui/Panel.svelte index 4c68a27..b0b6286 100644 --- a/src/lib/components/ui/Panel.svelte +++ b/src/lib/components/ui/Panel.svelte @@ -2,14 +2,30 @@ import type { Snippet } from '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(); + } @@ -34,6 +50,10 @@ background: color-mix(in srgb, var(--foreground) 3%, transparent); } + .linked { + cursor: pointer; + } + .linked:hover { border-color: var(--accent); background: color-mix(in srgb, var(--foreground) 7%, transparent); diff --git a/src/lib/content/index.ts b/src/lib/content/index.ts index 1e777c0..1a94ea8 100644 --- a/src/lib/content/index.ts +++ b/src/lib/content/index.ts @@ -12,14 +12,11 @@ import researchData from '#content/projects/research.json'; import organizationsData from '#content/projects/organizations.json'; import tcgDecksData from '#content/tcgDecks.json'; import wallpapersData from '#content/wallpapers.json'; -import gameDetailsData from '#data/gameDetails.json'; import type { AboutContent, BlogSettings, DesktopContent, Experience, - Game, - GameDetails, Leadership, Playlist, Profile, @@ -36,17 +33,7 @@ export type * from './types'; export const profile: Profile = profileData; export const experience: Experience[] = experienceData; export const leadership: Leadership[] = leadershipData; -const gameDetails = gameDetailsData as Record; -const mobygamesIdPattern = /mobygames\.com\/game\/(\d+)/; - -function withGameDetails(game: Game): Game { - const details = gameDetails[game.mobygames?.match(mobygamesIdPattern)?.[1] ?? '']; - return details ? { ...game, details } : game; -} - -const aboutContent = aboutData as AboutContent; - -export const about: AboutContent = { ...aboutContent, games: aboutContent.games.map(withGameDetails) }; +export const about: AboutContent = aboutData as AboutContent; export const desktop: DesktopContent = desktopData; export const blogSettings: BlogSettings = blogData; export const playlist: Playlist = musicData; diff --git a/src/lib/content/types.ts b/src/lib/content/types.ts index 7b21e25..a0da350 100644 --- a/src/lib/content/types.ts +++ b/src/lib/content/types.ts @@ -125,30 +125,34 @@ export interface Hobby { name: string; icon: string; description: string; + folder?: string; } -export interface GameDetails { - url: string; - title: string; +export type PlayStatus = 'played' | 'playing' | 'planning' | 'replayable'; + +export interface Game { + igdbId: string; + name: string; + url: string | null; + igdbUrl: string | null; + image: string | null; + cover: string | null; + status: PlayStatus; + rating?: number; released: string | null; platforms: string[]; developers: string[]; publishers: string[]; genres: string[]; - mobyScore: number | null; - criticScore: number | null; - playerScore: number | null; - description: string | null; - cover: string | null; - screenshot: string | null; -} - -export interface Game { - name: string; - image?: string; - url: string; - mobygames?: string; - details?: GameDetails; + 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'; @@ -212,14 +216,13 @@ export interface AboutContent { lastfmUsername?: string; setup?: DesktopSetup; hobbies: Hobby[]; - games: Game[]; } export type GameName = 'snake' | 'breakout'; -export type LibraryAppName = 'movies' | 'shows' | 'books'; +export type LibraryAppName = 'movies' | 'shows' | 'books' | 'games'; -export type DesktopApp = 'btop' | 'tcg' | 'pet' | GameName | LibraryAppName; +export type DesktopApp = 'btop' | 'tcg' | 'pet' | 'files' | GameName | LibraryAppName; export type DesktopAction = { page: string } | { terminal: string } | { url: string } | { app: DesktopApp }; diff --git a/src/lib/desktop/actions.ts b/src/lib/desktop/actions.ts index 9f7097f..bb924ee 100644 --- a/src/lib/desktop/actions.ts +++ b/src/lib/desktop/actions.ts @@ -20,7 +20,7 @@ export function openPage(path: string) { goto(path); } -const libraryAppNames: DesktopApp[] = ['movies', 'shows', 'books']; +const libraryAppNames: DesktopApp[] = ['movies', 'shows', 'books', 'games']; function isLibraryApp(app: DesktopApp): app is LibraryAppName { return libraryAppNames.includes(app); @@ -30,6 +30,7 @@ export function openApp(app: DesktopApp) { 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); } diff --git a/src/lib/desktop/windows.svelte.ts b/src/lib/desktop/windows.svelte.ts index a72d266..e57b8b1 100644 --- a/src/lib/desktop/windows.svelte.ts +++ b/src/lib/desktop/windows.svelte.ts @@ -26,6 +26,11 @@ export interface TcgBrowserWindow { id: string; } +export interface FileExplorerWindow { + id: string; + startPath: string; +} + export interface LibraryWindow { id: string; app: LibraryAppName; @@ -50,6 +55,7 @@ class WindowManager { games = $state([]); tcgBrowsers = $state([]); libraryWindows = $state([]); + fileExplorers = $state([]); floatingRects = $state>({}); focusedId = $state(pageWindowId); maximizedId = $state(null); @@ -61,6 +67,7 @@ class WindowManager { private nextGameNumber = 1; private nextTcgBrowserNumber = 1; private nextLibraryNumber = 1; + private nextFileExplorerNumber = 1; get extraWindowIds(): string[] { return [ @@ -69,7 +76,8 @@ class WindowManager { ...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.libraryWindows.map((libraryWindow) => libraryWindow.id), + ...this.fileExplorers.map((explorer) => explorer.id) ]; } @@ -115,6 +123,12 @@ class WindowManager { this.focus(libraryWindow.id); } + openFileExplorer(startPath = '') { + const explorer = { id: `thunar-${this.nextFileExplorerNumber++}`, startPath }; + this.fileExplorers.push(explorer); + this.focus(explorer.id); + } + closeWindow(id: string) { this.terminals = this.terminals.filter((terminal) => terminal.id !== id); this.imageViewers = this.imageViewers.filter((viewer) => viewer.id !== id); @@ -122,6 +136,7 @@ class WindowManager { 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]; if (this.maximizedId === id) this.maximizedId = null; if (this.focusedId === id) this.focusedId = this.extraWindowIds.at(-1) ?? pageWindowId; diff --git a/src/lib/files/explorer.svelte.ts b/src/lib/files/explorer.svelte.ts new file mode 100644 index 0000000..2488e83 --- /dev/null +++ b/src/lib/files/explorer.svelte.ts @@ -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([]); + state = $state('loading'); + selectedPath = $state(null); + view = $state('grid'); + query = $state(''); + places = $state([]); + + private history = $state([]); + 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 { + 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; + } +} diff --git a/src/lib/files/types.ts b/src/lib/files/types.ts new file mode 100644 index 0000000..cc8a9bc --- /dev/null +++ b/src/lib/files/types.ts @@ -0,0 +1,51 @@ +export type FileKind = 'folder' | 'image' | 'video' | 'audio' | 'text' | 'font' | 'document' | 'other'; + +export interface FileEntry { + name: string; + path: string; + kind: FileKind; + size: number; + modified: string; + itemCount: number | null; +} + +export interface FolderListing { + path: string; + entries: FileEntry[]; +} + +const kindIcons: Record = { + folder: 'mdi:folder', + image: 'mdi:file-image-outline', + video: 'mdi:file-video-outline', + audio: 'mdi:file-music-outline', + text: 'mdi:file-document-outline', + font: 'mdi:format-font', + document: 'mdi:file-pdf-box', + other: 'mdi:file-outline' +}; + +export function kindIcon(kind: FileKind): string { + return kindIcons[kind]; +} + +export const filesFolder = 'files'; + +export function fileUrl(path: string): string { + return `/${filesFolder}/${path.split('/').map(encodeURIComponent).join('/')}`; +} + +export function formatSize(bytes: number): string { + const units = ['B', 'KB', 'MB', 'GB']; + let size = bytes; + let unitIndex = 0; + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + return unitIndex === 0 ? `${size} ${units[0]}` : `${size.toFixed(1)} ${units[unitIndex]}`; +} + +export function parentPath(path: string): string { + return path.split('/').slice(0, -1).join('/'); +} diff --git a/src/lib/library/apps.ts b/src/lib/library/apps.ts index f16dc89..ba2b28a 100644 --- a/src/lib/library/apps.ts +++ b/src/lib/library/apps.ts @@ -1,6 +1,6 @@ import type { LibraryAppName } from '#lib/content'; import type { LibraryData } from './types'; -import { bookEntries, mediaEntries, type LibraryEntry } from './entries'; +import { bookEntries, gameEntries, mediaEntries, type LibraryEntry } from './entries'; export interface LibraryStatus { id: string; @@ -24,6 +24,13 @@ const watchStatuses: LibraryStatus[] = [ { id: 'planning', label: 'planning', color: 'magenta' } ]; +const playStatuses: LibraryStatus[] = [ + { id: 'playing', label: 'playing', color: 'blue' }, + { id: 'played', label: 'played', color: 'green' }, + { id: 'planning', label: 'planning', color: 'magenta' }, + { id: 'replayable', label: 'replayable', color: 'cyan' } +]; + const readStatuses: LibraryStatus[] = [ { id: 'reading', label: 'reading', color: 'blue' }, { id: 'read', label: 'read', color: 'green' }, @@ -57,6 +64,15 @@ export const libraryApps: Record = { groupLabel: 'Series', statuses: readStatuses, entries: (library) => bookEntries(library.books) + }, + games: { + name: 'games', + title: 'Games', + icon: 'mdi:gamepad-variant-outline', + itemLabel: 'games', + groupLabel: 'Series', + statuses: playStatuses, + entries: (library) => gameEntries(library.games) } }; diff --git a/src/lib/library/entries.ts b/src/lib/library/entries.ts index 696a425..d98deb2 100644 --- a/src/lib/library/entries.ts +++ b/src/lib/library/entries.ts @@ -1,4 +1,4 @@ -import type { Book, RatedTitle } from '#lib/content'; +import type { Book, Game, RatedTitle } from '#lib/content'; import type { MediaDetails, MediaKind } from '#lib/media/types'; export interface LibraryEntry { @@ -16,6 +16,8 @@ export interface LibraryEntry { groupPosition: string | null; groupOrder: number; facts: string[]; + tags: string[]; + note: string | null; added: string | null; started: string | null; finished: string | null; @@ -58,6 +60,8 @@ export function mediaEntries(items: RatedTitle[], details: Record ({ + id: game.igdbId, + title: game.name, + subtitle: game.developers.join(', '), + image: game.cover ?? game.image, + url: game.url ?? game.igdbUrl ?? '', + status: game.status, + rating: game.rating ?? null, + year: game.released?.slice(0, 4) ?? null, + genres: game.genres, + description: game.summary, + group: null, + groupPosition: null, + groupOrder: 0, + facts: [...profileFacts(game), game.platforms.join(', '), game.score !== null ? `IGDB ${game.score}` : ''].filter(Boolean), + tags: game.tags, + note: game.note, + added: game.added ?? null, + started: game.started ?? null, + finished: game.finished ?? null + })); +} diff --git a/src/lib/library/gameProfile.ts b/src/lib/library/gameProfile.ts new file mode 100644 index 0000000..5132ec5 --- /dev/null +++ b/src/lib/library/gameProfile.ts @@ -0,0 +1,13 @@ +export const likedLimit = 20; + +export const suggestedTags: { label: string; icon: string }[] = [ + { label: 'Casual', icon: 'mdi:sofa-outline' }, + { label: 'Competitive', icon: 'mdi:sword-cross' }, + { label: 'Love it', icon: 'mdi:thumb-up-outline' }, + { label: 'Open to play', icon: 'mdi:account-multiple-plus-outline' }, + { label: 'Looking to discuss', icon: 'mdi:chat-outline' } +]; + +export function tagIcon(tag: string): string { + return suggestedTags.find((suggested) => suggested.label.toLowerCase() === tag.toLowerCase())?.icon ?? 'mdi:tag-outline'; +} diff --git a/src/lib/library/stores.ts b/src/lib/library/stores.ts new file mode 100644 index 0000000..424047b --- /dev/null +++ b/src/lib/library/stores.ts @@ -0,0 +1,24 @@ +export interface GameStore { + label: string; + icon: string; +} + +const knownStores: Record = { + 'store.steampowered.com': { label: 'Steam', icon: 'mdi:steam' }, + 'www.xbox.com': { label: 'Xbox', icon: 'mdi:microsoft-xbox' }, + 'www.nintendo.com': { label: 'Nintendo', icon: 'mdi:nintendo-switch' }, + 'store.epicgames.com': { label: 'Epic', icon: 'simple-icons:epicgames' }, + 'www.gog.com': { label: 'GOG', icon: 'simple-icons:gogdotcom' }, + 'store.playstation.com': { label: 'PlayStation', icon: 'mdi:sony-playstation' }, + 'www.igdb.com': { label: 'IGDB', icon: 'mdi:database-outline' } +}; + +export function storeFor(url: string): GameStore { + try { + const host = new URL(url).hostname; + if (host.endsWith('itch.io')) return { label: 'itch.io', icon: 'simple-icons:itchdotio' }; + return knownStores[host] ?? { label: host.replace(/^www\./, ''), icon: 'mdi:web' }; + } catch { + return { label: 'link', icon: 'mdi:web' }; + } +} diff --git a/src/lib/library/types.ts b/src/lib/library/types.ts index 4b9a17c..3cc5791 100644 --- a/src/lib/library/types.ts +++ b/src/lib/library/types.ts @@ -1,4 +1,4 @@ -import type { Book, RatedTitle } from '#lib/content'; +import type { Book, Game, RatedTitle } from '#lib/content'; import type { MediaDetails } from '#lib/media/types'; export interface LibraryMedia { @@ -9,6 +9,7 @@ export interface LibraryMedia { export interface LibraryData extends LibraryMedia { books: Book[]; + games: Game[]; } -export const emptyLibrary: LibraryData = { shows: [], movies: [], mediaDetails: {}, books: [] }; +export const emptyLibrary: LibraryData = { shows: [], movies: [], mediaDetails: {}, books: [], games: [] }; diff --git a/src/lib/server/files.ts b/src/lib/server/files.ts new file mode 100644 index 0000000..745e320 --- /dev/null +++ b/src/lib/server/files.ts @@ -0,0 +1,75 @@ +import { readdir, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { error } from '@sveltejs/kit'; +import { filesFolder, type FileEntry, type FileKind, type FolderListing } from '#lib/files/types'; + +const filesRoot = path.resolve('static', filesFolder); + +const extensionKinds: Record = { + '.png': 'image', + '.jpg': 'image', + '.jpeg': 'image', + '.gif': 'image', + '.webp': 'image', + '.avif': 'image', + '.svg': 'image', + '.ico': 'image', + '.mp4': 'video', + '.webm': 'video', + '.mov': 'video', + '.mp3': 'audio', + '.ogg': 'audio', + '.wav': 'audio', + '.flac': 'audio', + '.txt': 'text', + '.md': 'text', + '.json': 'text', + '.csv': 'text', + '.xml': 'text', + '.ttf': 'font', + '.otf': 'font', + '.woff': 'font', + '.woff2': 'font', + '.pdf': 'document' +}; + +function resolveInsideRoot(relativePath: string): string { + const cleaned = relativePath.replace(/^\/+|\/+$/g, ''); + const absolute = path.resolve(filesRoot, cleaned); + if (absolute !== filesRoot && !absolute.startsWith(`${filesRoot}${path.sep}`)) error(400, 'Path is outside the files folder'); + return absolute; +} + +function isHidden(name: string): boolean { + return name.startsWith('.'); +} + +async function toEntry(folder: string, relativeFolder: string, name: string): Promise { + const details = await stat(path.join(folder, name)).catch(() => null); + if (!details || (!details.isFile() && !details.isDirectory())) return null; + + const isFolder = details.isDirectory(); + const children = isFolder ? await readdir(path.join(folder, name)).catch(() => []) : []; + return { + name, + path: relativeFolder ? `${relativeFolder}/${name}` : name, + kind: isFolder ? 'folder' : (extensionKinds[path.extname(name).toLowerCase()] ?? 'other'), + size: isFolder ? 0 : details.size, + modified: details.mtime.toISOString(), + itemCount: isFolder ? children.filter((child) => !isHidden(child)).length : null + }; +} + +export async function listFolder(relativePath: string): Promise { + const folder = resolveInsideRoot(relativePath); + const names = await readdir(folder).catch(() => error(404, 'Folder not found')); + const relativeFolder = path.relative(filesRoot, folder).split(path.sep).join('/'); + const entries = await Promise.all(names.filter((name) => !isHidden(name)).map((name) => toEntry(folder, relativeFolder, name))); + + return { + path: relativeFolder, + entries: entries + .filter((entry): entry is FileEntry => entry !== null) + .sort((first, second) => Number(second.kind === 'folder') - Number(first.kind === 'folder') || first.name.localeCompare(second.name)) + }; +} diff --git a/src/lib/server/library/books.ts b/src/lib/server/library/books.ts index dfa74ac..a5529ad 100644 --- a/src/lib/server/library/books.ts +++ b/src/lib/server/library/books.ts @@ -2,7 +2,7 @@ import { error } from '@sveltejs/kit'; import type { Book, BookSeries, BookShelf } from '#lib/content'; import { timestamp, type SqlDatabase } from './database'; import { hasImage, imageUrl, removeImage, syncImage } from './images'; -import { readChoice, readHistory, readNumber, readRating, readText, readTextList, type RequestBody } from './input'; +import { parseList, readChoice, readHistory, readNumber, readRating, readText, readTextList, type RequestBody } from './input'; interface BookRow { goodreads_id: string; @@ -36,15 +36,6 @@ function coverImageId(goodreadsId: string): string { return `book-${goodreadsId}`; } -function parseList(text: string): string[] { - try { - const value = JSON.parse(text); - return Array.isArray(value) ? value : []; - } catch { - return []; - } -} - function seriesOf(row: BookRow): BookSeries | undefined { if (!row.series_name) return undefined; return { name: row.series_name, url: row.series_url ?? '', ...(row.series_position && { position: row.series_position }) }; diff --git a/src/lib/server/library/database.ts b/src/lib/server/library/database.ts index 7940683..b3bc7e8 100644 --- a/src/lib/server/library/database.ts +++ b/src/lib/server/library/database.ts @@ -1,5 +1,5 @@ import path from 'node:path'; -import { openSqlite, type SqlDatabase } from '../sqlite'; +import { addMissingColumns, openSqlite, type SqlDatabase } from '../sqlite'; export type { SqlDatabase }; @@ -45,6 +45,31 @@ const schema = ` finished TEXT, updated_at TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS games ( + igdb_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + store_url TEXT, + igdb_url TEXT, + status TEXT NOT NULL DEFAULT 'played', + rating INTEGER, + released TEXT, + platforms TEXT NOT NULL DEFAULT '[]', + developers TEXT NOT NULL DEFAULT '[]', + publishers TEXT NOT NULL DEFAULT '[]', + genres TEXT NOT NULL DEFAULT '[]', + summary TEXT, + score INTEGER, + image_source TEXT, + cover_source TEXT, + favorite INTEGER NOT NULL DEFAULT 0, + liked INTEGER NOT NULL DEFAULT 0, + tags TEXT NOT NULL DEFAULT '[]', + note TEXT, + added TEXT, + started TEXT, + finished TEXT, + updated_at TEXT NOT NULL + ); CREATE TABLE IF NOT EXISTS images ( id TEXT PRIMARY KEY, source TEXT NOT NULL, @@ -54,10 +79,23 @@ const schema = ` ); `; +const addedGameColumns = { + favorite: 'INTEGER NOT NULL DEFAULT 0', + liked: 'INTEGER NOT NULL DEFAULT 0', + tags: "TEXT NOT NULL DEFAULT '[]'", + note: 'TEXT' +}; + let opening: Promise | null = null; +async function openLibraryDatabase(): Promise { + const database = await openSqlite(databasePath, schema); + addMissingColumns(database, 'games', addedGameColumns); + return database; +} + export function libraryDatabase(): Promise { - opening ??= openSqlite(databasePath, schema); + opening ??= openLibraryDatabase(); return opening; } diff --git a/src/lib/server/library/games.ts b/src/lib/server/library/games.ts new file mode 100644 index 0000000..3db6f3b --- /dev/null +++ b/src/lib/server/library/games.ts @@ -0,0 +1,178 @@ +import { error } from '@sveltejs/kit'; +import type { Game, PlayStatus } from '#lib/content'; +import { likedLimit } from '#lib/library/gameProfile'; +import { timestamp, type SqlDatabase } from './database'; +import { hasImage, imageUrl, removeImage, syncImage } from './images'; +import { parseList, readChoice, readFlag, readHistory, readNumber, readRating, readText, readTextList, type RequestBody } from './input'; + +interface GameRow { + igdb_id: string; + name: string; + store_url: string | null; + igdb_url: string | null; + status: PlayStatus; + rating: number | null; + released: string | null; + platforms: string; + developers: string; + publishers: string; + genres: string; + summary: string | null; + score: number | null; + image_source: string | null; + cover_source: string | null; + favorite: number; + liked: number; + tags: string; + note: string | null; + added: string | null; + started: string | null; + finished: string | null; +} + +const playStatuses: PlayStatus[] = ['played', 'playing', 'planning', 'replayable']; +const igdbIdPattern = /^\d+$/; + +export function isIgdbId(text: string): boolean { + return igdbIdPattern.test(text); +} + +function gameImageId(igdbId: string): string { + return `game-${igdbId}`; +} + +function toGame(row: GameRow, hasStoredImage: boolean): Game { + return { + igdbId: row.igdb_id, + name: row.name, + url: row.store_url, + igdbUrl: row.igdb_url, + image: hasStoredImage ? imageUrl(gameImageId(row.igdb_id)) : row.image_source, + cover: row.cover_source, + status: row.status, + released: row.released, + platforms: parseList(row.platforms), + developers: parseList(row.developers), + publishers: parseList(row.publishers), + genres: parseList(row.genres), + summary: row.summary, + score: row.score, + favorite: !!row.favorite, + liked: !!row.liked, + tags: parseList(row.tags), + note: row.note, + ...(row.rating !== null && { rating: row.rating }), + ...(row.added && { added: row.added }), + ...(row.started && { started: row.started }), + ...(row.finished && { finished: row.finished }) + }; +} + +function findRow(database: SqlDatabase, igdbId: string): GameRow | null { + return (database.prepare('SELECT * FROM games WHERE igdb_id = ?').get(igdbId) as GameRow | undefined) ?? null; +} + +export function listGames(database: SqlDatabase, imageIds: Set): Game[] { + const rows = database.prepare('SELECT * FROM games ORDER BY name COLLATE NOCASE').all() as GameRow[]; + return rows.map((row) => toGame(row, imageIds.has(gameImageId(row.igdb_id)))); +} + +function readList(body: RequestBody, key: string, current: string | undefined): string { + return JSON.stringify(readTextList(body, key, parseList(current ?? '[]'))); +} + +function mergeRow(igdbId: string, existing: GameRow | null, body: RequestBody): GameRow { + const name = readText(body, 'name', existing?.name ?? null); + if (!name) error(400, 'name is required'); + const status = readChoice(body, 'status', playStatuses, existing?.status ?? 'played'); + const score = readNumber(body, 'score', existing?.score ?? null); + + return { + igdb_id: igdbId, + name, + store_url: readText(body, 'url', existing?.store_url ?? null), + igdb_url: readText(body, 'igdbUrl', existing?.igdb_url ?? null), + status, + rating: readRating(body, existing?.rating ?? null), + released: readText(body, 'released', existing?.released ?? null), + platforms: readList(body, 'platforms', existing?.platforms), + developers: readList(body, 'developers', existing?.developers), + publishers: readList(body, 'publishers', existing?.publishers), + genres: readList(body, 'genres', existing?.genres), + summary: readText(body, 'summary', existing?.summary ?? null), + score: score === null ? null : Math.round(score), + image_source: readText(body, 'imageSource', existing?.image_source ?? null), + cover_source: readText(body, 'coverSource', existing?.cover_source ?? null), + favorite: Number(readFlag(body, 'favorite', !!existing?.favorite)), + liked: Number(readFlag(body, 'liked', !!existing?.liked)), + tags: readList(body, 'tags', existing?.tags), + note: readText(body, 'note', existing?.note ?? null), + ...readHistory( + body, + { added: existing?.added ?? null, started: existing?.started ?? null, finished: existing?.finished ?? null }, + { isNew: !existing, previousStatus: existing?.status ?? null, status, activeStatus: 'playing', doneStatus: 'played' } + ) + }; +} + +function checkLikedLimit(database: SqlDatabase, row: GameRow) { + if (!row.liked) return; + const others = database.prepare('SELECT COUNT(*) AS total FROM games WHERE liked = 1 AND igdb_id != ?').get(row.igdb_id) as { total: number }; + if (Number(others.total) >= likedLimit) error(400, `Only ${likedLimit} games can be liked`); +} + +export async function saveGame(database: SqlDatabase, igdbId: string, body: RequestBody): Promise { + const row = mergeRow(igdbId, findRow(database, igdbId), body); + checkLikedLimit(database, row); + if (row.favorite) database.prepare('UPDATE games SET favorite = 0 WHERE igdb_id != ?').run(igdbId); + + database + .prepare( + `INSERT INTO games (igdb_id, name, store_url, igdb_url, status, rating, released, platforms, developers, publishers, genres, summary, + score, image_source, cover_source, favorite, liked, tags, note, added, started, finished, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(igdb_id) DO UPDATE SET name = excluded.name, store_url = excluded.store_url, igdb_url = excluded.igdb_url, + status = excluded.status, rating = excluded.rating, released = excluded.released, platforms = excluded.platforms, + developers = excluded.developers, publishers = excluded.publishers, genres = excluded.genres, summary = excluded.summary, + score = excluded.score, image_source = excluded.image_source, cover_source = excluded.cover_source, + favorite = excluded.favorite, liked = excluded.liked, tags = excluded.tags, + note = excluded.note, added = excluded.added, + started = excluded.started, finished = excluded.finished, updated_at = excluded.updated_at` + ) + .run( + row.igdb_id, + row.name, + row.store_url, + row.igdb_url, + row.status, + row.rating, + row.released, + row.platforms, + row.developers, + row.publishers, + row.genres, + row.summary, + row.score, + row.image_source, + row.cover_source, + row.favorite, + row.liked, + row.tags, + row.note, + row.added, + row.started, + row.finished, + timestamp() + ); + + if ('imageSource' in body) await syncImage(database, gameImageId(igdbId), row.image_source); + + return toGame(row, hasImage(database, gameImageId(igdbId))); +} + +export function deleteGame(database: SqlDatabase, igdbId: string): boolean { + if (!findRow(database, igdbId)) return false; + database.prepare('DELETE FROM games WHERE igdb_id = ?').run(igdbId); + removeImage(database, gameImageId(igdbId)); + return true; +} diff --git a/src/lib/server/library/images.ts b/src/lib/server/library/images.ts index fa21eb8..3f04db2 100644 --- a/src/lib/server/library/images.ts +++ b/src/lib/server/library/images.ts @@ -33,8 +33,11 @@ export function removeImage(database: SqlDatabase, id: string) { database.prepare('DELETE FROM images WHERE id = ?').run(id); } +function isWebAddress(source: string): boolean { + return /^https?:\/\//.test(source); +} + async function downloadImage(source: string): Promise { - if (!/^https?:\/\//.test(source)) throw new Error(`${source} is not a web address`); const response = await fetch(source, { headers: requestHeaders }); if (!response.ok) throw new Error(`image request returned ${response.status}`); @@ -47,7 +50,7 @@ async function downloadImage(source: string): Promise { } export async function syncImage(database: SqlDatabase, id: string, source: string | null) { - if (!source) { + if (!source || !isWebAddress(source)) { removeImage(database, id); return; } diff --git a/src/lib/server/library/index.ts b/src/lib/server/library/index.ts index 5fd98eb..18a40f9 100644 --- a/src/lib/server/library/index.ts +++ b/src/lib/server/library/index.ts @@ -3,9 +3,10 @@ import { libraryDatabase } from './database'; import { storedImageIds } from './images'; import { listMedia } from './media'; import { listBooks } from './books'; +import { listGames } from './games'; export async function getLibrary(): Promise { const database = await libraryDatabase(); const imageIds = storedImageIds(database); - return { ...listMedia(database, imageIds), books: listBooks(database, imageIds) }; + return { ...listMedia(database, imageIds), books: listBooks(database, imageIds), games: listGames(database, imageIds) }; } diff --git a/src/lib/server/library/input.ts b/src/lib/server/library/input.ts index 804073b..a92da60 100644 --- a/src/lib/server/library/input.ts +++ b/src/lib/server/library/input.ts @@ -49,6 +49,21 @@ export function readTextList(body: RequestBody, key: string, current: string[]): return (value as string[]).map((item) => item.trim()).filter(Boolean); } +export function readFlag(body: RequestBody, key: string, current: boolean): boolean { + if (!(key in body)) return current; + if (typeof body[key] !== 'boolean') error(400, `${key} must be true or false`); + return body[key] as boolean; +} + +export function parseList(text: string): string[] { + try { + const value = JSON.parse(text); + return Array.isArray(value) ? value : []; + } catch { + return []; + } +} + export function readDate(body: RequestBody, key: string, current: string | null): string | null { const value = readText(body, key, current); if (value !== null && !datePattern.test(value)) error(400, `${key} must be a date like 2026-01-31`); diff --git a/src/lib/server/sqlite.ts b/src/lib/server/sqlite.ts index 802da50..6ad8faf 100644 --- a/src/lib/server/sqlite.ts +++ b/src/lib/server/sqlite.ts @@ -28,6 +28,13 @@ export async function openSqlite(filePath: string, schema: string): Promise) { + const existing = new Set((database.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]).map((column) => column.name)); + for (const [name, definition] of Object.entries(columns)) { + if (!existing.has(name)) database.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`); + } +} + export function inTransaction(database: SqlDatabase, work: () => void) { database.exec('BEGIN'); try { diff --git a/src/lib/terminal/commands/games.ts b/src/lib/terminal/commands/games.ts index b43002c..3014a65 100644 --- a/src/lib/terminal/commands/games.ts +++ b/src/lib/terminal/commands/games.ts @@ -19,9 +19,13 @@ const launchCommands: Command[] = gameNames.map((game) => ({ const gamesCommand: Command = { name: 'games', description: 'List or launch the games', - usage: 'games [name]', - argumentOptions: () => gameNames, + usage: 'games [name | library]', + argumentOptions: () => [...gameNames, 'library'], run: (args, context) => { + if (args[0]?.toLowerCase() === 'library') { + context.openApp('games'); + return; + } const requestedGame = gameNames.find((game) => game === args[0]?.toLowerCase()); if (requestedGame) { context.openApp(requestedGame); @@ -30,6 +34,7 @@ const gamesCommand: Command = { gameNames.forEach((game) => context.print(line(span(game.padEnd(12), 'accent', { bold: true }), gameDescriptions[game])) ); + context.print(line(span('library'.padEnd(12), 'accent', { bold: true }), 'Games I play, my favorite and the ones I like')); context.print(line(span('Run ', 'muted'), span('games ', 'accent'), span(' to play', 'muted'))); } }; diff --git a/src/lib/terminal/commands/system.ts b/src/lib/terminal/commands/system.ts index ea6a617..5865b68 100644 --- a/src/lib/terminal/commands/system.ts +++ b/src/lib/terminal/commands/system.ts @@ -140,6 +140,12 @@ export const systemCommands: Command[] = [ description: 'Open the Pokemon TCG card browser', run: (_args, context) => context.openApp('tcg') }, + { + name: 'thunar', + aliases: ['files', 'explorer'], + description: 'Open the file explorer', + run: (_args, context) => context.openApp('files') + }, { name: 'movies', description: 'Open my movie watch history', diff --git a/src/routes/about/+page.svelte b/src/routes/about/+page.svelte index ccc5b70..8d88eeb 100644 --- a/src/routes/about/+page.svelte +++ b/src/routes/about/+page.svelte @@ -1,5 +1,7 @@