your personal website on atproto - mirror blento.app

Merge branch 'new-game-card' of https://github.com/unbedenklich/blento into new-game-card

+3747 -2107
+10
.claude/settings.local.json
··· 1 + { 2 + "permissions": { 3 + "allow": [ 4 + "Bash(pnpm check:*)", 5 + "mcp__ide__getDiagnostics", 6 + "mcp__plugin_svelte_svelte__svelte-autofixer", 7 + "mcp__plugin_svelte_svelte__list-sections" 8 + ] 9 + } 10 + }
+34
AGENTS.md
··· 1 + # Repository Guidelines 2 + 3 + ## Project Structure & Module Organization 4 + - `src/routes` contains SvelteKit routes, including dynamic handle pages in `src/routes/[handle]/[[page]]`, edit flows in `src/routes/[handle]/[[page]]/edit` and `src/routes/edit`, and API endpoints under `src/routes/api`. 5 + - `src/lib` holds reusable modules: card implementations in `src/lib/cards`, shared UI in `src/lib/components`, OAuth helpers in `src/lib/oauth`, and site data/loading in `src/lib/website`. 6 + - Root app setup lives in `src/app.html` and `src/app.css`. 7 + - `static` is for public assets served as-is. 8 + - `docs` includes contributor-facing docs like custom cards and self-hosting. 9 + 10 + ## Build, Test, and Development Commands 11 + - `pnpm dev` starts the Vite dev server. 12 + - `pnpm build` creates a production build. 13 + - `pnpm preview` builds and runs locally with Wrangler. 14 + - `pnpm check` runs `svelte-check` for type diagnostics. 15 + - `pnpm lint` runs Prettier check and ESLint. 16 + - `pnpm format` auto-formats the codebase with Prettier. 17 + - `pnpm deploy` builds and deploys to Cloudflare Workers. 18 + 19 + ## Coding Style & Naming Conventions 20 + - Indentation uses tabs, single quotes, and 100-column width (see `.prettierrc`). 21 + - Svelte components use PascalCase filenames; utilities and helpers use camelCase. 22 + - Prefer TypeScript in `src` and keep module boundaries aligned with `src/lib` subfolders (cards, components, website, oauth). 23 + 24 + ## Testing Guidelines 25 + - There is no dedicated test runner yet. Use `pnpm check` and `pnpm lint` before submitting changes. 26 + - For UI changes, verify key flows manually (login, card editing, save/load, and route navigation across `[handle]` pages). 27 + 28 + ## Commit & Pull Request Guidelines 29 + - Keep commits short and direct; recent history favors lowercase, imperative summaries (e.g., `small fixes`). 30 + - PRs should include a clear description, linked issues when relevant, and screenshots for UI changes. 31 + 32 + ## Configuration & Deployment Notes 33 + - Copy `.env.example` to `.env` and adjust `PUBLIC_*` values for local or self-hosted setups. 34 + - Cloudflare configuration lives in `wrangler.jsonc`; deployments use Wrangler via `pnpm deploy`.
+11 -9
CLAUDE.md
··· 4 4 5 5 ## Project Overview 6 6 7 - Blento is a Bluesky-powered customizable bento grid website builder. Users authenticate via ATProto OAuth and can create personalized websites with draggable/resizable cards that are stored directly in their Bluesky PDS (Personal Data Server) using the `app.blento.card` collection. 7 + Blento is a Bluesky-powered customizable bento grid website builder. Users authenticate via ATProto OAuth and create draggable/resizable cards stored in their Bluesky PDS (Personal Data Server) using the `app.blento.card` collection. 8 8 9 9 ## Commands 10 10 ··· 32 32 - Desktop position/size: `x`, `y`, `w`, `h` 33 33 - Mobile position/size: `mobileX`, `mobileY`, `mobileW`, `mobileH` 34 34 35 - Grid margins: 20px desktop, 12px mobile. 35 + Grid margins: 16px desktop, 12px mobile. 36 36 37 37 ### Key Components 38 38 39 39 **Website Rendering:** 40 40 41 - - `Website.svelte` - Read-only view of a user's bento grid 42 - - `EditableWebsite.svelte` - Full editing interface with drag-and-drop, card creation, and save functionality 41 + - `src/lib/website/Website.svelte` - Read-only view of a user's bento grid 42 + - `src/lib/website/EditableWebsite.svelte` - Full editing interface with drag-and-drop, card creation, and save functionality 43 + - `src/lib/website/Settings.svelte` and `src/lib/website/Profile.svelte` - Editing panels and profile UI 43 44 - Styling: two colors: base color (one the gray-ish tailwind colors: `gray`, `neutral`, `stone`, ...) and accent color (one of the not-gray-ish tailwind color: `rose`, `red`, `amber`, ...) 44 45 45 46 **Card System (`src/lib/cards/`):** 46 47 47 48 - `CardDefinition` type in `types.ts` defines the interface for card types 48 49 - Each card type exports a definition with: `type`, `contentComponent`, optional `editingContentComponent`, `creationModalComponent`, `sidebarComponent`, `loadData`, `upload` (see more info and description in `src/lib/cards/types.ts`) 49 - - Card types: Text, Link, Image, Youtube, BlueskyPost, Embed, Map, Livestream, ATProtoCollections, Section 50 + - Card types include Text, Link, Image, Bluesky, Embed, Map, Livestream, ATProto collections, and special cards (see `src/lib/cards`). 50 51 - `AllCardDefinitions` and `CardDefinitionsByType` in `index.ts` aggregate all card types 51 52 - See e.g. `src/lib/cards/EmbedCard/` and `src/lib/cards/LivestreamCard/` for examples of implementation. 52 53 - Cards should be styled to work in light and dark mode (with `dark:` class modifier) as well as when cards are colorful (= bg-color-500 for the card background) (with `accent:` modifier). ··· 59 60 60 61 **Data Loading (`src/lib/website/`):** 61 62 62 - - `load.ts` - Fetches user data from their PDS, with Cloudflare KV caching (`USER_DATA_CACHE`) 63 + - `load.ts` - Fetches user data from their PDS, with optional KV caching via `UserCache` 63 64 - `data.ts` - Defines which collections/records to fetch 64 65 - `context.ts` - Svelte contexts for passing DID, handle, and data down the component tree 65 66 66 67 ### Routes 67 68 68 69 - `/` - Landing page 69 - - `/[handle]` - View a user's bento site (loads from their PDS) 70 - - `/[handle]/edit` - Edit mode for the user's site 70 + - `/[handle]/[[page]]` - View a user's bento site (loads from their PDS) 71 + - `/[handle]/[[page]]/edit` - Edit mode for a user's site page 71 72 - `/edit` - Self-hosted edit mode 72 73 - `/api/links` - Link preview API 73 74 - `/api/geocoding` - Geocoding API for map cards 75 + - `/api/instagram`, `/api/reloadRecent`, `/api/update` - Additional data endpoints 74 76 75 77 ### Item Type 76 78 ··· 80 82 81 83 `src/lib/helper.ts` contains grid layout algorithms: 82 84 83 - - `fixCollisions` - Push cards down when they overlap 85 + - `fixAllCollisions` - Push cards down when they overlap 84 86 - `compactItems` - Move cards up to fill gaps 85 87 - `simulateFinalPosition` - Preview where a dragged card will land
+27 -11
README.md
··· 1 1 # blento 2 2 3 - WORK IN PROGRESS, not ready for use yet, but you can test it out at: https://blento.app (no guarantee that your blento wont be broken at some point though and might have to be recreated). 3 + Alpha version can be tried at https://blento.app 4 4 5 - your personal website in a bento style layout, using your bluesky PDS as a backend. 5 + Your personal website in a bento style layout, using your bluesky personal data server as a backend. 6 6 7 7 made with svelte, tailwind and hosted on cloudflare workers. 8 8 9 - ## Development 9 + 10 + https://github.com/user-attachments/assets/2b6f5e99-b5d4-4484-ab95-35445067bb80 11 + 12 + 13 + ## Why? 10 14 11 - ``` 12 - git clone https://github.com/flo-bit/blento.git 13 - cd blento 14 - cp .env.example .env 15 - pnpm install 16 - pnpm run dev 17 - ``` 15 + This started as a replacement/alternative for bento.me which is shutting down in a few weeks (Feb 2026) after being bought by a competitor ^^ I wanted to build a version that couldn't just shut down or where it would be very easy to spin up a new version (with all the data) should the old one disappear. 16 + 17 + That's why all your data is saved on your bluesky personal data server, so you can start setting up your website on blento.app, but then anytime you want to start self hosting you easily take your data with you (dedicated forks optimized for self hosting on different platforms coming soon). 18 + 19 + Should blento.app shut down at some point, someone else can also spin up a new version that shows all blentos (note: it's MIT licensed so you *could* do that now too and offer a competing service, but please don't (except for self-hosting your own profile ofc), legal != nice). 20 + 21 + One other note: for most independence I encourage everyone to get their own domain and either self host or redirect to blento.app/your-profile (still working on a way to make this as easy as possible for non-technical users, if you have any suggestions please reach out). 18 22 19 23 ## Selfhosting 20 24 ··· 22 26 23 27 ## Making Custom cards 24 28 25 - See [docs/CustomCards](./docs/CustomCards.md) 29 + See [docs/CustomCards](./docs/CustomCards.md) 30 + 31 + ## Contributing 32 + 33 + See [docs/Contributing](./docs/Contributing.md) 34 + 35 + ## Idea for a card? 36 + 37 + Open an issue 38 + 39 + ## License 40 + 41 + MIT
+37
docs/Beta.md
··· 1 + # Todo for beta version 2 + 3 + - fix opengraph image stuff (caching issue?) 4 + 5 + - site.standard 6 + - move subpages to own lexicon (app.blento.page) 7 + - move description to markdownDescription and set description as text only 8 + 9 + - fix recent blentos only showing the last ~5 blentos 10 + 11 + - card with big call to action button 12 + 13 + - link card: save favicon and og image as blobs 14 + 15 + - video card? 16 + 17 + - allow changing profile picture 18 + 19 + - allow editing profile stuff inline (in sidebar profile) 20 + 21 + - allow setting base and accent color 22 + 23 + - go straight to edit mode (and redirect to edit mode on succesfull login) 24 + 25 + - ask to fill with some default cards on page creation 26 + 27 + - share button (copy share link to blento, maybe post to bluesky?) 28 + 29 + - add icons to "change card to..." popover 30 + 31 + - show social icon instead of favicon in link card if platform found for link 32 + 33 + - when adding images try to add them in a size that best fits aspect ratio 34 + 35 + - onboarding 36 + 37 + - show alert when user tries to close window with unsaved changes
+45
docs/CardIdeas.md
··· 1 + # card ideas 2 + 3 + ## media 4 + 5 + - general video card 6 + - inline youtube video 7 + - cartoons: aka https://www.opendoodles.com/ 8 + - excalidraw (/svg card) 9 + - latest blog post (e.g. leaflet) 10 + - fake 3d image (with depth map) 11 + 12 + ## social accounts 13 + 14 + - instagram card (showing follow button, follower count, latest posts) 15 + - github card (showing activity grid) 16 + - bluesky account card (showing follow button, follower count, avatar, name, cover image) 17 + - youtube channel card (showing channel name, latest videos, follow button?) 18 + 19 + ## bluesky 20 + 21 + - bluesky feed 22 + - bluesky post (fixed or latest) 23 + - social accounts card (multiple) 24 + 25 + ## social 26 + 27 + - guestbook 28 + 29 + ## atmosphere 30 + 31 + - leaflet 32 + - skywatched 33 + - teal.fm 34 + - tangled.sh 35 + - popfeed.social 36 + - smokesignal.events (https://pdsls.dev/at://did:plc:xbtmt2zjwlrfegqvch7fboei/events.smokesignal.calendar.event/3ltn2qrxf3626) 37 + - statusphere.xyz 38 + - goals.garden 39 + - flashes.blue (https://pdsls.dev/at://did:plc:aytgljyikzbtgrnac2u4ccft/blue.flashes.actor.portfolio, https://app.flashes.blue/profile/j4ck.xyz) 40 + 41 + ## programming 42 + 43 + - svader 44 + - zdog 45 + - tixy
+22
docs/Contributing.md
··· 1 + # Contributing Guidelines 2 + 3 + Contributions are very welcome 🎉 4 + 5 + For creating new cards see [here](CustomCards.md) (and check out [existing card ideas](CardIdeas.md)) 6 + 7 + ## Development 8 + 9 + ``` 10 + git clone https://github.com/flo-bit/blento.git 11 + cd blento 12 + cp .env.example .env 13 + pnpm install 14 + pnpm run dev 15 + ``` 16 + 17 + ## AI assisted development 18 + 19 + You can submit PRs written with AI but please make sure: 20 + 21 + - there's no extra unnecessary changes/unnecessary verbose code (keep it simple) 22 + - you test everything yourself (in light/dark mode, with and without colored cards, in edit mode and not in edit mode)
+2 -3
docs/CustomCards.md
··· 1 1 # Custom Cards 2 2 3 - WORK IN PROGRESS, EARLY STATE, MIGHT CHANGE. 4 - 5 3 see `src/lib/cards` for how cards are made (and e.g. `src/lib/cards/EmbedCard/` and `src/lib/cards/LivestreamCard/` for examples of implementation). 6 4 7 5 Notes: 8 6 9 - Cards should be styled to work in light and dark mode (with dark: class modifier) as well as when cards are colorful (= bg-color-500 for the card) (with accent: modifier). 7 + - Cards should be styled to work in light and dark mode (with dark: class modifier) as well as when cards are colorful (= bg-color-500 for the card) (with accent: modifier). 8 + - Please test newly created cards both when editing (/your-user/edit) and in your user profile when saved (/your-user)
+4
docs/Lexicon.md
··· 1 + # Lexicons used by blento.app 2 + 3 + - `site.standard.publication` 4 + - `app.blento.card`
+4 -6
package.json
··· 1 1 { 2 2 "name": "blento", 3 3 "private": true, 4 - "version": "0.1.0", 4 + "version": "0.2.0", 5 5 "type": "module", 6 6 "scripts": { 7 7 "dev": "vite dev", ··· 18 18 "devDependencies": { 19 19 "@eslint/compat": "^1.2.5", 20 20 "@eslint/js": "^9.18.0", 21 - "@sveltejs/adapter-auto": "^4.0.0", 22 21 "@sveltejs/adapter-cloudflare": "^7.2.4", 23 - "@sveltejs/adapter-static": "^3.0.8", 24 - "@sveltejs/kit": "^2.16.0", 22 + "@sveltejs/kit": "^2.49.5", 25 23 "@sveltejs/vite-plugin-svelte": "^5.0.0", 26 24 "@tailwindcss/forms": "^0.5.10", 27 25 "@tailwindcss/vite": "^4.0.0", ··· 33 31 "prettier": "^3.4.2", 34 32 "prettier-plugin-svelte": "^3.3.3", 35 33 "prettier-plugin-tailwindcss": "^0.6.11", 36 - "svelte": "^5.45.8", 34 + "svelte": "^5.46.4", 37 35 "svelte-check": "^4.0.0", 38 36 "tailwindcss": "^4.0.0", 39 37 "typescript": "^5.0.0", ··· 43 41 "dependencies": { 44 42 "@atcute/client": "^3.1.0", 45 43 "@atcute/oauth-browser-client": "^1.0.13", 46 - "@atproto/api": "^0.18.13", 44 + "@atproto/api": "^0.18.16", 47 45 "@atproto/common-web": "^0.4.2", 48 46 "@cloudflare/workers-types": "^4.20260109.0", 49 47 "@ethercorps/sveltekit-og": "^4.2.1",
+167 -158
pnpm-lock.yaml
··· 15 15 specifier: ^1.0.13 16 16 version: 1.0.18 17 17 '@atproto/api': 18 - specifier: ^0.18.13 19 - version: 0.18.13 18 + specifier: ^0.18.16 19 + version: 0.18.16 20 20 '@atproto/common-web': 21 21 specifier: ^0.4.2 22 22 version: 0.4.2 ··· 25 25 version: 4.20260109.0 26 26 '@ethercorps/sveltekit-og': 27 27 specifier: ^4.2.1 28 - version: 4.2.1(@sveltejs/kit@2.20.8(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2))) 28 + version: 4.2.1(@sveltejs/kit@2.49.5(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.46.4)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.46.4)(typescript@5.8.3)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2))) 29 29 '@foxui/colors': 30 30 specifier: ^0.4.7 31 - version: 0.4.7(svelte@5.45.8)(tailwindcss@4.1.5) 31 + version: 0.4.7(svelte@5.46.4)(tailwindcss@4.1.5) 32 32 '@foxui/core': 33 33 specifier: ^0.4.7 34 - version: 0.4.7(svelte@5.45.8)(tailwindcss@4.1.5) 34 + version: 0.4.7(svelte@5.46.4)(tailwindcss@4.1.5) 35 35 '@foxui/social': 36 36 specifier: ^0.4.7 37 - version: 0.4.7(svelte@5.45.8)(tailwindcss@4.1.5) 37 + version: 0.4.7(svelte@5.46.4)(tailwindcss@4.1.5) 38 38 '@foxui/time': 39 39 specifier: ^0.4.7 40 - version: 0.4.7(svelte@5.45.8)(tailwindcss@4.1.5) 40 + version: 0.4.7(svelte@5.46.4)(tailwindcss@4.1.5) 41 41 '@tailwindcss/typography': 42 42 specifier: ^0.5.16 43 43 version: 0.5.16(tailwindcss@4.1.5) ··· 67 67 version: 2.12.0 68 68 bits-ui: 69 69 specifier: ^2.14.4 70 - version: 2.14.4(@internationalized/date@3.8.0)(@sveltejs/kit@2.20.8(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.45.8) 70 + version: 2.14.4(@internationalized/date@3.8.0)(@sveltejs/kit@2.49.5(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.46.4)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.46.4)(typescript@5.8.3)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.46.4) 71 71 clsx: 72 72 specifier: ^2.1.1 73 73 version: 2.1.1 ··· 94 94 version: 16.5.0 95 95 svelte-sonner: 96 96 specifier: ^1.0.7 97 - version: 1.0.7(svelte@5.45.8) 97 + version: 1.0.7(svelte@5.46.4) 98 98 tailwind-merge: 99 99 specifier: ^3.4.0 100 100 version: 3.4.0 ··· 114 114 '@eslint/js': 115 115 specifier: ^9.18.0 116 116 version: 9.26.0 117 - '@sveltejs/adapter-auto': 118 - specifier: ^4.0.0 119 - version: 4.0.0(@sveltejs/kit@2.20.8(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2))) 120 117 '@sveltejs/adapter-cloudflare': 121 118 specifier: ^7.2.4 122 - version: 7.2.4(@sveltejs/kit@2.20.8(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(wrangler@4.54.0(@cloudflare/workers-types@4.20260109.0)) 123 - '@sveltejs/adapter-static': 124 - specifier: ^3.0.8 125 - version: 3.0.8(@sveltejs/kit@2.20.8(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2))) 119 + version: 7.2.4(@sveltejs/kit@2.49.5(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.46.4)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.46.4)(typescript@5.8.3)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(wrangler@4.54.0(@cloudflare/workers-types@4.20260109.0)) 126 120 '@sveltejs/kit': 127 - specifier: ^2.16.0 128 - version: 2.20.8(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)) 121 + specifier: ^2.49.5 122 + version: 2.49.5(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.46.4)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.46.4)(typescript@5.8.3)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)) 129 123 '@sveltejs/vite-plugin-svelte': 130 124 specifier: ^5.0.0 131 - version: 5.0.3(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)) 125 + version: 5.0.3(svelte@5.46.4)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)) 132 126 '@tailwindcss/forms': 133 127 specifier: ^0.5.10 134 128 version: 0.5.10(tailwindcss@4.1.5) ··· 146 140 version: 10.1.3(eslint@9.26.0(jiti@2.4.2)) 147 141 eslint-plugin-svelte: 148 142 specifier: ^2.46.1 149 - version: 2.46.1(eslint@9.26.0(jiti@2.4.2))(svelte@5.45.8) 143 + version: 2.46.1(eslint@9.26.0(jiti@2.4.2))(svelte@5.46.4) 150 144 globals: 151 145 specifier: ^15.14.0 152 146 version: 15.15.0 ··· 155 149 version: 3.5.3 156 150 prettier-plugin-svelte: 157 151 specifier: ^3.3.3 158 - version: 3.3.3(prettier@3.5.3)(svelte@5.45.8) 152 + version: 3.3.3(prettier@3.5.3)(svelte@5.46.4) 159 153 prettier-plugin-tailwindcss: 160 154 specifier: ^0.6.11 161 - version: 0.6.11(prettier-plugin-svelte@3.3.3(prettier@3.5.3)(svelte@5.45.8))(prettier@3.5.3) 155 + version: 0.6.11(prettier-plugin-svelte@3.3.3(prettier@3.5.3)(svelte@5.46.4))(prettier@3.5.3) 162 156 svelte: 163 - specifier: ^5.45.8 164 - version: 5.45.8 157 + specifier: ^5.46.4 158 + version: 5.46.4 165 159 svelte-check: 166 160 specifier: ^4.0.0 167 - version: 4.1.7(picomatch@4.0.2)(svelte@5.45.8)(typescript@5.8.3) 161 + version: 4.1.7(picomatch@4.0.2)(svelte@5.46.4)(typescript@5.8.3) 168 162 tailwindcss: 169 163 specifier: ^4.0.0 170 164 version: 4.1.5 ··· 195 189 '@atproto/api@0.15.27': 196 190 resolution: {integrity: sha512-ok/WGafh1nz4t8pEQGtAF/32x2E2VDWU4af6BajkO5Gky2jp2q6cv6aB2A5yuvNNcc3XkYMYipsqVHVwLPMF9g==, tarball: https://registry.npmjs.org/@atproto/api/-/api-0.15.27.tgz} 197 191 198 - '@atproto/api@0.18.13': 199 - resolution: {integrity: sha512-CULZ01pSJDltLS/Gc9MMrhFzB6OM3ezyZw7KoeLT/sBfwgA1ddA4mWdTh7DIRosPRigXtA05bnoiCutZbQDo+Q==, tarball: https://registry.npmjs.org/@atproto/api/-/api-0.18.13.tgz} 192 + '@atproto/api@0.18.16': 193 + resolution: {integrity: sha512-tRGKSWr83pP5CQpSboePU21pE+GqLDYy1XHae4HH4hjaT0pr5V8wNgu70kbKB0B02GVUumeDRpJnlHKD+eMzLg==, tarball: https://registry.npmjs.org/@atproto/api/-/api-0.18.16.tgz} 200 194 201 195 '@atproto/common-web@0.4.11': 202 196 resolution: {integrity: sha512-VHejNmSABU8/03VrQ3e36AmT5U3UIeio+qSUqCrO1oNgrJcWfGy1rpj0FVtUugWF8Un29+yzkukzWGZfXL70rQ==, tarball: https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.11.tgz} 197 + 198 + '@atproto/common-web@0.4.12': 199 + resolution: {integrity: sha512-3aCJemqM/fkHQrVPbTCHCdiVstKFI+2LkFLvUhO6XZP0EqUZa/rg/CIZBKTFUWu9I5iYiaEiXL9VwcDRpEevSw==, tarball: https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.12.tgz} 203 200 204 201 '@atproto/common-web@0.4.2': 205 202 resolution: {integrity: sha512-vrXwGNoFGogodjQvJDxAeP3QbGtawgZute2ed1XdRO0wMixLk3qewtikZm06H259QDJVu6voKC5mubml+WgQUw==, tarball: https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.2.tgz} ··· 207 204 '@atproto/lex-data@0.0.7': 208 205 resolution: {integrity: sha512-W/Q5o9o7n2Sv3UywckChu01X5lwQUtaiiOkGJLnRsdkQTyC6813nPgY+p2sG7NwwM+82lu+FUV9fE/Ul3VqaJw==, tarball: https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.7.tgz} 209 206 207 + '@atproto/lex-data@0.0.8': 208 + resolution: {integrity: sha512-1Y5tz7BkS7380QuLNXaE8GW8Xba+mRWugt8BKM4BUFYjjUZdmirU8lr72iM4XlEBrzRu8Cfvj+MbsbYaZv+IgA==, tarball: https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.8.tgz} 209 + 210 210 '@atproto/lex-json@0.0.7': 211 211 resolution: {integrity: sha512-bjNPD5M/MhLfjNM7tcxuls80UgXpHqxdOxDXEUouAtZQV/nIDhGjmNUvKxOmOgnDsiZRnT2g5y3onrnjH3a44g==, tarball: https://registry.npmjs.org/@atproto/lex-json/-/lex-json-0.0.7.tgz} 212 + 213 + '@atproto/lex-json@0.0.8': 214 + resolution: {integrity: sha512-w1Qmkae1QhmNz+i1Zm3xr3jp0UPPRENmdlpU0qIrdxWDo9W4Mzkeyc3eSoa+Zs+zN8xkRSQw7RLZte/B7Ipdwg==, tarball: https://registry.npmjs.org/@atproto/lex-json/-/lex-json-0.0.8.tgz} 212 215 213 216 '@atproto/lexicon@0.4.14': 214 217 resolution: {integrity: sha512-jiKpmH1QER3Gvc7JVY5brwrfo+etFoe57tKPQX/SmPwjvUsFnJAow5xLIryuBaJgFAhnTZViXKs41t//pahGHQ==, tarball: https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.4.14.tgz} ··· 977 980 '@speed-highlight/core@1.2.12': 978 981 resolution: {integrity: sha512-uilwrK0Ygyri5dToHYdZSjcvpS2ZwX0w5aSt3GCEN9hrjxWCoeV4Z2DTXuxjwbntaLQIEEAlCeNQss5SoHvAEA==, tarball: https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.12.tgz} 979 982 983 + '@standard-schema/spec@1.1.0': 984 + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==, tarball: https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz} 985 + 980 986 '@sveltejs/acorn-typescript@1.0.5': 981 987 resolution: {integrity: sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==, tarball: https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.5.tgz} 982 988 peerDependencies: 983 989 acorn: ^8.9.0 984 990 985 - '@sveltejs/adapter-auto@4.0.0': 986 - resolution: {integrity: sha512-kmuYSQdD2AwThymQF0haQhM8rE5rhutQXG4LNbnbShwhMO4qQGnKaaTy+88DuNSuoQDi58+thpq8XpHc1+oEKQ==, tarball: https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-4.0.0.tgz} 987 - peerDependencies: 988 - '@sveltejs/kit': ^2.0.0 989 - 990 991 '@sveltejs/adapter-cloudflare@7.2.4': 991 992 resolution: {integrity: sha512-uD8VlOuGXGuZWL+zbBYSjtmC4WDtlonUodfqAZ/COd5uIy2Z0QptIicB/nkTrGNI9sbmzgf7z0N09CHyWYlUvQ==, tarball: https://registry.npmjs.org/@sveltejs/adapter-cloudflare/-/adapter-cloudflare-7.2.4.tgz} 992 993 peerDependencies: 993 994 '@sveltejs/kit': ^2.0.0 994 995 wrangler: ^4.0.0 995 996 996 - '@sveltejs/adapter-static@3.0.8': 997 - resolution: {integrity: sha512-YaDrquRpZwfcXbnlDsSrBQNCChVOT9MGuSg+dMAyfsAa1SmiAhrA5jUYUiIMC59G92kIbY/AaQOWcBdq+lh+zg==, tarball: https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.8.tgz} 998 - peerDependencies: 999 - '@sveltejs/kit': ^2.0.0 1000 - 1001 - '@sveltejs/kit@2.20.8': 1002 - resolution: {integrity: sha512-ep9qTxL7WALhfm0kFecL3VHeuNew8IccbYGqv5TqL/KSqWRKzEgDG8blNlIu1CkLTTua/kHjI+f5T8eCmWIxKw==, tarball: https://registry.npmjs.org/@sveltejs/kit/-/kit-2.20.8.tgz} 997 + '@sveltejs/kit@2.49.5': 998 + resolution: {integrity: sha512-dCYqelr2RVnWUuxc+Dk/dB/SjV/8JBndp1UovCyCZdIQezd8TRwFLNZctYkzgHxRJtaNvseCSRsuuHPeUgIN/A==, tarball: https://registry.npmjs.org/@sveltejs/kit/-/kit-2.49.5.tgz} 1003 999 engines: {node: '>=18.13'} 1004 1000 hasBin: true 1005 1001 peerDependencies: 1006 - '@sveltejs/vite-plugin-svelte': ^3.0.0 || ^4.0.0-next.1 || ^5.0.0 1002 + '@opentelemetry/api': ^1.0.0 1003 + '@sveltejs/vite-plugin-svelte': ^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 1007 1004 svelte: ^4.0.0 || ^5.0.0-next.0 1008 - vite: ^5.0.3 || ^6.0.0 1005 + typescript: ^5.3.3 1006 + vite: ^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 1007 + peerDependenciesMeta: 1008 + '@opentelemetry/api': 1009 + optional: true 1010 + typescript: 1011 + optional: true 1009 1012 1010 1013 '@sveltejs/vite-plugin-svelte-inspector@4.0.1': 1011 1014 resolution: {integrity: sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==, tarball: https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz} ··· 1638 1641 resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==, tarball: https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz} 1639 1642 engines: {node: '>=8'} 1640 1643 1641 - devalue@5.1.1: 1642 - resolution: {integrity: sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==, tarball: https://registry.npmjs.org/devalue/-/devalue-5.1.1.tgz} 1643 - 1644 - devalue@5.6.0: 1645 - resolution: {integrity: sha512-BaD1s81TFFqbD6Uknni42TrolvEWA1Ih5L+OiHWmi4OYMJVwAYPGtha61I9KxTf52OvVHozHyjPu8zljqdF3uA==, tarball: https://registry.npmjs.org/devalue/-/devalue-5.6.0.tgz} 1644 + devalue@5.6.2: 1645 + resolution: {integrity: sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==, tarball: https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz} 1646 1646 1647 1647 dom-serializer@2.0.0: 1648 1648 resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==, tarball: https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz} ··· 1969 1969 resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==, tarball: https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz} 1970 1970 engines: {node: '>=6'} 1971 1971 1972 - import-meta-resolve@4.1.0: 1973 - resolution: {integrity: sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==, tarball: https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz} 1974 - 1975 1972 imurmurhash@0.1.4: 1976 1973 resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==, tarball: https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz} 1977 1974 engines: {node: '>=0.8.19'} ··· 2755 2752 peerDependencies: 2756 2753 svelte: ^5.0.0 2757 2754 2758 - svelte@5.45.8: 2759 - resolution: {integrity: sha512-1Jh7FwVh/2Uxg0T7SeE1qFKMhwYH45b2v53bcZpW7qHa6O8iU1ByEj56PF0IQ6dU4HE5gRkic6h+vx+tclHeiw==, tarball: https://registry.npmjs.org/svelte/-/svelte-5.45.8.tgz} 2755 + svelte@5.46.4: 2756 + resolution: {integrity: sha512-VJwdXrmv9L8L7ZasJeWcCjoIuMRVbhuxbss0fpVnR8yorMmjNDwcjIH08vS6wmSzzzgAG5CADQ1JuXPS2nwt9w==, tarball: https://registry.npmjs.org/svelte/-/svelte-5.46.4.tgz} 2760 2757 engines: {node: '>=18'} 2761 2758 2762 2759 tabbable@6.2.0: ··· 3043 3040 tlds: 1.258.0 3044 3041 zod: 3.24.4 3045 3042 3046 - '@atproto/api@0.18.13': 3043 + '@atproto/api@0.18.16': 3047 3044 dependencies: 3048 - '@atproto/common-web': 0.4.11 3045 + '@atproto/common-web': 0.4.12 3049 3046 '@atproto/lexicon': 0.6.0 3050 3047 '@atproto/syntax': 0.4.2 3051 3048 '@atproto/xrpc': 0.7.7 ··· 3060 3057 '@atproto/lex-json': 0.0.7 3061 3058 zod: 3.24.4 3062 3059 3060 + '@atproto/common-web@0.4.12': 3061 + dependencies: 3062 + '@atproto/lex-data': 0.0.8 3063 + '@atproto/lex-json': 0.0.8 3064 + zod: 3.24.4 3065 + 3063 3066 '@atproto/common-web@0.4.2': 3064 3067 dependencies: 3065 3068 graphemer: 1.4.0 ··· 3075 3078 uint8arrays: 3.0.0 3076 3079 unicode-segmenter: 0.14.5 3077 3080 3081 + '@atproto/lex-data@0.0.8': 3082 + dependencies: 3083 + '@atproto/syntax': 0.4.2 3084 + multiformats: 9.9.0 3085 + tslib: 2.8.1 3086 + uint8arrays: 3.0.0 3087 + unicode-segmenter: 0.14.5 3088 + 3078 3089 '@atproto/lex-json@0.0.7': 3079 3090 dependencies: 3080 3091 '@atproto/lex-data': 0.0.7 3092 + tslib: 2.8.1 3093 + 3094 + '@atproto/lex-json@0.0.8': 3095 + dependencies: 3096 + '@atproto/lex-data': 0.0.8 3081 3097 tslib: 2.8.1 3082 3098 3083 3099 '@atproto/lexicon@0.4.14': ··· 3342 3358 '@eslint/core': 0.13.0 3343 3359 levn: 0.4.1 3344 3360 3345 - '@ethercorps/sveltekit-og@4.2.1(@sveltejs/kit@2.20.8(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))': 3361 + '@ethercorps/sveltekit-og@4.2.1(@sveltejs/kit@2.49.5(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.46.4)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.46.4)(typescript@5.8.3)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))': 3346 3362 dependencies: 3347 3363 '@resvg/resvg-wasm': 2.6.2 3348 - '@sveltejs/kit': 2.20.8(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)) 3364 + '@sveltejs/kit': 2.49.5(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.46.4)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.46.4)(typescript@5.8.3)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)) 3349 3365 '@takumi-rs/helpers': 0.55.4 3350 3366 '@takumi-rs/image-response': 0.55.4 3351 3367 '@takumi-rs/wasm': 0.55.4 ··· 3365 3381 3366 3382 '@floating-ui/utils@0.2.10': {} 3367 3383 3368 - '@foxui/colors@0.4.7(svelte@5.45.8)(tailwindcss@4.1.5)': 3384 + '@foxui/colors@0.4.7(svelte@5.46.4)(tailwindcss@4.1.5)': 3369 3385 dependencies: 3370 - '@foxui/core': 0.4.7(svelte@5.45.8)(tailwindcss@4.1.5) 3386 + '@foxui/core': 0.4.7(svelte@5.46.4)(tailwindcss@4.1.5) 3371 3387 '@texel/color': 1.1.11 3372 3388 '@use-gesture/vanilla': 10.3.1 3373 - bits-ui: 1.8.0(svelte@5.45.8) 3374 - svelte: 5.45.8 3389 + bits-ui: 1.8.0(svelte@5.46.4) 3390 + svelte: 5.46.4 3375 3391 tailwindcss: 4.1.5 3376 3392 3377 - '@foxui/core@0.4.7(svelte@5.45.8)(tailwindcss@4.1.5)': 3393 + '@foxui/core@0.4.7(svelte@5.46.4)(tailwindcss@4.1.5)': 3378 3394 dependencies: 3379 - '@number-flow/svelte': 0.3.9(svelte@5.45.8) 3380 - bits-ui: 1.8.0(svelte@5.45.8) 3395 + '@number-flow/svelte': 0.3.9(svelte@5.46.4) 3396 + bits-ui: 1.8.0(svelte@5.46.4) 3381 3397 clsx: 2.1.1 3382 - mode-watcher: 1.1.0(svelte@5.45.8) 3383 - svelte: 5.45.8 3384 - svelte-sonner: 0.3.28(svelte@5.45.8) 3398 + mode-watcher: 1.1.0(svelte@5.46.4) 3399 + svelte: 5.46.4 3400 + svelte-sonner: 0.3.28(svelte@5.46.4) 3385 3401 tailwind-merge: 3.4.0 3386 3402 tailwind-variants: 1.0.0(tailwindcss@4.1.5) 3387 3403 tailwindcss: 4.1.5 3388 3404 3389 - '@foxui/social@0.4.7(svelte@5.45.8)(tailwindcss@4.1.5)': 3405 + '@foxui/social@0.4.7(svelte@5.46.4)(tailwindcss@4.1.5)': 3390 3406 dependencies: 3391 3407 '@atproto/api': 0.15.27 3392 - '@foxui/core': 0.4.7(svelte@5.45.8)(tailwindcss@4.1.5) 3393 - '@foxui/time': 0.4.7(svelte@5.45.8)(tailwindcss@4.1.5) 3408 + '@foxui/core': 0.4.7(svelte@5.46.4)(tailwindcss@4.1.5) 3409 + '@foxui/time': 0.4.7(svelte@5.46.4)(tailwindcss@4.1.5) 3394 3410 '@use-gesture/vanilla': 10.3.1 3395 - bits-ui: 1.8.0(svelte@5.45.8) 3411 + bits-ui: 1.8.0(svelte@5.46.4) 3396 3412 emoji-picker-element: 1.28.1 3397 3413 hls.js: 1.6.15 3398 3414 is-emoji-supported: 0.0.5 3399 3415 plyr: 3.8.4 3400 - svelte: 5.45.8 3416 + svelte: 5.46.4 3401 3417 tailwindcss: 4.1.5 3402 3418 3403 - '@foxui/time@0.4.7(svelte@5.45.8)(tailwindcss@4.1.5)': 3419 + '@foxui/time@0.4.7(svelte@5.46.4)(tailwindcss@4.1.5)': 3404 3420 dependencies: 3405 - '@foxui/core': 0.4.7(svelte@5.45.8)(tailwindcss@4.1.5) 3406 - '@number-flow/svelte': 0.3.9(svelte@5.45.8) 3407 - bits-ui: 1.8.0(svelte@5.45.8) 3408 - svelte: 5.45.8 3421 + '@foxui/core': 0.4.7(svelte@5.46.4)(tailwindcss@4.1.5) 3422 + '@number-flow/svelte': 0.3.9(svelte@5.46.4) 3423 + bits-ui: 1.8.0(svelte@5.46.4) 3424 + svelte: 5.46.4 3409 3425 tailwindcss: 4.1.5 3410 3426 3411 3427 '@humanfs/core@0.19.1': {} ··· 3502 3518 3503 3519 '@jridgewell/gen-mapping@0.3.13': 3504 3520 dependencies: 3505 - '@jridgewell/sourcemap-codec': 1.5.0 3521 + '@jridgewell/sourcemap-codec': 1.5.5 3506 3522 '@jridgewell/trace-mapping': 0.3.31 3507 3523 3508 3524 '@jridgewell/remapping@2.3.5': ··· 3524 3540 '@jridgewell/trace-mapping@0.3.31': 3525 3541 dependencies: 3526 3542 '@jridgewell/resolve-uri': 3.1.2 3527 - '@jridgewell/sourcemap-codec': 1.5.0 3543 + '@jridgewell/sourcemap-codec': 1.5.5 3528 3544 3529 3545 '@jridgewell/trace-mapping@0.3.9': 3530 3546 dependencies: ··· 3560 3576 '@nodelib/fs.scandir': 2.1.5 3561 3577 fastq: 1.19.1 3562 3578 3563 - '@number-flow/svelte@0.3.9(svelte@5.45.8)': 3579 + '@number-flow/svelte@0.3.9(svelte@5.46.4)': 3564 3580 dependencies: 3565 3581 esm-env: 1.2.2 3566 3582 number-flow: 0.5.8 3567 - svelte: 5.45.8 3583 + svelte: 5.46.4 3568 3584 3569 3585 '@polka/url@1.0.0-next.29': {} 3570 3586 ··· 3653 3669 3654 3670 '@speed-highlight/core@1.2.12': {} 3655 3671 3656 - '@sveltejs/acorn-typescript@1.0.5(acorn@8.14.1)': 3657 - dependencies: 3658 - acorn: 8.14.1 3672 + '@standard-schema/spec@1.1.0': {} 3659 3673 3660 - '@sveltejs/adapter-auto@4.0.0(@sveltejs/kit@2.20.8(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))': 3674 + '@sveltejs/acorn-typescript@1.0.5(acorn@8.15.0)': 3661 3675 dependencies: 3662 - '@sveltejs/kit': 2.20.8(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)) 3663 - import-meta-resolve: 4.1.0 3676 + acorn: 8.15.0 3664 3677 3665 - '@sveltejs/adapter-cloudflare@7.2.4(@sveltejs/kit@2.20.8(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(wrangler@4.54.0(@cloudflare/workers-types@4.20260109.0))': 3678 + '@sveltejs/adapter-cloudflare@7.2.4(@sveltejs/kit@2.49.5(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.46.4)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.46.4)(typescript@5.8.3)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(wrangler@4.54.0(@cloudflare/workers-types@4.20260109.0))': 3666 3679 dependencies: 3667 3680 '@cloudflare/workers-types': 4.20260109.0 3668 - '@sveltejs/kit': 2.20.8(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)) 3681 + '@sveltejs/kit': 2.49.5(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.46.4)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.46.4)(typescript@5.8.3)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)) 3669 3682 worktop: 0.8.0-next.18 3670 3683 wrangler: 4.54.0(@cloudflare/workers-types@4.20260109.0) 3671 3684 3672 - '@sveltejs/adapter-static@3.0.8(@sveltejs/kit@2.20.8(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))': 3673 - dependencies: 3674 - '@sveltejs/kit': 2.20.8(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)) 3675 - 3676 - '@sveltejs/kit@2.20.8(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2))': 3685 + '@sveltejs/kit@2.49.5(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.46.4)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.46.4)(typescript@5.8.3)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2))': 3677 3686 dependencies: 3678 - '@sveltejs/vite-plugin-svelte': 5.0.3(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)) 3687 + '@standard-schema/spec': 1.1.0 3688 + '@sveltejs/acorn-typescript': 1.0.5(acorn@8.15.0) 3689 + '@sveltejs/vite-plugin-svelte': 5.0.3(svelte@5.46.4)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)) 3679 3690 '@types/cookie': 0.6.0 3691 + acorn: 8.15.0 3680 3692 cookie: 0.6.0 3681 - devalue: 5.1.1 3693 + devalue: 5.6.2 3682 3694 esm-env: 1.2.2 3683 - import-meta-resolve: 4.1.0 3684 3695 kleur: 4.1.5 3685 - magic-string: 0.30.17 3696 + magic-string: 0.30.21 3686 3697 mrmime: 2.0.1 3687 3698 sade: 1.8.1 3688 3699 set-cookie-parser: 2.7.1 3689 3700 sirv: 3.0.1 3690 - svelte: 5.45.8 3701 + svelte: 5.46.4 3691 3702 vite: 6.3.5(jiti@2.4.2)(lightningcss@1.29.2) 3703 + optionalDependencies: 3704 + typescript: 5.8.3 3692 3705 3693 - '@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2))': 3706 + '@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.46.4)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.46.4)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2))': 3694 3707 dependencies: 3695 - '@sveltejs/vite-plugin-svelte': 5.0.3(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)) 3708 + '@sveltejs/vite-plugin-svelte': 5.0.3(svelte@5.46.4)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)) 3696 3709 debug: 4.4.0 3697 - svelte: 5.45.8 3710 + svelte: 5.46.4 3698 3711 vite: 6.3.5(jiti@2.4.2)(lightningcss@1.29.2) 3699 3712 transitivePeerDependencies: 3700 3713 - supports-color 3701 3714 3702 - '@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2))': 3715 + '@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.46.4)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2))': 3703 3716 dependencies: 3704 - '@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)) 3717 + '@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.46.4)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.46.4)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)) 3705 3718 debug: 4.4.0 3706 3719 deepmerge: 4.3.1 3707 3720 kleur: 4.1.5 3708 3721 magic-string: 0.30.17 3709 - svelte: 5.45.8 3722 + svelte: 5.46.4 3710 3723 vite: 6.3.5(jiti@2.4.2)(lightningcss@1.29.2) 3711 3724 vitefu: 1.0.6(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)) 3712 3725 transitivePeerDependencies: ··· 4126 4139 4127 4140 base64-js@0.0.8: {} 4128 4141 4129 - bits-ui@1.8.0(svelte@5.45.8): 4142 + bits-ui@1.8.0(svelte@5.46.4): 4130 4143 dependencies: 4131 4144 '@floating-ui/core': 1.7.3 4132 4145 '@floating-ui/dom': 1.7.4 4133 4146 '@internationalized/date': 3.8.0 4134 4147 css.escape: 1.5.1 4135 4148 esm-env: 1.2.2 4136 - runed: 0.23.4(svelte@5.45.8) 4137 - svelte: 5.45.8 4138 - svelte-toolbelt: 0.7.1(svelte@5.45.8) 4149 + runed: 0.23.4(svelte@5.46.4) 4150 + svelte: 5.46.4 4151 + svelte-toolbelt: 0.7.1(svelte@5.46.4) 4139 4152 tabbable: 6.2.0 4140 4153 4141 - bits-ui@2.14.4(@internationalized/date@3.8.0)(@sveltejs/kit@2.20.8(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.45.8): 4154 + bits-ui@2.14.4(@internationalized/date@3.8.0)(@sveltejs/kit@2.49.5(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.46.4)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.46.4)(typescript@5.8.3)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.46.4): 4142 4155 dependencies: 4143 4156 '@floating-ui/core': 1.7.3 4144 4157 '@floating-ui/dom': 1.7.4 4145 4158 '@internationalized/date': 3.8.0 4146 4159 esm-env: 1.2.2 4147 - runed: 0.35.1(@sveltejs/kit@2.20.8(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.45.8) 4148 - svelte: 5.45.8 4149 - svelte-toolbelt: 0.10.6(@sveltejs/kit@2.20.8(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.45.8) 4160 + runed: 0.35.1(@sveltejs/kit@2.49.5(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.46.4)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.46.4)(typescript@5.8.3)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.46.4) 4161 + svelte: 5.46.4 4162 + svelte-toolbelt: 0.10.6(@sveltejs/kit@2.49.5(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.46.4)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.46.4)(typescript@5.8.3)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.46.4) 4150 4163 tabbable: 6.2.0 4151 4164 transitivePeerDependencies: 4152 4165 - '@sveltejs/kit' ··· 4322 4335 4323 4336 detect-libc@2.0.4: {} 4324 4337 4325 - devalue@5.1.1: {} 4326 - 4327 - devalue@5.6.0: {} 4338 + devalue@5.6.2: {} 4328 4339 4329 4340 dom-serializer@2.0.0: 4330 4341 dependencies: ··· 4447 4458 dependencies: 4448 4459 eslint: 9.26.0(jiti@2.4.2) 4449 4460 4450 - eslint-plugin-svelte@2.46.1(eslint@9.26.0(jiti@2.4.2))(svelte@5.45.8): 4461 + eslint-plugin-svelte@2.46.1(eslint@9.26.0(jiti@2.4.2))(svelte@5.46.4): 4451 4462 dependencies: 4452 4463 '@eslint-community/eslint-utils': 4.7.0(eslint@9.26.0(jiti@2.4.2)) 4453 4464 '@jridgewell/sourcemap-codec': 1.5.0 ··· 4460 4471 postcss-safe-parser: 6.0.0(postcss@8.5.3) 4461 4472 postcss-selector-parser: 6.1.2 4462 4473 semver: 7.7.1 4463 - svelte-eslint-parser: 0.43.0(svelte@5.45.8) 4474 + svelte-eslint-parser: 0.43.0(svelte@5.46.4) 4464 4475 optionalDependencies: 4465 - svelte: 5.45.8 4476 + svelte: 5.46.4 4466 4477 transitivePeerDependencies: 4467 4478 - ts-node 4468 4479 ··· 4544 4555 4545 4556 esrap@2.2.1: 4546 4557 dependencies: 4547 - '@jridgewell/sourcemap-codec': 1.5.0 4558 + '@jridgewell/sourcemap-codec': 1.5.5 4548 4559 4549 4560 esrecurse@4.3.0: 4550 4561 dependencies: ··· 4744 4755 parent-module: 1.0.1 4745 4756 resolve-from: 4.0.0 4746 4757 4747 - import-meta-resolve@4.1.0: {} 4748 - 4749 4758 imurmurhash@0.1.4: {} 4750 4759 4751 4760 inherits@2.0.4: {} ··· 4960 4969 pkg-types: 1.3.1 4961 4970 ufo: 1.6.2 4962 4971 4963 - mode-watcher@1.1.0(svelte@5.45.8): 4972 + mode-watcher@1.1.0(svelte@5.46.4): 4964 4973 dependencies: 4965 - runed: 0.25.0(svelte@5.45.8) 4966 - svelte: 5.45.8 4967 - svelte-toolbelt: 0.7.1(svelte@5.45.8) 4974 + runed: 0.25.0(svelte@5.46.4) 4975 + svelte: 5.46.4 4976 + svelte-toolbelt: 0.7.1(svelte@5.46.4) 4968 4977 4969 4978 mri@1.2.0: {} 4970 4979 ··· 5114 5123 5115 5124 prelude-ls@1.2.1: {} 5116 5125 5117 - prettier-plugin-svelte@3.3.3(prettier@3.5.3)(svelte@5.45.8): 5126 + prettier-plugin-svelte@3.3.3(prettier@3.5.3)(svelte@5.46.4): 5118 5127 dependencies: 5119 5128 prettier: 3.5.3 5120 - svelte: 5.45.8 5129 + svelte: 5.46.4 5121 5130 5122 - prettier-plugin-tailwindcss@0.6.11(prettier-plugin-svelte@3.3.3(prettier@3.5.3)(svelte@5.45.8))(prettier@3.5.3): 5131 + prettier-plugin-tailwindcss@0.6.11(prettier-plugin-svelte@3.3.3(prettier@3.5.3)(svelte@5.46.4))(prettier@3.5.3): 5123 5132 dependencies: 5124 5133 prettier: 3.5.3 5125 5134 optionalDependencies: 5126 - prettier-plugin-svelte: 3.3.3(prettier@3.5.3)(svelte@5.45.8) 5135 + prettier-plugin-svelte: 3.3.3(prettier@3.5.3)(svelte@5.46.4) 5127 5136 5128 5137 prettier@3.5.3: {} 5129 5138 ··· 5306 5315 dependencies: 5307 5316 queue-microtask: 1.2.3 5308 5317 5309 - runed@0.23.4(svelte@5.45.8): 5318 + runed@0.23.4(svelte@5.46.4): 5310 5319 dependencies: 5311 5320 esm-env: 1.2.2 5312 - svelte: 5.45.8 5321 + svelte: 5.46.4 5313 5322 5314 - runed@0.25.0(svelte@5.45.8): 5323 + runed@0.25.0(svelte@5.46.4): 5315 5324 dependencies: 5316 5325 esm-env: 1.2.2 5317 - svelte: 5.45.8 5326 + svelte: 5.46.4 5318 5327 5319 - runed@0.28.0(svelte@5.45.8): 5328 + runed@0.28.0(svelte@5.46.4): 5320 5329 dependencies: 5321 5330 esm-env: 1.2.2 5322 - svelte: 5.45.8 5331 + svelte: 5.46.4 5323 5332 5324 - runed@0.35.1(@sveltejs/kit@2.20.8(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.45.8): 5333 + runed@0.35.1(@sveltejs/kit@2.49.5(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.46.4)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.46.4)(typescript@5.8.3)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.46.4): 5325 5334 dependencies: 5326 5335 dequal: 2.0.3 5327 5336 esm-env: 1.2.2 5328 5337 lz-string: 1.5.0 5329 - svelte: 5.45.8 5338 + svelte: 5.46.4 5330 5339 optionalDependencies: 5331 - '@sveltejs/kit': 2.20.8(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)) 5340 + '@sveltejs/kit': 2.49.5(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.46.4)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.46.4)(typescript@5.8.3)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)) 5332 5341 5333 5342 sade@1.8.1: 5334 5343 dependencies: ··· 5480 5489 dependencies: 5481 5490 has-flag: 4.0.0 5482 5491 5483 - svelte-check@4.1.7(picomatch@4.0.2)(svelte@5.45.8)(typescript@5.8.3): 5492 + svelte-check@4.1.7(picomatch@4.0.2)(svelte@5.46.4)(typescript@5.8.3): 5484 5493 dependencies: 5485 5494 '@jridgewell/trace-mapping': 0.3.25 5486 5495 chokidar: 4.0.3 5487 5496 fdir: 6.4.4(picomatch@4.0.2) 5488 5497 picocolors: 1.1.1 5489 5498 sade: 1.8.1 5490 - svelte: 5.45.8 5499 + svelte: 5.46.4 5491 5500 typescript: 5.8.3 5492 5501 transitivePeerDependencies: 5493 5502 - picomatch 5494 5503 5495 - svelte-eslint-parser@0.43.0(svelte@5.45.8): 5504 + svelte-eslint-parser@0.43.0(svelte@5.46.4): 5496 5505 dependencies: 5497 5506 eslint-scope: 7.2.2 5498 5507 eslint-visitor-keys: 3.4.3 ··· 5500 5509 postcss: 8.5.3 5501 5510 postcss-scss: 4.0.9(postcss@8.5.3) 5502 5511 optionalDependencies: 5503 - svelte: 5.45.8 5512 + svelte: 5.46.4 5504 5513 5505 - svelte-sonner@0.3.28(svelte@5.45.8): 5514 + svelte-sonner@0.3.28(svelte@5.46.4): 5506 5515 dependencies: 5507 - svelte: 5.45.8 5516 + svelte: 5.46.4 5508 5517 5509 - svelte-sonner@1.0.7(svelte@5.45.8): 5518 + svelte-sonner@1.0.7(svelte@5.46.4): 5510 5519 dependencies: 5511 - runed: 0.28.0(svelte@5.45.8) 5512 - svelte: 5.45.8 5520 + runed: 0.28.0(svelte@5.46.4) 5521 + svelte: 5.46.4 5513 5522 5514 - svelte-toolbelt@0.10.6(@sveltejs/kit@2.20.8(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.45.8): 5523 + svelte-toolbelt@0.10.6(@sveltejs/kit@2.49.5(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.46.4)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.46.4)(typescript@5.8.3)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.46.4): 5515 5524 dependencies: 5516 5525 clsx: 2.1.1 5517 - runed: 0.35.1(@sveltejs/kit@2.20.8(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.45.8)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.45.8) 5526 + runed: 0.35.1(@sveltejs/kit@2.49.5(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.46.4)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.46.4)(typescript@5.8.3)(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)))(svelte@5.46.4) 5518 5527 style-to-object: 1.0.8 5519 - svelte: 5.45.8 5528 + svelte: 5.46.4 5520 5529 transitivePeerDependencies: 5521 5530 - '@sveltejs/kit' 5522 5531 5523 - svelte-toolbelt@0.7.1(svelte@5.45.8): 5532 + svelte-toolbelt@0.7.1(svelte@5.46.4): 5524 5533 dependencies: 5525 5534 clsx: 2.1.1 5526 - runed: 0.23.4(svelte@5.45.8) 5535 + runed: 0.23.4(svelte@5.46.4) 5527 5536 style-to-object: 1.0.8 5528 - svelte: 5.45.8 5537 + svelte: 5.46.4 5529 5538 5530 - svelte@5.45.8: 5539 + svelte@5.46.4: 5531 5540 dependencies: 5532 5541 '@jridgewell/remapping': 2.3.5 5533 - '@jridgewell/sourcemap-codec': 1.5.0 5534 - '@sveltejs/acorn-typescript': 1.0.5(acorn@8.14.1) 5542 + '@jridgewell/sourcemap-codec': 1.5.5 5543 + '@sveltejs/acorn-typescript': 1.0.5(acorn@8.15.0) 5535 5544 '@types/estree': 1.0.7 5536 - acorn: 8.14.1 5545 + acorn: 8.15.0 5537 5546 aria-query: 5.3.2 5538 5547 axobject-query: 4.1.0 5539 5548 clsx: 2.1.1 5540 - devalue: 5.6.0 5549 + devalue: 5.6.2 5541 5550 esm-env: 1.2.2 5542 5551 esrap: 2.2.1 5543 5552 is-reference: 3.0.3 5544 5553 locate-character: 3.0.0 5545 - magic-string: 0.30.17 5554 + magic-string: 0.30.21 5546 5555 zimmerframe: 1.1.2 5547 5556 5548 5557 tabbable@6.2.0: {}
-631
src/lib/EditableWebsite.svelte
··· 1 - <script lang="ts"> 2 - import { client, login } from '$lib/oauth/auth.svelte.js'; 3 - 4 - import { Navbar, Button, toast, Toaster, Toggle, Sidebar } from '@foxui/core'; 5 - import { BlueskyLogin } from '@foxui/social'; 6 - 7 - import { COLUMNS, margin, mobileMargin } from '$lib'; 8 - import { 9 - cardsEqual, 10 - clamp, 11 - compactItems, 12 - fixCollisions, 13 - setCanEdit, 14 - setIsMobile, 15 - setPositionOfNewItem, 16 - simulateFinalPosition 17 - } from './helper'; 18 - import Profile from './Profile.svelte'; 19 - import type { Item } from './types'; 20 - import { deleteRecord, putRecord } from './oauth/atproto'; 21 - import { innerWidth } from 'svelte/reactivity/window'; 22 - import { TID } from '@atproto/common-web'; 23 - import EditingCard from './cards/Card/EditingCard.svelte'; 24 - import { AllCardDefinitions, CardDefinitionsByType } from './cards'; 25 - import { tick, type Component } from 'svelte'; 26 - import type { CreationModalComponentProps } from './cards/types'; 27 - import { dev } from '$app/environment'; 28 - import { setDidContext, setHandleContext } from './website/context'; 29 - import BaseEditingCard from './cards/BaseCard/BaseEditingCard.svelte'; 30 - import Settings from './Settings.svelte'; 31 - import ImageDropper from './components/ImageDropper.svelte'; 32 - 33 - let { 34 - handle, 35 - did, 36 - data, 37 - items: originalItems, 38 - settings 39 - }: { handle: string; did: string; data: any; items: Item[]; settings: any } = $props(); 40 - 41 - // svelte-ignore state_referenced_locally 42 - let items: Item[] = $state(originalItems); 43 - 44 - let container: HTMLDivElement | undefined = $state(); 45 - 46 - let activeDragElement: { 47 - element: HTMLDivElement | null; 48 - item: Item | null; 49 - w: number; 50 - h: number; 51 - x: number; 52 - y: number; 53 - mouseDeltaX: number; 54 - mouseDeltaY: number; 55 - } = $state({ 56 - element: null, 57 - item: null, 58 - w: 0, 59 - h: 0, 60 - x: -1, 61 - y: -1, 62 - mouseDeltaX: 0, 63 - mouseDeltaY: 0 64 - }); 65 - 66 - let showingMobileView = $state(false); 67 - let isMobile = $derived(showingMobileView || (innerWidth.current ?? 1000) < 1024); 68 - 69 - setIsMobile(() => isMobile); 70 - 71 - setCanEdit(() => dev || (client.isLoggedIn && client.profile?.did === did)); 72 - 73 - // svelte-ignore state_referenced_locally 74 - setDidContext(did); 75 - // svelte-ignore state_referenced_locally 76 - setHandleContext(handle); 77 - 78 - const getX = (item: Item) => (isMobile ? (item.mobileX ?? item.x) : item.x); 79 - const getY = (item: Item) => (isMobile ? (item.mobileY ?? item.y) : item.y); 80 - const getW = (item: Item) => (isMobile ? (item.mobileW ?? item.w) : item.w); 81 - const getH = (item: Item) => (isMobile ? (item.mobileH ?? item.h) : item.h); 82 - 83 - let maxHeight = $derived(items.reduce((max, item) => Math.max(max, getY(item) + getH(item)), 0)); 84 - 85 - function newCard(type: string = 'link') { 86 - // close sidebar if open 87 - const popover = document.getElementById('mobile-menu'); 88 - if (popover) { 89 - popover.hidePopover(); 90 - } 91 - 92 - let item: Item = { 93 - id: TID.nextStr(), 94 - x: 0, 95 - y: 0, 96 - w: 2, 97 - h: 2, 98 - mobileH: 4, 99 - mobileW: 4, 100 - mobileX: 0, 101 - mobileY: 0, 102 - cardType: type, 103 - cardData: {} 104 - }; 105 - const cardDef = CardDefinitionsByType[type]; 106 - cardDef?.createNew?.(item); 107 - 108 - newItem.item = item; 109 - 110 - if (cardDef?.creationModalComponent) { 111 - newItem.modal = cardDef.creationModalComponent; 112 - } else { 113 - saveNewItem(); 114 - } 115 - } 116 - 117 - async function saveNewItem() { 118 - if (!newItem.item) return; 119 - const item = newItem.item; 120 - 121 - setPositionOfNewItem(item, items); 122 - 123 - items = [...items, item]; 124 - 125 - const containerRect = container?.getBoundingClientRect(); 126 - 127 - newItem = {}; 128 - 129 - await tick(); 130 - 131 - // scroll to newly created card 132 - if (!containerRect) return; 133 - const currentMargin = isMobile ? mobileMargin : margin; 134 - const currentY = isMobile ? item.mobileY : item.y; 135 - const bodyRect = document.body.getBoundingClientRect(); 136 - const offset = containerRect.top - bodyRect.top; 137 - const cellSize = (containerRect.width - currentMargin * 2) / COLUMNS; 138 - window.scrollTo({ top: offset + cellSize * (currentY - 1), behavior: 'smooth' }); 139 - } 140 - 141 - let isSaving = $state(false); 142 - 143 - let newItem: { modal?: Component<CreationModalComponentProps>; item?: Item } = $state({}); 144 - 145 - async function save() { 146 - isSaving = true; 147 - 148 - const promises = []; 149 - // find all cards that have been updated (where items differ from originalItems) 150 - for (let item of items) { 151 - const originalItem = originalItems.find((i) => cardsEqual(i, item)); 152 - 153 - if (!originalItem) { 154 - console.log('updated or new item', item); 155 - item.updatedAt = new Date().toISOString(); 156 - // run optional upload function for this card type 157 - const cardDef = CardDefinitionsByType[item.cardType]; 158 - 159 - if (cardDef?.upload) { 160 - item = await cardDef?.upload(item); 161 - } 162 - 163 - item.version = 1; 164 - 165 - promises.push( 166 - putRecord({ 167 - collection: 'app.blento.card', 168 - rkey: item.id, 169 - record: item 170 - }) 171 - ); 172 - } 173 - } 174 - 175 - // delete items that are in originalItems but not in items 176 - for (let originalItem of originalItems) { 177 - const item = items.find((i) => i.id === originalItem.id); 178 - if (!item) { 179 - console.log('deleting item', originalItem); 180 - promises.push(deleteRecord({ collection: 'app.blento.card', rkey: originalItem.id, did })); 181 - } 182 - } 183 - 184 - await Promise.all(promises); 185 - 186 - isSaving = false; 187 - 188 - fetch('/' + handle + '/api/refreshData'); 189 - console.log('refreshing data'); 190 - 191 - toast('Saved', { 192 - description: 'Your website has been saved!' 193 - }); 194 - } 195 - 196 - const sidebarItems = AllCardDefinitions.filter( 197 - (cardDef) => cardDef.sidebarComponent || cardDef.sidebarButtonText 198 - ); 199 - 200 - let showSettings = $state(false); 201 - 202 - let debugPoint = $state({ x: 0, y: 0 }); 203 - 204 - function getDragXY( 205 - e: DragEvent & { 206 - currentTarget: EventTarget & HTMLDivElement; 207 - } 208 - ) { 209 - if (!container) return; 210 - 211 - const x = e.clientX + activeDragElement.mouseDeltaX; 212 - const y = e.clientY + activeDragElement.mouseDeltaY; 213 - 214 - const rect = container.getBoundingClientRect(); 215 - 216 - debugPoint.x = x - rect.left; 217 - debugPoint.y = y - rect.top + margin; 218 - console.log(rect.top); 219 - 220 - let gridX = clamp( 221 - Math.floor(((x - rect.left) / rect.width) * 8), 222 - 0, 223 - COLUMNS - (activeDragElement.w ?? 0) 224 - ); 225 - gridX = Math.floor(gridX / 2) * 2; 226 - let gridY = Math.max( 227 - Math.round(((y - rect.top + margin) / (rect.width - margin)) * COLUMNS), 228 - 0 229 - ); 230 - if (isMobile) { 231 - gridX = Math.floor(gridX / 2) * 2; 232 - gridY = Math.floor(gridY / 2) * 2; 233 - } 234 - return { x: gridX, y: gridY }; 235 - } 236 - </script> 237 - 238 - <svelte:body 239 - onpaste={(event) => { 240 - const target = event.target; 241 - 242 - const active = document.activeElement; 243 - const isEditable = 244 - active instanceof HTMLInputElement || 245 - active instanceof HTMLTextAreaElement || 246 - active?.isContentEditable; 247 - 248 - if (isEditable) { 249 - // Let normal paste happen 250 - return; 251 - } 252 - 253 - const text = event.clipboardData?.getData('text/plain'); 254 - 255 - if (!text) return; 256 - 257 - try { 258 - const url = new URL(text); 259 - 260 - let item: Item = { 261 - id: TID.nextStr(), 262 - x: 0, 263 - y: 0, 264 - w: 2, 265 - h: 2, 266 - mobileH: 4, 267 - mobileW: 4, 268 - mobileX: 0, 269 - mobileY: 0, 270 - cardType: '', 271 - cardData: {} 272 - }; 273 - 274 - newItem.item = item; 275 - 276 - for (const cardDef of AllCardDefinitions) { 277 - if (cardDef.onUrlHandler?.(text, item)) { 278 - item.cardType = cardDef.type; 279 - saveNewItem(); 280 - } 281 - } 282 - 283 - newItem = {}; 284 - } catch (e) { 285 - return; 286 - } 287 - }} 288 - /> 289 - 290 - <!-- <ImageDropper processImageFile={(file: File) => {}} /> --> 291 - 292 - {#if !dev} 293 - <div 294 - class="bg-base-200 dark:bg-base-800 fixed inset-0 z-50 inline-flex h-full w-full items-center justify-center p-4 text-center lg:hidden" 295 - > 296 - Editing on mobile is not supported yet. Please use a desktop browser. 297 - </div> 298 - {/if} 299 - 300 - {#if showingMobileView} 301 - <div 302 - class="bg-base-200 dark:bg-base-900 pointer-events-none fixed inset-0 -z-10 h-full w-full" 303 - ></div> 304 - {/if} 305 - 306 - {#if newItem.modal && newItem.item} 307 - <newItem.modal 308 - oncreate={() => { 309 - saveNewItem(); 310 - }} 311 - bind:item={newItem.item} 312 - oncancel={() => { 313 - newItem = {}; 314 - }} 315 - /> 316 - {/if} 317 - 318 - <div 319 - class={[ 320 - '@container/wrapper relative w-full', 321 - showingMobileView 322 - ? 'bg-base-50 dark:bg-base-950 my-4 min-h-[calc(100dhv-2em)] rounded-2xl lg:mx-auto lg:w-[400px]' 323 - : '' 324 - ]} 325 - > 326 - <Profile {handle} {did} {data} /> 327 - 328 - <div class="mx-auto max-w-lg @5xl/wrapper:grid @5xl/wrapper:max-w-7xl @5xl/wrapper:grid-cols-4"> 329 - <div></div> 330 - <!-- svelte-ignore a11y_no_static_element_interactions --> 331 - <div 332 - bind:this={container} 333 - ondragover={(e) => { 334 - e.preventDefault(); 335 - 336 - const cell = getDragXY(e); 337 - if (!cell) return; 338 - 339 - activeDragElement.x = cell.x; 340 - activeDragElement.y = cell.y; 341 - 342 - if (activeDragElement.item) { 343 - if (isMobile) { 344 - activeDragElement.item.mobileX = cell.x; 345 - activeDragElement.item.mobileY = cell.y; 346 - } else { 347 - activeDragElement.item.x = cell.x; 348 - activeDragElement.item.y = cell.y; 349 - } 350 - 351 - fixCollisions(items, activeDragElement.item, isMobile); 352 - } 353 - 354 - // Auto-scroll when dragging near top or bottom of viewport 355 - const scrollZone = 100; 356 - const scrollSpeed = 10; 357 - const viewportHeight = window.innerHeight; 358 - 359 - if (e.clientY < scrollZone) { 360 - // Near top - scroll up 361 - const intensity = 1 - e.clientY / scrollZone; 362 - window.scrollBy(0, -scrollSpeed * intensity); 363 - } else if (e.clientY > viewportHeight - scrollZone) { 364 - // Near bottom - scroll down 365 - const intensity = 1 - (viewportHeight - e.clientY) / scrollZone; 366 - window.scrollBy(0, scrollSpeed * intensity); 367 - } 368 - }} 369 - ondragend={async (e) => { 370 - e.preventDefault(); 371 - const cell = getDragXY(e); 372 - if (!cell) return; 373 - 374 - if (activeDragElement.item) { 375 - if (isMobile) { 376 - activeDragElement.item.mobileX = cell.x; 377 - activeDragElement.item.mobileY = cell.y; 378 - } else { 379 - activeDragElement.item.x = cell.x; 380 - activeDragElement.item.y = cell.y; 381 - } 382 - 383 - fixCollisions(items, activeDragElement.item, isMobile); 384 - } 385 - activeDragElement.x = -1; 386 - activeDragElement.y = -1; 387 - activeDragElement.element = null; 388 - return true; 389 - }} 390 - class="@container/grid relative col-span-3 px-2 py-8 @5xl/wrapper:px-8" 391 - > 392 - {#each items as item, i (item.id)} 393 - <!-- {#if item !== activeDragElement.item} --> 394 - <BaseEditingCard 395 - bind:item={items[i]} 396 - ondelete={() => { 397 - items = items.filter((it) => it !== item); 398 - compactItems(items, isMobile); 399 - }} 400 - onsetsize={(newW: number, newH: number) => { 401 - if (isMobile) { 402 - item.mobileW = newW; 403 - item.mobileH = newH; 404 - } else { 405 - item.w = newW; 406 - item.h = newH; 407 - } 408 - 409 - fixCollisions(items, item, isMobile); 410 - }} 411 - ondragstart={(e) => { 412 - const target = e.currentTarget as HTMLDivElement; 413 - activeDragElement.element = target; 414 - activeDragElement.w = item.w; 415 - activeDragElement.h = item.h; 416 - activeDragElement.item = item; 417 - 418 - const rect = target.getBoundingClientRect(); 419 - activeDragElement.mouseDeltaX = rect.left - e.clientX; 420 - activeDragElement.mouseDeltaY = rect.top - e.clientY; 421 - console.log(activeDragElement.mouseDeltaY); 422 - console.log(rect.width); 423 - }} 424 - > 425 - <EditingCard bind:item={items[i]} /> 426 - </BaseEditingCard> 427 - <!-- {/if} --> 428 - {/each} 429 - 430 - <div style="height: {((maxHeight + 2) / 8) * 100}cqw;"></div> 431 - </div> 432 - </div> 433 - </div> 434 - 435 - <Settings bind:open={showSettings} /> 436 - 437 - <Sidebar mobileOnly mobileClasses="lg:block p-4 gap-4"> 438 - <div class="flex flex-col gap-2"> 439 - {#each sidebarItems as cardDef} 440 - {#if cardDef.sidebarComponent} 441 - <cardDef.sidebarComponent onclick={() => newCard(cardDef.type)} /> 442 - {:else if cardDef.sidebarButtonText} 443 - <Button onclick={() => newCard(cardDef.type)} variant="ghost" class="w-full justify-start" 444 - >{cardDef.sidebarButtonText}</Button 445 - > 446 - {/if} 447 - {/each} 448 - </div> 449 - </Sidebar> 450 - 451 - {#if dev || (!client.isLoggedIn && !client.isInitializing) || client.profile?.did === did} 452 - <Navbar 453 - class={[ 454 - 'dark:bg-base-900 bg-base-100 top-auto bottom-2 mx-4 mt-3 max-w-3xl rounded-full px-4 md:mx-auto lg:inline-flex', 455 - !dev ? 'hidden' : '' 456 - ]} 457 - > 458 - <div class="flex items-center gap-2"> 459 - {#if dev} 460 - <Button 461 - size="iconLg" 462 - variant="ghost" 463 - class="mr-4 backdrop-blur-none" 464 - onclick={() => { 465 - showSettings = true; 466 - }} 467 - > 468 - <svg 469 - xmlns="http://www.w3.org/2000/svg" 470 - fill="none" 471 - viewBox="0 0 24 24" 472 - stroke-width="1.5" 473 - stroke="currentColor" 474 - > 475 - <path 476 - stroke-linecap="round" 477 - stroke-linejoin="round" 478 - d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" 479 - /> 480 - <path 481 - stroke-linecap="round" 482 - stroke-linejoin="round" 483 - d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 484 - /> 485 - </svg> 486 - </Button> 487 - {/if} 488 - 489 - <Button 490 - size="iconLg" 491 - variant="ghost" 492 - class="backdrop-blur-none" 493 - onclick={() => { 494 - newCard('text'); 495 - }} 496 - > 497 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" 498 - ><path 499 - fill="none" 500 - stroke="currentColor" 501 - stroke-linecap="round" 502 - stroke-linejoin="round" 503 - stroke-width="1.5" 504 - d="m15 16l2.536-7.328a1.02 1.02 1 0 1 1.928 0L22 16m-6.303-2h5.606M2 16l4.039-9.69a.5.5 0 0 1 .923 0L11 16m-7.696-3h6.392" 505 - /></svg 506 - > 507 - </Button> 508 - <Button 509 - size="iconLg" 510 - variant="ghost" 511 - class="backdrop-blur-none" 512 - onclick={() => { 513 - newCard('link'); 514 - }} 515 - > 516 - <svg 517 - xmlns="http://www.w3.org/2000/svg" 518 - fill="none" 519 - viewBox="-2 -2 28 28" 520 - stroke-width="1.5" 521 - stroke="currentColor" 522 - > 523 - <path 524 - stroke-linecap="round" 525 - stroke-linejoin="round" 526 - d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" 527 - /> 528 - </svg> 529 - </Button> 530 - 531 - <Button 532 - size="iconLg" 533 - variant="ghost" 534 - class="backdrop-blur-none" 535 - onclick={() => { 536 - newCard('image'); 537 - }} 538 - > 539 - <svg 540 - xmlns="http://www.w3.org/2000/svg" 541 - fill="none" 542 - viewBox="0 0 24 24" 543 - stroke-width="1.5" 544 - stroke="currentColor" 545 - > 546 - <path 547 - stroke-linecap="round" 548 - stroke-linejoin="round" 549 - d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" 550 - /> 551 - </svg> 552 - </Button> 553 - 554 - <Button size="iconLg" variant="ghost" class="backdrop-blur-none" popovertarget="mobile-menu"> 555 - <svg 556 - xmlns="http://www.w3.org/2000/svg" 557 - fill="none" 558 - viewBox="0 0 24 24" 559 - stroke-width="1.5" 560 - stroke="currentColor" 561 - > 562 - <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> 563 - </svg> 564 - </Button> 565 - 566 - <!-- for special stuff --> 567 - {#if handle === 'blento.app'} 568 - <Button 569 - size="iconLg" 570 - variant="ghost" 571 - class="backdrop-blur-none" 572 - onclick={() => { 573 - newCard('updatedBlentos'); 574 - }} 575 - > 576 - <svg 577 - xmlns="http://www.w3.org/2000/svg" 578 - fill="none" 579 - viewBox="0 0 24 24" 580 - stroke-width="1.5" 581 - stroke="currentColor" 582 - > 583 - <path 584 - stroke-linecap="round" 585 - stroke-linejoin="round" 586 - d="M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.259 8.715 18 9.75l-.259-1.035a3.375 3.375 0 0 0-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 0 0 2.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 0 0 2.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 0 0-2.456 2.456ZM16.894 20.567 16.5 21.75l-.394-1.183a2.25 2.25 0 0 0-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 0 0 1.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 0 0 1.423 1.423l1.183.394-1.183.394a2.25 2.25 0 0 0-1.423 1.423Z" 587 - /> 588 - </svg> 589 - </Button> 590 - {/if} 591 - </div> 592 - <div class="flex items-center gap-2"> 593 - <Toggle 594 - class="hidden bg-transparent backdrop-blur-none lg:block dark:bg-transparent" 595 - bind:pressed={showingMobileView} 596 - > 597 - <svg 598 - xmlns="http://www.w3.org/2000/svg" 599 - fill="none" 600 - viewBox="0 0 24 24" 601 - stroke-width="1.5" 602 - stroke="currentColor" 603 - class="size-6" 604 - > 605 - <path 606 - stroke-linecap="round" 607 - stroke-linejoin="round" 608 - d="M10.5 1.5H8.25A2.25 2.25 0 0 0 6 3.75v16.5a2.25 2.25 0 0 0 2.25 2.25h7.5A2.25 2.25 0 0 0 18 20.25V3.75a2.25 2.25 0 0 0-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" 609 - /> 610 - </svg> 611 - </Toggle> 612 - {#if client.isLoggedIn} 613 - <Button 614 - disabled={isSaving} 615 - onclick={async () => { 616 - save(); 617 - }}>{isSaving ? 'Saving...' : 'Save'}</Button 618 - > 619 - {:else} 620 - <BlueskyLogin 621 - login={async (handle) => { 622 - await login(handle); 623 - return true; 624 - }} 625 - /> 626 - {/if} 627 - </div> 628 - </Navbar> 629 - {/if} 630 - 631 - <Toaster />
-20
src/lib/Head.svelte
··· 1 - <script lang="ts"> 2 - let { favicon, title, image }: { favicon: string | null; title: string | null; image?: string } = 3 - $props(); 4 - </script> 5 - 6 - <svelte:head> 7 - {#if favicon} 8 - <link rel="icon" href={favicon} /> 9 - {/if} 10 - 11 - {#if title} 12 - <title>{title}</title> 13 - {/if} 14 - 15 - {#if image} 16 - <meta property="og:image" content={image} /> 17 - <meta name="twitter:image" content={image} /> 18 - <meta name="twitter:card" content="summary_large_image" /> 19 - {/if} 20 - </svelte:head>
-166
src/lib/Profile.svelte
··· 1 - <script lang="ts"> 2 - import Head from './Head.svelte'; 3 - 4 - import { marked } from 'marked'; 5 - import { client, login } from './oauth'; 6 - import { Button, Subheading } from '@foxui/core'; 7 - import { BlueskyLogin } from '@foxui/social'; 8 - import { env } from '$env/dynamic/public'; 9 - let { 10 - handle, 11 - did, 12 - data, 13 - showEditButton = false 14 - }: { handle: string; did: string; data: any; showEditButton?: boolean } = $props(); 15 - 16 - // svelte-ignore state_referenced_locally 17 - const profileData = data?.data?.['app.bsky.actor.profile']?.self?.value; 18 - 19 - const renderer = new marked.Renderer(); 20 - renderer.link = ({ href, title, text }) => 21 - `<a target="_blank" href="${href}" title="${title}">${text}</a>`; 22 - </script> 23 - 24 - <Head 25 - favicon={profileData?.avatar?.ref?.$link ? 'https://cdn.bsky.app/img/avatar/plain/' + did + '/' + profileData.avatar.ref.$link : null} 26 - title={profileData?.displayName || handle} 27 - image={'/' + handle + '/og.png'} 28 - /> 29 - 30 - <!-- lg:fixed lg:h-screen lg:w-1/4 lg:max-w-none lg:px-12 lg:pt-24 xl:w-1/3 --> 31 - <div 32 - class="mx-auto flex max-w-lg flex-col justify-between px-8 @5xl/wrapper:fixed @5xl/wrapper:h-screen @5xl/wrapper:w-1/4 @5xl/wrapper:max-w-none @5xl/wrapper:px-12" 33 - > 34 - <div class="flex flex-col gap-4 pt-16 pb-8 @5xl/wrapper:h-screen @5xl/wrapper:pt-24"> 35 - {#if profileData?.avatar?.ref?.$link} 36 - <img 37 - class="size-32 rounded-full @5xl/wrapper:size-44 border border-base-400 dark:border-base-800" 38 - src={'https://cdn.bsky.app/img/avatar/plain/' + did + '/' + profileData.avatar.ref.$link} 39 - alt="" 40 - /> 41 - {:else} 42 - <div class="bg-base-300 dark:bg-base-700 size-32 rounded-full @5xl/wrapper:size-44"></div> 43 - {/if} 44 - <div class="text-4xl font-bold wrap-anywhere"> 45 - {profileData?.displayName || handle} 46 - </div> 47 - 48 - <div class="scrollbar -mx-4 flex-grow overflow-y-scroll px-4 overflow-x-hidden"> 49 - <div 50 - class="text-base-600 dark:text-base-400 prose dark:prose-invert prose-a:text-accent-500 prose-a:no-underline" 51 - > 52 - {@html marked.parse(profileData?.description ?? '', { renderer })} 53 - </div> 54 - </div> 55 - 56 - {#if showEditButton && client.isLoggedIn && client.profile?.did === did} 57 - <div> 58 - <Button href="{env.PUBLIC_IS_SELFHOSTED ? '' : client.profile?.handle}/edit" class="mt-2"> 59 - <svg 60 - xmlns="http://www.w3.org/2000/svg" 61 - fill="none" 62 - viewBox="0 0 24 24" 63 - stroke-width="1.5" 64 - stroke="currentColor" 65 - > 66 - <path 67 - stroke-linecap="round" 68 - stroke-linejoin="round" 69 - d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" 70 - /> 71 - </svg> 72 - 73 - Edit Your Website</Button 74 - > 75 - </div> 76 - {/if} 77 - 78 - {#if !env.PUBLIC_IS_SELFHOSTED && handle === 'blento.app' && client.profile?.handle !== handle} 79 - {#if !client.isInitializing && !client.isLoggedIn} 80 - <div> 81 - <div class="my-4 text-sm"> 82 - To create your own blento, sign in with your bluesky account 83 - </div> 84 - <BlueskyLogin 85 - login={async (handle) => { 86 - await login(handle); 87 - return true; 88 - }} 89 - /> 90 - </div> 91 - {:else if client.isLoggedIn} 92 - <div> 93 - <Button href={'/' + client.profile?.handle} class="mt-2"> 94 - <svg 95 - xmlns="http://www.w3.org/2000/svg" 96 - fill="none" 97 - viewBox="0 0 24 24" 98 - stroke-width="1.5" 99 - stroke="currentColor" 100 - > 101 - <path 102 - stroke-linecap="round" 103 - stroke-linejoin="round" 104 - d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" 105 - /> 106 - </svg> 107 - 108 - Open Your Blento</Button 109 - > 110 - </div> 111 - {/if} 112 - {/if} 113 - <div class="hidden text-xs font-light @5xl/wrapper:block"> 114 - made with <a 115 - href="https://blento.app" 116 - target="_blank" 117 - class="hover:text-accent-600 dark:hover:text-accent-400 font-medium transition-colors duration-200" 118 - >blento</a 119 - > 120 - </div> 121 - </div> 122 - </div> 123 - 124 - <style> 125 - .scrollbar::-webkit-scrollbar-track { 126 - background-color: transparent; 127 - } 128 - 129 - @supports (scrollbar-width: auto) { 130 - .scrollbar { 131 - scrollbar-color: var(--color-base-400) transparent; 132 - scrollbar-width: thin; 133 - } 134 - 135 - :global(.dark .scrollbar) { 136 - scrollbar-color: var(--color-base-800) transparent; 137 - } 138 - } 139 - 140 - @supports not (scrollbar-width: auto) { 141 - :global(.scrollbar::-webkit-scrollbar) { 142 - width: 14px; 143 - height: 14px; 144 - } 145 - } 146 - 147 - .scrollbar::-webkit-scrollbar-thumb { 148 - background-color: var(--color-base-400); 149 - border-radius: 20px; 150 - border: 4px solid transparent; 151 - background-clip: content-box; 152 - } 153 - 154 - .scrollbar::-webkit-scrollbar-thumb:hover { 155 - background-color: var(--color-base-500); 156 - } 157 - 158 - /* Dark mode rules */ 159 - :global(.dark .scrollbar::-webkit-scrollbar-thumb) { 160 - background-color: var(--color-base-800); 161 - } 162 - 163 - :global(.dark .scrollbar::-webkit-scrollbar-thumb:hover) { 164 - background-color: var(--color-base-700); 165 - } 166 - </style>
-11
src/lib/Settings.svelte
··· 1 - <script lang="ts"> 2 - import { Modal } from '@foxui/core'; 3 - 4 - export type Settings = { 5 - title: string; 6 - }; 7 - 8 - let { open = $bindable(), settings }: { open: boolean; settings: Settings } = $props(); 9 - </script> 10 - 11 - <Modal bind:open>Settings</Modal>
-65
src/lib/Website.svelte
··· 1 - <script lang="ts"> 2 - import Card from './cards/Card/Card.svelte'; 3 - import Profile from './Profile.svelte'; 4 - import { setIsMobile, sortItems } from './helper'; 5 - import type { Item } from './types'; 6 - import { innerWidth } from 'svelte/reactivity/window'; 7 - import { setDidContext, setHandleContext } from './website/context'; 8 - import BaseCard from './cards/BaseCard/BaseCard.svelte'; 9 - import { onMount } from 'svelte'; 10 - import { describeRepo } from './oauth/atproto'; 11 - 12 - let { handle, did, items, data }: { handle: string; did: string; items: Item[]; data: any } = 13 - $props(); 14 - 15 - let isMobile = $derived((innerWidth.current ?? 1000) < 1024); 16 - 17 - setIsMobile(() => isMobile); 18 - 19 - // svelte-ignore state_referenced_locally 20 - setDidContext(did); 21 - // svelte-ignore state_referenced_locally 22 - setHandleContext(handle); 23 - 24 - 25 - let maxHeight = $derived( 26 - items.reduce( 27 - (max, item) => Math.max(max, isMobile ? item.mobileY + item.mobileH : item.y + item.h), 28 - 0 29 - ) 30 - ); 31 - 32 - let container: HTMLDivElement | undefined = $state(); 33 - 34 - onMount(() => { 35 - describeRepo({did}); 36 - }); 37 - </script> 38 - 39 - <div class="@container/wrapper relative w-full"> 40 - <Profile {handle} {did} {data} showEditButton={true} /> 41 - 42 - <div class="mx-auto max-w-lg lg:grid lg:max-w-none lg:grid-cols-4"> 43 - <div></div> 44 - <div 45 - bind:this={container} 46 - class="@container/grid relative col-span-3 px-2 py-8 lg:px-8" 47 - > 48 - {#each items.toSorted(sortItems) as item} 49 - <BaseCard {item}> 50 - <Card {item} /> 51 - </BaseCard> 52 - {/each} 53 - <div style="height: {(maxHeight / 8) * 100}cqw;"></div> 54 - </div> 55 - </div> 56 - 57 - <div class="mx-auto block pb-8 text-center text-xs font-light @5xl/wrapper:hidden"> 58 - made with <a 59 - href="https://blento.app" 60 - target="_blank" 61 - class="hover:text-accent-600 dark:hover:text-accent-400 font-medium transition-colors duration-200" 62 - >blento</a 63 - > 64 - </div> 65 - </div>
+1 -2
src/lib/cards/ATProtoCollectionsCard/ATProtoCollectionsCard.svelte
··· 1 1 <script lang="ts"> 2 - import { getAdditionalUserData } from '$lib/helper'; 3 2 import { onMount } from 'svelte'; 4 3 import type { ContentComponentProps } from '../types'; 5 4 import { CardDefinitionsByType } from '..'; 6 - import { getDidContext, getHandleContext } from '$lib/website/context'; 5 + import { getAdditionalUserData, getDidContext, getHandleContext } from '$lib/website/context'; 7 6 import { Badge, Button } from '@foxui/core'; 8 7 9 8 let { item }: ContentComponentProps = $props();
+1 -1
src/lib/cards/BaseCard/BaseCard.svelte
··· 40 40 bind:this={ref} 41 41 draggable={isEditing} 42 42 class={[ 43 - 'card group focus-within:outline-accent-500 @container/card absolute z-0 rounded-3xl outline-offset-2 transition-all duration-200 focus-within:outline-2 isolate', 43 + 'card group selection:bg-accent-600/50 focus-within:outline-accent-500 @container/card absolute z-0 rounded-3xl outline-offset-2 transition-all duration-200 focus-within:outline-2 isolate', 44 44 color ? (colors[color] ?? colors.accent) : colors.base, 45 45 color !== 'accent' && item.color !== 'base' && item.color !== 'transparent' ? color : '', 46 46 showOutline ? 'outline-2' : '',
+121 -44
src/lib/cards/BaseCard/BaseEditingCard.svelte
··· 3 3 import type { HTMLAttributes } from 'svelte/elements'; 4 4 import BaseCard from './BaseCard.svelte'; 5 5 import type { Item } from '$lib/types'; 6 - import { Button, Popover } from '@foxui/core'; 7 - import { getCanEdit, getIsMobile } from '$lib/helper'; 6 + import { Button, Label, Popover } from '@foxui/core'; 8 7 import { ColorSelect } from '@foxui/colors'; 9 - import { CardDefinitionsByType, getColor } from '..'; 8 + import { AllCardDefinitions, CardDefinitionsByType, getColor } from '..'; 10 9 import { COLUMNS } from '$lib'; 10 + import { getCanEdit, getIsMobile } from '$lib/website/context'; 11 11 12 12 let colorsChoices = [ 13 13 { class: 'text-base-500', label: 'base' }, ··· 136 136 if (!cardDef) return false; 137 137 138 138 if (isMobile()) { 139 - 140 - return w >= minW && w*2 <= maxW && h >= minH && h*2 <= maxH; 139 + return w >= minW && w * 2 <= maxW && h >= minH && h * 2 <= maxH; 141 140 } 142 141 143 142 return w >= minW && w <= maxW && h >= minH && h <= maxH; ··· 152 151 } 153 152 154 153 let settingsPopoverOpen = $state(false); 154 + let changePopoverOpen = $state(false); 155 + 156 + const changeOptions = $derived( 157 + AllCardDefinitions.filter((def) => def.type !== item.cardType && def.canChange?.(item)) 158 + ); 159 + 160 + function applyChange(def: (typeof AllCardDefinitions)[number]) { 161 + const updated = def.change ? def.change(item) : item; 162 + if (updated !== item) { 163 + item = updated; 164 + } 165 + item.cardType = def.type; 166 + changePopoverOpen = false; 167 + } 168 + 169 + function getChangeLabel(def: (typeof AllCardDefinitions)[number]) { 170 + return def.name; 171 + } 155 172 </script> 156 173 157 - <BaseCard {item} isEditing={true} bind:ref showOutline={isResizing} class="starting:scale-0 scale-100 starting:opacity-0 opacity-100" {...rest} > 174 + <BaseCard 175 + {item} 176 + isEditing={true} 177 + bind:ref 178 + showOutline={isResizing} 179 + class="scale-100 opacity-100 starting:scale-0 starting:opacity-0" 180 + {...rest} 181 + > 182 + <div class="absolute inset-0 cursor-grab"></div> 158 183 {@render children?.()} 159 184 160 185 {#snippet controls()} 161 186 <!-- class="bg-base-100 border-base-200 dark:bg-base-800 dark:border-base-700 absolute -top-3 -left-3 hidden cursor-pointer items-center justify-center rounded-full border p-2 shadow-lg group-focus-within:inline-flex group-hover:inline-flex" --> 162 187 {#if canEdit()} 188 + {#if changeOptions.length > 0} 189 + <div 190 + class={[ 191 + 'absolute -top-3 -right-3 hidden group-focus-within:inline-flex group-hover:inline-flex', 192 + changePopoverOpen ? 'inline-flex' : '' 193 + ]} 194 + > 195 + <Popover bind:open={changePopoverOpen} class="bg-base-50 dark:bg-base-900"> 196 + {#snippet child({ props })} 197 + <Button size="icon" variant="secondary" {...props}> 198 + <svg 199 + xmlns="http://www.w3.org/2000/svg" 200 + fill="none" 201 + viewBox="0 0 24 24" 202 + stroke-width="1.5" 203 + stroke="currentColor" 204 + class="size-6" 205 + > 206 + <path 207 + stroke-linecap="round" 208 + stroke-linejoin="round" 209 + d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" 210 + /> 211 + </svg> 212 + 213 + <span class="sr-only">Change card type</span> 214 + </Button> 215 + {/snippet} 216 + 217 + <div class="flex min-w-36 flex-col gap-1"> 218 + <Label class="mb-2">Change card to</Label> 219 + {#each changeOptions as changeDef} 220 + <Button 221 + class="justify-start" 222 + variant="ghost" 223 + onclick={() => applyChange(changeDef)} 224 + > 225 + {getChangeLabel(changeDef)} 226 + </Button> 227 + {/each} 228 + </div> 229 + </Popover> 230 + </div> 231 + {/if} 232 + 163 233 <Button 164 234 size="icon" 165 235 variant="rose" ··· 194 264 <div 195 265 class="bg-base-100 border-base-200 dark:bg-base-800 dark:border-base-700 z-[100] inline-flex items-center gap-0.5 rounded-2xl border p-1 px-2 shadow-lg" 196 266 > 197 - <Popover bind:open={colorPopoverOpen}> 198 - {#snippet child({ props })} 199 - <button 200 - {...props} 201 - class={[ 202 - 'm-2 size-4 cursor-pointer rounded-full', 203 - !item.color || item.color === 'base' || item.color === 'transparent' 204 - ? 'text-base-800 dark:text-base-200' 205 - : 'text-accent-500' 206 - ]} 207 - > 208 - <svg 209 - xmlns="http://www.w3.org/2000/svg" 210 - viewBox="0 0 24 24" 211 - fill="currentColor" 212 - class="size-4" 267 + {#if cardDef.allowSetColor !== false} 268 + <Popover bind:open={colorPopoverOpen}> 269 + {#snippet child({ props })} 270 + <button 271 + {...props} 272 + class={[ 273 + 'm-2 size-4 cursor-pointer rounded-full', 274 + !item.color || item.color === 'base' || item.color === 'transparent' 275 + ? 'text-base-800 dark:text-base-200' 276 + : 'text-accent-500' 277 + ]} 213 278 > 214 - <path 215 - fill-rule="evenodd" 216 - d="M20.599 1.5c-.376 0-.743.111-1.055.32l-5.08 3.385a18.747 18.747 0 0 0-3.471 2.987 10.04 10.04 0 0 1 4.815 4.815 18.748 18.748 0 0 0 2.987-3.472l3.386-5.079A1.902 1.902 0 0 0 20.599 1.5Zm-8.3 14.025a18.76 18.76 0 0 0 1.896-1.207 8.026 8.026 0 0 0-4.513-4.513A18.75 18.75 0 0 0 8.475 11.7l-.278.5a5.26 5.26 0 0 1 3.601 3.602l.502-.278ZM6.75 13.5A3.75 3.75 0 0 0 3 17.25a1.5 1.5 0 0 1-1.601 1.497.75.75 0 0 0-.7 1.123 5.25 5.25 0 0 0 9.8-2.62 3.75 3.75 0 0 0-3.75-3.75Z" 217 - clip-rule="evenodd" 218 - /> 219 - </svg> 220 - </button> 221 - {/snippet} 222 - <ColorSelect 223 - selected={selectedColor} 224 - colors={colorsChoices} 225 - onselected={(color, previous) => { 226 - if (typeof previous === 'string' || typeof color === 'string') { 227 - return; 228 - } 279 + <svg 280 + xmlns="http://www.w3.org/2000/svg" 281 + viewBox="0 0 24 24" 282 + fill="currentColor" 283 + class="size-4" 284 + > 285 + <path 286 + fill-rule="evenodd" 287 + d="M20.599 1.5c-.376 0-.743.111-1.055.32l-5.08 3.385a18.747 18.747 0 0 0-3.471 2.987 10.04 10.04 0 0 1 4.815 4.815 18.748 18.748 0 0 0 2.987-3.472l3.386-5.079A1.902 1.902 0 0 0 20.599 1.5Zm-8.3 14.025a18.76 18.76 0 0 0 1.896-1.207 8.026 8.026 0 0 0-4.513-4.513A18.75 18.75 0 0 0 8.475 11.7l-.278.5a5.26 5.26 0 0 1 3.601 3.602l.502-.278ZM6.75 13.5A3.75 3.75 0 0 0 3 17.25a1.5 1.5 0 0 1-1.601 1.497.75.75 0 0 0-.7 1.123 5.25 5.25 0 0 0 9.8-2.62 3.75 3.75 0 0 0-3.75-3.75Z" 288 + clip-rule="evenodd" 289 + /> 290 + </svg> 291 + </button> 292 + {/snippet} 293 + <ColorSelect 294 + selected={selectedColor} 295 + colors={colorsChoices} 296 + onselected={(color, previous) => { 297 + if (typeof previous === 'string' || typeof color === 'string') { 298 + return; 299 + } 229 300 230 - item.color = color.label; 231 - }} 232 - class="w-64" 233 - /> 234 - </Popover> 301 + item.color = color.label; 302 + }} 303 + class="w-64" 304 + /> 305 + </Popover> 306 + {/if} 235 307 236 308 {#if canSetSize(2, 2)} 237 309 <button ··· 283 355 {/if} 284 356 285 357 {#if cardDef.settingsComponent} 286 - <Popover bind:open={settingsPopoverOpen}> 358 + <Popover bind:open={settingsPopoverOpen} class="bg-base-50 dark:bg-base-900"> 287 359 {#snippet child({ props })} 288 360 <button {...props} class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2"> 289 361 <svg ··· 307 379 </svg> 308 380 </button> 309 381 {/snippet} 310 - <cardDef.settingsComponent bind:item /> 382 + <cardDef.settingsComponent 383 + bind:item 384 + onclose={() => { 385 + settingsPopoverOpen = false; 386 + }} 387 + /> 311 388 </Popover> 312 389 {/if} 313 390 </div>
-21
src/lib/cards/BigSocialCard/SidebarItemBigSocialCard.svelte
··· 1 - <script lang="ts"> 2 - import { Button } from '@foxui/core'; 3 - 4 - let { onclick }: { onclick: () => void } = $props(); 5 - </script> 6 - 7 - <Button {onclick} variant="ghost" class="w-full justify-start"> 8 - <svg 9 - xmlns="http://www.w3.org/2000/svg" 10 - viewBox="0 0 24 24" 11 - fill="currentColor" 12 - class="text-accent-600 dark:text-accent-400" 13 - > 14 - <path 15 - fill-rule="evenodd" 16 - d="M4.848 2.771A49.144 49.144 0 0 1 12 2.25c2.43 0 4.817.178 7.152.52 1.978.292 3.348 2.024 3.348 3.97v6.02c0 1.946-1.37 3.678-3.348 3.97a48.901 48.901 0 0 1-3.476.383.39.39 0 0 0-.297.17l-2.755 4.133a.75.75 0 0 1-1.248 0l-2.755-4.133a.39.39 0 0 0-.297-.17 48.9 48.9 0 0 1-3.476-.384c-1.978-.29-3.348-2.024-3.348-3.97V6.741c0-1.946 1.37-3.68 3.348-3.97ZM6.75 8.25a.75.75 0 0 1 .75-.75h9a.75.75 0 0 1 0 1.5h-9a.75.75 0 0 1-.75-.75Zm.75 2.25a.75.75 0 0 0 0 1.5H12a.75.75 0 0 0 0-1.5H7.5Z" 17 - clip-rule="evenodd" 18 - /> 19 - </svg> 20 - Big Social Icon</Button 21 - >
+30 -3
src/lib/cards/BigSocialCard/index.ts
··· 1 1 import type { CardDefinition } from '../types'; 2 2 import BigSocialCard from './BigSocialCard.svelte'; 3 3 import CreateBigSocialCardModal from './CreateBigSocialCardModal.svelte'; 4 - import SidebarItemBigSocialCard from './SidebarItemBigSocialCard.svelte'; 5 4 6 5 export const BigSocialCardDefinition = { 7 6 type: 'bigsocial', 8 7 contentComponent: BigSocialCard, 9 8 creationModalComponent: CreateBigSocialCardModal, 10 - sidebarComponent: SidebarItemBigSocialCard, 9 + 11 10 createNew: (card) => { 12 11 card.cardType = 'bigsocial'; 13 12 card.cardData = { ··· 19 18 card.mobileW = 4; 20 19 card.mobileH = 4; 21 20 }, 21 + 22 + canChange: (item) => { 23 + const href = item.cardData?.href; 24 + if (!href) return false; 25 + return Boolean(detectPlatform(href)); 26 + }, 27 + change: (item) => { 28 + const href = item.cardData?.href; 29 + const platform = href ? detectPlatform(href) : null; 30 + if (!href || !platform) return item; 31 + item.cardData = { 32 + ...item.cardData, 33 + platform, 34 + color: platformsData[platform].hex 35 + }; 36 + return item; 37 + }, 38 + name: 'Social Icon', 22 39 allowSetColor: false, 23 40 defaultColor: 'transparent', 24 41 minW: 2, ··· 32 49 item.cardData.href = url; 33 50 34 51 return item; 35 - } 52 + }, 53 + urlHandlerPriority: 1 36 54 } as CardDefinition & { type: 'bigsocial' }; 37 55 38 56 import { ··· 154 172 medium: siMedium, 155 173 devto: siDevdotto, 156 174 hashnode: siHashnode, 175 + linkedin: { 176 + slug: 'linkedin', 177 + path: '', 178 + title: 'LinkedIn', 179 + hex: '0A66C2', 180 + source: 'https://brand.linkedin.com', 181 + guidelines: 'https://brand.linkedin.com/policies', 182 + svg: `<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>LinkedIn</title><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>` 183 + }, 157 184 158 185 // support / monetization 159 186 patreon: siPatreon,
+1 -3
src/lib/cards/BlueskyMediaCard/BlueskyMediaCard.svelte
··· 1 1 <script lang="ts"> 2 2 import { getDidContext } from '$lib/website/context'; 3 - import { getImageBlobUrl } from '$lib/website/utils'; 3 + import { getImageBlobUrl } from '$lib/oauth/utils'; 4 4 import type { ContentComponentProps } from '../types'; 5 5 import Video from './Video.svelte'; 6 6 ··· 16 16 } 17 17 return item.cardData.image; 18 18 } 19 - 20 - $inspect(item.cardData); 21 19 </script> 22 20 23 21 {#if item.cardData.image}
-1
src/lib/cards/BlueskyMediaCard/CreateBlueskyMediaCardModal.svelte
··· 4 4 import { onMount } from 'svelte'; 5 5 import { AtpBaseClient } from '@atproto/api'; 6 6 import { getDidContext } from '$lib/website/context'; 7 - import { getImageBlobUrl } from '$lib/website/utils'; 8 7 9 8 let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props(); 10 9
+29
src/lib/cards/BlueskyMediaCard/SidebarItemBlueskyMediaCard.svelte
··· 1 + <script lang="ts"> 2 + import { Button } from '@foxui/core'; 3 + 4 + let { onclick }: { onclick: () => void } = $props(); 5 + </script> 6 + 7 + <Button {onclick} variant="ghost" class="w-full justify-start"> 8 + <svg 9 + xmlns="http://www.w3.org/2000/svg" 10 + class="text-accent-600 dark:text-accent-400" 11 + viewBox="0 0 24 24" 12 + fill="none" 13 + stroke="currentColor" 14 + stroke-width="2" 15 + stroke-linecap="round" 16 + stroke-linejoin="round" 17 + ><path d="m22 11-1.296-1.296a2.4 2.4 0 0 0-3.408 0L11 16" /><path 18 + d="M4 8a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2" 19 + /><circle cx="13" cy="7" r="1" fill="currentColor" /><rect 20 + x="8" 21 + y="2" 22 + width="14" 23 + height="14" 24 + rx="2" 25 + /></svg 26 + > 27 + 28 + Bluesky media 29 + </Button>
+3 -1
src/lib/cards/BlueskyMediaCard/index.ts
··· 1 1 import type { CardDefinition } from '../types'; 2 2 import BlueskyMediaCard from './BlueskyMediaCard.svelte'; 3 3 import CreateBlueskyMediaCardModal from './CreateBlueskyMediaCardModal.svelte'; 4 + import SidebarItemBlueskyMediaCard from './SidebarItemBlueskyMediaCard.svelte'; 4 5 5 6 export const BlueskyMediaCardDefinition = { 6 7 type: 'blueskyMedia', 7 8 contentComponent: BlueskyMediaCard, 8 9 createNew: (card) => {}, 9 10 creationModalComponent: CreateBlueskyMediaCardModal, 10 - sidebarButtonText: 'Bluesky Media' 11 + sidebarButtonText: 'Bluesky Media', 12 + sidebarComponent: SidebarItemBlueskyMediaCard 11 13 } as CardDefinition & { type: 'blueskyMedia' };
+1 -2
src/lib/cards/BlueskyPostCard/BlueskyPostCard.svelte
··· 1 1 <script lang="ts"> 2 - import { getAdditionalUserData } from '$lib/helper'; 3 2 import type { Item } from '$lib/types'; 4 3 import { onMount } from 'svelte'; 5 4 import { BlueskyPost } from '../../components/bluesky-post'; 6 - import { getDidContext, getHandleContext } from '$lib/website/context'; 5 + import { getAdditionalUserData, getDidContext, getHandleContext } from '$lib/website/context'; 7 6 import { CardDefinitionsByType } from '..'; 8 7 9 8 let { item }: { item: Item } = $props();
+20
src/lib/cards/BlueskyProfileCard/BlueskyProfileCard.svelte
··· 1 + <script lang="ts"> 2 + import type { Item } from '$lib/types'; 3 + 4 + let { item }: { item: Item } = $props(); 5 + </script> 6 + 7 + <a 8 + target="_blank" 9 + href="/{item.cardData.handle}" 10 + class="flex h-full w-full flex-col items-center justify-center gap-2 rounded-xl p-2 transition-colors duration-150" 11 + > 12 + <img 13 + src={item.cardData.avatar} 14 + class="aspect-square size-24 rounded-full transition-all duration-100 group-hover:scale-105" 15 + alt="" 16 + /> 17 + <div class="text-md line-clamp-1 text-center font-bold"> 18 + {item.cardData.displayName || item.cardData.handle} 19 + </div> 20 + </a>
+13
src/lib/cards/BlueskyProfileCard/index.ts
··· 1 + import type { CardDefinition } from '../types'; 2 + import BlueskyProfileCard from './BlueskyProfileCard.svelte'; 3 + 4 + export const BlueskyProfileCardDefinition = { 5 + type: 'blueskyProfile', 6 + contentComponent: BlueskyProfileCard, 7 + createNew: (card) => { 8 + // card.w = 4; 9 + // card.mobileW = 8; 10 + // card.h = 4; 11 + // card.mobileH = 8; 12 + } 13 + } as CardDefinition & { type: 'blueskyProfile' };
+57 -53
src/lib/cards/DinoGameCard/DinoGameCard.svelte src/lib/cards/GameCards/DinoGameCard/DinoGameCard.svelte
··· 1 1 <script lang="ts"> 2 - import type { ContentComponentProps } from '../types'; 2 + import { isTyping } from '$lib/helper'; 3 + import type { ContentComponentProps } from '../../types'; 3 4 import { onMount, onDestroy } from 'svelte'; 4 5 5 6 let { item }: ContentComponentProps = $props(); ··· 16 17 // Sprite images (processed with transparent backgrounds) 17 18 let spritesLoaded = $state(false); 18 19 const sprites: Record<string, HTMLCanvasElement> = {}; 19 - let tilemap: HTMLImageElement | null = null; 20 20 21 21 // Tile size (original is 16x16) 22 22 const TILE_SIZE = 16; 23 - const TILEMAP_COLS = 20; 24 23 25 24 // Dynamic scaling - will be calculated based on canvas size 26 25 let scale = 2.5; ··· 59 58 60 59 let gameSpeed = 5; 61 60 let frameCount = 0; 61 + let lastFrameTimestamp = 0; 62 + let lastSpawnFrame = 0; 63 + let lastWalkFrame = 0; 64 + let lastBatFrame = 0; 65 + let lastSpeedScore = 0; 66 + const FRAME_TIME_MS = 1000 / 60; 67 + const MAX_SPEED_BASE = 10.5; 62 68 63 69 // Sprite positions in tilemap (row, column - 1-indexed based on cells.txt) 64 70 const SPRITE_POSITIONS: Record<string, { row: number; col: number }> = { ··· 88 94 }; 89 95 90 96 // Extract a tile from the tilemap and process it (white to black) 91 - function extractTile( 92 - img: HTMLImageElement, 93 - row: number, 94 - col: number 95 - ): HTMLCanvasElement { 97 + function extractTile(img: HTMLImageElement, row: number, col: number): HTMLCanvasElement { 96 98 const offscreen = document.createElement('canvas'); 97 99 offscreen.width = TILE_SIZE; 98 100 offscreen.height = TILE_SIZE; ··· 105 107 106 108 offCtx.drawImage(img, sx, sy, TILE_SIZE, TILE_SIZE, 0, 0, TILE_SIZE, TILE_SIZE); 107 109 108 - // Process: turn white to black for light mode 109 - const imageData = offCtx.getImageData(0, 0, TILE_SIZE, TILE_SIZE); 110 - const data = imageData.data; 111 - 112 - for (let i = 0; i < data.length; i += 4) { 113 - const r = data[i]; 114 - const g = data[i + 1]; 115 - const b = data[i + 2]; 116 - 117 - // Turn white/near-white pixels to black 118 - if (r > 220 && g > 220 && b > 220) { 119 - data[i] = 0; 120 - data[i + 1] = 0; 121 - data[i + 2] = 0; 122 - } 123 - } 124 - 125 - offCtx.putImageData(imageData, 0, 0); 126 110 return offscreen; 127 111 } 128 112 ··· 130 114 return new Promise<void>((resolve) => { 131 115 const img = new Image(); 132 116 img.onload = () => { 133 - tilemap = img; 134 - // Extract all sprites from the tilemap 135 117 for (const [key, pos] of Object.entries(SPRITE_POSITIONS)) { 136 118 sprites[key] = extractTile(img, pos.row, pos.col); 137 119 } ··· 176 158 frame: 0 177 159 }; 178 160 obstacles = []; 179 - gameSpeed = 3.5 * (scale / 2.5); 161 + gameSpeed = 4.2 * (scale / 2.5); 180 162 score = 0; 181 163 frameCount = 0; 164 + lastSpawnFrame = 0; 165 + lastWalkFrame = 0; 166 + lastBatFrame = 0; 167 + lastSpeedScore = 0; 182 168 initGroundTiles(); 183 169 } 184 170 ··· 222 208 } 223 209 224 210 function handleKeyDown(e: KeyboardEvent) { 211 + if(isTyping()) return; 212 + 225 213 if (e.code === 'Space' || e.code === 'ArrowUp' || e.code === 'KeyW') { 226 214 e.preventDefault(); 227 215 jump(); ··· 316 304 ctx.drawImage(sprites[spriteKey], x, y, width, height); 317 305 } 318 306 319 - function gameLoop() { 307 + function gameLoop(timestamp = 0) { 320 308 if (!ctx || !canvas || !spritesLoaded) { 321 309 animationId = requestAnimationFrame(gameLoop); 322 310 return; 323 311 } 324 312 313 + if (!lastFrameTimestamp) { 314 + lastFrameTimestamp = timestamp; 315 + } 316 + 317 + const deltaMs = timestamp - lastFrameTimestamp; 318 + lastFrameTimestamp = timestamp; 319 + const deltaFrames = Math.min(deltaMs / FRAME_TIME_MS, 3); 320 + 325 321 const canvasWidth = canvas.width; 326 322 const canvasHeight = canvas.height; 327 323 const groundY = canvasHeight - groundHeight; ··· 335 331 } 336 332 337 333 if (gameState === 'playing') { 338 - frameCount++; 334 + frameCount += deltaFrames; 339 335 340 336 // Update ground tiles - seamless scrolling 341 337 for (const tile of groundTiles) { 342 - tile.x -= gameSpeed; 338 + tile.x -= gameSpeed * deltaFrames; 343 339 } 344 340 345 341 // Find the rightmost tile and reposition tiles that went off-screen ··· 352 348 353 349 // Update player physics 354 350 if (player.isJumping) { 355 - player.velocityY += gravity; 356 - player.y += player.velocityY; 351 + player.velocityY += gravity * deltaFrames; 352 + player.y += player.velocityY * deltaFrames; 357 353 358 354 if (player.y >= groundY - player.height) { 359 355 player.y = groundY - player.height; ··· 365 361 } 366 362 367 363 // Animate player (3 walk frames) 368 - if (frameCount % 8 === 0) { 364 + if (frameCount - lastWalkFrame >= 8) { 369 365 player.frame = (player.frame + 1) % 3; 366 + lastWalkFrame = frameCount; 370 367 } 371 368 372 369 // Animate flying obstacles 373 370 for (const obs of obstacles) { 374 - if (obs.type === 'air' && frameCount % 12 === 0) { 371 + if (obs.type === 'air' && frameCount - lastBatFrame >= 12) { 375 372 obs.frame = obs.frame === 1 ? 2 : 1; 376 373 obs.sprite = `bat${obs.frame}`; 374 + lastBatFrame = frameCount; 377 375 } 378 376 } 379 377 380 378 // Spawn obstacles 381 379 const baseSpawnRate = 120; 382 380 const spawnRate = Math.max(60, baseSpawnRate - Math.floor(score / 100) * 5); 383 - if (frameCount % spawnRate === 0 || (obstacles.length === 0 && frameCount > 60)) { 381 + if ( 382 + frameCount - lastSpawnFrame >= spawnRate || 383 + (obstacles.length === 0 && frameCount > 60) 384 + ) { 384 385 spawnObstacle(canvasWidth, groundY); 386 + lastSpawnFrame = frameCount; 385 387 } 386 388 387 389 // Update obstacles 388 390 obstacles = obstacles.filter((obs) => { 389 - obs.x -= gameSpeed; 391 + obs.x -= gameSpeed * deltaFrames; 390 392 return obs.x > -obs.width; 391 393 }); 392 394 ··· 430 432 // Update score 431 433 score = Math.floor(frameCount / 5); 432 434 433 - // Increase speed over time (slower progression) 434 - if (frameCount % 700 === 0) { 435 - gameSpeed = Math.min(gameSpeed + 0.2 * (scale / 2.5), 8 * (scale / 2.5)); 435 + // Increase speed every 100 points up to a cap 436 + if (score >= lastSpeedScore + 100) { 437 + gameSpeed = Math.min(gameSpeed + 0.25 * (scale / 2.5), MAX_SPEED_BASE * (scale / 2.5)); 438 + lastSpeedScore = score - (score % 100); 436 439 } 437 440 } 438 441 ··· 468 471 drawSprite(playerSprite, player.x, playerY, player.width, drawHeight); 469 472 470 473 // Draw score 471 - ctx.fillStyle = '#000000'; 474 + ctx.fillStyle = '#ffffff'; 472 475 ctx.font = `bold ${Math.max(12, Math.floor(14 * (scale / 2.5)))}px monospace`; 473 476 ctx.textAlign = 'right'; 474 477 ctx.fillText(String(score).padStart(5, '0'), canvasWidth - 10, 25); 475 478 476 479 if (highScore > 0) { 477 - ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'; 480 + ctx.fillStyle = 'rgba(256, 256, 256, 0.5)'; 478 481 ctx.fillText( 479 482 'HI ' + String(highScore).padStart(5, '0'), 480 483 canvasWidth - 70 * (scale / 2.5), ··· 484 487 485 488 // Draw game over text (no overlay background) 486 489 if (gameState === 'gameover') { 487 - ctx.fillStyle = '#000000'; 488 - ctx.font = `bold ${Math.max(14, Math.floor(16 * (scale / 2.5)))}px monospace`; 490 + ctx.fillStyle = '#ffffff'; 491 + ctx.font = `bold ${Math.max(14, Math.floor(20 * (scale / 2.5)))}px monospace`; 489 492 ctx.textAlign = 'center'; 490 - ctx.fillText('GAME OVER', canvasWidth / 2, canvasHeight / 2 - 30); 493 + ctx.fillText('GAME OVER', canvasWidth / 2, canvasHeight / 2 - 40); 491 494 } 492 495 493 496 animationId = requestAnimationFrame(gameLoop); ··· 502 505 calculateScale(); 503 506 initGroundTiles(); 504 507 } 508 + 509 + let resizeObserver: ResizeObserver | undefined = $state(); 505 510 506 511 onMount(async () => { 507 512 ctx = canvas.getContext('2d'); 508 513 await loadSprites(); 509 514 resizeCanvas(); 510 515 511 - const resizeObserver = new ResizeObserver(() => { 516 + resizeObserver = new ResizeObserver(() => { 512 517 resizeCanvas(); 513 518 }); 514 519 resizeObserver.observe(canvas.parentElement!); 515 520 516 521 gameLoop(); 517 - 518 - return () => { 519 - resizeObserver.disconnect(); 520 - }; 521 522 }); 522 523 523 524 onDestroy(() => { 525 + resizeObserver?.disconnect(); 526 + 524 527 if (animationId) { 525 528 cancelAnimationFrame(animationId); 526 529 } ··· 530 533 <svelte:window onkeydown={handleKeyDown} onkeyup={handleKeyUp} /> 531 534 532 535 <div class="relative h-full w-full overflow-hidden"> 533 - <canvas bind:this={canvas} class="h-full w-full dark:invert" ontouchstart={handleTouch}></canvas> 536 + <canvas bind:this={canvas} class="h-full w-full invert dark:invert-0" ontouchstart={handleTouch} 537 + ></canvas> 534 538 535 539 {#if gameState === 'idle' || gameState === 'gameover'} 536 540 <button 537 541 onclick={startGame} 538 - class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 transform cursor-pointer rounded-lg border-2 border-black bg-white/30 px-6 py-3 font-mono text-sm font-bold text-black transition-colors hover:bg-black hover:text-white" 542 + class="bg-base-50/80 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 transform cursor-pointer rounded-lg border-2 border-black px-6 py-3 font-mono font-bold text-black transition-colors duration-200 hover:bg-black hover:text-white" 539 543 > 540 544 {gameState === 'gameover' ? 'PLAY AGAIN' : 'START'} 541 545 </button>
src/lib/cards/DinoGameCard/SidebarItemDinoGameCard.svelte src/lib/cards/GameCards/DinoGameCard/SidebarItemDinoGameCard.svelte
+1 -1
src/lib/cards/DinoGameCard/index.ts src/lib/cards/GameCards/DinoGameCard/index.ts
··· 1 - import type { CardDefinition } from '../types'; 1 + import type { CardDefinition } from '$lib/cards/types'; 2 2 import DinoGameCard from './DinoGameCard.svelte'; 3 3 import SidebarItemDinoGameCard from './SidebarItemDinoGameCard.svelte'; 4 4
+4 -1
src/lib/cards/EmbedCard/EmbedCard.svelte
··· 1 1 <script lang="ts"> 2 + import { getCanEdit } from '$lib/website/context'; 2 3 import type { ContentComponentProps } from '../types'; 3 4 4 5 let { item }: ContentComponentProps = $props(); 6 + 7 + let isEditing = getCanEdit() 5 8 </script> 6 9 7 10 <iframe 8 11 src={item.cardData.href} 9 12 sandbox="allow-scripts" 10 13 referrerpolicy="no-referrer" 11 - class="absolute inset-0 h-full w-full" 14 + class={["absolute inset-0 h-full w-full", isEditing() && "pointer-events-none"]} 12 15 title="" 13 16 ></iframe>
+9 -3
src/lib/cards/EmbedCard/index.ts
··· 1 1 import type { CardDefinition } from '../types'; 2 2 import CreateEmbedCardModal from './CreateEmbedCardModal.svelte'; 3 3 import EmbedCard from './EmbedCard.svelte'; 4 - import SidebarItemEmbedCard from './SidebarItemEmbedCard.svelte'; 5 4 6 5 export const EmbedCardDefinition = { 7 6 type: 'embed', 8 7 contentComponent: EmbedCard, 9 8 creationModalComponent: CreateEmbedCardModal, 10 - sidebarComponent: SidebarItemEmbedCard, 9 + 11 10 createNew: (card) => { 12 11 card.w = 4; 13 12 card.h = 4; 14 13 card.mobileH = 8; 15 14 card.mobileW = 8; 16 - } 15 + }, 16 + 17 + canChange: (item) => Boolean(item.cardData.href), 18 + 19 + change: (item) => { 20 + return item; 21 + }, 22 + name: 'Embed Card' 17 23 } as CardDefinition & { type: 'embed' };
+90
src/lib/cards/GitHubProfileCard/GitHubProfileCard.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { siGithub } from 'simple-icons'; 4 + import { getAdditionalUserData, getIsMobile } from '$lib/website/context'; 5 + import type { ContentComponentProps } from '../types'; 6 + import type { GithubProfileLoadedData } from '.'; 7 + import GithubContributionsGraph from './GithubContributionsGraph.svelte'; 8 + import { Button } from '@foxui/core'; 9 + import { browser } from '$app/environment'; 10 + import { fade } from 'svelte/transition'; 11 + 12 + let { item }: ContentComponentProps = $props(); 13 + 14 + const data = getAdditionalUserData(); 15 + 16 + let isLoaded = $state(false); 17 + // svelte-ignore state_referenced_locally 18 + let contributionsData = $state( 19 + (data[item.cardType] as GithubProfileLoadedData)?.[item.cardData.user] 20 + ); 21 + 22 + onMount(async () => { 23 + console.log(contributionsData); 24 + if (!contributionsData && item.cardData?.user) { 25 + try { 26 + const response = await fetch(`/api/github?user=${encodeURIComponent(item.cardData.user)}`); 27 + if (response.ok) { 28 + contributionsData = await response.json(); 29 + data[item.cardType] ??= {}; 30 + data[item.cardType][item.cardData.user] = contributionsData; 31 + } 32 + } catch (error) { 33 + console.error('Failed to fetch GitHub contributions:', error); 34 + } 35 + } 36 + isLoaded = true; 37 + }); 38 + 39 + let isMobile = getIsMobile(); 40 + </script> 41 + 42 + <div class="h-full overflow-hidden p-4"> 43 + <div class="flex h-full flex-col justify-between"> 44 + <!-- Header --> 45 + <div class="flex justify-between"> 46 + <div class="flex items-center gap-3"> 47 + <div class="fill-base-950 size-6 shrink-0 dark:fill-white [&_svg]:size-full"> 48 + {@html siGithub.svg} 49 + </div> 50 + <a 51 + href="https://github.com/{item.cardData.user}" 52 + target="_blank" 53 + rel="noopener noreferrer" 54 + class=" flex truncate text-2xl font-bold transition-colors" 55 + > 56 + {item.cardData.user} 57 + </a> 58 + </div> 59 + 60 + {#if isMobile() ? item.mobileW > 4 : item.w > 2} 61 + <Button 62 + href="https://github.com/{item.cardData.user}" 63 + target="_blank" 64 + rel="noopener noreferrer" 65 + class="z-50">Follow</Button 66 + > 67 + {/if} 68 + </div> 69 + 70 + {#if contributionsData && browser} 71 + <div class="flex opacity-100 transition-opacity duration-300 starting:opacity-0"> 72 + <GithubContributionsGraph 73 + data={contributionsData} 74 + isBig={isMobile() ? item.mobileH > 5 : item.h > 2} 75 + /> 76 + </div> 77 + {/if} 78 + </div> 79 + </div> 80 + 81 + {#if item.cardData.href} 82 + <a 83 + href={item.cardData.href} 84 + class="absolute inset-0 h-full w-full" 85 + target="_blank" 86 + rel="noopener noreferrer" 87 + > 88 + <span class="sr-only"> Show on github </span> 89 + </a> 90 + {/if}
+29
src/lib/cards/GitHubProfileCard/GithubContributionsGraph.svelte
··· 1 + <script lang="ts"> 2 + import { cn } from '@foxui/core'; 3 + import type { GitHubContributionsData } from './types'; 4 + 5 + let { data, isBig = false }: { data: GitHubContributionsData; isBig: boolean } = $props(); 6 + 7 + let colors: Record<string, string> = { 8 + '#ebedf0': 'bg-accent-200/50 dark:bg-accent-950/30 accent:bg-accent-800/20', 9 + '#9be9a8': 'bg-accent-300/50 dark:bg-accent-800/70 accent:bg-accent-800/40', 10 + '#40c463': 'bg-accent-300 dark:bg-accent-700 accent:bg-accent-800/60', 11 + '#30a14e': 'bg-accent-400 dark:bg-accent-600 accent:bg-accent-800/80', 12 + '#216e39': 'bg-accent-500 accent:bg-accent-800' 13 + }; 14 + </script> 15 + 16 + <div class={cn('flex h-full w-full justify-end gap-0.5', isBig && 'gap-1')}> 17 + {#each data.contributionsCollection.contributionCalendar.weeks as week (week.contributionDays)} 18 + <div class={cn('flex w-full flex-col gap-0.5', isBig && 'gap-1')}> 19 + {#if week.contributionDays.length === 7} 20 + {#each week.contributionDays as day (day.date)} 21 + <div 22 + class={cn('size-2.5 rounded-sm', colors[day.color], isBig && 'size-3')} 23 + title="Contributions: {day.contributionCount} on {day.date}" 24 + ></div> 25 + {/each} 26 + {/if} 27 + </div> 28 + {/each} 29 + </div>
+90
src/lib/cards/GitHubProfileCard/index.ts
··· 1 + import type { CardDefinition } from '../types'; 2 + import type GithubContributionsGraph from './GithubContributionsGraph.svelte'; 3 + import GitHubProfileCard from './GitHubProfileCard.svelte'; 4 + import type { GitHubContributionsData } from './types'; 5 + 6 + export type GithubProfileLoadedData = Record<string, GitHubContributionsData | undefined>; 7 + 8 + export const GithubProfileCardDefitition = { 9 + type: 'githubProfile', 10 + contentComponent: GitHubProfileCard, 11 + 12 + loadData: async (items) => { 13 + const githubData: Record<string, GithubContributionsGraph> = {}; 14 + for (const item of items) { 15 + try { 16 + const response = await fetch( 17 + `https://blento.app/api/github?user=${encodeURIComponent(item.cardData.user)}` 18 + ); 19 + if (response.ok) { 20 + githubData[item.cardData.user] = await response.json(); 21 + } 22 + } catch (error) { 23 + console.error('Failed to fetch GitHub contributions:', error); 24 + } 25 + } 26 + return githubData; 27 + }, 28 + onUrlHandler: (url, item) => { 29 + const username = getGitHubUsername(url); 30 + 31 + console.log(username); 32 + if (!username) return; 33 + 34 + item.cardData.href = url; 35 + item.cardData.user = username; 36 + 37 + item.w = 6; 38 + item.mobileW = 8; 39 + item.h = 3; 40 + item.mobileH = 6; 41 + return item; 42 + }, 43 + urlHandlerPriority: 5, 44 + minH: 2, 45 + minW: 2, 46 + 47 + canChange: (item) => Boolean(getGitHubUsername(item.cardData.href)), 48 + change: (item) => { 49 + item.cardData.user = getGitHubUsername(item.cardData.href); 50 + 51 + return item; 52 + }, 53 + name: 'Github Profile' 54 + } as CardDefinition & { type: 'githubProfile' }; 55 + 56 + function getGitHubUsername(url: string | undefined): string | undefined { 57 + if (!url) return; 58 + 59 + try { 60 + const parsed = new URL(url); 61 + 62 + // Must be github.com (optionally with www.) 63 + if (!/^(www\.)?github\.com$/.test(parsed.hostname)) { 64 + return undefined; 65 + } 66 + 67 + // Remove empty segments 68 + const segments = parsed.pathname.split('/').filter(Boolean); 69 + 70 + // Profile URLs have exactly one path segment: /username 71 + if (segments.length !== 1) { 72 + return undefined; 73 + } 74 + 75 + const username = segments[0]; 76 + 77 + // GitHub username rules (simplified but accurate) 78 + // - Alphanumeric or hyphens 79 + // - Cannot start or end with a hyphen 80 + // - Max length 39 81 + if (!/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(username)) { 82 + return undefined; 83 + } 84 + 85 + return username; 86 + } catch { 87 + // Invalid URL 88 + return undefined; 89 + } 90 + }
+25
src/lib/cards/GitHubProfileCard/types.ts
··· 1 + export type GitHubContributionDay = { 2 + date: string; 3 + contributionCount: number; 4 + color: string; 5 + }; 6 + 7 + export type GitHubContributionWeek = { 8 + contributionDays: GitHubContributionDay[]; 9 + }; 10 + 11 + export type GitHubContributionsData = { 12 + login: string; 13 + avatarUrl: string; 14 + contributionsCollection: { 15 + contributionCalendar: { 16 + totalContributions: number; 17 + weeks: GitHubContributionWeek[]; 18 + }; 19 + }; 20 + followers: { 21 + totalCount: number; 22 + }; 23 + 24 + updatedAt: number; 25 + };
-188
src/lib/cards/ImageCard/CreateImageCardModal.svelte
··· 1 - <script lang="ts"> 2 - import { Button, Input, Label, Modal, Subheading } from '@foxui/core'; 3 - import type { CreationModalComponentProps } from '../types'; 4 - 5 - let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props(); 6 - 7 - async function handleFileChange(event: Event) { 8 - const target = event.target as HTMLInputElement; 9 - if (!target.files || target.files.length < 1) return; 10 - 11 - const file = target.files[0]; 12 - const compressedFile = await compressImage(file); 13 - 14 - if (item.cardData.objectUrl) URL.revokeObjectURL(item.cardData.objectUrl); 15 - 16 - item.cardData.blob = compressedFile; 17 - item.cardData.objectUrl = URL.createObjectURL(compressedFile); 18 - } 19 - 20 - export function compressImage(file: File, maxSize: number = 600 * 1024): Promise<Blob> { 21 - return new Promise((resolve, reject) => { 22 - const img = new Image(); 23 - const reader = new FileReader(); 24 - 25 - reader.onload = (e) => { 26 - if (!e.target?.result) { 27 - return reject(new Error('Failed to read file.')); 28 - } 29 - img.src = e.target.result as string; 30 - }; 31 - 32 - reader.onerror = (err) => reject(err); 33 - reader.readAsDataURL(file); 34 - 35 - img.onload = () => { 36 - let width = img.width; 37 - let height = img.height; 38 - const maxDimension = 2048; 39 - 40 - if (width > maxDimension || height > maxDimension) { 41 - if (width > height) { 42 - height = Math.round((maxDimension / width) * height); 43 - width = maxDimension; 44 - } else { 45 - width = Math.round((maxDimension / height) * width); 46 - height = maxDimension; 47 - } 48 - } 49 - 50 - // Create a canvas to draw the image 51 - const canvas = document.createElement('canvas'); 52 - canvas.width = width; 53 - canvas.height = height; 54 - const ctx = canvas.getContext('2d'); 55 - if (!ctx) return reject(new Error('Failed to get canvas context.')); 56 - ctx.drawImage(img, 0, 0, width, height); 57 - 58 - // Function to try compressing at a given quality 59 - let quality = 0.8; 60 - function attemptCompression() { 61 - canvas.toBlob( 62 - (blob) => { 63 - if (!blob) { 64 - return reject(new Error('Compression failed.')); 65 - } 66 - // If the blob is under our size limit, or quality is too low, resolve it 67 - if (blob.size <= maxSize || quality < 0.3) { 68 - console.log('Compression successful. Blob size:', blob.size); 69 - console.log('Quality:', quality); 70 - resolve(blob); 71 - } else { 72 - // Otherwise, reduce the quality and try again 73 - quality -= 0.1; 74 - attemptCompression(); 75 - } 76 - }, 77 - 'image/jpeg', 78 - quality 79 - ); 80 - } 81 - 82 - attemptCompression(); 83 - }; 84 - 85 - img.onerror = (err) => reject(err); 86 - }); 87 - } 88 - 89 - let inputRef = $state<HTMLInputElement | null>(null); 90 - 91 - function handleDragOver(event: DragEvent) { 92 - event.preventDefault(); 93 - if (event.dataTransfer) { 94 - event.dataTransfer.dropEffect = 'copy'; 95 - } 96 - } 97 - 98 - function handleDrop(event: DragEvent) { 99 - event.preventDefault(); 100 - const file = event.dataTransfer?.files[0]; 101 - if (file) { 102 - handleFileChange({ target: { files: [file] } } as unknown as Event); 103 - } 104 - } 105 - 106 - function handleDragLeave(event: DragEvent) { 107 - event.preventDefault(); 108 - if (event.dataTransfer) { 109 - event.dataTransfer.dropEffect = 'none'; 110 - } 111 - } 112 - </script> 113 - 114 - <Modal 115 - bind:open={ 116 - () => true, 117 - (change) => { 118 - if (!change) oncancel(); 119 - } 120 - } 121 - closeButton={false} 122 - > 123 - <Subheading>Select an image</Subheading> 124 - 125 - <!-- svelte-ignore a11y_click_events_have_key_events --> 126 - <!-- svelte-ignore a11y_no_static_element_interactions --> 127 - <div 128 - ondragover={handleDragOver} 129 - ondrop={handleDrop} 130 - ondragleave={handleDragLeave} 131 - onclick={() => { 132 - inputRef?.click(); 133 - }} 134 - class="dark:bg-accent-600/5 hover:bg-accent-400/10 dark:hover:bg-accent-600/10 border-accent-400 bg-accent-400/5 dark:border-accent-800 flex h-32 w-full cursor-pointer items-center justify-center gap-2 rounded-2xl border border-dashed p-2 transition-colors duration-200" 135 - > 136 - {#if !item.cardData.objectUrl} 137 - <svg 138 - xmlns="http://www.w3.org/2000/svg" 139 - fill="none" 140 - viewBox="0 0 24 24" 141 - stroke-width="1.5" 142 - stroke="currentColor" 143 - class="text-accent-600 dark:text-accent-400 size-6" 144 - > 145 - <path 146 - stroke-linecap="round" 147 - stroke-linejoin="round" 148 - d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" 149 - /> 150 - </svg> 151 - <span class="text-accent-600 dark:text-accent-400 text-sm">Click to upload image</span> 152 - {:else} 153 - <img 154 - alt="" 155 - src={item.cardData.objectUrl} 156 - class="max-h-full max-w-full rounded-xl object-contain" 157 - /> 158 - {/if} 159 - <input 160 - type="file" 161 - accept="image/*" 162 - onchange={handleFileChange} 163 - class="hidden" 164 - multiple 165 - bind:this={inputRef} 166 - /> 167 - </div> 168 - <Label class="mt-4">Link (optional):</Label> 169 - <Input bind:value={item.cardData.href} /> 170 - 171 - 172 - <div class="mt-4 flex justify-end gap-2"> 173 - <Button 174 - onclick={() => { 175 - if (item.cardData.objectUrl) URL.revokeObjectURL(item.cardData.objectUrl); 176 - 177 - oncancel(); 178 - }} 179 - variant="ghost">Cancel</Button 180 - > 181 - <Button 182 - disabled={!item.cardData.objectUrl} 183 - onclick={async () => { 184 - oncreate(); 185 - }}>Create</Button 186 - > 187 - </div> 188 - </Modal>
+1 -1
src/lib/cards/ImageCard/ImageCard.svelte
··· 1 1 <script lang="ts"> 2 2 import { getDidContext } from '$lib/website/context'; 3 - import { getImageBlobUrl } from '$lib/website/utils'; 3 + import { getImageBlobUrl } from '$lib/oauth/utils'; 4 4 import type { ContentComponentProps } from '../types'; 5 5 6 6 let { item = $bindable(), ...rest }: ContentComponentProps = $props();
+54
src/lib/cards/ImageCard/ImageCardSettings.svelte
··· 1 + <script lang="ts"> 2 + import { validateLink } from '$lib/helper'; 3 + import type { Item } from '$lib/types'; 4 + import { Button, Input, toast } from '@foxui/core'; 5 + 6 + let { item, onclose }: { item: Item; onclose: () => void } = $props(); 7 + 8 + let linkValue = $derived( 9 + item.cardData.href?.replace('https://', '').replace('http://', '') ?? '' 10 + ); 11 + 12 + function updateLink() { 13 + if (!linkValue.trim()) { 14 + item.cardData.href = ''; 15 + item.cardData.domain = ''; 16 + } 17 + 18 + let link = validateLink(linkValue); 19 + if (!link) { 20 + toast.error('Invalid link'); 21 + return; 22 + } 23 + 24 + item.cardData.href = link; 25 + item.cardData.domain = new URL(link).hostname; 26 + 27 + onclose?.(); 28 + } 29 + </script> 30 + 31 + <Input 32 + spellcheck={false} 33 + type="url" 34 + bind:value={linkValue} 35 + onkeydown={(event) => { 36 + if (event.code === 'Enter') { 37 + updateLink(); 38 + event.preventDefault(); 39 + } 40 + }} 41 + placeholder="Enter link" 42 + /> 43 + <Button onclick={updateLink} size="icon" 44 + ><svg 45 + xmlns="http://www.w3.org/2000/svg" 46 + fill="none" 47 + viewBox="0 0 24 24" 48 + stroke-width="1.5" 49 + stroke="currentColor" 50 + class="size-6" 51 + > 52 + <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /> 53 + </svg> 54 + </Button>
+11 -4
src/lib/cards/ImageCard/index.ts
··· 1 - import { uploadBlob } from '$lib/website/utils'; 1 + import { uploadBlob } from '$lib/oauth/utils'; 2 2 import type { CardDefinition } from '../types'; 3 - import CreateImageCardModal from './CreateImageCardModal.svelte'; 4 3 import ImageCard from './ImageCard.svelte'; 4 + import ImageCardSettings from './ImageCardSettings.svelte'; 5 5 6 6 export const ImageCardDefinition = { 7 7 type: 'image', ··· 14 14 href: '' 15 15 }; 16 16 }, 17 - creationModalComponent: CreateImageCardModal, 18 17 upload: async (item) => { 19 18 if (item.cardData.blob) { 20 19 item.cardData.image = await uploadBlob(item.cardData.blob); ··· 29 28 } 30 29 31 30 return item; 32 - } 31 + }, 32 + settingsComponent: ImageCardSettings, 33 + 34 + canChange: (item) => Boolean(item.cardData.image), 35 + 36 + change: (item) => { 37 + return item; 38 + }, 39 + name: 'Image Card' 33 40 } as CardDefinition & { type: 'image' };
-59
src/lib/cards/LinkCard/CreateLinkCardModal.svelte
··· 1 - <script lang="ts"> 2 - import { Alert, Button, Input, Modal, Subheading } from '@foxui/core'; 3 - import type { CreationModalComponentProps } from '../types'; 4 - 5 - let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props(); 6 - 7 - let isFetchingMetadata = $state(false); 8 - 9 - let errorMessage = $state(''); 10 - 11 - async function fetchMetadata() { 12 - errorMessage = ''; 13 - try { 14 - item.cardData.domain = new URL(item.cardData.href).hostname; 15 - } catch (error) { 16 - errorMessage = 'Invalid URL!'; 17 - return false; 18 - } 19 - isFetchingMetadata = true; 20 - 21 - try { 22 - const response = await fetch('/api/links?link=' + encodeURIComponent(item.cardData.href)); 23 - if (response.ok) { 24 - const data = await response.json(); 25 - item.cardData.description = data.description || ''; 26 - item.cardData.title = data.title || ''; 27 - item.cardData.image = data.images?.[0] || ''; 28 - item.cardData.favicon = data.favicons?.[0] || undefined; 29 - } else { 30 - throw new Error(); 31 - } 32 - } catch (error) { 33 - errorMessage = "Couldn't fetch metadata for this link!"; 34 - return false; 35 - } finally { 36 - isFetchingMetadata = false; 37 - } 38 - return true; 39 - } 40 - </script> 41 - 42 - <Modal open={true} closeButton={false}> 43 - <Subheading>Enter a link</Subheading> 44 - <Input bind:value={item.cardData.href} /> 45 - 46 - {#if errorMessage} 47 - <Alert type="error" title="Failed to create link card"><span>{errorMessage}</span></Alert> 48 - {/if} 49 - 50 - <div class="mt-4 flex justify-end gap-2"> 51 - <Button onclick={oncancel} variant="ghost">Cancel</Button> 52 - <Button 53 - disabled={isFetchingMetadata} 54 - onclick={async () => { 55 - if (await fetchMetadata()) oncreate(); 56 - }}>{isFetchingMetadata ? 'Creating...' : 'Create'}</Button 57 - > 58 - </div> 59 - </Modal>
+102 -39
src/lib/cards/LinkCard/EditingLinkCard.svelte
··· 1 1 <script lang="ts"> 2 2 import { browser } from '$app/environment'; 3 - import { getIsMobile } from '$lib/helper'; 3 + import { getIsMobile } from '$lib/website/context'; 4 4 import type { ContentComponentProps } from '../types'; 5 5 import PlainTextEditor from '../utils/PlainTextEditor.svelte'; 6 + import { onMount } from 'svelte'; 6 7 7 8 let { item = $bindable() }: ContentComponentProps = $props(); 8 9 9 10 let isMobile = getIsMobile(); 10 11 11 12 let faviconHasError = $state(false); 13 + let isFetchingMetadata = $state(false); 14 + 15 + let hasFetched = $derived(item.cardData.hasFetched !== false); 16 + 17 + async function fetchMetadata() { 18 + let domain: string; 19 + try { 20 + domain = new URL(item.cardData.href).hostname; 21 + } catch (error) { 22 + return; 23 + } 24 + item.cardData.domain = domain; 25 + faviconHasError = false; 26 + 27 + try { 28 + const response = await fetch('/api/links?link=' + encodeURIComponent(item.cardData.href)); 29 + if (!response.ok) { 30 + throw new Error(); 31 + } 32 + const data = await response.json(); 33 + item.cardData.description = data.description || ''; 34 + item.cardData.title = data.title || ''; 35 + item.cardData.image = data.images?.[0] || ''; 36 + item.cardData.favicon = data.favicons?.[0] || undefined; 37 + } catch (error) { 38 + return; 39 + } 40 + } 41 + 42 + $inspect(hasFetched); 43 + 44 + $effect(() => { 45 + if (hasFetched !== false || isFetchingMetadata) { 46 + return; 47 + } 48 + 49 + isFetchingMetadata = true; 50 + 51 + fetchMetadata().then(() => { 52 + item.cardData.hasFetched = true; 53 + isFetchingMetadata = false; 54 + }); 55 + }); 12 56 </script> 13 57 14 - <div class="flex h-full flex-col justify-between p-4"> 15 - <div> 16 - {#if item.cardData.favicon} 17 - <div 18 - class="bg-base-100 border-base-300 dark:border-base-800 dark:bg-base-900 mb-2 inline-flex size-8 items-center justify-center rounded-xl border shadow-sm" 19 - > 20 - {#if !faviconHasError} 21 - <img 22 - class="size-6 rounded-lg object-cover" 23 - onerror={() => (faviconHasError = true)} 24 - src={item.cardData.favicon} 25 - alt="" 58 + <div class="relative flex h-full flex-col justify-between p-4"> 59 + <div 60 + class={[ 61 + 'accent:bg-accent-500/50 absolute inset-0 z-20 bg-white/50 dark:bg-black/50', 62 + !hasFetched ? 'animate-pulse' : 'hidden' 63 + ]} 64 + ></div> 65 + 66 + <div class={isFetchingMetadata ? 'pointer-events-none' : ''}> 67 + <div 68 + class="bg-base-100 border-base-300 accent:bg-accent-100/50 accent:border-accent-200 dark:border-base-800 dark:bg-base-900 mb-2 inline-flex size-8 items-center justify-center rounded-xl border" 69 + > 70 + {#if hasFetched && item.cardData.favicon && !faviconHasError} 71 + <img 72 + class="size-6 rounded-lg object-cover" 73 + onerror={() => (faviconHasError = true)} 74 + src={item.cardData.favicon} 75 + alt="" 76 + /> 77 + {:else} 78 + <svg 79 + xmlns="http://www.w3.org/2000/svg" 80 + fill="none" 81 + viewBox="0 0 24 24" 82 + stroke-width="1.5" 83 + stroke="currentColor" 84 + class="size-4" 85 + > 86 + <path 87 + stroke-linecap="round" 88 + stroke-linejoin="round" 89 + d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 1 1.242 7.244" 26 90 /> 27 - {:else} 28 - <svg 29 - xmlns="http://www.w3.org/2000/svg" 30 - fill="none" 31 - viewBox="0 0 24 24" 32 - stroke-width="1.5" 33 - stroke="currentColor" 34 - class="size-4" 35 - > 36 - <path 37 - stroke-linecap="round" 38 - stroke-linejoin="round" 39 - d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" 40 - /> 41 - </svg> 42 - {/if} 43 - </div> 44 - {/if} 91 + </svg> 92 + {/if} 93 + </div> 45 94 46 95 <div 47 - class="hover:bg-base-200/70 dark:hover:bg-base-800/70 accent:hover:bg-accent-400 -m-1 rounded-md p-1 transition-colors duration-200" 96 + class={[ 97 + '-m-1 rounded-md p-1 transition-colors duration-200', 98 + hasFetched 99 + ? 'hover:bg-base-200/70 dark:hover:bg-base-800/70 accent:hover:bg-accent-200/30' 100 + : '' 101 + ]} 48 102 > 49 - <PlainTextEditor 50 - class="text-base-900 dark:text-base-50 text-lg font-bold" 51 - key="title" 52 - bind:item 53 - /> 103 + {#if hasFetched} 104 + <PlainTextEditor 105 + class="text-base-900 dark:text-base-50 line-clamp-2 text-lg font-bold" 106 + key="title" 107 + bind:item 108 + placeholder="Title here" 109 + /> 110 + {:else} 111 + <span class={'text-base-900 dark:text-base-50 line-clamp-2 text-lg font-bold'}> 112 + Loading data... 113 + </span> 114 + {/if} 54 115 </div> 55 116 <!-- <div class="text-base-800 dark:text-base-100 mt-2 text-xs">{item.cardData.description}</div> --> 56 - <div class="text-accent-600 accent:text-accent-950 font-semibold dark:text-accent-400 mt-2 text-xs"> 117 + <div 118 + class="text-accent-600 accent:text-accent-950 dark:text-accent-400 mt-2 text-xs font-semibold" 119 + > 57 120 {item.cardData.domain} 58 121 </div> 59 122 </div> 60 123 61 - {#if browser && ((isMobile() && item.mobileH >= 8) || (!isMobile() && item.h >= 4)) && item.cardData.image} 124 + {#if hasFetched && browser && ((isMobile() && item.mobileH >= 8) || (!isMobile() && item.h >= 4)) && item.cardData.image} 62 125 <img class=" mb-2 max-h-32 w-full rounded-xl object-cover" src={item.cardData.image} alt="" /> 63 126 {/if} 64 127 </div>
+32 -30
src/lib/cards/LinkCard/LinkCard.svelte
··· 1 1 <script lang="ts"> 2 2 import { browser } from '$app/environment'; 3 - import { getIsMobile } from '$lib/helper'; 3 + import { getIsMobile } from '$lib/website/context'; 4 4 import type { ContentComponentProps } from '../types'; 5 5 6 6 let { item }: ContentComponentProps = $props(); ··· 12 12 13 13 <div class="flex h-full flex-col justify-between p-4"> 14 14 <div> 15 - {#if item.cardData.favicon} 16 - <div 17 - class="bg-base-100 border-base-300 dark:border-base-800 dark:bg-base-900 mb-2 inline-flex size-8 items-center justify-center rounded-xl border shadow-sm" 18 - > 19 - {#if !faviconHasError} 20 - <img 21 - class="size-6 rounded-lg object-cover" 22 - onerror={() => (faviconHasError = true)} 23 - src={item.cardData.favicon} 24 - alt="" 15 + <div 16 + class="bg-base-100 border-base-300 accent:bg-accent-100/50 accent:border-accent-200 dark:border-base-800 dark:bg-base-900 mb-2 inline-flex size-8 items-center justify-center rounded-xl border" 17 + > 18 + {#if item.cardData.favicon && !faviconHasError} 19 + <img 20 + class="size-6 rounded-lg object-cover" 21 + onerror={() => (faviconHasError = true)} 22 + src={item.cardData.favicon} 23 + alt="" 24 + /> 25 + {:else} 26 + <svg 27 + xmlns="http://www.w3.org/2000/svg" 28 + fill="none" 29 + viewBox="0 0 24 24" 30 + stroke-width="1.5" 31 + stroke="currentColor" 32 + class="size-4" 33 + > 34 + <path 35 + stroke-linecap="round" 36 + stroke-linejoin="round" 37 + d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" 25 38 /> 26 - {:else} 27 - <svg 28 - xmlns="http://www.w3.org/2000/svg" 29 - fill="none" 30 - viewBox="0 0 24 24" 31 - stroke-width="1.5" 32 - stroke="currentColor" 33 - class="size-4" 34 - > 35 - <path 36 - stroke-linecap="round" 37 - stroke-linejoin="round" 38 - d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" 39 - /> 40 - </svg> 41 - {/if} 42 - </div> 43 - {/if} 39 + </svg> 40 + {/if} 41 + </div> 44 42 <div 45 43 class={[ 46 44 'text-base-900 dark:text-base-50 text-lg font-bold', ··· 58 56 </div> 59 57 60 58 {#if browser && ((isMobile() && item.mobileH >= 8) || (!isMobile() && item.h >= 4)) && item.cardData.image} 61 - <img class="mb-2 max-h-32 w-full starting:opacity-0 opacity-100 transition-opacity duration-100 rounded-xl object-cover" src={item.cardData.image} alt="" /> 59 + <img 60 + class="mb-2 max-h-32 w-full rounded-xl object-cover opacity-100 transition-opacity duration-100 starting:opacity-0" 61 + src={item.cardData.image} 62 + alt="" 63 + /> 62 64 {/if} 63 65 {#if item.cardData.href} 64 66 <a
+50
src/lib/cards/LinkCard/LinkCardSettings.svelte
··· 1 + <script lang="ts"> 2 + import { validateLink } from '$lib/helper'; 3 + import type { Item } from '$lib/types'; 4 + import { Button, Input, toast } from '@foxui/core'; 5 + 6 + let { item, onclose }: { item: Item; onclose: () => void } = $props(); 7 + 8 + let linkValue = $derived(item.cardData.href.replace('https://', '').replace('http://', '')); 9 + 10 + function updateLink() { 11 + if (!linkValue.trim()) return; 12 + 13 + let link = validateLink(linkValue); 14 + if (!link) { 15 + toast.error('Invalid link'); 16 + return; 17 + } 18 + 19 + item.cardData.href = link; 20 + item.cardData.domain = new URL(link).hostname; 21 + item.cardData.hasFetched = false; 22 + 23 + onclose?.(); 24 + } 25 + </script> 26 + 27 + <Input 28 + spellcheck={false} 29 + type="url" 30 + bind:value={linkValue} 31 + onkeydown={(event) => { 32 + if (event.code === 'Enter') { 33 + updateLink(); 34 + event.preventDefault(); 35 + } 36 + }} 37 + placeholder="Enter link" 38 + /> 39 + <Button onclick={updateLink} size="icon" 40 + ><svg 41 + xmlns="http://www.w3.org/2000/svg" 42 + fill="none" 43 + viewBox="0 0 24 24" 44 + stroke-width="1.5" 45 + stroke="currentColor" 46 + class="size-6" 47 + > 48 + <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /> 49 + </svg> 50 + </Button>
+23 -5
src/lib/cards/LinkCard/index.ts
··· 1 + import { validateLink } from '$lib/helper'; 1 2 import type { CardDefinition } from '../types'; 2 - import CreateLinkCardModal from './CreateLinkCardModal.svelte'; 3 3 import EditingLinkCard from './EditingLinkCard.svelte'; 4 4 import LinkCard from './LinkCard.svelte'; 5 + import LinkCardSettings from './LinkCardSettings.svelte'; 5 6 6 7 export const LinkCardDefinition = { 7 8 type: 'link', 8 9 contentComponent: LinkCard, 9 10 editingContentComponent: EditingLinkCard, 10 11 createNew: (card) => { 11 - card.cardType = 'link'; 12 - card.cardData = { 13 - href: 'https://' 12 + card.cardData.hasFetched = false; 13 + }, 14 + settingsComponent: LinkCardSettings, 15 + 16 + name: 'Link Card', 17 + canChange: (item) => Boolean(validateLink(item.cardData?.href)), 18 + change: (item) => { 19 + const href = validateLink(item.cardData?.href); 20 + if (!href) return item; 21 + 22 + item.cardData = { 23 + ...item.cardData, 24 + hasFetched: false 14 25 }; 26 + return item; 15 27 }, 16 - creationModalComponent: CreateLinkCardModal 28 + onUrlHandler: (url, item) => { 29 + item.cardData.href = url; 30 + item.cardData.domain = new URL(url).hostname; 31 + item.cardData.hasFetched = false; 32 + return item; 33 + }, 34 + urlHandlerPriority: 0 17 35 } as CardDefinition & { type: 'link' };
+3 -6
src/lib/cards/LivestreamCard/LivestreamCard.svelte
··· 1 1 <script lang="ts"> 2 2 import { onMount } from 'svelte'; 3 3 import Icon from './Icon.svelte'; 4 - import { getDidContext, getHandleContext } from '$lib/website/context'; 5 - import { listRecords } from '$lib/oauth/atproto'; 6 - import { getAdditionalUserData, getIsMobile } from '$lib/helper'; 4 + import { getAdditionalUserData, getDidContext, getHandleContext, getIsMobile } from '$lib/website/context'; 7 5 import type { ContentComponentProps } from '../types'; 8 - import { getImageBlobUrl } from '$lib/website/utils'; 9 6 import { RelativeTime } from '@foxui/time'; 10 - import { online } from 'svelte/reactivity/window'; 11 7 import { Badge } from '@foxui/core'; 12 8 import { CardDefinitionsByType } from '..'; 9 + import { browser } from '$app/environment'; 13 10 14 11 let { item = $bindable() }: ContentComponentProps = $props(); 15 12 ··· 94 91 </a> 95 92 </div> 96 93 97 - {#if ((isMobile() && item.mobileH >= 4) || (!isMobile() && item.h >= 2)) && latestLivestream?.thumb} 94 + {#if browser && ((isMobile() && item.mobileH >= 7) || (!isMobile() && item.h >= 4)) && latestLivestream?.thumb} 98 95 <a href={latestLivestream?.href} target="_blank" rel="noopener noreferrer"> 99 96 <img 100 97 class="my-4 max-h-32 w-full rounded-xl object-cover"
+3 -8
src/lib/cards/LivestreamCard/LivestreamEmbedCard.svelte
··· 1 1 <script lang="ts"> 2 2 import type { ContentComponentProps } from '../types'; 3 3 4 - let { 5 - item, 6 - sandbox 7 - }: ContentComponentProps & { 8 - sandbox: string; 9 - } = $props(); 4 + let { item }: ContentComponentProps = $props(); 10 5 11 6 // svelte-ignore state_referenced_locally 12 - let domain = new URL(item.cardData.href).hostname; 7 + let domain = new URL(item.cardData.embed || item.cardData.href).hostname; 13 8 </script> 14 9 15 10 {#if domain === 'stream.place'} 16 11 <iframe 17 - src={item.cardData.href} 12 + src={item.cardData.embed || item.cardData.href} 18 13 sandbox="allow-scripts allow-same-origin" 19 14 referrerpolicy="no-referrer" 20 15 class="absolute inset-0 h-full w-full"
+29 -6
src/lib/cards/LivestreamCard/index.ts
··· 1 1 import { client } from '$lib/oauth'; 2 2 import { listRecords } from '$lib/oauth/atproto'; 3 - import { getImageBlobUrl } from '$lib/website/utils'; 4 - import EmbedCard from '../EmbedCard/EmbedCard.svelte'; 3 + import { getImageBlobUrl } from '$lib/oauth/utils'; 5 4 import type { CardDefinition } from '../types'; 6 5 import LivestreamCard from './LivestreamCard.svelte'; 7 6 import LivestreamEmbedCard from './LivestreamEmbedCard.svelte'; 8 - import SidebarItemEmbedLivestreamCard from './SidebarItemEmbedLivestreamCard.svelte'; 9 7 import SidebarItemLivestreamCard from './SidebarItemLivestreamCard.svelte'; 10 8 11 9 export const LivestreamCardDefitition = { ··· 17 15 card.h = 4; 18 16 card.mobileH = 8; 19 17 card.mobileW = 8; 18 + 19 + card.cardType = 'latestLivestream'; 20 20 }, 21 21 loadData: async (items, { did }) => { 22 22 const records = await listRecords({ did, collection: 'place.stream.livestream', limit: 3 }); ··· 64 64 } 65 65 66 66 return latestLivestream; 67 - } 67 + }, 68 + 69 + onUrlHandler: (url, item) => { 70 + console.log(url, 'https://stream.place/' + client.profile?.handle); 71 + if (url === 'https://stream.place/' + client.profile?.handle) { 72 + item.w = 4; 73 + item.h = 4; 74 + item.mobileH = 8; 75 + item.mobileW = 8; 76 + item.cardData.href = 'https://stream.place/' + client.profile?.handle; 77 + return item; 78 + } 79 + }, 80 + 81 + canChange: (item) => item.cardData.href === 'https://stream.place/' + client.profile?.handle, 82 + 83 + urlHandlerPriority: 5, 84 + 85 + name: 'stream.place Card' 68 86 } as CardDefinition & { type: 'latestLivestream' }; 69 87 70 88 export const LivestreamEmbedCardDefitition = { 71 89 type: 'livestreamEmbed', 72 90 contentComponent: LivestreamEmbedCard, 73 - sidebarComponent: SidebarItemEmbedLivestreamCard, 74 91 createNew: (card) => { 75 92 card.w = 4; 76 93 card.h = 2; ··· 78 95 card.mobileH = 4; 79 96 80 97 card.cardData = { 81 - href: 'https://stream.place/embed/' + client.profile?.handle 98 + href: 'https://stream.place/' + client.profile?.handle, 99 + embed: 'https://stream.place/embed/' + client.profile?.handle 82 100 }; 83 101 } 102 + // canChange: (item) => item.cardData.href === 'https://stream.place/' + client.profile?.handle, 103 + 104 + // change: (item) => { 105 + // item.cardData.embed = 'https://stream.place/embed/' + client.profile?.handle; 106 + // }, 84 107 } as CardDefinition & { type: 'livestreamEmbed' };
-1
src/lib/cards/SectionCard/index.ts
··· 20 20 card.mobileW = COLUMNS; 21 21 }, 22 22 23 - sidebarButtonText: 'Section Headline', 24 23 defaultColor: 'transparent', 25 24 maxH: 1, 26 25 canResize: false
+15 -9
src/lib/cards/SpecialCards/UpdatedBlentos/UpdatedBlentosCard.svelte
··· 1 1 <script lang="ts"> 2 2 import type { ContentComponentProps } from '$lib/cards/types'; 3 - import { getAdditionalUserData } from '$lib/helper'; 4 - import { getProfile } from '$lib/oauth/atproto'; 3 + import { getAdditionalUserData } from '$lib/website/context'; 5 4 import type { ProfileViewDetailed } from '@atproto/api/dist/client/types/app/bsky/actor/defs'; 6 - import { onMount } from 'svelte'; 7 5 8 6 let { item }: ContentComponentProps = $props(); 9 7 10 8 const data = getAdditionalUserData(); 11 9 // svelte-ignore state_referenced_locally 12 10 const profiles = data[item.cardType] as ProfileViewDetailed[]; 11 + 12 + $inspect(profiles); 13 13 </script> 14 14 15 - <div class="h-full flex flex-col"> 16 - <div class="text-2xl font-bold px-4 py-2">Recently updated blentos</div> 17 - <div class="flex grow max-w-full items-center gap-4 overflow-x-scroll overflow-y-hidden px-4"> 15 + <div class="flex h-full flex-col"> 16 + <div class="px-4 py-2 text-2xl font-bold">Recently updated blentos</div> 17 + <div class="flex max-w-full grow items-center gap-4 overflow-x-scroll overflow-y-hidden px-4"> 18 18 {#each profiles as profile} 19 19 <a 20 20 href="/{profile.handle}" 21 - class="bg-base-100 dark:bg-base-800 hover:bg-base-200 dark:hover:bg-base-700 flex h-52 w-44 min-w-44 flex-col items-center justify-center gap-2 rounded-xl transition-colors duration-150 p-2 accent:bg-accent-200/30 accent:hover:bg-accent-200/50" 21 + class="bg-base-100 dark:bg-base-800 hover:bg-base-200 dark:hover:bg-base-700 accent:bg-accent-200/30 accent:hover:bg-accent-200/50 flex h-52 w-44 min-w-44 flex-col items-center justify-center gap-2 rounded-xl p-2 transition-colors duration-150" 22 22 target="_blank" 23 23 > 24 - <img src={profile.avatar} class="aspect-square size-28 rounded-full" alt="" /> 25 - <div class="line-clamp-1 text-md font-bold text-center">{profile.displayName || profile.handle}</div> 24 + <img 25 + src={profile.avatar} 26 + class="bg-base-200 dark:bg-base-700 accent:bg-accent-300 aspect-square size-28 rounded-full" 27 + alt="" 28 + /> 29 + <div class="text-md line-clamp-1 text-center font-bold"> 30 + {profile.displayName || profile.handle} 31 + </div> 26 32 </a> 27 33 {/each} 28 34 </div>
+4 -4
src/lib/cards/SpecialCards/UpdatedBlentos/index.ts
··· 6 6 export const UpdatedBlentosCardDefitition = { 7 7 type: 'updatedBlentos', 8 8 contentComponent: UpdatedBlentosCard, 9 - loadData: async (items, { platform }) => { 9 + loadData: async (items, { cache }) => { 10 10 try { 11 11 const response = await fetch( 12 12 'https://ufos-api.microcosm.blue/records?collection=app.blento.card' 13 13 ); 14 14 const recentRecords = await response.json(); 15 - const existingUsers = await platform?.env?.USER_DATA_CACHE?.get('updatedBlentos'); 15 + const existingUsers = await cache?.get('updatedBlentos'); 16 16 const existingUsersArray: ProfileViewDetailed[] = existingUsers 17 17 ? JSON.parse(existingUsers) 18 18 : []; ··· 34 34 35 35 const result = [...(await Promise.all(profiles)), ...existingUsersArray]; 36 36 37 - if (platform?.env?.USER_DATA_CACHE) { 38 - await platform?.env?.USER_DATA_CACHE.put('updatedBlentos', JSON.stringify(result)); 37 + if (cache) { 38 + await cache?.put('updatedBlentos', JSON.stringify(result)); 39 39 } 40 40 return JSON.parse(JSON.stringify(result)); 41 41 } catch (error) {
+1 -1
src/lib/cards/TextCard/TextCard.svelte
··· 13 13 14 14 <div 15 15 class={cn( 16 - 'prose dark:prose-invert prose-neutral prose-sm prose-a:no-underline prose-a:text-accent-600 dark:prose-a:text-accent-400 accent:prose-a:text-accent-950 accent:prose-a:underline accent:prose-p:text-base-900 prose-p:first:mt-0 prose-p:last:mb-0 prose-headings:first:mt-0 prose-headings:last:mb-0 inline-flex h-full min-h-full w-full max-w-none overflow-y-scroll rounded-md p-3 text-lg', 16 + 'prose dark:prose-invert prose-neutral prose-sm prose-a:no-underline prose-a:text-accent-600 dark:prose-a:text-accent-400 accent:prose-a:text-accent-950 accent:prose-a:underline accent:prose-p:text-base-900 prose-p:first:mt-0 prose-p:last:mb-0 prose-headings:first:mt-0 prose-headings:last:mb-0 inline-flex h-full min-h-full w-full max-w-none overflow-y-scroll rounded-md p-3 text-lg overflow-x-hidden', 17 17 textAlignClasses?.[item.cardData.textAlign as string], 18 18 verticalAlignClasses[item.cardData.verticalAlign as string], 19 19 textSizeClasses[(item.cardData.textSize ?? 0) as number]
+44 -42
src/lib/cards/TextCard/TextCardSettings.svelte
··· 9 9 </script> 10 10 11 11 <div class="flex flex-col gap-2"> 12 + 12 13 <ToggleGroup 13 14 type="single" 14 15 bind:value={ 15 16 () => { 16 - return item.cardData.textAlign ?? 'left'; 17 + return item.cardData.verticalAlign ?? 'top'; 17 18 }, 18 19 (value) => { 19 20 if (!value) return; 20 - item.cardData.textAlign = value; 21 + item.cardData.verticalAlign = value; 21 22 } 22 23 } 23 24 > 24 - <ToggleGroupItem size="sm" value="left" class={classes} 25 + <ToggleGroupItem size="sm" value="top" class={classes} 25 26 ><svg 26 27 xmlns="http://www.w3.org/2000/svg" 27 28 viewBox="0 0 24 24" 28 29 fill="none" 29 30 stroke="currentColor" 30 - stroke-width="1.5" 31 + stroke-width="2" 31 32 stroke-linecap="round" 32 - stroke-linejoin="round"><path d="M21 5H3" /><path d="M15 12H3" /><path d="M17 19H3" /></svg 33 - ></ToggleGroupItem 34 - > 33 + stroke-linejoin="round" 34 + ><rect width="6" height="16" x="4" y="6" rx="2" /><rect 35 + width="6" 36 + height="9" 37 + x="14" 38 + y="6" 39 + rx="2" 40 + /><path d="M22 2H2" /></svg 41 + > 42 + </ToggleGroupItem> 35 43 <ToggleGroupItem size="sm" value="center" class={classes} 36 44 ><svg 37 45 xmlns="http://www.w3.org/2000/svg" 38 46 viewBox="0 0 24 24" 39 47 fill="none" 40 48 stroke="currentColor" 41 - stroke-width="1.5" 49 + stroke-width="2" 42 50 stroke-linecap="round" 43 - stroke-linejoin="round"><path d="M21 5H3" /><path d="M17 12H7" /><path d="M19 19H5" /></svg 51 + stroke-linejoin="round" 52 + ><rect width="10" height="6" x="7" y="9" rx="2" /><path d="M22 20H2" /><path 53 + d="M22 4H2" 54 + /></svg 44 55 ></ToggleGroupItem 45 56 > 46 - <ToggleGroupItem size="sm" value="right" class={classes} 57 + <ToggleGroupItem size="sm" value="bottom" class={classes} 47 58 ><svg 48 59 xmlns="http://www.w3.org/2000/svg" 49 60 viewBox="0 0 24 24" 50 61 fill="none" 51 62 stroke="currentColor" 52 - stroke-width="1.5" 63 + stroke-width="2" 53 64 stroke-linecap="round" 54 - stroke-linejoin="round"><path d="M21 5H3" /><path d="M21 12H9" /><path d="M21 19H7" /></svg 65 + stroke-linejoin="round" 66 + ><rect width="14" height="6" x="5" y="12" rx="2" /><rect 67 + width="10" 68 + height="6" 69 + x="7" 70 + y="2" 71 + rx="2" 72 + /><path d="M2 22h20" /></svg 55 73 ></ToggleGroupItem 56 74 > 57 75 </ToggleGroup> 58 - 76 + 59 77 <ToggleGroup 60 78 type="single" 61 79 bind:value={ 62 80 () => { 63 - return item.cardData.verticalAlign ?? 'top'; 81 + return item.cardData.textAlign ?? 'left'; 64 82 }, 65 83 (value) => { 66 84 if (!value) return; 67 - item.cardData.verticalAlign = value; 85 + item.cardData.textAlign = value; 68 86 } 69 87 } 70 88 > 71 - <ToggleGroupItem size="sm" value="top" class={classes} 89 + <ToggleGroupItem size="sm" value="left" class={classes} 72 90 ><svg 73 91 xmlns="http://www.w3.org/2000/svg" 74 92 viewBox="0 0 24 24" 75 93 fill="none" 76 94 stroke="currentColor" 77 - stroke-width="1.5" 95 + stroke-width="2" 78 96 stroke-linecap="round" 79 - stroke-linejoin="round" 80 - ><rect width="6" height="16" x="4" y="6" rx="2" /><rect 81 - width="6" 82 - height="9" 83 - x="14" 84 - y="6" 85 - rx="2" 86 - /><path d="M22 2H2" /></svg 87 - > 88 - </ToggleGroupItem> 97 + stroke-linejoin="round"><path d="M21 5H3" /><path d="M15 12H3" /><path d="M17 19H3" /></svg 98 + ></ToggleGroupItem 99 + > 89 100 <ToggleGroupItem size="sm" value="center" class={classes} 90 101 ><svg 91 102 xmlns="http://www.w3.org/2000/svg" 92 103 viewBox="0 0 24 24" 93 104 fill="none" 94 105 stroke="currentColor" 95 - stroke-width="1.5" 106 + stroke-width="2" 96 107 stroke-linecap="round" 97 - stroke-linejoin="round" 98 - ><rect width="10" height="6" x="7" y="9" rx="2" /><path d="M22 20H2" /><path 99 - d="M22 4H2" 100 - /></svg 108 + stroke-linejoin="round"><path d="M21 5H3" /><path d="M17 12H7" /><path d="M19 19H5" /></svg 101 109 ></ToggleGroupItem 102 110 > 103 - <ToggleGroupItem size="sm" value="bottom" class={classes} 111 + <ToggleGroupItem size="sm" value="right" class={classes} 104 112 ><svg 105 113 xmlns="http://www.w3.org/2000/svg" 106 114 viewBox="0 0 24 24" 107 115 fill="none" 108 116 stroke="currentColor" 109 - stroke-width="1.5" 117 + stroke-width="2" 110 118 stroke-linecap="round" 111 - stroke-linejoin="round" 112 - ><rect width="14" height="6" x="5" y="12" rx="2" /><rect 113 - width="10" 114 - height="6" 115 - x="7" 116 - y="2" 117 - rx="2" 118 - /><path d="M2 22h20" /></svg 119 + stroke-linejoin="round"><path d="M21 5H3" /><path d="M21 12H9" /><path d="M21 19H7" /></svg 119 120 ></ToggleGroupItem 120 121 > 121 122 </ToggleGroup> 123 + 122 124 123 125 <div> 124 126 <Button
+94
src/lib/cards/VideoCard/VideoCard.svelte
··· 1 + <script lang="ts"> 2 + import { getDidContext } from '$lib/website/context'; 3 + import { getBlob } from '$lib/oauth/atproto'; 4 + import { onMount } from 'svelte'; 5 + import type { ContentComponentProps } from '../types'; 6 + 7 + let { item = $bindable() }: ContentComponentProps = $props(); 8 + 9 + const did = getDidContext(); 10 + 11 + let element: HTMLVideoElement | undefined = $state(); 12 + 13 + onMount(async () => { 14 + const el = element; 15 + if (!el) return; 16 + 17 + el.muted = true; 18 + 19 + // If we already have an objectUrl (preview before upload), use it directly 20 + if (item.cardData.objectUrl) { 21 + el.src = item.cardData.objectUrl; 22 + el.play().catch((e) => { 23 + console.error('Video play error:', e); 24 + }); 25 + return; 26 + } 27 + 28 + // Fetch the video blob from the PDS 29 + if (item.cardData.video?.video && typeof item.cardData.video.video === 'object') { 30 + const cid = item.cardData.video.video?.ref?.$link; 31 + if (!cid) return; 32 + 33 + try { 34 + const blobUrl = await getBlob({ did, cid }); 35 + const res = await fetch(blobUrl); 36 + if (!res.ok) throw new Error(res.statusText); 37 + const blob = await res.blob(); 38 + const url = URL.createObjectURL(blob); 39 + el.src = url; 40 + el.play().catch((e) => { 41 + console.error('Video play error:', e); 42 + }); 43 + } catch (e) { 44 + console.error('Failed to load video:', e); 45 + } 46 + } 47 + }); 48 + </script> 49 + 50 + {#key item.cardData.video || item.cardData.objectUrl} 51 + <!-- svelte-ignore a11y_media_has_caption --> 52 + <video 53 + bind:this={element} 54 + muted 55 + loop 56 + autoplay 57 + playsinline 58 + class={[ 59 + 'absolute inset-0 h-full w-full object-cover opacity-100 transition-transform duration-300 ease-in-out', 60 + item.cardData.href ? 'group-hover:scale-102' : '' 61 + ]} 62 + ></video> 63 + {/key} 64 + {#if item.cardData.href} 65 + <a 66 + href={item.cardData.href} 67 + class="absolute inset-0 h-full w-full" 68 + target="_blank" 69 + rel="noopener noreferrer" 70 + > 71 + <span class="sr-only"> 72 + {item.cardData.hrefText ?? 'Learn more'} 73 + </span> 74 + 75 + <div 76 + class="bg-base-800/30 border-base-900/30 absolute top-2 right-2 rounded-full border p-1 text-white opacity-50 backdrop-blur-lg group-focus-within:opacity-100 group-hover:opacity-100" 77 + > 78 + <svg 79 + xmlns="http://www.w3.org/2000/svg" 80 + fill="none" 81 + viewBox="0 0 24 24" 82 + stroke-width="2.5" 83 + stroke="currentColor" 84 + class="size-4" 85 + > 86 + <path 87 + stroke-linecap="round" 88 + stroke-linejoin="round" 89 + d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" 90 + /> 91 + </svg> 92 + </div> 93 + </a> 94 + {/if}
+54
src/lib/cards/VideoCard/VideoCardSettings.svelte
··· 1 + <script lang="ts"> 2 + import { validateLink } from '$lib/helper'; 3 + import type { Item } from '$lib/types'; 4 + import { Button, Input, toast } from '@foxui/core'; 5 + 6 + let { item, onclose }: { item: Item; onclose: () => void } = $props(); 7 + 8 + let linkValue = $derived( 9 + item.cardData.href?.replace('https://', '').replace('http://', '') ?? '' 10 + ); 11 + 12 + function updateLink() { 13 + if (!linkValue.trim()) { 14 + item.cardData.href = ''; 15 + item.cardData.domain = ''; 16 + } 17 + 18 + let link = validateLink(linkValue); 19 + if (!link) { 20 + toast.error('Invalid link'); 21 + return; 22 + } 23 + 24 + item.cardData.href = link; 25 + item.cardData.domain = new URL(link).hostname; 26 + 27 + onclose?.(); 28 + } 29 + </script> 30 + 31 + <Input 32 + spellcheck={false} 33 + type="url" 34 + bind:value={linkValue} 35 + onkeydown={(event) => { 36 + if (event.code === 'Enter') { 37 + updateLink(); 38 + event.preventDefault(); 39 + } 40 + }} 41 + placeholder="Enter link" 42 + /> 43 + <Button onclick={updateLink} size="icon" 44 + ><svg 45 + xmlns="http://www.w3.org/2000/svg" 46 + fill="none" 47 + viewBox="0 0 24 24" 48 + stroke-width="1.5" 49 + stroke="currentColor" 50 + class="size-6" 51 + > 52 + <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /> 53 + </svg> 54 + </Button>
+63
src/lib/cards/VideoCard/index.ts
··· 1 + import { uploadBlob } from '$lib/oauth/utils'; 2 + import type { CardDefinition } from '../types'; 3 + import VideoCard from './VideoCard.svelte'; 4 + import VideoCardSettings from './VideoCardSettings.svelte'; 5 + 6 + async function getAspectRatio(videoBlob: Blob): Promise<{ width: number; height: number }> { 7 + return new Promise((resolve, reject) => { 8 + const video = document.createElement('video'); 9 + video.preload = 'metadata'; 10 + 11 + video.onloadedmetadata = () => { 12 + URL.revokeObjectURL(video.src); 13 + resolve({ 14 + width: video.videoWidth, 15 + height: video.videoHeight 16 + }); 17 + }; 18 + 19 + video.onerror = () => { 20 + URL.revokeObjectURL(video.src); 21 + reject(new Error('Failed to load video metadata')); 22 + }; 23 + 24 + video.src = URL.createObjectURL(videoBlob); 25 + }); 26 + } 27 + 28 + export const VideoCardDefinition = { 29 + type: 'video', 30 + contentComponent: VideoCard, 31 + createNew: (card) => { 32 + card.cardType = 'video'; 33 + card.cardData = { 34 + video: null, 35 + href: '' 36 + }; 37 + }, 38 + upload: async (item) => { 39 + if (item.cardData.blob) { 40 + const blob = item.cardData.blob; 41 + const aspectRatio = await getAspectRatio(blob); 42 + const uploadedBlob = await uploadBlob(blob); 43 + 44 + item.cardData.video = { 45 + $type: 'app.bsky.embed.video', 46 + video: uploadedBlob, 47 + aspectRatio 48 + }; 49 + 50 + delete item.cardData.blob; 51 + } 52 + 53 + if (item.cardData.objectUrl) { 54 + URL.revokeObjectURL(item.cardData.objectUrl); 55 + delete item.cardData.objectUrl; 56 + } 57 + 58 + return item; 59 + }, 60 + settingsComponent: VideoCardSettings, 61 + 62 + name: 'Video Card' 63 + } as CardDefinition & { type: 'video' };
-54
src/lib/cards/YoutubeVideo/CreateYoutubeCardModal.svelte
··· 1 - <script lang="ts"> 2 - import { Alert, Button, Input, Modal, Subheading } from '@foxui/core'; 3 - import type { CreationModalComponentProps } from '../types'; 4 - import { matcher } from '.'; 5 - 6 - let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props(); 7 - 8 - let isFetchingMetadata = $state(false); 9 - 10 - let errorMessage = $state(''); 11 - 12 - const idRegExp = /^[A-Za-z0-9-_]+$/; 13 - 14 - function extractID(idOrUrl: string) { 15 - if (idRegExp.test(idOrUrl)) return idOrUrl; 16 - return matcher(idOrUrl); 17 - } 18 - 19 - async function fetchMetadata() { 20 - errorMessage = ''; 21 - 22 - const videoid = extractID(item.cardData.href); 23 - if (!videoid) { 24 - errorMessage = 'Not a valid youtube URL!'; 25 - return false; 26 - } 27 - const posterFile = 'hqdefault'; 28 - const posterURL = `https://i.ytimg.com/vi/${videoid}/${posterFile}.jpg`; 29 - 30 - item.cardData.poster = posterURL; 31 - item.cardData.youtubeId = videoid; 32 - 33 - return true; 34 - } 35 - </script> 36 - 37 - <Modal open={true} closeButton={false}> 38 - <Subheading>Enter a link to a youtube video</Subheading> 39 - <Input bind:value={item.cardData.href} /> 40 - 41 - {#if errorMessage} 42 - <Alert type="error" title="Failed to create youtube card"><span>{errorMessage}</span></Alert> 43 - {/if} 44 - 45 - <div class="mt-4 flex justify-end gap-2"> 46 - <Button onclick={oncancel} variant="ghost">Cancel</Button> 47 - <Button 48 - disabled={isFetchingMetadata} 49 - onclick={async () => { 50 - if (await fetchMetadata()) oncreate(); 51 - }}>{isFetchingMetadata ? 'Creating...' : 'Create'}</Button 52 - > 53 - </div> 54 - </Modal>
-15
src/lib/cards/YoutubeVideo/SidebarItemYoutubeCard.svelte
··· 1 - <script lang="ts"> 2 - import { Button } from '@foxui/core'; 3 - 4 - let { onclick }: { onclick: () => void } = $props(); 5 - </script> 6 - 7 - <Button {onclick} variant="ghost" class="w-full justify-start"> 8 - <svg xmlns="http://www.w3.org/2000/svg" class="text-accent-600 dark:text-accent-400 h-4" viewBox="0 0 256 180" 9 - ><path 10 - fill="currentColor" 11 - d="M250.346 28.075A32.18 32.18 0 0 0 227.69 5.418C207.824 0 127.87 0 127.87 0S47.912.164 28.046 5.582A32.18 32.18 0 0 0 5.39 28.24c-6.009 35.298-8.34 89.084.165 122.97a32.18 32.18 0 0 0 22.656 22.657c19.866 5.418 99.822 5.418 99.822 5.418s79.955 0 99.82-5.418a32.18 32.18 0 0 0 22.657-22.657c6.338-35.348 8.291-89.1-.164-123.134" 12 - /><path fill="#fff" d="m102.421 128.06l66.328-38.418l-66.328-38.418z" /></svg 13 - > 14 - Youtube Video Card 15 - </Button>
-32
src/lib/cards/YoutubeVideo/YoutubeCard.svelte
··· 1 - <script lang="ts"> 2 - import { videoPlayer } from '../utils/YoutubeVideoPlayer.svelte'; 3 - import type { ContentComponentProps } from '../types'; 4 - 5 - let { item }: ContentComponentProps = $props(); 6 - </script> 7 - 8 - <img 9 - class={[ 10 - 'absolute inset-0 h-full w-full object-cover opacity-100 transition-transform duration-300 ease-in-out', 11 - item.cardData.href ? 'group-hover:scale-102' : '' 12 - ]} 13 - src={item.cardData.poster} 14 - alt="" 15 - /> 16 - <button 17 - onclick={() => { 18 - videoPlayer.show(item.cardData.youtubeId); 19 - }} 20 - class="absolute inset-0 flex h-full w-full cursor-pointer items-center justify-center" 21 - > 22 - <span class="sr-only"> 23 - {item.cardData.hrefText ?? 'Learn more'} 24 - </span> 25 - 26 - <svg xmlns="http://www.w3.org/2000/svg" class="text-accent-500 w-14" viewBox="0 0 256 180" 27 - ><path 28 - fill="currentColor" 29 - d="M250.346 28.075A32.18 32.18 0 0 0 227.69 5.418C207.824 0 127.87 0 127.87 0S47.912.164 28.046 5.582A32.18 32.18 0 0 0 5.39 28.24c-6.009 35.298-8.34 89.084.165 122.97a32.18 32.18 0 0 0 22.656 22.657c19.866 5.418 99.822 5.418 99.822 5.418s79.955 0 99.82-5.418a32.18 32.18 0 0 0 22.657-22.657c6.338-35.348 8.291-89.1-.164-123.134" 30 - /><path fill="#fff" d="m102.421 128.06l66.328-38.418l-66.328-38.418z" /></svg 31 - > 32 - </button>
-32
src/lib/cards/YoutubeVideo/index.ts
··· 1 - import type { CardDefinition } from '../types'; 2 - import CreateYoutubeCardModal from './CreateYoutubeCardModal.svelte'; 3 - import SidebarItemYoutubeCard from './SidebarItemYoutubeCard.svelte'; 4 - import YoutubeCard from './YoutubeCard.svelte'; 5 - 6 - export const YoutubeCardDefinition = { 7 - type: 'youtubeVideo', 8 - contentComponent: YoutubeCard, 9 - creationModalComponent: CreateYoutubeCardModal, 10 - createNew: (card) => { 11 - card.cardType = 'youtubeVideo'; 12 - card.cardData = {}; 13 - card.w = 4; 14 - card.mobileW = 8; 15 - }, 16 - sidebarComponent: SidebarItemYoutubeCard 17 - } as CardDefinition & { type: 'youtubeVideo' }; 18 - 19 - // Thanks to eleventy-plugin-youtube-embed 20 - // https://github.com/gfscott/eleventy-plugin-youtube-embed/blob/main/lib/extractMatches.js 21 - const urlPattern = 22 - /(?=(\s*))\1(?:<a [^>]*?>)??(?=(\s*))\2(?:https?:\/\/)??(?:w{3}\.)??(?:youtube\.com|youtu\.be)\/(?:watch\?v=|embed\/|shorts\/)??([A-Za-z0-9-_]{11})(?:[^\s<>]*)(?=(\s*))\4(?:<\/a>)??(?=(\s*))\5/; 23 - 24 - /** 25 - * Extract a YouTube ID from a URL if it matches the pattern. 26 - * @param url URL to test 27 - * @returns A YouTube video ID or undefined if none matched 28 - */ 29 - export function matcher(url: string): string | undefined { 30 - const match = url.match(urlPattern); 31 - return match?.[3]; 32 - }
+46
src/lib/cards/YoutubeVideoCard/YoutubeCard.svelte
··· 1 + <script lang="ts"> 2 + import { videoPlayer } from '../utils/YoutubeVideoPlayer.svelte'; 3 + import type { ContentComponentProps } from '../types'; 4 + 5 + let { item }: ContentComponentProps = $props(); 6 + 7 + let isPlaying = $state(false); 8 + </script> 9 + 10 + {#if isPlaying && item.cardData.showInline} 11 + <iframe 12 + class="absolute inset-0 h-full w-full" 13 + src="https://www.youtube.com/embed/{item.cardData.youtubeId}?autoplay=1" 14 + title="YouTube video player" 15 + frameborder="0" 16 + allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" 17 + allowfullscreen 18 + ></iframe> 19 + {:else} 20 + <img 21 + class={[ 22 + 'absolute inset-0 h-full w-full object-cover opacity-100 transition-transform duration-300 ease-in-out', 23 + item.cardData.href ? 'group-hover:scale-102' : '' 24 + ]} 25 + src={item.cardData.poster} 26 + alt="" 27 + /> 28 + <button 29 + onclick={() => { 30 + if (item.cardData.showInline) isPlaying = true; 31 + else videoPlayer.show(item.cardData.youtubeId); 32 + }} 33 + class="absolute inset-0 flex h-full w-full cursor-pointer items-center justify-center" 34 + > 35 + <span class="sr-only"> 36 + {item.cardData.hrefText ?? 'Learn more'} 37 + </span> 38 + 39 + <svg xmlns="http://www.w3.org/2000/svg" class="text-accent-500 w-14" viewBox="0 0 256 180" 40 + ><path 41 + fill="currentColor" 42 + d="M250.346 28.075A32.18 32.18 0 0 0 227.69 5.418C207.824 0 127.87 0 127.87 0S47.912.164 28.046 5.582A32.18 32.18 0 0 0 5.39 28.24c-6.009 35.298-8.34 89.084.165 122.97a32.18 32.18 0 0 0 22.656 22.657c19.866 5.418 99.822 5.418 99.822 5.418s79.955 0 99.82-5.418a32.18 32.18 0 0 0 22.657-22.657c6.338-35.348 8.291-89.1-.164-123.134" 43 + /><path fill="#fff" d="m102.421 128.06l66.328-38.418l-66.328-38.418z" /></svg 44 + > 45 + </button> 46 + {/if}
+24
src/lib/cards/YoutubeVideoCard/YoutubeCardSettings.svelte
··· 1 + <script lang="ts"> 2 + import type { Item } from '$lib/types'; 3 + import { Checkbox, Label } from '@foxui/core'; 4 + 5 + let { item }: { item: Item; onclose: () => void } = $props(); 6 + </script> 7 + 8 + <div class="flex items-center space-x-2"> 9 + <Checkbox 10 + bind:checked={ 11 + () => !item.cardData.showInline, (val) => (item.cardData.showInline = !val) 12 + } 13 + id="show-inline" 14 + aria-labelledby="show-inline-label" 15 + variant="secondary" 16 + /> 17 + <Label 18 + id="show-inline-label" 19 + for="show-inline" 20 + class="text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 21 + > 22 + Show fullscreen 23 + </Label> 24 + </div>
+72
src/lib/cards/YoutubeVideoCard/index.ts
··· 1 + import type { CardDefinition } from '../types'; 2 + import YoutubeCard from './YoutubeCard.svelte'; 3 + import YoutubeCardSettings from './YoutubeCardSettings.svelte'; 4 + 5 + export const YoutubeCardDefinition = { 6 + type: 'youtubeVideo', 7 + contentComponent: YoutubeCard, 8 + settingsComponent: YoutubeCardSettings, 9 + createNew: (card) => { 10 + card.cardType = 'youtubeVideo'; 11 + card.cardData = {}; 12 + card.w = 4; 13 + card.mobileW = 8; 14 + }, 15 + 16 + onUrlHandler: (url, item) => { 17 + const id = matcher(url); 18 + if (!id) return; 19 + 20 + const posterFile = 'hqdefault'; 21 + const posterURL = `https://i.ytimg.com/vi/${id}/${posterFile}.jpg`; 22 + 23 + item.cardData.poster = posterURL; 24 + item.cardData.youtubeId = id; 25 + item.cardData.href = url; 26 + item.cardData.showInline = true; 27 + 28 + item.w = 4; 29 + item.mobileW = 8; 30 + item.h = 3; 31 + item.mobileH = 5; 32 + 33 + return item; 34 + }, 35 + urlHandlerPriority: 2, 36 + 37 + canChange: (item) => Boolean(matcher(item.cardData.href)), 38 + 39 + change: (item) => { 40 + const href = item.cardData?.href; 41 + 42 + const id = matcher(href); 43 + if (!id) return; 44 + 45 + const posterFile = 'hqdefault'; 46 + const posterURL = `https://i.ytimg.com/vi/${id}/${posterFile}.jpg`; 47 + 48 + item.cardData.poster = posterURL; 49 + item.cardData.youtubeId = id; 50 + item.cardData.showInline ??= true; 51 + 52 + return item; 53 + }, 54 + name: 'Youtube Video' 55 + } as CardDefinition & { type: 'youtubeVideo' }; 56 + 57 + // Thanks to eleventy-plugin-youtube-embed 58 + // https://github.com/gfscott/eleventy-plugin-youtube-embed/blob/main/lib/extractMatches.js 59 + const urlPattern = 60 + /(?=(\s*))\1(?:<a [^>]*?>)??(?=(\s*))\2(?:https?:\/\/)??(?:w{3}\.)??(?:youtube\.com|youtu\.be)\/(?:watch\?v=|embed\/|shorts\/)??([A-Za-z0-9-_]{11})(?:[^\s<>]*)(?=(\s*))\4(?:<\/a>)??(?=(\s*))\5/; 61 + 62 + /** 63 + * Extract a YouTube ID from a URL if it matches the pattern. 64 + * @param url URL to test 65 + * @returns A YouTube video ID or undefined if none matched 66 + */ 67 + export function matcher(url: string | undefined): string | undefined { 68 + if (!url) return; 69 + 70 + const match = url.match(urlPattern); 71 + return match?.[3]; 72 + }
+8 -2
src/lib/cards/index.ts
··· 3 3 import { BigSocialCardDefinition } from './BigSocialCard'; 4 4 import { BlueskyMediaCardDefinition } from './BlueskyMediaCard'; 5 5 import { BlueskyPostCardDefinition } from './BlueskyPostCard'; 6 - import { DinoGameCardDefinition } from './DinoGameCard'; 6 + import { DinoGameCardDefinition } from './GameCards/DinoGameCard'; 7 7 import { EmbedCardDefinition } from './EmbedCard'; 8 8 import { TetrisCardDefinition } from './TetrisCard'; 9 9 import { ImageCardDefinition } from './ImageCard'; ··· 14 14 import { UpdatedBlentosCardDefitition } from './SpecialCards/UpdatedBlentos'; 15 15 import { TextCardDefinition } from './TextCard'; 16 16 import type { CardDefinition } from './types'; 17 - import { YoutubeCardDefinition } from './YoutubeVideo'; 17 + import { VideoCardDefinition } from './VideoCard'; 18 + import { YoutubeCardDefinition } from './YoutubeVideoCard'; 19 + import { BlueskyProfileCardDefinition } from './BlueskyProfileCard'; 20 + import { GithubProfileCardDefitition } from './GitHubProfileCard'; 18 21 19 22 export const AllCardDefinitions = [ 20 23 ImageCardDefinition, 24 + VideoCardDefinition, 21 25 TextCardDefinition, 22 26 LinkCardDefinition, 23 27 BigSocialCardDefinition, ··· 32 36 SectionCardDefinition, 33 37 BlueskyMediaCardDefinition, 34 38 DinoGameCardDefinition, 39 + BlueskyProfileCardDefinition, 40 + GithubProfileCardDefitition 35 41 TetrisCardDefinition 36 42 ] as const; 37 43
+13 -7
src/lib/cards/types.ts
··· 1 1 import type { Component } from 'svelte'; 2 - import type { Item } from '$lib/types'; 2 + import type { Item, UserCache } from '$lib/types'; 3 3 4 4 export type CreationModalComponentProps = { 5 5 item: Item; ··· 7 7 oncancel: () => void; 8 8 }; 9 9 10 - export type SettingsModalComponentProps = { 10 + export type SettingsComponentProps = { 11 11 item: Item; 12 - onsave: (item: Item) => void; 13 - oncancel: () => void; 12 + onclose: () => void; 14 13 }; 15 14 16 15 export type SidebarComponentProps = { ··· 37 36 sidebarButtonText?: string; 38 37 39 38 // if this component exists, a settings button with a popover will be shown containing this component 40 - settingsComponent?: Component<ContentComponentProps>; 39 + settingsComponent?: Component<SettingsComponentProps>; 41 40 42 41 // optionally load some extra data 43 42 loadData?: ( 43 + // all cards of that type 44 44 items: Item[], 45 - { did, handle, platform }: { did: string; handle: string; platform?: App.Platform } 45 + { did, handle, cache }: { did: string; handle: string; cache?: UserCache } 46 46 ) => Promise<unknown>; 47 47 48 48 // show color selection popup ··· 62 62 canResize?: boolean; 63 63 64 64 onUrlHandler?: (url: string, item: Item) => Item | null; 65 - }; 65 + urlHandlerPriority?: number; 66 + 67 + canChange?: (item: Item) => boolean; 68 + change?: (item: Item) => Item; 69 + 70 + name?: string; 71 + };
+1 -1
src/lib/cards/utils/MarkdownTextEditor.svelte
··· 119 119 }); 120 120 </script> 121 121 122 - <div class="w-full" bind:this={element}></div> 122 + <div class="w-full cursor-text" bind:this={element}></div> 123 123 124 124 <style> 125 125 :global(.tiptap p.is-editor-empty:first-child::before) {
+1
src/lib/cards/utils/PlainTextEditor.svelte
··· 76 76 :global(.tiptap p.is-editor-empty:first-child::before) { 77 77 color: var(--color-base-800); 78 78 content: attr(data-placeholder); 79 + opacity: 50%; 79 80 float: left; 80 81 height: 0; 81 82 pointer-events: none;
+9 -6
src/lib/cards/utils/YoutubeVideoPlayer.svelte
··· 19 19 20 20 <script lang="ts"> 21 21 import { cn } from '@foxui/core'; 22 - import { onMount } from 'svelte'; 22 + import { onDestroy, onMount } from 'svelte'; 23 23 24 24 const { class: className }: { class?: string } = $props(); 25 25 26 26 let Plyr = $state(); 27 + 28 + let player = $state(); 29 + 27 30 28 31 onMount(async () => { 29 32 if (!Plyr) Plyr = (await import('plyr')).default; ··· 60 63 player.play(); 61 64 //player.fullscreen.enter(); 62 65 }); 63 - 64 - return () => { 65 - player.destroy(); 66 - }; 67 66 }); 67 + 68 + onDestroy(() => { 69 + player?.destroy(); 70 + }) 68 71 69 72 let glow = 50; 70 73 </script> ··· 142 145 143 146 <svg width="0" height="0"> 144 147 <filter id="blur" y="-50%" x="-50%" width="300%" height="300%"> 145 - <feGaussianBlur in="SourceGraphic" stdDeviation={50} result="blurred" /> 148 + <feGaussianBlur in="SourceGraphic" stdDeviation={glow} result="blurred" /> 146 149 <feColorMatrix type="saturate" in="blurred" values="3" /> 147 150 <feComposite in="SourceGraphic" operator="over" /> 148 151 </filter>
+3 -3
src/lib/components/ImageDropper.svelte
··· 1 1 <script lang="ts"> 2 2 import { Portal } from 'bits-ui'; 3 3 4 - let isDragOver = $state(false); 5 - 6 4 let { 7 - processImageFile 5 + processImageFile, 6 + isDragOver = $bindable() 8 7 }: { 9 8 processImageFile: (file: File) => Promise<void>; 9 + isDragOver: boolean; 10 10 } = $props(); 11 11 12 12 function handleDragOver(event: DragEvent) {
+276 -15
src/lib/helper.ts
··· 1 - import { createContext } from 'svelte'; 2 - import type { Item } from './types'; 3 - import { COLUMNS } from '$lib'; 1 + import type { Item, WebsiteData } from './types'; 2 + import { COLUMNS, margin, mobileMargin } from '$lib'; 3 + import { CardDefinitionsByType } from './cards'; 4 + import { deleteRecord, putRecord } from './oauth/atproto'; 5 + import { toast } from '@foxui/core'; 6 + import { TID } from '@atproto/common-web'; 4 7 5 8 export function clamp(value: number, min: number, max: number): number { 6 9 return Math.min(Math.max(value, min), max); ··· 39 42 return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y; 40 43 }; 41 44 42 - export function fixCollisions(items: Item[], movedItem: Item, mobile: boolean = false) { 45 + export function fixCollisions( 46 + items: Item[], 47 + movedItem: Item, 48 + mobile: boolean = false, 49 + skipCompact: boolean = false 50 + ) { 43 51 const clampX = (item: Item) => { 44 52 if (mobile) item.mobileX = clamp(item.mobileX, 0, COLUMNS - item.mobileW); 45 53 else item.x = clamp(item.x, 0, COLUMNS - item.w); ··· 94 102 else it.x = clamp(it.x, 0, COLUMNS - it.w); 95 103 } 96 104 97 - compactItems(items, mobile); 105 + if (!skipCompact) { 106 + compactItems(items, mobile); 107 + } 98 108 } 99 109 100 110 // Fix all collisions between items (not just one moved item) ··· 226 236 a.y === b.y && 227 237 a.mobileX === b.mobileX && 228 238 a.mobileY === b.mobileY && 229 - a.color === b.color 239 + a.color === b.color && 240 + a.page === b.page 230 241 ); 231 242 } 232 243 ··· 257 268 } 258 269 } 259 270 260 - export const [getIsMobile, setIsMobile] = createContext<() => boolean>(); 261 - 262 - export const [getCanEdit, setCanEdit] = createContext<() => boolean>(); 263 - 264 - export const [getAdditionalUserData, setAdditionalUserData] = 265 - createContext<Record<string, unknown>>(); 266 - 267 271 export async function refreshData(data: { updatedAt?: number; handle: string }) { 268 - const FIVE_MINUTES = 5 * 60 * 1000; 272 + const TEN_MINUTES = 10 * 60 * 1000; 269 273 const now = Date.now(); 270 274 271 - if (now - (data.updatedAt || 0) > FIVE_MINUTES && data.handle !== 'blento.app') { 275 + if (now - (data.updatedAt || 0) > TEN_MINUTES) { 272 276 try { 273 277 await fetch('/' + data.handle + '/api/refreshData'); 274 278 console.log('successfully refreshed data', data.handle); ··· 279 283 console.log('data still fresh, skipping refreshing', data.handle); 280 284 } 281 285 } 286 + 287 + export function getName(data: WebsiteData): string { 288 + return (data.publication?.name ?? data.profile.displayName) || data.handle; 289 + } 290 + 291 + export function getDescription(data: WebsiteData): string { 292 + return data.publication?.description ?? data.profile.description ?? ''; 293 + } 294 + 295 + export function getHideProfileSection(data: WebsiteData): boolean { 296 + if (data?.publication?.preferences?.hideProfileSection !== undefined) 297 + return data?.publication?.preferences?.hideProfileSection; 298 + 299 + if (data?.publication?.preferences?.hideProfile !== undefined) 300 + return data?.publication?.preferences?.hideProfile; 301 + 302 + return data.page !== 'blento.self'; 303 + } 304 + 305 + export function isTyping() { 306 + const active = document.activeElement; 307 + 308 + const isEditable = 309 + active instanceof HTMLInputElement || 310 + active instanceof HTMLTextAreaElement || 311 + // @ts-expect-error this fine 312 + active?.isContentEditable; 313 + 314 + return isEditable; 315 + } 316 + 317 + export function validateLink( 318 + link: string | undefined, 319 + tryAdding: boolean = true 320 + ): string | undefined { 321 + if (!link) return; 322 + try { 323 + new URL(link); 324 + 325 + return link; 326 + // eslint-disable-next-line @typescript-eslint/no-unused-vars 327 + } catch (e) { 328 + if (!tryAdding) return; 329 + 330 + try { 331 + link = 'https://' + link; 332 + new URL(link); 333 + 334 + return link; 335 + // eslint-disable-next-line @typescript-eslint/no-unused-vars 336 + } catch (e) { 337 + return; 338 + } 339 + } 340 + } 341 + 342 + export function compressImage(file: File, maxSize: number = 900 * 1024): Promise<Blob> { 343 + return new Promise((resolve, reject) => { 344 + const img = new Image(); 345 + const reader = new FileReader(); 346 + 347 + reader.onload = (e) => { 348 + if (!e.target?.result) { 349 + return reject(new Error('Failed to read file.')); 350 + } 351 + img.src = e.target.result as string; 352 + }; 353 + 354 + reader.onerror = (err) => reject(err); 355 + reader.readAsDataURL(file); 356 + 357 + img.onload = () => { 358 + let width = img.width; 359 + let height = img.height; 360 + const maxDimension = 2048; 361 + 362 + if (width > maxDimension || height > maxDimension) { 363 + if (width > height) { 364 + height = Math.round((maxDimension / width) * height); 365 + width = maxDimension; 366 + } else { 367 + width = Math.round((maxDimension / height) * width); 368 + height = maxDimension; 369 + } 370 + } 371 + 372 + // Create a canvas to draw the image 373 + const canvas = document.createElement('canvas'); 374 + canvas.width = width; 375 + canvas.height = height; 376 + const ctx = canvas.getContext('2d'); 377 + if (!ctx) return reject(new Error('Failed to get canvas context.')); 378 + ctx.drawImage(img, 0, 0, width, height); 379 + 380 + // Function to try compressing at a given quality 381 + let quality = 0.8; 382 + function attemptCompression() { 383 + canvas.toBlob( 384 + (blob) => { 385 + if (!blob) { 386 + return reject(new Error('Compression failed.')); 387 + } 388 + // If the blob is under our size limit, or quality is too low, resolve it 389 + if (blob.size <= maxSize || quality < 0.3) { 390 + console.log('Compression successful. Blob size:', blob.size); 391 + console.log('Quality:', quality); 392 + resolve(blob); 393 + } else { 394 + // Otherwise, reduce the quality and try again 395 + quality -= 0.1; 396 + attemptCompression(); 397 + } 398 + }, 399 + 'image/jpeg', 400 + quality 401 + ); 402 + } 403 + 404 + attemptCompression(); 405 + }; 406 + 407 + img.onerror = (err) => reject(err); 408 + }); 409 + } 410 + 411 + export async function savePage( 412 + data: WebsiteData, 413 + currentItems: Item[], 414 + originalPublication: string 415 + ) { 416 + const promises = []; 417 + // find all cards that have been updated (where items differ from originalItems) 418 + for (let item of currentItems) { 419 + const originalItem = data.cards.find((i) => cardsEqual(i, item)); 420 + 421 + if (!originalItem) { 422 + console.log('updated or new item', item); 423 + item.updatedAt = new Date().toISOString(); 424 + // run optional upload function for this card type 425 + const cardDef = CardDefinitionsByType[item.cardType]; 426 + 427 + if (cardDef?.upload) { 428 + item = await cardDef?.upload(item); 429 + } 430 + 431 + item.page = data.page; 432 + item.version = 2; 433 + 434 + promises.push( 435 + putRecord({ 436 + collection: 'app.blento.card', 437 + rkey: item.id, 438 + record: item 439 + }) 440 + ); 441 + } 442 + } 443 + 444 + // delete items that are in originalItems but not in items 445 + for (const originalItem of data.cards) { 446 + const item = currentItems.find((i) => i.id === originalItem.id); 447 + if (!item) { 448 + console.log('deleting item', originalItem); 449 + promises.push( 450 + deleteRecord({ collection: 'app.blento.card', rkey: originalItem.id, did: data.did }) 451 + ); 452 + } 453 + } 454 + 455 + if ( 456 + data.publication?.preferences?.hideProfile !== undefined && 457 + data.publication?.preferences?.hideProfileSection === undefined 458 + ) { 459 + data.publication.preferences.hideProfileSection = data.publication?.preferences?.hideProfile; 460 + } 461 + 462 + if (!originalPublication || originalPublication !== JSON.stringify(data.publication)) { 463 + data.publication ??= { 464 + name: getName(data), 465 + description: getDescription(data), 466 + preferences: { 467 + hideProfileSection: getHideProfileSection(data) 468 + } 469 + }; 470 + 471 + if (!data.publication.url) { 472 + data.publication.url = 'https://blento.app/' + data.handle; 473 + 474 + if (data.page !== 'blento.self') { 475 + data.publication.url += '/' + data.page.replace('blento.', ''); 476 + } 477 + } 478 + promises.push( 479 + putRecord({ 480 + collection: 'site.standard.publication', 481 + rkey: data.page, 482 + record: data.publication 483 + }) 484 + ); 485 + 486 + console.log('updating or adding publication', data.publication); 487 + } 488 + 489 + await Promise.all(promises); 490 + 491 + fetch('/' + data.handle + '/api/refreshData').then(() => { 492 + console.log('data refreshed!'); 493 + }); 494 + console.log('refreshing data'); 495 + 496 + toast('Saved', { 497 + description: 'Your website has been saved!' 498 + }); 499 + } 500 + 501 + export function createEmptyCard(page: string) { 502 + return { 503 + id: TID.nextStr(), 504 + x: 0, 505 + y: 0, 506 + w: 2, 507 + h: 2, 508 + mobileH: 4, 509 + mobileW: 4, 510 + mobileX: 0, 511 + mobileY: 0, 512 + cardType: '', 513 + cardData: {}, 514 + page 515 + } as Item; 516 + } 517 + 518 + export function scrollToItem( 519 + item: Item, 520 + isMobile: boolean, 521 + container: HTMLDivElement | undefined, 522 + force: boolean = false 523 + ) { 524 + // scroll to newly created card only if not fully visible 525 + const containerRect = container?.getBoundingClientRect(); 526 + if (!containerRect) return; 527 + const currentMargin = isMobile ? mobileMargin : margin; 528 + const currentY = isMobile ? item.mobileY : item.y; 529 + const currentH = isMobile ? item.mobileH : item.h; 530 + const cellSize = (containerRect.width - currentMargin * 2) / COLUMNS; 531 + 532 + const cardTop = containerRect.top + currentMargin + currentY * cellSize; 533 + const cardBottom = containerRect.top + currentMargin + (currentY + currentH) * cellSize; 534 + 535 + const isFullyVisible = cardTop >= 0 && cardBottom <= window.innerHeight; 536 + 537 + if (!isFullyVisible || force) { 538 + const bodyRect = document.body.getBoundingClientRect(); 539 + const offset = containerRect.top - bodyRect.top; 540 + window.scrollTo({ top: offset + cellSize * (currentY - 1), behavior: 'smooth' }); 541 + } 542 + }
+19 -22
src/lib/oauth/atproto.ts
··· 1 1 import { AtpBaseClient } from '@atproto/api'; 2 2 import { client } from './auth.svelte'; 3 + import type { Record as ListRecord } from '@atproto/api/dist/client/types/com/atproto/repo/listRecords'; 4 + import type { At } from '@atcute/client/lexicons'; 3 5 4 6 export async function resolveHandle({ handle }: { handle: string }) { 5 7 const agent = new AtpBaseClient({ service: 'https://api.bsky.app' }); ··· 39 41 did, 40 42 collection, 41 43 cursor, 42 - limit = 100 44 + limit = 0 43 45 }: { 44 46 did: string; 45 47 collection: string; ··· 50 52 51 53 const agent = new AtpBaseClient({ service: pds }); 52 54 53 - const room = await agent.com.atproto.repo.listRecords({ 54 - repo: did, 55 - collection, 56 - limit, 57 - cursor 58 - }); 55 + const allRecords = []; 59 56 60 - // convert to { [rkey]: record } 61 - const records = room.data.records.reduce( 62 - (acc, record) => { 63 - acc[parseUri(record.uri).rkey] = record; 64 - return acc; 65 - }, 66 - {} as Record<string, ListRecord> 67 - ); 57 + let currentCursor = cursor; 58 + do { 59 + const response = await agent.com.atproto.repo.listRecords({ 60 + repo: did, 61 + collection, 62 + limit: limit || 100, 63 + cursor: currentCursor 64 + }); 65 + allRecords.push(...response.data.records); 66 + currentCursor = response.data.cursor; 67 + } while (currentCursor && (!limit || allRecords.length < limit)); 68 68 69 - return records; 69 + return allRecords; 70 70 } 71 - 72 - import type { Record as ListRecord } from '@atproto/api/dist/client/types/com/atproto/repo/listRecords'; 73 - import { parseUri } from '$lib/website/utils'; 74 71 75 72 export async function getRecord({ 76 73 did, ··· 112 109 const response = await client.rpc.call('com.atproto.repo.putRecord', { 113 110 data: { 114 111 collection, 115 - repo: client.profile.did, 112 + repo: client.profile.did as At.Identifier, 116 113 rkey, 117 114 record: { 118 115 ...record ··· 137 134 const response = await client.rpc.call('com.atproto.repo.deleteRecord', { 138 135 data: { 139 136 collection, 140 - repo: did, 137 + repo: did as At.Identifier, 141 138 rkey 142 139 } 143 140 }); ··· 196 193 }); 197 194 198 195 return repo; 199 - } 196 + }
+3 -2
src/lib/oauth/const.ts
··· 3 3 import { env } from '$env/dynamic/public'; 4 4 5 5 export const metadata = { 6 - client_id: `${env.PUBLIC_DOMAIN}${base}/client-metadata.json`, 6 + client_id: `${env.PUBLIC_DOMAIN}${base}/oauth-client-metadata.json`, 7 7 8 8 redirect_uris: [env.PUBLIC_DOMAIN + base], 9 9 10 - scope: 'atproto transition:generic', 10 + scope: 11 + 'atproto repo:app.blento.card repo:app.blento.page repo:app.blento.settings repo:app.blento.comment repo:site.standard.publication repo:site.standard.document blob:*/* rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app%23bsky_appview', 11 12 grant_types: ['authorization_code', 'refresh_token'], 12 13 response_types: ['code'], 13 14 token_endpoint_auth_method: 'none',
+41
src/lib/types.ts
··· 1 + import type { At } from '@atcute/client/lexicons'; 2 + import type { ProfileViewDetailed } from '@atproto/api/dist/client/types/app/bsky/actor/defs'; 3 + 1 4 export type Item = { 2 5 id: string; 3 6 ··· 21 24 updatedAt?: string; 22 25 23 26 version?: number; 27 + 28 + page?: string; 29 + }; 30 + 31 + export type WebsiteData = { 32 + page: string; 33 + did: string; 34 + handle: string; 35 + 36 + cards: Item[]; 37 + publication: 38 + | { 39 + url?: string; 40 + name?: string; 41 + description?: string; 42 + icon?: At.Blob; 43 + preferences?: { 44 + /** 45 + * @deprecated 46 + * 47 + * use hideProfileSection instead 48 + */ 49 + hideProfile?: boolean; 50 + // use this instead 51 + hideProfileSection?: boolean; 52 + }; 53 + } 54 + | undefined; 55 + profile: ProfileViewDetailed; 56 + 57 + additionalData: Record<string, unknown>; 58 + updatedAt: number; 59 + version?: number; 60 + }; 61 + 62 + export type UserCache = { 63 + get: (key: string) => string; 64 + put: (key: string, value: string) => void; 24 65 };
+27
src/lib/website/Context.svelte
··· 1 + <script lang="ts"> 2 + import type { WebsiteData } from '$lib/types'; 3 + import type { Snippet } from 'svelte'; 4 + import { setAdditionalUserData, setCanEdit, setDidContext, setHandleContext } from './context'; 5 + import { dev } from '$app/environment'; 6 + import { client } from '$lib/oauth'; 7 + 8 + let { 9 + data, 10 + children 11 + }: { 12 + data: WebsiteData; 13 + children: Snippet<[]>; 14 + } = $props(); 15 + 16 + // svelte-ignore state_referenced_locally 17 + setAdditionalUserData(data.additionalData); 18 + 19 + setCanEdit(() => dev || (client.isLoggedIn && client.profile?.did === data.did)); 20 + 21 + // svelte-ignore state_referenced_locally 22 + setDidContext(data.did); 23 + // svelte-ignore state_referenced_locally 24 + setHandleContext(data.handle); 25 + </script> 26 + 27 + {@render children()}
+1001
src/lib/website/EditableWebsite.svelte
··· 1 + <script lang="ts"> 2 + import { client, login } from '$lib/oauth/auth.svelte.js'; 3 + 4 + import { Navbar, Button, toast, Toaster, Toggle, Sidebar, Popover, Input } from '@foxui/core'; 5 + import { BlueskyLogin } from '@foxui/social'; 6 + 7 + import { COLUMNS, margin, mobileMargin } from '$lib'; 8 + import { 9 + clamp, 10 + compactItems, 11 + createEmptyCard, 12 + fixCollisions, 13 + getHideProfileSection, 14 + getName, 15 + isTyping, 16 + savePage, 17 + scrollToItem, 18 + setPositionOfNewItem, 19 + validateLink 20 + } from '../helper'; 21 + import Profile from './Profile.svelte'; 22 + import type { Item, WebsiteData } from '../types'; 23 + import { innerWidth } from 'svelte/reactivity/window'; 24 + import EditingCard from '../cards/Card/EditingCard.svelte'; 25 + import { AllCardDefinitions, CardDefinitionsByType } from '../cards'; 26 + import { tick, type Component } from 'svelte'; 27 + import type { CreationModalComponentProps } from '../cards/types'; 28 + import { dev } from '$app/environment'; 29 + import { setIsMobile } from './context'; 30 + import BaseEditingCard from '../cards/BaseCard/BaseEditingCard.svelte'; 31 + import Context from './Context.svelte'; 32 + import Settings from './Settings.svelte'; 33 + import Head from './Head.svelte'; 34 + import { compressImage } from '../helper'; 35 + 36 + let { 37 + data 38 + }: { 39 + data: WebsiteData; 40 + } = $props(); 41 + 42 + let imageInputRef: HTMLInputElement | undefined = $state(); 43 + let videoInputRef: HTMLInputElement | undefined = $state(); 44 + let imageDragOver = $state(false); 45 + let imageDragPosition: { x: number; y: number } | null = $state(null); 46 + 47 + // svelte-ignore state_referenced_locally 48 + let items: Item[] = $state(data.cards); 49 + 50 + // svelte-ignore state_referenced_locally 51 + let publication = $state(JSON.stringify(data.publication)); 52 + 53 + let container: HTMLDivElement | undefined = $state(); 54 + 55 + let activeDragElement: { 56 + element: HTMLDivElement | null; 57 + item: Item | null; 58 + w: number; 59 + h: number; 60 + x: number; 61 + y: number; 62 + mouseDeltaX: number; 63 + mouseDeltaY: number; 64 + // For hysteresis - track last decision to prevent flickering 65 + lastTargetId: string | null; 66 + lastPlacement: 'above' | 'below' | null; 67 + // Store original positions to reset from during drag 68 + originalPositions: Map<string, { x: number; y: number; mobileX: number; mobileY: number }>; 69 + } = $state({ 70 + element: null, 71 + item: null, 72 + w: 0, 73 + h: 0, 74 + x: -1, 75 + y: -1, 76 + mouseDeltaX: 0, 77 + mouseDeltaY: 0, 78 + lastTargetId: null, 79 + lastPlacement: null, 80 + originalPositions: new Map() 81 + }); 82 + 83 + let showingMobileView = $state(false); 84 + let isMobile = $derived(showingMobileView || (innerWidth.current ?? 1000) < 1024); 85 + 86 + setIsMobile(() => isMobile); 87 + 88 + const getY = (item: Item) => (isMobile ? (item.mobileY ?? item.y) : item.y); 89 + const getH = (item: Item) => (isMobile ? (item.mobileH ?? item.h) : item.h); 90 + 91 + let maxHeight = $derived(items.reduce((max, item) => Math.max(max, getY(item) + getH(item)), 0)); 92 + 93 + function newCard(type: string = 'link', cardData?: any) { 94 + // close sidebar if open 95 + const popover = document.getElementById('mobile-menu'); 96 + if (popover) { 97 + popover.hidePopover(); 98 + } 99 + 100 + let item = createEmptyCard(data.page); 101 + item.cardType = type; 102 + 103 + item.cardData = cardData ?? {}; 104 + 105 + const cardDef = CardDefinitionsByType[type]; 106 + cardDef?.createNew?.(item); 107 + 108 + newItem.item = item; 109 + 110 + if (cardDef?.creationModalComponent) { 111 + newItem.modal = cardDef.creationModalComponent; 112 + } else { 113 + saveNewItem(); 114 + } 115 + } 116 + 117 + async function saveNewItem() { 118 + if (!newItem.item) return; 119 + const item = newItem.item; 120 + 121 + setPositionOfNewItem(item, items); 122 + 123 + items = [...items, item]; 124 + 125 + newItem = {}; 126 + 127 + await tick(); 128 + 129 + scrollToItem(item, isMobile, container); 130 + } 131 + 132 + let isSaving = $state(false); 133 + 134 + let newItem: { modal?: Component<CreationModalComponentProps>; item?: Item } = $state({}); 135 + 136 + async function save() { 137 + isSaving = true; 138 + 139 + await savePage(data, items, publication); 140 + 141 + publication = JSON.stringify(data.publication); 142 + } 143 + 144 + const sidebarItems = AllCardDefinitions.filter( 145 + (cardDef) => cardDef.sidebarComponent || cardDef.sidebarButtonText 146 + ); 147 + 148 + let showSettings = $state(false); 149 + 150 + let debugPoint = $state({ x: 0, y: 0 }); 151 + 152 + let linkPopoverOpen = $state(false); 153 + 154 + function getDragXY( 155 + e: DragEvent & { 156 + currentTarget: EventTarget & HTMLDivElement; 157 + } 158 + ): 159 + | { x: number; y: number; swapWithId: string | null; placement: 'above' | 'below' | null } 160 + | undefined { 161 + if (!container || !activeDragElement.item) return; 162 + 163 + // x, y represent the top-left corner of the dragged card 164 + const x = e.clientX + activeDragElement.mouseDeltaX; 165 + const y = e.clientY + activeDragElement.mouseDeltaY; 166 + 167 + const rect = container.getBoundingClientRect(); 168 + const currentMargin = isMobile ? mobileMargin : margin; 169 + const cellSize = (rect.width - currentMargin * 2) / COLUMNS; 170 + 171 + // Get card dimensions based on current view mode 172 + const cardW = isMobile 173 + ? (activeDragElement.item?.mobileW ?? activeDragElement.w) 174 + : activeDragElement.w; 175 + const cardH = isMobile 176 + ? (activeDragElement.item?.mobileH ?? activeDragElement.h) 177 + : activeDragElement.h; 178 + 179 + // Get dragged card's original position 180 + const draggedOrigPos = activeDragElement.originalPositions.get(activeDragElement.item.id); 181 + const draggedOrigX = draggedOrigPos 182 + ? isMobile 183 + ? draggedOrigPos.mobileX 184 + : draggedOrigPos.x 185 + : 0; 186 + const draggedOrigY = draggedOrigPos 187 + ? isMobile 188 + ? draggedOrigPos.mobileY 189 + : draggedOrigPos.y 190 + : 0; 191 + 192 + // Calculate raw grid position based on top-left of dragged card 193 + let gridX = clamp(Math.round((x - rect.left - currentMargin) / cellSize), 0, COLUMNS - cardW); 194 + gridX = Math.floor(gridX / 2) * 2; 195 + 196 + let gridY = Math.max(Math.round((y - rect.top - currentMargin) / cellSize), 0); 197 + 198 + if (isMobile) { 199 + gridX = Math.floor(gridX / 2) * 2; 200 + gridY = Math.floor(gridY / 2) * 2; 201 + } 202 + 203 + // Find if we're hovering over another card (using ORIGINAL positions) 204 + const centerGridY = gridY + cardH / 2; 205 + const centerGridX = gridX + cardW / 2; 206 + 207 + let swapWithId: string | null = null; 208 + let placement: 'above' | 'below' | null = null; 209 + 210 + for (const other of items) { 211 + if (other === activeDragElement.item) continue; 212 + 213 + // Use original positions for hit testing 214 + const origPos = activeDragElement.originalPositions.get(other.id); 215 + if (!origPos) continue; 216 + 217 + const otherX = isMobile ? origPos.mobileX : origPos.x; 218 + const otherY = isMobile ? origPos.mobileY : origPos.y; 219 + const otherW = isMobile ? other.mobileW : other.w; 220 + const otherH = isMobile ? other.mobileH : other.h; 221 + 222 + // Check if dragged card's center point is within this card's original bounds 223 + if ( 224 + centerGridX >= otherX && 225 + centerGridX < otherX + otherW && 226 + centerGridY >= otherY && 227 + centerGridY < otherY + otherH 228 + ) { 229 + // Check if this is a swap situation: 230 + // Cards have the same dimensions and are on the same row 231 + const canSwap = cardW === otherW && cardH === otherH && draggedOrigY === otherY; 232 + 233 + if (canSwap) { 234 + // Swap positions 235 + swapWithId = other.id; 236 + gridX = otherX; 237 + gridY = otherY; 238 + placement = null; 239 + 240 + activeDragElement.lastTargetId = other.id; 241 + activeDragElement.lastPlacement = null; 242 + } else { 243 + // Vertical placement (above/below) 244 + // Detect drag direction: if dragging up, always place above 245 + const isDraggingUp = gridY < draggedOrigY; 246 + 247 + if (isDraggingUp) { 248 + // When dragging up, always place above 249 + placement = 'above'; 250 + } else { 251 + // When dragging down, use top/bottom half logic 252 + const midpointY = otherY + otherH / 2; 253 + const hysteresis = 0.3; 254 + 255 + if (activeDragElement.lastTargetId === other.id && activeDragElement.lastPlacement) { 256 + if (activeDragElement.lastPlacement === 'above') { 257 + placement = centerGridY > midpointY + hysteresis ? 'below' : 'above'; 258 + } else { 259 + placement = centerGridY < midpointY - hysteresis ? 'above' : 'below'; 260 + } 261 + } else { 262 + placement = centerGridY < midpointY ? 'above' : 'below'; 263 + } 264 + } 265 + 266 + activeDragElement.lastTargetId = other.id; 267 + activeDragElement.lastPlacement = placement; 268 + 269 + if (placement === 'above') { 270 + gridY = otherY; 271 + } else { 272 + gridY = otherY + otherH; 273 + } 274 + } 275 + break; 276 + } 277 + } 278 + 279 + // If we're not over any card, clear the tracking 280 + if (!swapWithId && !placement) { 281 + activeDragElement.lastTargetId = null; 282 + activeDragElement.lastPlacement = null; 283 + } 284 + 285 + debugPoint.x = x - rect.left; 286 + debugPoint.y = y - rect.top + currentMargin; 287 + 288 + return { x: gridX, y: gridY, swapWithId, placement }; 289 + } 290 + 291 + let linkValue = $state(''); 292 + 293 + function addLink(url: string) { 294 + let link = validateLink(url); 295 + if (!link) { 296 + toast.error('invalid link'); 297 + return; 298 + } 299 + let item = createEmptyCard(data.page); 300 + 301 + for (const cardDef of AllCardDefinitions.toSorted( 302 + (a, b) => (b.urlHandlerPriority ?? 0) - (a.urlHandlerPriority ?? 0) 303 + )) { 304 + if (cardDef.onUrlHandler?.(link, item)) { 305 + item.cardType = cardDef.type; 306 + 307 + newItem.item = item; 308 + saveNewItem(); 309 + toast(cardDef.name + ' added!'); 310 + break; 311 + } 312 + } 313 + 314 + if (linkValue === url) { 315 + linkValue = ''; 316 + linkPopoverOpen = false; 317 + } 318 + } 319 + 320 + async function processImageFile(file: File, gridX?: number, gridY?: number) { 321 + const compressedFile = await compressImage(file); 322 + const objectUrl = URL.createObjectURL(compressedFile); 323 + 324 + let item = createEmptyCard(data.page); 325 + 326 + item.cardType = 'image'; 327 + item.cardData = { 328 + blob: compressedFile, 329 + objectUrl 330 + }; 331 + 332 + // If grid position is provided 333 + if (gridX !== undefined && gridY !== undefined) { 334 + if (isMobile) { 335 + item.mobileX = gridX; 336 + item.mobileY = gridY; 337 + } else { 338 + item.x = gridX; 339 + item.y = gridY; 340 + } 341 + 342 + items = [...items, item]; 343 + fixCollisions(items, item, isMobile); 344 + } else { 345 + setPositionOfNewItem(item, items); 346 + items = [...items, item]; 347 + } 348 + 349 + await tick(); 350 + 351 + scrollToItem(item, isMobile, container); 352 + } 353 + 354 + function handleImageDragOver(event: DragEvent) { 355 + const dt = event.dataTransfer; 356 + if (!dt) return; 357 + 358 + let hasImage = false; 359 + if (dt.items) { 360 + for (let i = 0; i < dt.items.length; i++) { 361 + const item = dt.items[i]; 362 + if (item && item.kind === 'file' && item.type.startsWith('image/')) { 363 + hasImage = true; 364 + break; 365 + } 366 + } 367 + } else if (dt.files) { 368 + for (let i = 0; i < dt.files.length; i++) { 369 + const file = dt.files[i]; 370 + if (file?.type.startsWith('image/')) { 371 + hasImage = true; 372 + break; 373 + } 374 + } 375 + } 376 + 377 + if (hasImage) { 378 + event.preventDefault(); 379 + event.stopPropagation(); 380 + 381 + imageDragOver = true; 382 + imageDragPosition = { x: event.clientX, y: event.clientY }; 383 + } 384 + } 385 + 386 + function handleImageDragLeave(event: DragEvent) { 387 + event.preventDefault(); 388 + event.stopPropagation(); 389 + imageDragOver = false; 390 + imageDragPosition = null; 391 + } 392 + 393 + async function handleImageDrop(event: DragEvent) { 394 + event.preventDefault(); 395 + event.stopPropagation(); 396 + const dropX = event.clientX; 397 + const dropY = event.clientY; 398 + imageDragOver = false; 399 + imageDragPosition = null; 400 + 401 + if (!event.dataTransfer?.files?.length) return; 402 + 403 + const imageFiles = Array.from(event.dataTransfer.files).filter((f) => 404 + f?.type.startsWith('image/') 405 + ); 406 + if (imageFiles.length === 0) return; 407 + 408 + // Calculate starting grid position from drop coordinates 409 + let gridX = 0; 410 + let gridY = 0; 411 + if (container) { 412 + const rect = container.getBoundingClientRect(); 413 + const currentMargin = isMobile ? mobileMargin : margin; 414 + const cellSize = (rect.width - currentMargin * 2) / COLUMNS; 415 + const cardW = isMobile ? 4 : 2; 416 + 417 + gridX = clamp(Math.round((dropX - rect.left - currentMargin) / cellSize), 0, COLUMNS - cardW); 418 + gridX = Math.floor(gridX / 2) * 2; 419 + 420 + gridY = Math.max(Math.round((dropY - rect.top - currentMargin) / cellSize), 0); 421 + if (isMobile) { 422 + gridY = Math.floor(gridY / 2) * 2; 423 + } 424 + } 425 + 426 + for (const file of imageFiles) { 427 + await processImageFile(file, gridX, gridY); 428 + 429 + // Move to next cell position 430 + const cardW = isMobile ? 4 : 2; 431 + gridX += cardW; 432 + if (gridX + cardW > COLUMNS) { 433 + gridX = 0; 434 + gridY += isMobile ? 4 : 2; 435 + } 436 + } 437 + } 438 + 439 + async function handleImageInputChange(event: Event) { 440 + const target = event.target as HTMLInputElement; 441 + if (!target.files || target.files.length < 1) return; 442 + 443 + const files = Array.from(target.files); 444 + 445 + if (files.length === 1) { 446 + // Single file: use default positioning 447 + await processImageFile(files[0]); 448 + } else { 449 + // Multiple files: place in grid pattern starting from first available position 450 + let gridX = 0; 451 + let gridY = maxHeight; 452 + const cardW = isMobile ? 4 : 2; 453 + const cardH = isMobile ? 4 : 2; 454 + 455 + for (const file of files) { 456 + await processImageFile(file, gridX, gridY); 457 + 458 + // Move to next cell position 459 + gridX += cardW; 460 + if (gridX + cardW > COLUMNS) { 461 + gridX = 0; 462 + gridY += cardH; 463 + } 464 + } 465 + } 466 + 467 + // Reset the input so the same file can be selected again 468 + target.value = ''; 469 + } 470 + 471 + async function processVideoFile(file: File) { 472 + const objectUrl = URL.createObjectURL(file); 473 + 474 + let item = createEmptyCard(data.page); 475 + 476 + item.cardType = 'video'; 477 + item.cardData = { 478 + blob: file, 479 + objectUrl 480 + }; 481 + 482 + setPositionOfNewItem(item, items); 483 + items = [...items, item]; 484 + 485 + await tick(); 486 + 487 + scrollToItem(item, isMobile, container); 488 + } 489 + 490 + async function handleVideoInputChange(event: Event) { 491 + const target = event.target as HTMLInputElement; 492 + if (!target.files || target.files.length < 1) return; 493 + 494 + const files = Array.from(target.files); 495 + 496 + for (const file of files) { 497 + await processVideoFile(file); 498 + } 499 + 500 + // Reset the input so the same file can be selected again 501 + target.value = ''; 502 + } 503 + </script> 504 + 505 + <svelte:body 506 + onpaste={(event) => { 507 + if (isTyping()) return; 508 + 509 + const text = event.clipboardData?.getData('text/plain'); 510 + const link = validateLink(text, false); 511 + if (!link) return; 512 + 513 + addLink(link); 514 + }} 515 + /> 516 + 517 + <svelte:window 518 + ondragover={handleImageDragOver} 519 + ondragleave={handleImageDragLeave} 520 + ondrop={handleImageDrop} 521 + /> 522 + 523 + <Head 524 + favicon={data.profile.avatar ?? null} 525 + title={getName(data)} 526 + image={'/' + data.handle + '/og.png'} 527 + /> 528 + 529 + <Settings bind:open={showSettings} bind:data /> 530 + 531 + <Context {data}> 532 + <input 533 + type="file" 534 + accept="image/*" 535 + onchange={handleImageInputChange} 536 + class="hidden" 537 + multiple 538 + bind:this={imageInputRef} 539 + /> 540 + <input 541 + type="file" 542 + accept="video/*" 543 + onchange={handleVideoInputChange} 544 + class="hidden" 545 + multiple 546 + bind:this={videoInputRef} 547 + /> 548 + 549 + {#if !dev} 550 + <div 551 + class="bg-base-200 dark:bg-base-800 fixed inset-0 z-50 inline-flex h-full w-full items-center justify-center p-4 text-center lg:hidden" 552 + > 553 + Editing on mobile is not supported yet. Please use a desktop browser. 554 + </div> 555 + {/if} 556 + 557 + {#if showingMobileView} 558 + <div 559 + class="bg-base-200 dark:bg-base-950 pointer-events-none fixed inset-0 -z-10 h-full w-full" 560 + ></div> 561 + {/if} 562 + 563 + {#if newItem.modal && newItem.item} 564 + <newItem.modal 565 + oncreate={() => { 566 + saveNewItem(); 567 + }} 568 + bind:item={newItem.item} 569 + oncancel={() => { 570 + newItem = {}; 571 + }} 572 + /> 573 + {/if} 574 + 575 + <div 576 + class={[ 577 + '@container/wrapper relative w-full', 578 + showingMobileView 579 + ? 'bg-base-50 dark:bg-base-900 my-4 min-h-[calc(100dhv-2em)] rounded-2xl lg:mx-auto lg:w-[375px]' 580 + : '' 581 + ]} 582 + > 583 + {#if !getHideProfileSection(data)} 584 + <Profile {data} /> 585 + {/if} 586 + 587 + <div 588 + class={[ 589 + 'mx-auto max-w-lg', 590 + !getHideProfileSection(data) 591 + ? '@5xl/wrapper:grid @5xl/wrapper:max-w-7xl @5xl/wrapper:grid-cols-4' 592 + : '@5xl/wrapper:max-w-4xl' 593 + ]} 594 + > 595 + <div></div> 596 + <!-- svelte-ignore a11y_no_static_element_interactions --> 597 + <div 598 + bind:this={container} 599 + ondragover={(e) => { 600 + e.preventDefault(); 601 + 602 + const result = getDragXY(e); 603 + if (!result) return; 604 + 605 + activeDragElement.x = result.x; 606 + activeDragElement.y = result.y; 607 + 608 + if (activeDragElement.item) { 609 + // Get dragged card's original position for swapping 610 + const draggedOrigPos = activeDragElement.originalPositions.get( 611 + activeDragElement.item.id 612 + ); 613 + 614 + // Reset all items to original positions first 615 + for (const it of items) { 616 + const origPos = activeDragElement.originalPositions.get(it.id); 617 + if (origPos && it !== activeDragElement.item) { 618 + if (isMobile) { 619 + it.mobileX = origPos.mobileX; 620 + it.mobileY = origPos.mobileY; 621 + } else { 622 + it.x = origPos.x; 623 + it.y = origPos.y; 624 + } 625 + } 626 + } 627 + 628 + // Update dragged item position 629 + if (isMobile) { 630 + activeDragElement.item.mobileX = result.x; 631 + activeDragElement.item.mobileY = result.y; 632 + } else { 633 + activeDragElement.item.x = result.x; 634 + activeDragElement.item.y = result.y; 635 + } 636 + 637 + // Handle horizontal swap 638 + if (result.swapWithId && draggedOrigPos) { 639 + const swapTarget = items.find((it) => it.id === result.swapWithId); 640 + if (swapTarget) { 641 + // Move swap target to dragged card's original position 642 + if (isMobile) { 643 + swapTarget.mobileX = draggedOrigPos.mobileX; 644 + swapTarget.mobileY = draggedOrigPos.mobileY; 645 + } else { 646 + swapTarget.x = draggedOrigPos.x; 647 + swapTarget.y = draggedOrigPos.y; 648 + } 649 + } 650 + } 651 + 652 + // Now fix collisions (with compacting) 653 + fixCollisions(items, activeDragElement.item, isMobile); 654 + } 655 + 656 + // Auto-scroll when dragging near top or bottom of viewport 657 + const scrollZone = 100; 658 + const scrollSpeed = 10; 659 + const viewportHeight = window.innerHeight; 660 + 661 + if (e.clientY < scrollZone) { 662 + // Near top - scroll up 663 + const intensity = 1 - e.clientY / scrollZone; 664 + window.scrollBy(0, -scrollSpeed * intensity); 665 + } else if (e.clientY > viewportHeight - scrollZone) { 666 + // Near bottom - scroll down 667 + const intensity = 1 - (viewportHeight - e.clientY) / scrollZone; 668 + window.scrollBy(0, scrollSpeed * intensity); 669 + } 670 + }} 671 + ondragend={async (e) => { 672 + e.preventDefault(); 673 + const cell = getDragXY(e); 674 + if (!cell) return; 675 + 676 + if (activeDragElement.item) { 677 + if (isMobile) { 678 + activeDragElement.item.mobileX = cell.x; 679 + activeDragElement.item.mobileY = cell.y; 680 + } else { 681 + activeDragElement.item.x = cell.x; 682 + activeDragElement.item.y = cell.y; 683 + } 684 + 685 + // Fix collisions and compact items after drag ends 686 + fixCollisions(items, activeDragElement.item, isMobile); 687 + } 688 + activeDragElement.x = -1; 689 + activeDragElement.y = -1; 690 + activeDragElement.element = null; 691 + activeDragElement.item = null; 692 + activeDragElement.lastTargetId = null; 693 + activeDragElement.lastPlacement = null; 694 + return true; 695 + }} 696 + class={[ 697 + '@container/grid relative col-span-3 rounded-4xl px-2 py-8 @5xl/wrapper:px-8', 698 + imageDragOver && 'outline-accent-500 outline-3 -outline-offset-10 outline-dashed' 699 + ]} 700 + > 701 + {#each items as item, i (item.id)} 702 + <!-- {#if item !== activeDragElement.item} --> 703 + <BaseEditingCard 704 + bind:item={items[i]} 705 + ondelete={() => { 706 + items = items.filter((it) => it !== item); 707 + compactItems(items, isMobile); 708 + }} 709 + onsetsize={(newW: number, newH: number) => { 710 + if (isMobile) { 711 + item.mobileW = newW; 712 + item.mobileH = newH; 713 + } else { 714 + item.w = newW; 715 + item.h = newH; 716 + } 717 + 718 + fixCollisions(items, item, isMobile); 719 + }} 720 + ondragstart={(e) => { 721 + const target = e.currentTarget as HTMLDivElement; 722 + activeDragElement.element = target; 723 + activeDragElement.w = item.w; 724 + activeDragElement.h = item.h; 725 + activeDragElement.item = item; 726 + 727 + // Store original positions of all items 728 + activeDragElement.originalPositions = new Map(); 729 + for (const it of items) { 730 + activeDragElement.originalPositions.set(it.id, { 731 + x: it.x, 732 + y: it.y, 733 + mobileX: it.mobileX, 734 + mobileY: it.mobileY 735 + }); 736 + } 737 + 738 + const rect = target.getBoundingClientRect(); 739 + activeDragElement.mouseDeltaX = rect.left - e.clientX; 740 + activeDragElement.mouseDeltaY = rect.top - e.clientY; 741 + }} 742 + > 743 + <EditingCard bind:item={items[i]} /> 744 + </BaseEditingCard> 745 + <!-- {/if} --> 746 + {/each} 747 + 748 + <div style="height: {((maxHeight + 2) / 8) * 100}cqw;"></div> 749 + </div> 750 + </div> 751 + </div> 752 + 753 + <!-- <Settings bind:open={showSettings} /> --> 754 + 755 + <Sidebar mobileOnly mobileClasses="lg:block p-4 gap-4"> 756 + <div class="flex flex-col gap-2"> 757 + {#each sidebarItems as cardDef} 758 + {#if cardDef.sidebarComponent} 759 + <cardDef.sidebarComponent onclick={() => newCard(cardDef.type)} /> 760 + {:else if cardDef.sidebarButtonText} 761 + <Button onclick={() => newCard(cardDef.type)} variant="ghost" class="w-full justify-start" 762 + >{cardDef.sidebarButtonText}</Button 763 + > 764 + {/if} 765 + {/each} 766 + </div> 767 + </Sidebar> 768 + 769 + {#if dev || (!client.isLoggedIn && !client.isInitializing) || client.profile?.did === data.did} 770 + <Navbar 771 + class={[ 772 + 'dark:bg-base-900 bg-base-100 top-auto bottom-2 mx-4 mt-3 max-w-3xl rounded-full px-4 md:mx-auto lg:inline-flex', 773 + !dev ? 'hidden' : '' 774 + ]} 775 + > 776 + <div class="flex items-center gap-2"> 777 + <Button 778 + size="iconLg" 779 + variant="ghost" 780 + class="backdrop-blur-none" 781 + onclick={() => { 782 + newCard('section'); 783 + }} 784 + > 785 + <svg 786 + xmlns="http://www.w3.org/2000/svg" 787 + viewBox="0 0 24 24" 788 + fill="none" 789 + stroke="currentColor" 790 + stroke-width="2" 791 + stroke-linecap="round" 792 + stroke-linejoin="round" 793 + ><path d="M6 12h12" /><path d="M6 20V4" /><path d="M18 20V4" /></svg 794 + > 795 + </Button> 796 + 797 + <Button 798 + size="iconLg" 799 + variant="ghost" 800 + class="backdrop-blur-none" 801 + onclick={() => { 802 + newCard('text'); 803 + }} 804 + > 805 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" 806 + ><path 807 + fill="none" 808 + stroke="currentColor" 809 + stroke-linecap="round" 810 + stroke-linejoin="round" 811 + stroke-width="2" 812 + d="m15 16l2.536-7.328a1.02 1.02 1 0 1 1.928 0L22 16m-6.303-2h5.606M2 16l4.039-9.69a.5.5 0 0 1 .923 0L11 16m-7.696-3h6.392" 813 + /></svg 814 + > 815 + </Button> 816 + 817 + <Popover sideOffset={16} bind:open={linkPopoverOpen} class="bg-base-100 dark:bg-base-900"> 818 + {#snippet child({ props })} 819 + <Button 820 + size="iconLg" 821 + variant="ghost" 822 + class="backdrop-blur-none" 823 + onclick={() => { 824 + newCard('link'); 825 + }} 826 + {...props} 827 + > 828 + <svg 829 + xmlns="http://www.w3.org/2000/svg" 830 + fill="none" 831 + viewBox="-2 -2 28 28" 832 + stroke-width="2" 833 + stroke="currentColor" 834 + > 835 + <path 836 + stroke-linecap="round" 837 + stroke-linejoin="round" 838 + d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" 839 + /> 840 + </svg> 841 + </Button> 842 + {/snippet} 843 + <Input 844 + spellcheck={false} 845 + type="url" 846 + bind:value={linkValue} 847 + onkeydown={(event) => { 848 + if (event.code === 'Enter') { 849 + addLink(linkValue); 850 + event.preventDefault(); 851 + } 852 + }} 853 + placeholder="Enter link" 854 + /> 855 + <Button onclick={() => addLink(linkValue)} size="icon" 856 + ><svg 857 + xmlns="http://www.w3.org/2000/svg" 858 + fill="none" 859 + viewBox="0 0 24 24" 860 + stroke-width="2" 861 + stroke="currentColor" 862 + class="size-6" 863 + > 864 + <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /> 865 + </svg> 866 + </Button> 867 + </Popover> 868 + 869 + <Button 870 + size="iconLg" 871 + variant="ghost" 872 + class="backdrop-blur-none" 873 + onclick={() => { 874 + imageInputRef?.click(); 875 + }} 876 + > 877 + <svg 878 + xmlns="http://www.w3.org/2000/svg" 879 + fill="none" 880 + viewBox="0 0 24 24" 881 + stroke-width="2" 882 + stroke="currentColor" 883 + > 884 + <path 885 + stroke-linecap="round" 886 + stroke-linejoin="round" 887 + d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" 888 + /> 889 + </svg> 890 + </Button> 891 + 892 + {#if dev} 893 + <Button 894 + size="iconLg" 895 + variant="ghost" 896 + class="backdrop-blur-none" 897 + onclick={() => { 898 + videoInputRef?.click(); 899 + }} 900 + > 901 + <svg 902 + xmlns="http://www.w3.org/2000/svg" 903 + fill="none" 904 + viewBox="0 0 24 24" 905 + stroke-width="1.5" 906 + stroke="currentColor" 907 + > 908 + <path 909 + stroke-linecap="round" 910 + stroke-linejoin="round" 911 + d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z" 912 + /> 913 + </svg> 914 + </Button> 915 + {/if} 916 + 917 + <Button 918 + size="iconLg" 919 + variant="ghost" 920 + class="backdrop-blur-none" 921 + popovertarget="mobile-menu" 922 + > 923 + <svg 924 + xmlns="http://www.w3.org/2000/svg" 925 + fill="none" 926 + viewBox="0 0 24 24" 927 + stroke-width="1.5" 928 + stroke="currentColor" 929 + > 930 + <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> 931 + </svg> 932 + </Button> 933 + </div> 934 + <div class="flex items-center gap-2"> 935 + <Button 936 + size="iconLg" 937 + variant="ghost" 938 + class="backdrop-blur-none" 939 + onclick={() => { 940 + showSettings = true; 941 + }} 942 + > 943 + <svg 944 + xmlns="http://www.w3.org/2000/svg" 945 + fill="none" 946 + viewBox="0 0 24 24" 947 + stroke-width="1.5" 948 + stroke="currentColor" 949 + > 950 + <path 951 + stroke-linecap="round" 952 + stroke-linejoin="round" 953 + d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" 954 + /> 955 + <path 956 + stroke-linecap="round" 957 + stroke-linejoin="round" 958 + d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 959 + /> 960 + </svg> 961 + </Button> 962 + <Toggle 963 + class="hidden bg-transparent backdrop-blur-none lg:block dark:bg-transparent" 964 + bind:pressed={showingMobileView} 965 + > 966 + <svg 967 + xmlns="http://www.w3.org/2000/svg" 968 + fill="none" 969 + viewBox="0 0 24 24" 970 + stroke-width="1.5" 971 + stroke="currentColor" 972 + class="size-6" 973 + > 974 + <path 975 + stroke-linecap="round" 976 + stroke-linejoin="round" 977 + d="M10.5 1.5H8.25A2.25 2.25 0 0 0 6 3.75v16.5a2.25 2.25 0 0 0 2.25 2.25h7.5A2.25 2.25 0 0 0 18 20.25V3.75a2.25 2.25 0 0 0-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" 978 + /> 979 + </svg> 980 + </Toggle> 981 + {#if client.isLoggedIn} 982 + <Button 983 + disabled={isSaving} 984 + onclick={async () => { 985 + save(); 986 + }}>{isSaving ? 'Saving...' : 'Save'}</Button 987 + > 988 + {:else} 989 + <BlueskyLogin 990 + login={async (handle) => { 991 + await login(handle); 992 + return true; 993 + }} 994 + /> 995 + {/if} 996 + </div> 997 + </Navbar> 998 + {/if} 999 + 1000 + <Toaster /> 1001 + </Context>
+33
src/lib/website/Head.svelte
··· 1 + <script lang="ts"> 2 + let { 3 + favicon, 4 + title, 5 + image, 6 + description 7 + }: { favicon: string | null; title: string | null; image?: string; description?: string } = 8 + $props(); 9 + </script> 10 + 11 + <svelte:head> 12 + {#if favicon} 13 + <link rel="icon" href={favicon} /> 14 + {/if} 15 + 16 + {#if title} 17 + <title>{title}</title> 18 + <meta property="og:title" content={title} /> 19 + <meta name="twitter:title" content={title} /> 20 + {/if} 21 + 22 + {#if image} 23 + <meta property="og:image" content={image} /> 24 + <meta name="twitter:image" content={image} /> 25 + <meta name="twitter:card" content="summary_large_image" /> 26 + {/if} 27 + 28 + {#if description} 29 + <meta name="description" content={description} /> 30 + <meta property="og:description" content={description} /> 31 + <meta name="twitter:description" content={description} /> 32 + {/if} 33 + </svelte:head>
+120
src/lib/website/Profile.svelte
··· 1 + <script lang="ts"> 2 + import { marked } from 'marked'; 3 + import { client, login } from '../oauth'; 4 + import { Button } from '@foxui/core'; 5 + import { BlueskyLogin } from '@foxui/social'; 6 + import { env } from '$env/dynamic/public'; 7 + import type { WebsiteData } from '$lib/types'; 8 + import { getDescription, getName } from '$lib/helper'; 9 + 10 + let { 11 + data, 12 + showEditButton = false 13 + }: { 14 + data: WebsiteData; 15 + showEditButton?: boolean; 16 + } = $props(); 17 + 18 + const renderer = new marked.Renderer(); 19 + renderer.link = ({ href, title, text }) => 20 + `<a target="_blank" href="${href}" title="${title}">${text}</a>`; 21 + </script> 22 + 23 + <!-- lg:fixed lg:h-screen lg:w-1/4 lg:max-w-none lg:px-12 lg:pt-24 xl:w-1/3 --> 24 + <div 25 + class="mx-auto flex max-w-lg flex-col justify-between px-8 @5xl/wrapper:fixed @5xl/wrapper:h-screen @5xl/wrapper:w-1/4 @5xl/wrapper:max-w-none @5xl/wrapper:px-12" 26 + > 27 + <div class="flex flex-col gap-4 pt-16 pb-8 @5xl/wrapper:h-screen @5xl/wrapper:pt-24"> 28 + {#if data.profile.avatar} 29 + <img 30 + class="border-base-400 dark:border-base-800 size-32 rounded-full border @5xl/wrapper:size-44" 31 + src={data.profile.avatar} 32 + alt="" 33 + /> 34 + {:else} 35 + <div class="bg-base-300 dark:bg-base-700 size-32 rounded-full @5xl/wrapper:size-44"></div> 36 + {/if} 37 + 38 + <div class="text-4xl font-bold wrap-anywhere"> 39 + {getName(data)} 40 + </div> 41 + 42 + <div class="scrollbar -mx-4 flex-grow overflow-x-hidden overflow-y-scroll px-4"> 43 + <div 44 + class="text-base-600 dark:text-base-400 prose dark:prose-invert prose-a:text-accent-500 prose-a:no-underline" 45 + > 46 + {@html marked.parse(getDescription(data), { 47 + renderer 48 + })} 49 + </div> 50 + </div> 51 + 52 + {#if showEditButton && client.isLoggedIn && client.profile?.did === data.did} 53 + <div> 54 + <Button href="{env.PUBLIC_IS_SELFHOSTED ? '' : client.profile?.handle}/edit" class="mt-2"> 55 + <svg 56 + xmlns="http://www.w3.org/2000/svg" 57 + fill="none" 58 + viewBox="0 0 24 24" 59 + stroke-width="1.5" 60 + stroke="currentColor" 61 + > 62 + <path 63 + stroke-linecap="round" 64 + stroke-linejoin="round" 65 + d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" 66 + /> 67 + </svg> 68 + 69 + Edit Your Website</Button 70 + > 71 + </div> 72 + {:else} 73 + <div class="h-[42px] w-1 @5xl/wrapper:hidden"></div> 74 + {/if} 75 + 76 + {#if !env.PUBLIC_IS_SELFHOSTED && data.handle === 'blento.app' && client.profile?.handle !== data.handle} 77 + {#if !client.isInitializing && !client.isLoggedIn} 78 + <div> 79 + <div class="my-4 text-sm"> 80 + To create your own blento, sign in with your bluesky account 81 + </div> 82 + <BlueskyLogin 83 + login={async (handle) => { 84 + await login(handle); 85 + return true; 86 + }} 87 + /> 88 + </div> 89 + {:else if client.isLoggedIn} 90 + <div> 91 + <Button href="/{env.PUBLIC_IS_SELFHOSTED ? '' : client.profile?.handle}/edit" class="mt-2"> 92 + <svg 93 + xmlns="http://www.w3.org/2000/svg" 94 + fill="none" 95 + viewBox="0 0 24 24" 96 + stroke-width="1.5" 97 + stroke="currentColor" 98 + > 99 + <path 100 + stroke-linecap="round" 101 + stroke-linejoin="round" 102 + d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" 103 + /> 104 + </svg> 105 + 106 + Edit Your Blento</Button 107 + > 108 + </div> 109 + {/if} 110 + {/if} 111 + <div class="hidden text-xs font-light @5xl/wrapper:block"> 112 + made with <a 113 + href="https://blento.app" 114 + target="_blank" 115 + class="hover:text-accent-600 dark:hover:text-accent-400 font-medium transition-colors duration-200" 116 + >blento</a 117 + > 118 + </div> 119 + </div> 120 + </div>
+74
src/lib/website/Settings.svelte
··· 1 + <script lang="ts"> 2 + import { getDescription, getHideProfileSection, getName } from '$lib/helper'; 3 + import type { WebsiteData } from '$lib/types'; 4 + import { Button, Checkbox, Heading, Input, Label, Modal, Textarea } from '@foxui/core'; 5 + 6 + export type Settings = { 7 + title: string; 8 + }; 9 + 10 + let { open = $bindable(), data = $bindable() }: { open: boolean; data: WebsiteData } = $props(); 11 + 12 + let name = $state(getName(data)); 13 + 14 + $effect(() => { 15 + if (!open && name && name !== getName(data)) { 16 + data.publication ??= {}; 17 + data.publication.name = name; 18 + 19 + data = { ...data }; 20 + } 21 + }); 22 + </script> 23 + 24 + <Modal bind:open class="dark:bg-base-900"> 25 + <Heading>Settings</Heading> 26 + <Label>Name</Label> 27 + <Input bind:value={name} /> 28 + <Label class="mt-4">Description</Label> 29 + <Textarea 30 + rows={5} 31 + bind:value={ 32 + () => { 33 + return getDescription(data); 34 + }, 35 + (value) => { 36 + data.publication ??= {}; 37 + data.publication.description = value; 38 + 39 + data = { ...data }; 40 + } 41 + } 42 + /> 43 + 44 + <div class="flex items-center space-x-2"> 45 + <Checkbox 46 + bind:checked={ 47 + () => { 48 + return getHideProfileSection(data); 49 + }, 50 + (value) => { 51 + data.publication ??= {}; 52 + data.publication.preferences ??= {}; 53 + data.publication.preferences.hideProfile = value; 54 + 55 + data = { ...data }; 56 + } 57 + } 58 + id="hide-profile" 59 + aria-labelledby="hide-profile-label" 60 + variant="secondary" 61 + /> 62 + <Label 63 + id="hide-profile-label" 64 + for="hide-profile" 65 + class="text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 66 + > 67 + Hide Profile 68 + </Label> 69 + </div> 70 + 71 + <div class="flex w-full justify-end space-x-2"> 72 + <Button onclick={() => (open = false)}>Close</Button> 73 + </div> 74 + </Modal>
+73
src/lib/website/Website.svelte
··· 1 + <script lang="ts"> 2 + import Card from '../cards/Card/Card.svelte'; 3 + import Profile from './Profile.svelte'; 4 + import { getDescription, getHideProfileSection, getName, sortItems } from '../helper'; 5 + import { innerWidth } from 'svelte/reactivity/window'; 6 + import { setDidContext, setHandleContext, setIsMobile } from './context'; 7 + import BaseCard from '../cards/BaseCard/BaseCard.svelte'; 8 + import type { WebsiteData } from '$lib/types'; 9 + import Context from './Context.svelte'; 10 + import Head from './Head.svelte'; 11 + 12 + let { data }: { data: WebsiteData } = $props(); 13 + 14 + let isMobile = $derived((innerWidth.current ?? 1000) < 1024); 15 + setIsMobile(() => isMobile); 16 + 17 + // svelte-ignore state_referenced_locally 18 + setDidContext(data.did); 19 + // svelte-ignore state_referenced_locally 20 + setHandleContext(data.handle); 21 + 22 + let maxHeight = $derived( 23 + data.cards.reduce( 24 + (max, item) => Math.max(max, isMobile ? item.mobileY + item.mobileH : item.y + item.h), 25 + 0 26 + ) 27 + ); 28 + 29 + let container: HTMLDivElement | undefined = $state(); 30 + </script> 31 + 32 + <Head 33 + favicon={data.profile.avatar ?? null} 34 + title={getName(data)} 35 + image={'/' + data.handle + '/og.png'} 36 + description={getDescription(data)} 37 + /> 38 + 39 + <Context {data}> 40 + <div class="@container/wrapper relative w-full"> 41 + {#if !getHideProfileSection(data)} 42 + <Profile {data} showEditButton={true} /> 43 + {/if} 44 + 45 + <div 46 + class={[ 47 + 'mx-auto max-w-lg', 48 + !getHideProfileSection(data) 49 + ? '@5xl/wrapper:grid @5xl/wrapper:max-w-7xl @5xl/wrapper:grid-cols-4' 50 + : '@5xl/wrapper:max-w-4xl' 51 + ]} 52 + > 53 + <div></div> 54 + <div bind:this={container} class="@container/grid relative col-span-3 px-2 py-8 lg:px-8"> 55 + {#each data.cards.toSorted(sortItems) as item} 56 + <BaseCard {item}> 57 + <Card {item} /> 58 + </BaseCard> 59 + {/each} 60 + <div style="height: {(maxHeight / 8) * 100}cqw;"></div> 61 + </div> 62 + </div> 63 + 64 + <div class="mx-auto block pb-8 text-center text-xs font-light @5xl/wrapper:hidden"> 65 + made with <a 66 + href="https://blento.app" 67 + target="_blank" 68 + class="hover:text-accent-600 dark:hover:text-accent-400 font-medium transition-colors duration-200" 69 + >blento</a 70 + > 71 + </div> 72 + </div> 73 + </Context>
+4 -14
src/lib/website/context.ts
··· 1 1 import { createContext } from 'svelte'; 2 - import type { DownloadedData } from './types'; 3 - 4 - export type UpdateFunction = () => boolean | Promise<boolean>; 5 - 6 - export type UpdateRecordFunction = () => Record<string, unknown> | Promise<Record<string, unknown>>; 7 - 8 - export const [getUpdateFunctionsContext, setUpdateFunctionsContext] = 9 - createContext<UpdateFunction[]>(); 10 - export const [getUpdateRecordFunctionsContext, setUpdateRecordFunctionsContext] = 11 - createContext<UpdateRecordFunction[]>(); 12 2 13 3 export const [getDidContext, setDidContext] = createContext<string>(); 14 4 export const [getHandleContext, setHandleContext] = createContext<string>(); 15 - 16 - export const [getDataContext, setDataContext] = createContext<DownloadedData>(); 17 - 18 - export const [isEditing, setIsEditing] = createContext<boolean>(); 5 + export const [getIsMobile, setIsMobile] = createContext<() => boolean>(); 6 + export const [getCanEdit, setCanEdit] = createContext<() => boolean>(); 7 + export const [getAdditionalUserData, setAdditionalUserData] = 8 + createContext<Record<string, unknown>>();
-9
src/lib/website/data.ts
··· 1 - export const image_collection = 'com.example.image' as const; 2 - 3 - // collections and records we want to grab 4 - export const data = { 5 - 'app.bsky.actor.profile': ['self'], 6 - 7 - 'app.blento.card': 'all', 8 - 'app.blento.settings': ['self'] 9 - } as const;
+106 -113
src/lib/website/load.ts
··· 1 - import { 2 - type Collection, 3 - type DownloadedData, 4 - type IndividualCollections, 5 - type ListCollections 6 - } from './types'; 7 - import { getRecord, listRecords, resolveHandle } from '$lib/oauth/atproto'; 1 + import { getProfile, listRecords, resolveHandle } from '$lib/oauth/atproto'; 8 2 import type { Record as ListRecord } from '@atproto/api/dist/client/types/com/atproto/repo/listRecords'; 9 - import { data } from './data'; 10 3 import { CardDefinitionsByType } from '$lib/cards'; 11 - import type { Item } from '$lib/types'; 12 - import { compactItems, fixAllCollisions, fixCollisions } from '$lib/helper'; 4 + import type { Item, UserCache, WebsiteData } from '$lib/types'; 5 + import { compactItems, fixAllCollisions } from '$lib/helper'; 6 + import { parseUri } from '$lib/oauth/utils'; 7 + import { error } from '@sveltejs/kit'; 13 8 14 - type LoadedData = { 15 - did: string; 16 - data: DownloadedData; 17 - additionalData: Record<string, unknown>; 18 - updatedAt: number; 19 - }; 9 + const CURRENT_CACHE_VERSION = 1; 20 10 21 - export async function loadData( 22 - handle: string, 23 - platform?: App.Platform, 24 - forceUpdate: boolean = false 25 - ): Promise<LoadedData> { 26 - console.log(handle); 27 - if (!forceUpdate) { 28 - try { 29 - const cachedResult = await platform?.env?.USER_DATA_CACHE?.get(handle); 11 + export async function getCache(handle: string, page: string, cache?: UserCache) { 12 + try { 13 + const cachedResult = await cache?.get?.(handle); 14 + 15 + if (!cachedResult) return; 16 + const result = JSON.parse(cachedResult); 17 + const update = result.updatedAt; 18 + const timePassed = (Date.now() - update) / 1000; 30 19 31 - if (cachedResult) { 32 - const result = JSON.parse(cachedResult); 33 - const update = result.updatedAt; 34 - const timePassed = (Date.now() - update) / 1000; 35 - console.log( 36 - 'using cached result for handle', 37 - handle, 38 - 'last update', 39 - timePassed, 40 - 'seconds ago' 41 - ); 42 - return checkData(migrateData(JSON.parse(cachedResult))); 43 - } 44 - } catch (error) { 45 - console.log('getting cached result failed', error); 20 + if (!result.version || result.version !== CURRENT_CACHE_VERSION) { 21 + console.log('skipping cache because of version mismatch'); 22 + return; 46 23 } 24 + 25 + result.page = 'blento.' + page; 26 + 27 + result.publication = (result.publications as ListRecord[]).find( 28 + (v) => parseUri(v.uri).rkey === result.page 29 + )?.value; 30 + 31 + delete result['publications']; 32 + 33 + console.log('using cached result for handle', handle, 'last update', timePassed, 'seconds ago'); 34 + return checkData(result); 35 + } catch (error) { 36 + console.log('getting cached result failed', error); 47 37 } 38 + } 48 39 49 - const did = await resolveHandle({ handle }); 40 + export async function loadData( 41 + handle: string, 42 + cache: UserCache | undefined, 43 + forceUpdate: boolean = false, 44 + page: string = 'self' 45 + ): Promise<WebsiteData> { 46 + if (!handle) throw error(404); 50 47 51 - const downloadedData = {} as DownloadedData; 48 + if (!forceUpdate) { 49 + const cachedResult = await getCache(handle, page, cache); 52 50 53 - const promises: { 54 - collection: string; 55 - rkey?: string; 56 - record: Promise<ListRecord> | Promise<Record<string, ListRecord>>; 57 - }[] = []; 51 + if (cachedResult) return cachedResult; 52 + } 58 53 59 - for (const collection of Object.keys(data) as Collection[]) { 60 - const cfg = data[collection]; 54 + if (handle === 'favicon.ico') throw error(404); 61 55 62 - try { 63 - if (Array.isArray(cfg)) { 64 - for (const rkey of cfg) { 65 - const record = getRecord({ did, collection, rkey }).catch((error) => { 66 - console.error('error getting record', rkey, 'for collection', collection); 67 - }); 68 - promises.push({ 69 - collection, 70 - rkey, 71 - record 72 - }); 73 - } 74 - } else if (cfg === 'all') { 75 - const records = listRecords({ did, collection }).catch((error) => { 76 - console.error('error getting records for collection', collection); 77 - }); 78 - promises.push({ collection, record: records }); 79 - } 80 - } catch (error) { 81 - console.error('failed getting', collection, cfg, error); 82 - } 83 - } 56 + console.log('resolving', handle); 57 + const did = await resolveHandle({ handle }); 84 58 85 - await Promise.all(promises.map((v) => v.record)); 59 + const cards = await listRecords({ did, collection: 'app.blento.card' }).catch(() => { 60 + console.error('error getting records for collection app.blento.card'); 61 + return [] as ListRecord[]; 62 + }); 86 63 87 - for (const promise of promises) { 88 - if (promise.rkey) { 89 - downloadedData[promise.collection as IndividualCollections] ??= {} as Record< 90 - string, 91 - ListRecord 92 - >; 93 - downloadedData[promise.collection as IndividualCollections][promise.rkey] = 94 - (await promise.record) as ListRecord; 95 - } else { 96 - downloadedData[promise.collection as ListCollections] ??= (await promise.record) as Record< 97 - string, 98 - ListRecord 99 - >; 64 + const publications = await listRecords({ did, collection: 'site.standard.publication' }).catch( 65 + () => { 66 + console.error('error getting records for collection site.standard.publication'); 67 + return [] as ListRecord[]; 100 68 } 101 - } 69 + ); 102 70 103 - const cardTypes = new Set( 104 - Object.values(downloadedData['app.blento.card']).map((v) => v.value.cardType) as string[] 105 - ); 71 + const profile = await getProfile({ did }); 106 72 73 + const cardTypes = new Set(cards.map((v) => v.value.cardType ?? '') as string[]); 107 74 const cardTypesArray = Array.from(cardTypes); 108 75 109 76 const additionDataPromises: Record<string, Promise<unknown>> = {}; 110 77 111 - const loadOptions = { did, handle, platform }; 78 + const loadOptions = { did, handle, cache }; 112 79 113 80 for (const cardType of cardTypesArray) { 114 81 const cardDef = CardDefinitionsByType[cardType]; 115 82 116 - if (cardDef.loadData) { 117 - additionDataPromises[cardType] = cardDef 118 - .loadData( 119 - Object.values(downloadedData['app.blento.card']) 120 - .filter((v) => cardType == v.value.cardType) 121 - .map((v) => v.value) as Item[], 122 - loadOptions 123 - ) 124 - .catch((error) => { 125 - console.error('error getting additional data for', cardType, error); 126 - }); 127 - } 83 + if (!cardDef.loadData) continue; 84 + 85 + additionDataPromises[cardType] = cardDef 86 + .loadData( 87 + cards.filter((v) => cardType === v.value.cardType).map((v) => v.value) as Item[], 88 + loadOptions 89 + ) 90 + .catch((error: Error) => { 91 + console.error('error getting additional data for', cardType, error); 92 + }); 128 93 } 129 94 130 95 await Promise.all(Object.values(additionDataPromises)); ··· 139 104 } 140 105 141 106 const result = { 107 + page: 'blento.' + page, 108 + handle, 142 109 did, 143 - data: JSON.parse(JSON.stringify(downloadedData)) as DownloadedData, 110 + cards: (cards.map((v) => { 111 + return { ...v.value }; 112 + }) ?? []) as Item[], 113 + publications: publications, 144 114 additionalData, 145 - updatedAt: Date.now() 115 + profile, 116 + updatedAt: Date.now(), 117 + version: CURRENT_CACHE_VERSION 146 118 }; 147 119 148 - await platform?.env?.USER_DATA_CACHE?.put(handle, JSON.stringify(result)); 120 + const stringifiedResult = JSON.stringify(result); 121 + await cache?.put?.(handle, stringifiedResult); 122 + 123 + const parsedResult = JSON.parse(stringifiedResult); 124 + parsedResult.publication = (parsedResult.publications as ListRecord[]).find( 125 + (v) => parseUri(v.uri).rkey === parsedResult.page 126 + )?.value; 149 127 150 - return checkData(migrateData(result)); 128 + delete parsedResult['publications']; 129 + 130 + return checkData(parsedResult); 151 131 } 152 132 153 - function migrateFromV0ToV1(data: LoadedData): LoadedData { 154 - for (const card of Object.values(data.data['app.blento.card']).map((i) => i.value) as Item[]) { 133 + function migrateFromV0ToV1(data: WebsiteData): WebsiteData { 134 + for (const card of data.cards) { 155 135 if (card.version) continue; 156 136 card.x *= 2; 157 137 card.y *= 2; ··· 167 147 return data; 168 148 } 169 149 170 - function checkData(data: LoadedData): LoadedData { 171 - const cards = Object.values(data.data['app.blento.card']).map((i) => i.value) as Item[]; 150 + function migrateFromV1ToV2(data: WebsiteData): WebsiteData { 151 + for (const card of data.cards) { 152 + if (!card.version || card.version < 2) { 153 + card.page = 'blento.self'; 154 + card.version = 2; 155 + } 156 + } 157 + return data; 158 + } 159 + 160 + function checkData(data: WebsiteData): WebsiteData { 161 + data = migrateData(data); 162 + 163 + const cards = data.cards.filter((v) => v.page === data.page); 172 164 173 - console.log(cards); 174 165 if (cards.length > 0) { 175 166 fixAllCollisions(cards); 176 167 fixAllCollisions(cards, true); ··· 179 170 compactItems(cards, true); 180 171 } 181 172 173 + data.cards = cards; 174 + 182 175 return data; 183 176 } 184 177 185 - function migrateData(data: LoadedData): LoadedData { 186 - return migrateFromV0ToV1(data); 178 + function migrateData(data: WebsiteData): WebsiteData { 179 + return migrateFromV1ToV2(migrateFromV0ToV1(data)); 187 180 }
-16
src/lib/website/types.ts
··· 1 - import type { data } from './data'; 2 - import type { Record as ListRecord } from '@atproto/api/dist/client/types/com/atproto/repo/listRecords'; 3 - 4 - export type Collection = keyof typeof data; 5 - 6 - export type IndividualCollections = { 7 - [K in Collection]: (typeof data)[K] extends readonly unknown[] ? K : never; 8 - }[Collection]; 9 - 10 - export type ListCollections = Exclude<Collection, IndividualCollections>; 11 - 12 - export type ElementType<C extends Collection> = (typeof data)[C] extends readonly (infer U)[] 13 - ? U 14 - : unknown; 15 - 16 - export type DownloadedData = { [C in Collection]: Record<string, ListRecord> };
src/lib/website/utils.ts src/lib/oauth/utils.ts
+3 -2
src/routes/+page.server.ts
··· 1 1 import { loadData } from '$lib/website/load'; 2 2 import { env } from '$env/dynamic/public'; 3 + import type { UserCache } from '$lib/types'; 3 4 4 5 export async function load({ platform, url }) { 5 6 const hostname = url.hostname; ··· 8 9 if (hostname === 'flo-bit.blento.app') { 9 10 handle = 'flo-bit.dev'; 10 11 } 12 + const cache = platform?.env?.USER_DATA_CACHE as unknown; 11 13 12 - const data = await loadData(handle, platform); 13 - return { ...data, handle }; 14 + return await loadData(handle, cache as UserCache); 14 15 }
+3 -12
src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 - import { refreshData, setAdditionalUserData } from '$lib/helper.js'; 3 - import { type Item } from '$lib/types.js'; 4 - import Website from '$lib/Website.svelte'; 2 + import { refreshData } from '$lib/helper.js'; 3 + import Website from '$lib/website/Website.svelte'; 5 4 import { onMount } from 'svelte'; 6 5 7 6 let { data } = $props(); 8 - 9 - // svelte-ignore state_referenced_locally 10 - setAdditionalUserData(data.additionalData); 11 7 12 8 onMount(() => { 13 9 refreshData(data); 14 10 }); 15 11 </script> 16 12 17 - <Website 18 - {data} 19 - handle={data.handle} 20 - did={data.did} 21 - items={Object.values(data.data['app.blento.card']).map((i) => i.value) as Item[]} 22 - /> 13 + <Website {data} />
-9
src/routes/[handle]/+layout.server.ts
··· 1 - import { loadData } from '$lib/website/load'; 2 - import { env } from '$env/dynamic/private'; 3 - import { error } from '@sveltejs/kit'; 4 - 5 - export async function load({ params, platform }) { 6 - if (env.PUBLIC_IS_SELFHOSTED) error(404); 7 - const data = await loadData(params.handle, platform); 8 - return { ...data, handle: params.handle }; 9 - }
-24
src/routes/[handle]/+page.svelte
··· 1 - <script lang="ts"> 2 - import { page } from '$app/state'; 3 - import { refreshData, setAdditionalUserData } from '$lib/helper.js'; 4 - import { type Item } from '$lib/types.js'; 5 - import Website from '$lib/Website.svelte'; 6 - import { onMount } from 'svelte'; 7 - 8 - let { data } = $props(); 9 - 10 - // svelte-ignore state_referenced_locally 11 - setAdditionalUserData(data.additionalData); 12 - 13 - onMount(() => { 14 - refreshData(data); 15 - }) 16 - </script> 17 - 18 - <Website 19 - {data} 20 - handle={page.params.handle} 21 - did={data.did} 22 - items={Object.values(data.data['app.blento.card']).map((i) => i.value) as Item[]} 23 - settings={data.data['app.blento.settings']?.['self']?.value} 24 - />
+14
src/routes/[handle]/.well-known/site.standard.publication/+server.ts
··· 1 + import { loadData } from '$lib/website/load'; 2 + import { error } from '@sveltejs/kit'; 3 + import type { UserCache } from '$lib/types'; 4 + import { text } from '@sveltejs/kit'; 5 + 6 + export async function GET({ params, platform }) { 7 + const cache = platform?.env?.USER_DATA_CACHE as unknown; 8 + 9 + const data = await loadData(params.handle, cache as UserCache, false, params.page); 10 + 11 + if (!data.publication) throw error(300); 12 + 13 + return text(data.did + '/site.standard.publication/blento.self'); 14 + }
+14
src/routes/[handle]/[[page]]/+layout.server.ts
··· 1 + import { loadData } from '$lib/website/load'; 2 + import { env } from '$env/dynamic/private'; 3 + import { error } from '@sveltejs/kit'; 4 + import type { UserCache } from '$lib/types'; 5 + 6 + export async function load({ params, platform }) { 7 + if (env.PUBLIC_IS_SELFHOSTED) error(404); 8 + 9 + const cache = platform?.env?.USER_DATA_CACHE as unknown; 10 + 11 + console.log(params.page); 12 + 13 + return await loadData(params.handle, cache as UserCache, false, params.page); 14 + }
+13
src/routes/[handle]/[[page]]/+page.svelte
··· 1 + <script lang="ts"> 2 + import { refreshData } from '$lib/helper.js'; 3 + import Website from '$lib/website/Website.svelte'; 4 + import { onMount } from 'svelte'; 5 + 6 + let { data } = $props(); 7 + 8 + onMount(() => { 9 + refreshData(data); 10 + }); 11 + </script> 12 + 13 + <Website {data} />
+6
src/routes/[handle]/[[page]]/edit/+page.svelte
··· 1 + <script lang="ts"> 2 + import EditableWebsite from '$lib/website/EditableWebsite.svelte'; 3 + let { data } = $props(); 4 + </script> 5 + 6 + <EditableWebsite {data} />
+3 -1
src/routes/[handle]/api/refreshData/+server.ts
··· 1 + import type { UserCache } from '$lib/types'; 1 2 import { loadData } from '$lib/website/load.js'; 2 3 import { json } from '@sveltejs/kit'; 3 4 ··· 5 6 if (!platform?.env?.USER_DATA_CACHE) return json('no cache'); 6 7 const handle = params.handle; 7 8 8 - await loadData(handle, platform, true); 9 + const cache = platform?.env?.USER_DATA_CACHE as unknown; 10 + await loadData(handle, cache as UserCache, true); 9 11 10 12 return json('ok'); 11 13 }
-20
src/routes/[handle]/edit/+page.svelte
··· 1 - <script lang="ts"> 2 - import { page } from '$app/state'; 3 - import EditableWebsite from '$lib/EditableWebsite.svelte'; 4 - import { setAdditionalUserData } from '$lib/helper.js'; 5 - import { type Item } from '$lib/types.js'; 6 - 7 - let { data } = $props(); 8 - 9 - // svelte-ignore state_referenced_locally 10 - setAdditionalUserData(data.additionalData); 11 - </script> 12 - 13 - <EditableWebsite 14 - handle={page.params.handle} 15 - did={data.did} 16 - {data} 17 - items={Object.values(data.data['app.blento.card']).map((i) => i.value) as Item[]} 18 - 19 - settings={data.data['app.blento.settings']?.['self']?.value} 20 - />
+6 -7
src/routes/[handle]/og.png/+server.ts
··· 1 + import type { UserCache } from '$lib/types'; 1 2 import { loadData } from '$lib/website/load'; 2 3 import { ImageResponse } from '@ethercorps/sveltekit-og'; 3 4 4 5 export async function GET({ params, platform }) { 5 6 const handle = params.handle; 6 - const data = await loadData(params.handle, platform); 7 + 8 + const cache = platform?.env?.USER_DATA_CACHE as unknown; 9 + 10 + const data = await loadData(params.handle, cache as UserCache); 7 11 8 - console.log(data.data['app.bsky.actor.profile'].self); 9 - const image = 10 - 'https://cdn.bsky.app/img/avatar/plain/' + 11 - data.did + 12 - '/' + 13 - data.data['app.bsky.actor.profile'].self.value.avatar.ref.$link; 12 + const image = data.profile.avatar; 14 13 15 14 const htmlString = ` 16 15 <div class="flex flex-col p-8 w-full h-full bg-neutral-900">
+34
src/routes/all/+page.server.ts
··· 1 + import { env } from '$env/dynamic/public'; 2 + import type { UserCache, WebsiteData } from '$lib/types.js'; 3 + import { loadData } from '$lib/website/load'; 4 + import type { ProfileViewDetailed } from '@atproto/api/dist/client/types/app/bsky/actor/defs.js'; 5 + 6 + export async function load({ platform }) { 7 + const cache = platform?.env?.USER_DATA_CACHE; 8 + 9 + const list = await cache?.list(); 10 + 11 + const profiles: ProfileViewDetailed[] = []; 12 + for (const value of list?.keys ?? []) { 13 + // check if at least one card 14 + const result = await cache?.get(value.name); 15 + if (!result) continue; 16 + const parsed = JSON.parse(result) as WebsiteData; 17 + 18 + if (parsed.version !== 1 || !parsed.cards?.length) continue; 19 + 20 + profiles.push(parsed.profile); 21 + } 22 + 23 + profiles.sort((a, b) => a.handle.localeCompare(b.handle)); 24 + 25 + const handle = env.PUBLIC_HANDLE; 26 + 27 + const data = await loadData(handle, cache as unknown as UserCache); 28 + 29 + data.publication ??= {}; 30 + data.publication.preferences ??= {}; 31 + data.publication.preferences.hideProfileSection = true; 32 + 33 + return { ...data, profiles }; 34 + }
+32
src/routes/all/+page.svelte
··· 1 + <script lang="ts"> 2 + import { createEmptyCard, refreshData } from '$lib/helper.js'; 3 + import Website from '$lib/website/Website.svelte'; 4 + import { onMount } from 'svelte'; 5 + 6 + let { data } = $props(); 7 + 8 + $inspect(data.profiles); 9 + </script> 10 + 11 + <Website 12 + data={{ 13 + ...data, 14 + cards: data.profiles.map((v, i) => { 15 + const card = createEmptyCard(''); 16 + card.cardType = 'blueskyProfile'; 17 + card.cardData = { 18 + avatar: v.avatar, 19 + handle: v.handle, 20 + displayName: v.displayName 21 + }; 22 + 23 + card.x = (i % 4) * 2; 24 + card.y = Math.floor(i / 4) * 2; 25 + 26 + card.mobileX = (i % 2) * 4; 27 + card.mobileY = Math.floor(i / 2) * 4; 28 + 29 + return card; 30 + }) 31 + }} 32 + />
+56
src/routes/api/github/+server.ts
··· 1 + import { json } from '@sveltejs/kit'; 2 + import type { RequestHandler } from './$types'; 3 + import type { GitHubContributionsData } from '$lib/cards/GitHubProfileCard/types'; 4 + 5 + const GithubAPIURL = 'https://edge-function-github-contribution.vercel.app/api/github-data?user='; 6 + 7 + export const GET: RequestHandler = async ({ url, platform }) => { 8 + const user = url.searchParams.get('user'); 9 + 10 + if (!user) { 11 + return json({ error: 'No user provided' }, { status: 400 }); 12 + } 13 + 14 + const cachedData = await platform?.env?.USER_DATA_CACHE?.get('#github:' + user); 15 + 16 + if (cachedData) { 17 + const parsedCache = JSON.parse(cachedData); 18 + 19 + const TWELVE_HOURS = 12 * 60 * 60 * 1000; 20 + const now = Date.now(); 21 + 22 + if (now - (parsedCache.updatedAt || 0) < TWELVE_HOURS) { 23 + return json(parsedCache); 24 + } 25 + } 26 + 27 + try { 28 + const response = await fetch(GithubAPIURL + user); 29 + console.log('hello', user); 30 + 31 + if (!response.ok) { 32 + console.log('error', response.statusText); 33 + return json( 34 + { error: 'Failed to fetch GitHub data ' + response.statusText }, 35 + { status: response.status } 36 + ); 37 + } 38 + 39 + const data = await response.json(); 40 + 41 + if (!data?.user) { 42 + console.log('user not found', response.statusText); 43 + return json({ error: 'User not found' }, { status: 404 }); 44 + } 45 + 46 + const result = data.user as GitHubContributionsData; 47 + result.updatedAt = Date.now(); 48 + 49 + await platform?.env?.USER_DATA_CACHE?.put('#github:' + user, JSON.stringify(result)); 50 + 51 + return json(result); 52 + } catch (error) { 53 + console.error('Error fetching GitHub contributions:', error); 54 + return json({ error: 'Failed to fetch GitHub data' }, { status: 500 }); 55 + } 56 + };
+25
src/routes/api/reloadRecent/+server.ts
··· 1 + import { getProfile } from '$lib/oauth/atproto'; 2 + import type { ProfileViewDetailed } from '@atproto/api/dist/client/types/app/bsky/actor/defs'; 3 + import { json } from '@sveltejs/kit'; 4 + 5 + export async function GET({ platform }) { 6 + if (!platform?.env?.USER_DATA_CACHE) return json('no cache'); 7 + const existingUsers = await platform?.env?.USER_DATA_CACHE?.get('updatedBlentos'); 8 + 9 + const existingUsersArray: ProfileViewDetailed[] = existingUsers ? JSON.parse(existingUsers) : []; 10 + 11 + const existingUsersSet = new Set(existingUsersArray.map((v) => v.did)); 12 + 13 + const newProfilesPromises: Promise<ProfileViewDetailed>[] = []; 14 + for (const did of Array.from(existingUsersSet)) { 15 + const profile = getProfile({ did }); 16 + newProfilesPromises.push(profile); 17 + if (newProfilesPromises.length > 20) break; 18 + } 19 + 20 + const newProfiles = await Promise.all(newProfilesPromises); 21 + 22 + await platform?.env?.USER_DATA_CACHE.put('updatedBlentos', JSON.stringify(newProfiles)); 23 + 24 + return json('ok'); 25 + }
+31
src/routes/api/update/+server.ts
··· 1 + import type { UserCache } from '$lib/types'; 2 + import { getCache, loadData } from '$lib/website/load'; 3 + import type { ProfileViewDetailed } from '@atproto/api/dist/client/types/app/bsky/actor/defs'; 4 + import { json } from '@sveltejs/kit'; 5 + 6 + export async function GET({ platform }) { 7 + if (!platform?.env?.USER_DATA_CACHE) return json('no cache'); 8 + const existingUsers = await platform?.env?.USER_DATA_CACHE?.get('updatedBlentos'); 9 + 10 + const existingUsersArray: ProfileViewDetailed[] = existingUsers ? JSON.parse(existingUsers) : []; 11 + 12 + const existingUsersHandle = existingUsersArray.map((v) => v.handle); 13 + 14 + const cache = platform?.env?.USER_DATA_CACHE as unknown; 15 + 16 + for (const handle of existingUsersHandle) { 17 + if (!handle) continue; 18 + 19 + console.log('updating', handle); 20 + try { 21 + const cached = await getCache(handle, 'self', cache as UserCache); 22 + if (!cached) await loadData(handle, cache as UserCache, true); 23 + } catch (error) { 24 + console.error(error); 25 + return json('error'); 26 + } 27 + console.log('updated', handle); 28 + } 29 + 30 + return json('ok'); 31 + }
src/routes/client-metadata.json/+server.ts src/routes/oauth-client-metadata.json/+server.ts
+4 -2
src/routes/edit/+page.server.ts
··· 1 1 import { loadData } from '$lib/website/load'; 2 2 import { env } from '$env/dynamic/public'; 3 + import type { UserCache } from '$lib/types'; 3 4 4 5 export async function load({ url, platform }) { 5 6 const hostname = url.hostname; ··· 9 10 handle = 'flo-bit.dev'; 10 11 } 11 12 12 - const data = await loadData(handle, platform); 13 - return { ...data, handle }; 13 + const cache = platform?.env?.USER_DATA_CACHE as unknown; 14 + 15 + return await loadData(handle, cache as UserCache); 14 16 }
+2 -12
src/routes/edit/+page.svelte
··· 1 1 <script lang="ts"> 2 - import EditableWebsite from '$lib/EditableWebsite.svelte'; 3 - import { setAdditionalUserData } from '$lib/helper.js'; 4 - import { type Item } from '$lib/types.js'; 2 + import EditableWebsite from '$lib/website/EditableWebsite.svelte'; 5 3 6 4 let { data } = $props(); 7 - 8 - // svelte-ignore state_referenced_locally 9 - setAdditionalUserData(data.additionalData); 10 5 </script> 11 6 12 - <EditableWebsite 13 - handle={data.handle} 14 - did={data.did} 15 - {data} 16 - items={Object.values(data.data['app.blento.card']).map((i) => i.value) as Item[]} 17 - /> 7 + <EditableWebsite {data} />
+29
src/routes/random/+page.server.ts
··· 1 + import type { UserCache, WebsiteData } from '$lib/types.js'; 2 + import { getCache } from '$lib/website/load.js'; 3 + import { error } from '@sveltejs/kit'; 4 + 5 + export async function load({ platform }) { 6 + const cache = platform?.env?.USER_DATA_CACHE; 7 + 8 + const list = await cache?.list(); 9 + 10 + if (!list) { 11 + throw error(404); 12 + } 13 + 14 + let foundData: WebsiteData | undefined = undefined; 15 + let i = 0; 16 + 17 + while (!foundData && i < 20) { 18 + const rando = Math.floor(Math.random() * list.keys.length); 19 + 20 + foundData = await getCache(list.keys[rando].name, 'self', cache as unknown as UserCache); 21 + 22 + if (!foundData?.cards.length) foundData = undefined; 23 + i++; 24 + } 25 + 26 + if (!foundData) throw error(404); 27 + 28 + return foundData; 29 + }
+40
src/routes/random/+page.svelte
··· 1 + <script lang="ts"> 2 + import Website from '$lib/website/Website.svelte'; 3 + import { Button } from '@foxui/core'; 4 + 5 + let { data } = $props(); 6 + </script> 7 + 8 + <svelte:body 9 + onkeydown={(e) => { 10 + if (e.key === 'ArrowRight' || e.key === 'r') { 11 + window.location.reload(); 12 + } 13 + }} 14 + /> 15 + 16 + <Website {data} /> 17 + 18 + <Button 19 + onclick={() => { 20 + window.location.reload(); 21 + }} 22 + size="lg" 23 + class="bg-accent-100 hover:bg-accent-200 dark:bg-accent-950/50 dark:hover:bg-accent-900/50 fixed right-4 bottom-4" 24 + ><svg 25 + xmlns="http://www.w3.org/2000/svg" 26 + width="24" 27 + height="24" 28 + viewBox="0 0 24 24" 29 + fill="none" 30 + stroke="currentColor" 31 + stroke-width="2" 32 + stroke-linecap="round" 33 + stroke-linejoin="round" 34 + class="lucide lucide-dices-icon lucide-dices" 35 + ><rect width="12" height="12" x="2" y="10" rx="2" ry="2" /><path 36 + d="m17.92 14 3.5-3.5a2.24 2.24 0 0 0 0-3l-5-4.92a2.24 2.24 0 0 0-3 0L10 6" 37 + /><path d="M6 18h.01" /><path d="M10 14h.01" /><path d="M15 6h.01" /><path d="M18 9h.01" /></svg 38 + >Next 39 + <span class="sr-only">Next random profile</span></Button 40 + >
+7 -56
todo.md
··· 1 1 # todo 2 2 3 - - [x] video card or image from bluesky 4 3 - general video card 5 - - edit already created cards (e.g. change link) 6 - - link card: save favicon and og image to pds 7 - - [x] more cards list 8 - - paste handler for card creation (+ when entering link) 9 - - [x] text cards: align text top middle bottom and left center right 10 - - change general settings: 11 - - show profile 12 - - profile on side or top 13 - - base, accent color 14 - - title 15 - - favicon 16 - - [x] set custom card size 17 - - spacer card 18 4 - option to hide cards on mobile 19 - 20 - - [x] og images 21 5 - separate og image for main page 22 - 23 - - more cards: 24 - - instagram 25 - - github 26 - - bluesky account 27 - - bluesky feed 28 - - bluesky post (fixed or latest) 29 - - social accounts card (multiple) 30 - - cartoons: aka https://www.opendoodles.com/ 31 - - excalidraw 32 - - [x] map 33 - - [x] youtube video 34 - - youtube channel 35 - - guestbook 36 - 37 - - other atproto apps 38 - - leaflet 39 - - skywatched 40 - - teal.fm 41 - - tangled.sh 42 - - popfeed.social 43 - - smoke signal 44 - - statusphere.xyz 45 - - goals.garden 46 - 47 - - [x] add some caching to user data 48 - 49 - - other fun card ideas: 50 - - svader 51 - - zdog 52 - - tixy 53 - 54 6 - image cards: different images for dark and light mode 55 - - allow changing avatar and description to be different than bluesky 56 7 - allow adding background image 57 - - [x] borderless cards 58 8 59 - - selfhosting options: 60 - - [x] cloudflare workers 61 - - other serverless option 62 - - github pages 9 + - analytics (get page views) 10 + - custom subdomain 11 + 12 + ## selfhosting 63 13 64 - - analytics (get page views) 65 - - custom subdomain 14 + - [x] cloudflare workers 15 + - other serverless option? or adapter-auto 16 + - github pages (adapter-static)