├── .dockerignore ├── .env ├── .eslintignore ├── .eslintrc.cjs ├── .github └── FUNDING.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── .yarnrc.yml ├── Dockerfile ├── README.md ├── data └── chinook.db ├── mdsvex.config.js ├── package.json ├── server-assets └── fonts │ ├── Inter-Bold.ttf │ └── Inter-Regular.ttf ├── src ├── app.d.ts ├── app.html ├── hooks.server.ts ├── lib │ ├── assets │ │ └── img │ │ │ └── erik-mclean-QzpgqElvSiA-unsplash.jpg │ ├── components │ │ ├── img │ │ │ ├── picture.svelte │ │ │ └── types.ts │ │ ├── layouts │ │ │ ├── components-layout.svelte │ │ │ ├── components │ │ │ │ ├── breadcrumb.svelte │ │ │ │ ├── quizz.svelte │ │ │ │ └── types.ts │ │ │ ├── default-layout.svelte │ │ │ ├── fancy-layout.svelte │ │ │ └── github-markdown-light.css │ │ └── tanstackTable │ │ │ ├── FacetCheckboxes.svelte │ │ │ └── FacetMinMax.svelte │ ├── excelExport │ │ └── index.ts │ └── server │ │ ├── db │ │ ├── index.ts │ │ ├── subscriptionDb.ts │ │ └── types.ts │ │ ├── pdf │ │ └── generateAlbumPdf.ts │ │ └── sesstionStore │ │ └── index.ts ├── routes │ ├── +layout.server.ts │ ├── +layout.svelte │ ├── +page.server.ts │ ├── +page.svelte │ ├── about │ │ ├── +page.svx │ │ └── advanced │ │ │ ├── +page.svx │ │ │ └── level3 │ │ │ └── +page.svx │ ├── admin │ │ ├── charts_css │ │ │ ├── +page.server.ts │ │ │ └── +page.svelte │ │ ├── invoices │ │ │ ├── +page.server.ts │ │ │ ├── +page.svelte │ │ │ ├── editRowBtn.svelte │ │ │ ├── editRowInputs.svelte │ │ │ ├── invEmailBtn.svelte │ │ │ ├── stores.ts │ │ │ └── types.ts │ │ └── playlistTracks │ │ │ └── +page.svelte │ ├── album │ │ └── [albumId] │ │ │ ├── +error.svelte │ │ │ ├── +page.server.ts │ │ │ ├── +page.svelte │ │ │ └── edit-tracks │ │ │ ├── +page.server.ts │ │ │ └── +page.svelte │ ├── api │ │ ├── addSubscription │ │ │ └── +server.ts │ │ ├── album │ │ │ └── [albumId] │ │ │ │ ├── handleTrackGrid │ │ │ │ └── +server.ts │ │ │ │ ├── image │ │ │ │ └── [imageName] │ │ │ │ │ └── +server.ts │ │ │ │ └── pdf │ │ │ │ └── [pdfSheet] │ │ │ │ └── +server.ts │ │ ├── invoices │ │ │ ├── [id] │ │ │ │ └── email │ │ │ │ │ └── +server.ts │ │ │ └── saveChanges │ │ │ │ └── +server.ts │ │ ├── playlistTracks │ │ │ └── +server.ts │ │ ├── searchTracks │ │ │ └── +server.ts │ │ └── vapidPubKey │ │ │ └── +server.ts │ ├── login │ │ ├── +page.server.ts │ │ └── +page.svelte │ ├── logout │ │ └── +page.server.ts │ └── user │ │ └── settings │ │ ├── +page.server.ts │ │ └── +page.svelte └── service-worker.ts ├── static ├── favicon.png ├── icons │ ├── icon-192.png │ └── icon-512.png └── manifest.json ├── svelte.config.js ├── tsconfig.json ├── vite.config.ts └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .svelte-kit 3 | build 4 | .yarn 5 | .yarnrc.yml 6 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | DB_PATH=./data/chinook.db 2 | SERVER_ASSETS=./server-assets 3 | SUBSCRIPTION_DB_PATH=./data/subscriptions.db 4 | # They are not real keys; gen with "yarn web-push generate-vapid-keys" 5 | VAPID_PUBLIC_KEY=AAAaAAaaAaaaaxxH7ya9EGbq3NHaVrE8eFL07BVIPckc5dQMujxB3Pbp8uKfXEIecggUbdJaLvF-DDUhfzxCoC4 6 | VAPID_PRIVATE_KEY=_AAAaaaaaa0FJG27ntCf5q2KVtifHqF-mfW6TgtXOOg 7 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'], 5 | plugins: ['svelte3', '@typescript-eslint'], 6 | ignorePatterns: ['*.cjs'], 7 | overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }], 8 | settings: { 9 | 'svelte3/typescript': () => require('typescript') 10 | }, 11 | parserOptions: { 12 | sourceType: 'module', 13 | ecmaVersion: 2020 14 | }, 15 | env: { 16 | browser: true, 17 | es2017: true, 18 | node: true 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: ['https://paypal.me/phartenfeller', 'https://www.buymeacoffee.com/hartenfeller'] 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | vite.config.js.timestamp-* 7 | vite.config.ts.timestamp-* 8 | .yarn 9 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "printWidth": 100, 6 | "plugins": ["prettier-plugin-svelte"], 7 | "pluginSearchDirs": ["."], 8 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] 9 | } 10 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | yarnPath: .yarn/releases/yarn-3.3.1.cjs 2 | nodeLinker: node-modules 3 | supportedArchitectures: 4 | os: 5 | - 'current' 6 | - 'darwin' 7 | cpu: 8 | - 'current' 9 | - 'arm64' 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine AS builder 2 | 3 | RUN mkdir /app && mkdir /app/data 4 | 5 | COPY . /app 6 | 7 | RUN cd /app && yarn install && \ 8 | yarn build 9 | 10 | FROM node:20-alpine 11 | 12 | RUN mkdir /app 13 | 14 | COPY --from=builder /app/build /app/build 15 | COPY --from=builder /app/package.json /app/yarn.lock /app/ 16 | COPY --from=builder /app/server-assets/ /app/server-assets/ 17 | 18 | RUN cd /app && \ 19 | yarn install --production && \ 20 | yarn cache clean 21 | 22 | WORKDIR /app 23 | 24 | CMD ["node", "build/index.js"] 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Svelte Kit 1.0 + SQLite Demo App 2 | 3 | Code from following YouTube tutorials: 4 | - [SvelteKit 1.0 with SQLite Tutorial](https://youtu.be/iO4VUbQ6ua4) 5 | - [Authentication and Authorization in SvelteKit 1 with SQLite](https://youtu.be/XRa-b5E7x8w) 6 | - [Build and run SvelteKit Apps with Docker](https://youtu.be/LwzoWuHjOWk) 7 | - [AG-Grid in SvelteKit for Spreadsheet like data editing](https://youtu.be/VfFKEiMAloc) 8 | - [Installable SvelteKit App with Web App Manifest](https://youtu.be/ywXXOvfKoYg) 9 | - [Lazy Loading for slowly loading pages in SvelteKit](https://youtu.be/7Kl4sKez1bs) 10 | - [Upload, Store and Retrieve Images in SvelteKit (with SQLite)](https://youtu.be/OLg6RwESnSo) 11 | - [Generate a PDF with pdfmake (from SvelteKit)](https://youtu.be/gS1wlOdRLAk) 12 | - [Interactive Tables in SvelteKit with TanStack Table](https://youtu.be/-Zuo3UWjjI8) 13 | - [Export Table to XLSX and CSV with exceljs](https://youtu.be/xzdgUm2Ccbk) 14 | - [MailCrab: Mock Mailserver for development](https://youtu.be/w-aitQBsINc) 15 | - [Server-side filtered, paginated and sorted Table in SvelteKit (Part 1/2)](https://youtu.be/pjV3rCBBT_Q) 16 | - [Server-side filtered, paginated and sorted Table in SvelteKit (Part 2/2)](https://youtu.be/VgCU0cVWgJE) 17 | - [SvelteKit TanStack Table edit row (+ custom table components)](https://youtu.be/D_xoOSYzJ7U) 18 | - [Markdown in SvelteKit with custom Components: mdsvex](https://youtu.be/VJFkyGd0FEA) 19 | - [Accessible charts only with CSS? - Charts.css](https://youtu.be/W7P0nE709jg) 20 | - [Image optimization in SvelteKit with vite-imagetools](https://youtu.be/285vSLe9LQ8) 21 | - [Smooth Page / View Transitions in SvelteKit](https://youtu.be/whNdqjrVFsg) 22 | 23 | 24 | Full Playlist: [SvelteKit](https://www.youtube.com/playlist?list=PLIyDDWd5rhaYwAiXQyonufcZgc_xOMtId) 25 | 26 | ## Run locally 27 | 28 | - Install with yarn or npm `yarn install` or `npm install` 29 | - You might need to delete `supportedArchitectures` in the `.yarnrc.yml` file if you are not on an M1 Mac 30 | - Start with `yarn dev` or `npm run dev` 31 | - Username and Password for login is `philipp` 32 | 33 | ## Docker 34 | 35 | ### Build and Run App 36 | 37 | Build Container: 38 | 39 | ```sh 40 | docker build -t sveltekit-sqlite-img . 41 | ``` 42 | 43 | Run Container: 44 | 45 | ```sh 46 | docker run -d -p 3000:3000 \ 47 | --mount type=bind,source="$(pwd)"/data/,target=/app/data/ \ 48 | --rm --name sveltekit-sqlite \ 49 | sveltekit-sqlite-img 50 | ``` 51 | 52 | ### MailCrab 53 | 54 | ```sh 55 | docker run --rm -p 1080:1080 -p 1025:1025 marlonb/mailcrab:latest 56 | ``` 57 | 58 | More info: https://github.com/tweedegolf/mailcrab 59 | -------------------------------------------------------------------------------- /data/chinook.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phartenfeller/sveltekit-1.0-sqlite-demo-app/da869d6e0326e0388924aeef3bdd6c6d9a7269dc/data/chinook.db -------------------------------------------------------------------------------- /mdsvex.config.js: -------------------------------------------------------------------------------- 1 | import { defineMDSveXConfig as defineConfig } from 'mdsvex'; 2 | import path from 'node:path'; 3 | import { fileURLToPath } from 'node:url'; 4 | 5 | const dirname = path.resolve(fileURLToPath(import.meta.url), '../'); 6 | 7 | const config = defineConfig({ 8 | extensions: ['.md', '.svx'], 9 | layout: { 10 | default: path.join(dirname, './src/lib/components/layouts/default-layout.svelte'), 11 | fancy: path.join(dirname, './src/lib/components/layouts/fancy-layout.svelte'), 12 | components: path.join(dirname, './src/lib/components/layouts/components-layout.svelte') 13 | } 14 | }); 15 | 16 | export default config; 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sveltekit-sqlite-music-app", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite dev", 7 | "build": "vite build", 8 | "preview": "vite preview", 9 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 10 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 11 | "lint": "prettier --plugin-search-dir . --check . && eslint .", 12 | "format": "prettier --plugin-search-dir . --write ." 13 | }, 14 | "devDependencies": { 15 | "@sveltejs/adapter-auto": "^2.1.1", 16 | "@sveltejs/adapter-node": "^4.0.1", 17 | "@sveltejs/kit": "^1.30.4", 18 | "@tanstack/match-sorter-utils": "^8.11.8", 19 | "@tanstack/svelte-table": "^8.9.6", 20 | "@types/bcrypt": "^5.0.2", 21 | "@types/better-sqlite3": "^7.6.9", 22 | "@types/blob-stream": "^0.1.33", 23 | "@types/file-saver": "^2.0.7", 24 | "@types/nodemailer": "^6.4.14", 25 | "@types/pdfmake": "^0.2.9", 26 | "@types/web-push": "^3.6.3", 27 | "@typescript-eslint/eslint-plugin": "^6.21.0", 28 | "@typescript-eslint/parser": "^7.0.2", 29 | "ag-grid-community": "^31.1.1", 30 | "eslint": "^8.57.0", 31 | "eslint-config-prettier": "^9.1.0", 32 | "eslint-plugin-svelte3": "^4.0.0", 33 | "exceljs": "^4.3.0", 34 | "file-saver": "^2.0.5", 35 | "prettier": "^3.2.5", 36 | "prettier-plugin-svelte": "^3.2.1", 37 | "svelte": "^4.2.12", 38 | "svelte-check": "^3.6.4", 39 | "tslib": "^2.6.2", 40 | "typescript": "^5.3.3", 41 | "vite": "^4.5.2", 42 | "vite-imagetools": "^5.1.2" 43 | }, 44 | "type": "module", 45 | "packageManager": "yarn@3.3.1", 46 | "dependencies": { 47 | "bcrypt": "^5.1.1", 48 | "better-sqlite3": "^9.4.3", 49 | "blob-stream": "^0.1.3", 50 | "charts.css": "^0.9.0", 51 | "mdsvex": "^0.11.0", 52 | "nodemailer": "^6.9.4", 53 | "pdfmake": "^0.2.9", 54 | "web-push": "^3.6.7" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /server-assets/fonts/Inter-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phartenfeller/sveltekit-1.0-sqlite-demo-app/da869d6e0326e0388924aeef3bdd6c6d9a7269dc/server-assets/fonts/Inter-Bold.ttf -------------------------------------------------------------------------------- /server-assets/fonts/Inter-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phartenfeller/sveltekit-1.0-sqlite-demo-app/da869d6e0326e0388924aeef3bdd6c6d9a7269dc/server-assets/fonts/Inter-Regular.ttf -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://kit.svelte.dev/docs/types#app 2 | // for information about these interfaces 3 | // and what to do when importing types 4 | declare namespace App { 5 | // interface Error {} 6 | interface Locals { 7 | username?: string; 8 | roles?: string[]; 9 | } 10 | // interface PageData {} 11 | // interface Platform {} 12 | } 13 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | %sveltekit.head% 11 | 12 | 13 | 14 |
%sveltekit.body%
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/hooks.server.ts: -------------------------------------------------------------------------------- 1 | import { getSession } from '$lib/server/sesstionStore'; 2 | import type { Handle } from '@sveltejs/kit'; 3 | 4 | export const handle = (async ({ event, resolve }) => { 5 | const { cookies } = event; 6 | const sid = cookies.get('sid'); 7 | if (sid) { 8 | const session = getSession(sid); 9 | if (session) { 10 | event.locals.username = session.username; 11 | event.locals.roles = session.roles; 12 | } else { 13 | // session not found in our store -> remove cookie 14 | cookies.delete('sid'); 15 | } 16 | } 17 | 18 | const response = await resolve(event); 19 | return response; 20 | }) satisfies Handle; 21 | -------------------------------------------------------------------------------- /src/lib/assets/img/erik-mclean-QzpgqElvSiA-unsplash.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phartenfeller/sveltekit-1.0-sqlite-demo-app/da869d6e0326e0388924aeef3bdd6c6d9a7269dc/src/lib/assets/img/erik-mclean-QzpgqElvSiA-unsplash.jpg -------------------------------------------------------------------------------- /src/lib/components/img/picture.svelte: -------------------------------------------------------------------------------- 1 | 18 | 19 |
20 | 21 | {#each Object.entries(sources) as [type, srcMeta]} 22 | 23 | {/each} 24 | 25 | 26 |
27 | -------------------------------------------------------------------------------- /src/lib/components/img/types.ts: -------------------------------------------------------------------------------- 1 | type ImgProps = { 2 | src: string; 3 | w: number; 4 | h: number; 5 | }; 6 | 7 | export type ImgMeta = { img: ImgProps; sources: { [key: string]: string } }; 8 | -------------------------------------------------------------------------------- /src/lib/components/layouts/components-layout.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | {title} 13 | 14 | 15 | 16 |
17 |

{title}

18 | 19 |
20 | -------------------------------------------------------------------------------- /src/lib/components/layouts/components/breadcrumb.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 19 | -------------------------------------------------------------------------------- /src/lib/components/layouts/components/quizz.svelte: -------------------------------------------------------------------------------- 1 | 26 | 27 |
28 |

Question:

29 |

{question}

30 |
31 | {#each shuffled as option, i (i)} 32 | 41 | {/each} 42 |
43 |
44 | 45 | 50 | -------------------------------------------------------------------------------- /src/lib/components/layouts/components/types.ts: -------------------------------------------------------------------------------- 1 | export type BreadcrumbItem = { 2 | label: string; 3 | href: string; 4 | }; 5 | -------------------------------------------------------------------------------- /src/lib/components/layouts/default-layout.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | {title} 7 | 8 | 9 |
10 |

{title}

11 | 12 |
13 | -------------------------------------------------------------------------------- /src/lib/components/layouts/fancy-layout.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | {title} 8 | 9 | 10 |
11 |

{title}

12 | 13 |
14 | -------------------------------------------------------------------------------- /src/lib/components/layouts/github-markdown-light.css: -------------------------------------------------------------------------------- 1 | .markdown-body { 2 | -ms-text-size-adjust: 100%; 3 | -webkit-text-size-adjust: 100%; 4 | margin: 0; 5 | color: #24292f; 6 | background-color: #ffffff; 7 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, 8 | sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji'; 9 | font-size: 16px; 10 | line-height: 1.5; 11 | word-wrap: break-word; 12 | } 13 | 14 | .markdown-body .octicon { 15 | display: inline-block; 16 | fill: currentColor; 17 | vertical-align: text-bottom; 18 | } 19 | 20 | .markdown-body h1:hover .anchor .octicon-link:before, 21 | .markdown-body h2:hover .anchor .octicon-link:before, 22 | .markdown-body h3:hover .anchor .octicon-link:before, 23 | .markdown-body h4:hover .anchor .octicon-link:before, 24 | .markdown-body h5:hover .anchor .octicon-link:before, 25 | .markdown-body h6:hover .anchor .octicon-link:before { 26 | width: 16px; 27 | height: 16px; 28 | content: ' '; 29 | display: inline-block; 30 | background-color: currentColor; 31 | -webkit-mask-image: url("data:image/svg+xml,"); 32 | mask-image: url("data:image/svg+xml,"); 33 | } 34 | 35 | .markdown-body details, 36 | .markdown-body figcaption, 37 | .markdown-body figure { 38 | display: block; 39 | } 40 | 41 | .markdown-body summary { 42 | display: list-item; 43 | } 44 | 45 | .markdown-body [hidden] { 46 | display: none !important; 47 | } 48 | 49 | .markdown-body a { 50 | background-color: transparent; 51 | color: #0969da; 52 | text-decoration: none; 53 | } 54 | 55 | .markdown-body abbr[title] { 56 | border-bottom: none; 57 | text-decoration: underline dotted; 58 | } 59 | 60 | .markdown-body b, 61 | .markdown-body strong { 62 | font-weight: 600; 63 | } 64 | 65 | .markdown-body dfn { 66 | font-style: italic; 67 | } 68 | 69 | .markdown-body h1 { 70 | margin: 0.67em 0; 71 | font-weight: 600; 72 | padding-bottom: 0.3em; 73 | font-size: 2em; 74 | border-bottom: 1px solid hsla(210, 18%, 87%, 1); 75 | } 76 | 77 | .markdown-body mark { 78 | background-color: #fff8c5; 79 | color: #24292f; 80 | } 81 | 82 | .markdown-body small { 83 | font-size: 90%; 84 | } 85 | 86 | .markdown-body sub, 87 | .markdown-body sup { 88 | font-size: 75%; 89 | line-height: 0; 90 | position: relative; 91 | vertical-align: baseline; 92 | } 93 | 94 | .markdown-body sub { 95 | bottom: -0.25em; 96 | } 97 | 98 | .markdown-body sup { 99 | top: -0.5em; 100 | } 101 | 102 | .markdown-body img { 103 | border-style: none; 104 | max-width: 100%; 105 | box-sizing: content-box; 106 | background-color: #ffffff; 107 | } 108 | 109 | .markdown-body code, 110 | .markdown-body kbd, 111 | .markdown-body pre, 112 | .markdown-body samp { 113 | font-family: monospace; 114 | font-size: 1em; 115 | } 116 | 117 | .markdown-body figure { 118 | margin: 1em 40px; 119 | } 120 | 121 | .markdown-body hr { 122 | box-sizing: content-box; 123 | overflow: hidden; 124 | background: transparent; 125 | border-bottom: 1px solid hsla(210, 18%, 87%, 1); 126 | height: 0.25em; 127 | padding: 0; 128 | margin: 24px 0; 129 | background-color: #d0d7de; 130 | border: 0; 131 | } 132 | 133 | .markdown-body input { 134 | font: inherit; 135 | margin: 0; 136 | overflow: visible; 137 | font-family: inherit; 138 | font-size: inherit; 139 | line-height: inherit; 140 | } 141 | 142 | .markdown-body [type='button'], 143 | .markdown-body [type='reset'], 144 | .markdown-body [type='submit'] { 145 | -webkit-appearance: button; 146 | } 147 | 148 | .markdown-body [type='checkbox'], 149 | .markdown-body [type='radio'] { 150 | box-sizing: border-box; 151 | padding: 0; 152 | } 153 | 154 | .markdown-body [type='number']::-webkit-inner-spin-button, 155 | .markdown-body [type='number']::-webkit-outer-spin-button { 156 | height: auto; 157 | } 158 | 159 | .markdown-body [type='search']::-webkit-search-cancel-button, 160 | .markdown-body [type='search']::-webkit-search-decoration { 161 | -webkit-appearance: none; 162 | } 163 | 164 | .markdown-body ::-webkit-input-placeholder { 165 | color: inherit; 166 | opacity: 0.54; 167 | } 168 | 169 | .markdown-body ::-webkit-file-upload-button { 170 | -webkit-appearance: button; 171 | font: inherit; 172 | } 173 | 174 | .markdown-body a:hover { 175 | text-decoration: underline; 176 | } 177 | 178 | .markdown-body ::placeholder { 179 | color: #6e7781; 180 | opacity: 1; 181 | } 182 | 183 | .markdown-body hr::before { 184 | display: table; 185 | content: ''; 186 | } 187 | 188 | .markdown-body hr::after { 189 | display: table; 190 | clear: both; 191 | content: ''; 192 | } 193 | 194 | .markdown-body table { 195 | border-spacing: 0; 196 | border-collapse: collapse; 197 | display: block; 198 | width: max-content; 199 | max-width: 100%; 200 | overflow: auto; 201 | } 202 | 203 | .markdown-body td, 204 | .markdown-body th { 205 | padding: 0; 206 | } 207 | 208 | .markdown-body details summary { 209 | cursor: pointer; 210 | } 211 | 212 | .markdown-body details:not([open]) > *:not(summary) { 213 | display: none !important; 214 | } 215 | 216 | .markdown-body a:focus, 217 | .markdown-body [role='button']:focus, 218 | .markdown-body input[type='radio']:focus, 219 | .markdown-body input[type='checkbox']:focus { 220 | outline: 2px solid #0969da; 221 | outline-offset: -2px; 222 | box-shadow: none; 223 | } 224 | 225 | .markdown-body a:focus:not(:focus-visible), 226 | .markdown-body [role='button']:focus:not(:focus-visible), 227 | .markdown-body input[type='radio']:focus:not(:focus-visible), 228 | .markdown-body input[type='checkbox']:focus:not(:focus-visible) { 229 | outline: solid 1px transparent; 230 | } 231 | 232 | .markdown-body a:focus-visible, 233 | .markdown-body [role='button']:focus-visible, 234 | .markdown-body input[type='radio']:focus-visible, 235 | .markdown-body input[type='checkbox']:focus-visible { 236 | outline: 2px solid #0969da; 237 | outline-offset: -2px; 238 | box-shadow: none; 239 | } 240 | 241 | .markdown-body a:not([class]):focus, 242 | .markdown-body a:not([class]):focus-visible, 243 | .markdown-body input[type='radio']:focus, 244 | .markdown-body input[type='radio']:focus-visible, 245 | .markdown-body input[type='checkbox']:focus, 246 | .markdown-body input[type='checkbox']:focus-visible { 247 | outline-offset: 0; 248 | } 249 | 250 | .markdown-body kbd { 251 | display: inline-block; 252 | padding: 3px 5px; 253 | font: 11px ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace; 254 | line-height: 10px; 255 | color: #24292f; 256 | vertical-align: middle; 257 | background-color: #f6f8fa; 258 | border: solid 1px rgba(175, 184, 193, 0.2); 259 | border-bottom-color: rgba(175, 184, 193, 0.2); 260 | border-radius: 6px; 261 | box-shadow: inset 0 -1px 0 rgba(175, 184, 193, 0.2); 262 | } 263 | 264 | .markdown-body h1, 265 | .markdown-body h2, 266 | .markdown-body h3, 267 | .markdown-body h4, 268 | .markdown-body h5, 269 | .markdown-body h6 { 270 | margin-top: 24px; 271 | margin-bottom: 16px; 272 | font-weight: 600; 273 | line-height: 1.25; 274 | } 275 | 276 | .markdown-body h2 { 277 | font-weight: 600; 278 | padding-bottom: 0.3em; 279 | font-size: 1.5em; 280 | border-bottom: 1px solid hsla(210, 18%, 87%, 1); 281 | } 282 | 283 | .markdown-body h3 { 284 | font-weight: 600; 285 | font-size: 1.25em; 286 | } 287 | 288 | .markdown-body h4 { 289 | font-weight: 600; 290 | font-size: 1em; 291 | } 292 | 293 | .markdown-body h5 { 294 | font-weight: 600; 295 | font-size: 0.875em; 296 | } 297 | 298 | .markdown-body h6 { 299 | font-weight: 600; 300 | font-size: 0.85em; 301 | color: #57606a; 302 | } 303 | 304 | .markdown-body p { 305 | margin-top: 0; 306 | margin-bottom: 10px; 307 | } 308 | 309 | .markdown-body blockquote { 310 | margin: 0; 311 | padding: 0 1em; 312 | color: #57606a; 313 | border-left: 0.25em solid #d0d7de; 314 | } 315 | 316 | .markdown-body ul, 317 | .markdown-body ol { 318 | margin-top: 0; 319 | margin-bottom: 0; 320 | padding-left: 2em; 321 | } 322 | 323 | .markdown-body ol ol, 324 | .markdown-body ul ol { 325 | list-style-type: lower-roman; 326 | } 327 | 328 | .markdown-body ul ul ol, 329 | .markdown-body ul ol ol, 330 | .markdown-body ol ul ol, 331 | .markdown-body ol ol ol { 332 | list-style-type: lower-alpha; 333 | } 334 | 335 | .markdown-body dd { 336 | margin-left: 0; 337 | } 338 | 339 | .markdown-body tt, 340 | .markdown-body code, 341 | .markdown-body samp { 342 | font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace; 343 | font-size: 12px; 344 | } 345 | 346 | .markdown-body pre { 347 | margin-top: 0; 348 | margin-bottom: 0; 349 | font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace; 350 | font-size: 12px; 351 | word-wrap: normal; 352 | } 353 | 354 | .markdown-body .octicon { 355 | display: inline-block; 356 | overflow: visible !important; 357 | vertical-align: text-bottom; 358 | fill: currentColor; 359 | } 360 | 361 | .markdown-body input::-webkit-outer-spin-button, 362 | .markdown-body input::-webkit-inner-spin-button { 363 | margin: 0; 364 | -webkit-appearance: none; 365 | appearance: none; 366 | } 367 | 368 | .markdown-body::before { 369 | display: table; 370 | content: ''; 371 | } 372 | 373 | .markdown-body::after { 374 | display: table; 375 | clear: both; 376 | content: ''; 377 | } 378 | 379 | .markdown-body > *:first-child { 380 | margin-top: 0 !important; 381 | } 382 | 383 | .markdown-body > *:last-child { 384 | margin-bottom: 0 !important; 385 | } 386 | 387 | .markdown-body a:not([href]) { 388 | color: inherit; 389 | text-decoration: none; 390 | } 391 | 392 | .markdown-body .absent { 393 | color: #cf222e; 394 | } 395 | 396 | .markdown-body .anchor { 397 | float: left; 398 | padding-right: 4px; 399 | margin-left: -20px; 400 | line-height: 1; 401 | } 402 | 403 | .markdown-body .anchor:focus { 404 | outline: none; 405 | } 406 | 407 | .markdown-body p, 408 | .markdown-body blockquote, 409 | .markdown-body ul, 410 | .markdown-body ol, 411 | .markdown-body dl, 412 | .markdown-body table, 413 | .markdown-body pre, 414 | .markdown-body details { 415 | margin-top: 0; 416 | margin-bottom: 16px; 417 | } 418 | 419 | .markdown-body blockquote > :first-child { 420 | margin-top: 0; 421 | } 422 | 423 | .markdown-body blockquote > :last-child { 424 | margin-bottom: 0; 425 | } 426 | 427 | .markdown-body h1 .octicon-link, 428 | .markdown-body h2 .octicon-link, 429 | .markdown-body h3 .octicon-link, 430 | .markdown-body h4 .octicon-link, 431 | .markdown-body h5 .octicon-link, 432 | .markdown-body h6 .octicon-link { 433 | color: #24292f; 434 | vertical-align: middle; 435 | visibility: hidden; 436 | } 437 | 438 | .markdown-body h1:hover .anchor, 439 | .markdown-body h2:hover .anchor, 440 | .markdown-body h3:hover .anchor, 441 | .markdown-body h4:hover .anchor, 442 | .markdown-body h5:hover .anchor, 443 | .markdown-body h6:hover .anchor { 444 | text-decoration: none; 445 | } 446 | 447 | .markdown-body h1:hover .anchor .octicon-link, 448 | .markdown-body h2:hover .anchor .octicon-link, 449 | .markdown-body h3:hover .anchor .octicon-link, 450 | .markdown-body h4:hover .anchor .octicon-link, 451 | .markdown-body h5:hover .anchor .octicon-link, 452 | .markdown-body h6:hover .anchor .octicon-link { 453 | visibility: visible; 454 | } 455 | 456 | .markdown-body h1 tt, 457 | .markdown-body h1 code, 458 | .markdown-body h2 tt, 459 | .markdown-body h2 code, 460 | .markdown-body h3 tt, 461 | .markdown-body h3 code, 462 | .markdown-body h4 tt, 463 | .markdown-body h4 code, 464 | .markdown-body h5 tt, 465 | .markdown-body h5 code, 466 | .markdown-body h6 tt, 467 | .markdown-body h6 code { 468 | padding: 0 0.2em; 469 | font-size: inherit; 470 | } 471 | 472 | .markdown-body summary h1, 473 | .markdown-body summary h2, 474 | .markdown-body summary h3, 475 | .markdown-body summary h4, 476 | .markdown-body summary h5, 477 | .markdown-body summary h6 { 478 | display: inline-block; 479 | } 480 | 481 | .markdown-body summary h1 .anchor, 482 | .markdown-body summary h2 .anchor, 483 | .markdown-body summary h3 .anchor, 484 | .markdown-body summary h4 .anchor, 485 | .markdown-body summary h5 .anchor, 486 | .markdown-body summary h6 .anchor { 487 | margin-left: -40px; 488 | } 489 | 490 | .markdown-body summary h1, 491 | .markdown-body summary h2 { 492 | padding-bottom: 0; 493 | border-bottom: 0; 494 | } 495 | 496 | .markdown-body ul.no-list, 497 | .markdown-body ol.no-list { 498 | padding: 0; 499 | list-style-type: none; 500 | } 501 | 502 | .markdown-body ol[type='a'] { 503 | list-style-type: lower-alpha; 504 | } 505 | 506 | .markdown-body ol[type='A'] { 507 | list-style-type: upper-alpha; 508 | } 509 | 510 | .markdown-body ol[type='i'] { 511 | list-style-type: lower-roman; 512 | } 513 | 514 | .markdown-body ol[type='I'] { 515 | list-style-type: upper-roman; 516 | } 517 | 518 | .markdown-body ol[type='1'] { 519 | list-style-type: decimal; 520 | } 521 | 522 | .markdown-body div > ol:not([type]) { 523 | list-style-type: decimal; 524 | } 525 | 526 | .markdown-body ul ul, 527 | .markdown-body ul ol, 528 | .markdown-body ol ol, 529 | .markdown-body ol ul { 530 | margin-top: 0; 531 | margin-bottom: 0; 532 | } 533 | 534 | .markdown-body li > p { 535 | margin-top: 16px; 536 | } 537 | 538 | .markdown-body li + li { 539 | margin-top: 0.25em; 540 | } 541 | 542 | .markdown-body dl { 543 | padding: 0; 544 | } 545 | 546 | .markdown-body dl dt { 547 | padding: 0; 548 | margin-top: 16px; 549 | font-size: 1em; 550 | font-style: italic; 551 | font-weight: 600; 552 | } 553 | 554 | .markdown-body dl dd { 555 | padding: 0 16px; 556 | margin-bottom: 16px; 557 | } 558 | 559 | .markdown-body table th { 560 | font-weight: 600; 561 | } 562 | 563 | .markdown-body table th, 564 | .markdown-body table td { 565 | padding: 6px 13px; 566 | border: 1px solid #d0d7de; 567 | } 568 | 569 | .markdown-body table tr { 570 | background-color: #ffffff; 571 | border-top: 1px solid hsla(210, 18%, 87%, 1); 572 | } 573 | 574 | .markdown-body table tr:nth-child(2n) { 575 | background-color: #f6f8fa; 576 | } 577 | 578 | .markdown-body table img { 579 | background-color: transparent; 580 | } 581 | 582 | .markdown-body img[align='right'] { 583 | padding-left: 20px; 584 | } 585 | 586 | .markdown-body img[align='left'] { 587 | padding-right: 20px; 588 | } 589 | 590 | .markdown-body .emoji { 591 | max-width: none; 592 | vertical-align: text-top; 593 | background-color: transparent; 594 | } 595 | 596 | .markdown-body span.frame { 597 | display: block; 598 | overflow: hidden; 599 | } 600 | 601 | .markdown-body span.frame > span { 602 | display: block; 603 | float: left; 604 | width: auto; 605 | padding: 7px; 606 | margin: 13px 0 0; 607 | overflow: hidden; 608 | border: 1px solid #d0d7de; 609 | } 610 | 611 | .markdown-body span.frame span img { 612 | display: block; 613 | float: left; 614 | } 615 | 616 | .markdown-body span.frame span span { 617 | display: block; 618 | padding: 5px 0 0; 619 | clear: both; 620 | color: #24292f; 621 | } 622 | 623 | .markdown-body span.align-center { 624 | display: block; 625 | overflow: hidden; 626 | clear: both; 627 | } 628 | 629 | .markdown-body span.align-center > span { 630 | display: block; 631 | margin: 13px auto 0; 632 | overflow: hidden; 633 | text-align: center; 634 | } 635 | 636 | .markdown-body span.align-center span img { 637 | margin: 0 auto; 638 | text-align: center; 639 | } 640 | 641 | .markdown-body span.align-right { 642 | display: block; 643 | overflow: hidden; 644 | clear: both; 645 | } 646 | 647 | .markdown-body span.align-right > span { 648 | display: block; 649 | margin: 13px 0 0; 650 | overflow: hidden; 651 | text-align: right; 652 | } 653 | 654 | .markdown-body span.align-right span img { 655 | margin: 0; 656 | text-align: right; 657 | } 658 | 659 | .markdown-body span.float-left { 660 | display: block; 661 | float: left; 662 | margin-right: 13px; 663 | overflow: hidden; 664 | } 665 | 666 | .markdown-body span.float-left span { 667 | margin: 13px 0 0; 668 | } 669 | 670 | .markdown-body span.float-right { 671 | display: block; 672 | float: right; 673 | margin-left: 13px; 674 | overflow: hidden; 675 | } 676 | 677 | .markdown-body span.float-right > span { 678 | display: block; 679 | margin: 13px auto 0; 680 | overflow: hidden; 681 | text-align: right; 682 | } 683 | 684 | .markdown-body code, 685 | .markdown-body tt { 686 | padding: 0.2em 0.4em; 687 | margin: 0; 688 | font-size: 85%; 689 | white-space: break-spaces; 690 | background-color: rgba(175, 184, 193, 0.2); 691 | border-radius: 6px; 692 | } 693 | 694 | .markdown-body code br, 695 | .markdown-body tt br { 696 | display: none; 697 | } 698 | 699 | .markdown-body del code { 700 | text-decoration: inherit; 701 | } 702 | 703 | .markdown-body samp { 704 | font-size: 85%; 705 | } 706 | 707 | .markdown-body pre code { 708 | font-size: 100%; 709 | } 710 | 711 | .markdown-body pre > code { 712 | padding: 0; 713 | margin: 0; 714 | word-break: normal; 715 | white-space: pre; 716 | background: transparent; 717 | border: 0; 718 | } 719 | 720 | .markdown-body .highlight { 721 | margin-bottom: 16px; 722 | } 723 | 724 | .markdown-body .highlight pre { 725 | margin-bottom: 0; 726 | word-break: normal; 727 | } 728 | 729 | .markdown-body .highlight pre, 730 | .markdown-body pre { 731 | padding: 16px; 732 | overflow: auto; 733 | font-size: 85%; 734 | line-height: 1.45; 735 | background-color: #f6f8fa; 736 | border-radius: 6px; 737 | } 738 | 739 | .markdown-body pre code, 740 | .markdown-body pre tt { 741 | display: inline; 742 | max-width: auto; 743 | padding: 0; 744 | margin: 0; 745 | overflow: visible; 746 | line-height: inherit; 747 | word-wrap: normal; 748 | background-color: transparent; 749 | border: 0; 750 | } 751 | 752 | .markdown-body .csv-data td, 753 | .markdown-body .csv-data th { 754 | padding: 5px; 755 | overflow: hidden; 756 | font-size: 12px; 757 | line-height: 1; 758 | text-align: left; 759 | white-space: nowrap; 760 | } 761 | 762 | .markdown-body .csv-data .blob-num { 763 | padding: 10px 8px 9px; 764 | text-align: right; 765 | background: #ffffff; 766 | border: 0; 767 | } 768 | 769 | .markdown-body .csv-data tr { 770 | border-top: 0; 771 | } 772 | 773 | .markdown-body .csv-data th { 774 | font-weight: 600; 775 | background: #f6f8fa; 776 | border-top: 0; 777 | } 778 | 779 | .markdown-body [data-footnote-ref]::before { 780 | content: '['; 781 | } 782 | 783 | .markdown-body [data-footnote-ref]::after { 784 | content: ']'; 785 | } 786 | 787 | .markdown-body .footnotes { 788 | font-size: 12px; 789 | color: #57606a; 790 | border-top: 1px solid #d0d7de; 791 | } 792 | 793 | .markdown-body .footnotes ol { 794 | padding-left: 16px; 795 | } 796 | 797 | .markdown-body .footnotes ol ul { 798 | display: inline-block; 799 | padding-left: 16px; 800 | margin-top: 16px; 801 | } 802 | 803 | .markdown-body .footnotes li { 804 | position: relative; 805 | } 806 | 807 | .markdown-body .footnotes li:target::before { 808 | position: absolute; 809 | top: -8px; 810 | right: -8px; 811 | bottom: -8px; 812 | left: -24px; 813 | pointer-events: none; 814 | content: ''; 815 | border: 2px solid #0969da; 816 | border-radius: 6px; 817 | } 818 | 819 | .markdown-body .footnotes li:target { 820 | color: #24292f; 821 | } 822 | 823 | .markdown-body .footnotes .data-footnote-backref g-emoji { 824 | font-family: monospace; 825 | } 826 | 827 | .markdown-body .pl-c { 828 | color: #6e7781; 829 | } 830 | 831 | .markdown-body .pl-c1, 832 | .markdown-body .pl-s .pl-v { 833 | color: #0550ae; 834 | } 835 | 836 | .markdown-body .pl-e, 837 | .markdown-body .pl-en { 838 | color: #8250df; 839 | } 840 | 841 | .markdown-body .pl-smi, 842 | .markdown-body .pl-s .pl-s1 { 843 | color: #24292f; 844 | } 845 | 846 | .markdown-body .pl-ent { 847 | color: #116329; 848 | } 849 | 850 | .markdown-body .pl-k { 851 | color: #cf222e; 852 | } 853 | 854 | .markdown-body .pl-s, 855 | .markdown-body .pl-pds, 856 | .markdown-body .pl-s .pl-pse .pl-s1, 857 | .markdown-body .pl-sr, 858 | .markdown-body .pl-sr .pl-cce, 859 | .markdown-body .pl-sr .pl-sre, 860 | .markdown-body .pl-sr .pl-sra { 861 | color: #0a3069; 862 | } 863 | 864 | .markdown-body .pl-v, 865 | .markdown-body .pl-smw { 866 | color: #953800; 867 | } 868 | 869 | .markdown-body .pl-bu { 870 | color: #82071e; 871 | } 872 | 873 | .markdown-body .pl-ii { 874 | color: #f6f8fa; 875 | background-color: #82071e; 876 | } 877 | 878 | .markdown-body .pl-c2 { 879 | color: #f6f8fa; 880 | background-color: #cf222e; 881 | } 882 | 883 | .markdown-body .pl-sr .pl-cce { 884 | font-weight: bold; 885 | color: #116329; 886 | } 887 | 888 | .markdown-body .pl-ml { 889 | color: #3b2300; 890 | } 891 | 892 | .markdown-body .pl-mh, 893 | .markdown-body .pl-mh .pl-en, 894 | .markdown-body .pl-ms { 895 | font-weight: bold; 896 | color: #0550ae; 897 | } 898 | 899 | .markdown-body .pl-mi { 900 | font-style: italic; 901 | color: #24292f; 902 | } 903 | 904 | .markdown-body .pl-mb { 905 | font-weight: bold; 906 | color: #24292f; 907 | } 908 | 909 | .markdown-body .pl-md { 910 | color: #82071e; 911 | background-color: #ffebe9; 912 | } 913 | 914 | .markdown-body .pl-mi1 { 915 | color: #116329; 916 | background-color: #dafbe1; 917 | } 918 | 919 | .markdown-body .pl-mc { 920 | color: #953800; 921 | background-color: #ffd8b5; 922 | } 923 | 924 | .markdown-body .pl-mi2 { 925 | color: #eaeef2; 926 | background-color: #0550ae; 927 | } 928 | 929 | .markdown-body .pl-mdr { 930 | font-weight: bold; 931 | color: #8250df; 932 | } 933 | 934 | .markdown-body .pl-ba { 935 | color: #57606a; 936 | } 937 | 938 | .markdown-body .pl-sg { 939 | color: #8c959f; 940 | } 941 | 942 | .markdown-body .pl-corl { 943 | text-decoration: underline; 944 | color: #0a3069; 945 | } 946 | 947 | .markdown-body g-emoji { 948 | display: inline-block; 949 | min-width: 1ch; 950 | font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; 951 | font-size: 1em; 952 | font-style: normal !important; 953 | font-weight: 400; 954 | line-height: 1; 955 | vertical-align: -0.075em; 956 | } 957 | 958 | .markdown-body g-emoji img { 959 | width: 1em; 960 | height: 1em; 961 | } 962 | 963 | .markdown-body .task-list-item { 964 | list-style-type: none; 965 | } 966 | 967 | .markdown-body .task-list-item label { 968 | font-weight: 400; 969 | } 970 | 971 | .markdown-body .task-list-item.enabled label { 972 | cursor: pointer; 973 | } 974 | 975 | .markdown-body .task-list-item + .task-list-item { 976 | margin-top: 4px; 977 | } 978 | 979 | .markdown-body .task-list-item .handle { 980 | display: none; 981 | } 982 | 983 | .markdown-body .task-list-item-checkbox { 984 | margin: 0 0.2em 0.25em -1.4em; 985 | vertical-align: middle; 986 | } 987 | 988 | .markdown-body .contains-task-list:dir(rtl) .task-list-item-checkbox { 989 | margin: 0 -1.6em 0.25em 0.2em; 990 | } 991 | 992 | .markdown-body .contains-task-list { 993 | position: relative; 994 | } 995 | 996 | .markdown-body .contains-task-list:hover .task-list-item-convert-container, 997 | .markdown-body .contains-task-list:focus-within .task-list-item-convert-container { 998 | display: block; 999 | width: auto; 1000 | height: 24px; 1001 | overflow: visible; 1002 | clip: auto; 1003 | } 1004 | 1005 | .markdown-body ::-webkit-calendar-picker-indicator { 1006 | filter: invert(50%); 1007 | } 1008 | -------------------------------------------------------------------------------- /src/lib/components/tanstackTable/FacetCheckboxes.svelte: -------------------------------------------------------------------------------- 1 | 54 | 55 |
56 | {#each facetVals.top5 as top5} 57 |
58 | 62 |
63 | {/each} 64 | {#if facetVals.next20.length > 0} 65 |
66 |
67 | More 68 |
69 | {#each facetVals.next20 as next20} 70 |
71 | 75 |
76 | {/each} 77 | {#if facetVals.hasMore} 78 | More values not displayed... 79 | {/if} 80 |
81 |
82 |
83 | {/if} 84 |
85 | -------------------------------------------------------------------------------- /src/lib/components/tanstackTable/FacetMinMax.svelte: -------------------------------------------------------------------------------- 1 | 43 | 44 |
45 |
46 | 58 |
59 | 60 |
61 | 73 |
74 |
75 | -------------------------------------------------------------------------------- /src/lib/excelExport/index.ts: -------------------------------------------------------------------------------- 1 | import type { Table } from '@tanstack/svelte-table'; 2 | import { Workbook } from 'exceljs'; 3 | import { saveAs } from 'file-saver'; 4 | import type { InvoiceTableColMeta } from '../../routes/admin/invoices/types'; 5 | 6 | export default async function exportExcel( 7 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 8 | table: Table, 9 | filename: string, 10 | applyFilters = true 11 | ) { 12 | const wb = new Workbook(); 13 | const ws = wb.addWorksheet('Sheet 1'); 14 | 15 | const lastHeaderGroup = table.getHeaderGroups().at(-1); 16 | if (!lastHeaderGroup) { 17 | console.error('No header groups found', table.getHeaderGroups()); 18 | return; 19 | } 20 | 21 | ws.columns = lastHeaderGroup.headers 22 | .filter( 23 | (h) => h.column.getIsVisible() && !(h.column.columnDef.meta as InvoiceTableColMeta)?.noExport 24 | ) 25 | .map((header) => { 26 | return { 27 | header: header.column.columnDef.header as string, 28 | key: header.id, 29 | width: 20 30 | }; 31 | }); 32 | 33 | const exportRows = applyFilters ? table.getFilteredRowModel().rows : table.getCoreRowModel().rows; 34 | 35 | exportRows.forEach((row) => { 36 | const cells = row.getVisibleCells(); 37 | const exportCells = cells.filter( 38 | (cell) => !(cell.column.columnDef.meta as InvoiceTableColMeta)?.noExport 39 | ); 40 | const values = exportCells.map((cell) => cell.getValue() ?? ''); 41 | console.log('values', values); 42 | ws.addRow(values); 43 | }); 44 | 45 | ws.getRow(1).eachCell((cell) => { 46 | cell.font = { bold: true }; 47 | }); 48 | 49 | // for csv: await wb.csv.writeBuffer(); 50 | const buf = await wb.xlsx.writeBuffer(); 51 | saveAs(new Blob([buf]), `${filename}.xlsx`); 52 | } 53 | -------------------------------------------------------------------------------- /src/lib/server/db/index.ts: -------------------------------------------------------------------------------- 1 | import Database from 'better-sqlite3'; 2 | import { DB_PATH } from '$env/static/private'; 3 | import type { 4 | Album, 5 | AlbumImage, 6 | AlbumTrack, 7 | CustomersByCountry, 8 | Genre, 9 | GenreSales, 10 | Invoice, 11 | PlaylistTrack, 12 | PlaylistTrackResponse, 13 | SalesTotalByMonth, 14 | SessionInfo, 15 | SessionInfoCache, 16 | Track, 17 | TracksByMediaType, 18 | TracksGridSaveData 19 | } from './types'; 20 | import bcrypt from 'bcrypt'; 21 | 22 | const db = new Database(DB_PATH, { verbose: console.log }); 23 | addSessionsTable(); 24 | addImageTable(); 25 | 26 | function addSessionsTable() { 27 | const sql = ` 28 | create table if not exists sessions ( 29 | ses_id text primary key 30 | , ses_created integer not null default (strftime( '%s', 'now' ) * 1000) 31 | , ses_expires integer not null 32 | , ses_data text not null 33 | ) strict; 34 | `; 35 | const stmnt = db.prepare(sql); 36 | stmnt.run(); 37 | } 38 | 39 | function addImageTable() { 40 | const sql = ` 41 | create table if not exists album_images ( 42 | img_album_id integer primary key 43 | , img_name text not null 44 | , img_mime_type text not null 45 | , img_last_modified integer not null default (strftime( '%s', 'now' ) * 1000) 46 | , img_size integer not null 47 | , img_data blob not null 48 | , constraint img_album_id_fk foreign key (img_album_id) references albums (AlbumId) 49 | ) strict; 50 | `; 51 | const stmnt = db.prepare(sql); 52 | stmnt.run(); 53 | } 54 | 55 | export function deleteExpiredDbSessions(now: number) { 56 | const sql = ` 57 | delete from sessions 58 | where ses_expires < $now 59 | `; 60 | 61 | const stmnt = db.prepare(sql); 62 | stmnt.run({ now }); 63 | } 64 | 65 | export function insertDbSession(sid: string, sessionInfo: SessionInfo, expiresAt: number) { 66 | const sql = ` 67 | insert into sessions (ses_id, ses_expires, ses_data) 68 | values ($sid, $expires, $data) 69 | `; 70 | 71 | const stmnt = db.prepare(sql); 72 | stmnt.run({ sid, expires: expiresAt, data: JSON.stringify(sessionInfo) }); 73 | } 74 | 75 | export function deleteDbSession(sid: string) { 76 | const sql = ` 77 | delete from sessions 78 | where ses_id = $sid 79 | `; 80 | const stmnt = db.prepare(sql); 81 | stmnt.run({ sid }); 82 | } 83 | 84 | export function getDbSession(sid: string): SessionInfoCache | undefined { 85 | const sql = ` 86 | select ses_data as data 87 | , ses_expires as expires 88 | from sessions 89 | where ses_id = $sid 90 | `; 91 | 92 | const stmnt = db.prepare(sql); 93 | const row = stmnt.get({ sid }) as { data: string; expires: number }; 94 | if (row) { 95 | const data = JSON.parse(row.data); 96 | data.expires = row.expires; 97 | return data as SessionInfoCache; 98 | } 99 | return undefined; 100 | } 101 | 102 | export function getInitialTracks(limit = 50): Track[] { 103 | const sql = ` 104 | select t.TrackId as trackId 105 | , t.Name as trackName 106 | , a.AlbumId as albumId 107 | , a.Title as albumTitle 108 | , at.ArtistId as artistId 109 | , at.Name as artistName 110 | , g.Name as genre 111 | from tracks t 112 | join albums a 113 | on t.AlbumId = a.AlbumId 114 | join artists at 115 | on a.ArtistId = at.ArtistId 116 | join genres g 117 | on t.GenreId = g.GenreId 118 | limit $limit 119 | `; 120 | const stmnt = db.prepare(sql); 121 | const rows = stmnt.all({ limit }); 122 | return rows as Track[]; 123 | } 124 | 125 | export function searchTracks(searchTerm: string, limit = 50): Track[] { 126 | const sql = ` 127 | select t.TrackId as trackId 128 | , t.Name as trackName 129 | , a.AlbumId as albumId 130 | , a.Title as albumTitle 131 | , at.ArtistId as artistId 132 | , at.Name as artistName 133 | , g.Name as genre 134 | from tracks t 135 | join albums a 136 | on t.AlbumId = a.AlbumId 137 | join artists at 138 | on a.ArtistId = at.ArtistId 139 | join genres g 140 | on t.GenreId = g.GenreId 141 | where lower(t.Name) like lower('%' || $searchTerm || '%') 142 | limit $limit 143 | `; 144 | const stmnt = db.prepare(sql); 145 | const rows = stmnt.all({ searchTerm, limit }); 146 | return rows as Track[]; 147 | } 148 | 149 | export function getAlbumById(albumId: number): Album { 150 | const sql = ` 151 | select a.AlbumId as albumId 152 | , a.Title as albumTitle 153 | , at.ArtistId as artistId 154 | , at.Name as artistName 155 | , ai.img_name as imgName 156 | from albums a 157 | join artists at on a.ArtistId = at.ArtistId 158 | left join album_images ai on a.AlbumId = ai.img_album_id 159 | where a.AlbumId = $albumId; 160 | `; 161 | const stmnt = db.prepare(sql); 162 | const row = stmnt.get({ albumId }); 163 | return row as Album; 164 | } 165 | 166 | export function getAlbumTracks(albumId: number): AlbumTrack[] { 167 | const sql = ` 168 | select t.TrackId as trackId 169 | , t.Name as trackName 170 | , t.Milliseconds as trackMs 171 | , t.Composer as composer 172 | , g.Name as genre 173 | from tracks t 174 | left join genres g 175 | on t.GenreId = g.GenreId 176 | where t.AlbumId = $albumId 177 | order by t.TrackId 178 | `; 179 | const stmnt = db.prepare(sql); 180 | const rows = stmnt.all({ albumId }); 181 | return rows as AlbumTrack[]; 182 | } 183 | 184 | export function updateAlbumTitle(albumId: number, albumTitle: string): void { 185 | const sql = ` 186 | update albums 187 | set Title = $albumTitle 188 | where AlbumId = $albumId 189 | `; 190 | const stmnt = db.prepare(sql); 191 | stmnt.run({ albumId, albumTitle }); 192 | } 193 | 194 | export async function createUser(username: string, password: string): Promise { 195 | const sql = ` 196 | insert into users (username, password, roles) 197 | values ($username, $password, 'admin:moderator') 198 | `; 199 | 200 | const hashedPassword = await bcrypt.hash(password, 12); 201 | 202 | const stmnt = db.prepare(sql); 203 | stmnt.run({ username, password: hashedPassword }); 204 | } 205 | 206 | export async function checkUserCredentials(username: string, password: string): Promise { 207 | const sql = ` 208 | select password 209 | from users 210 | where username = $username 211 | `; 212 | const stmnt = db.prepare(sql); 213 | const row = stmnt.get({ username }) as { password: string }; 214 | if (row) { 215 | return bcrypt.compare(password, row.password); 216 | } else { 217 | await bcrypt.hash(password, 12); 218 | return false; 219 | } 220 | } 221 | 222 | export function getUserRoles(username: string): string[] { 223 | const sql = ` 224 | select roles 225 | from users 226 | where username = $username 227 | `; 228 | const stmnt = db.prepare(sql); 229 | const row = stmnt.get({ username }) as { roles: string }; 230 | if (row) { 231 | return row.roles.split(':'); 232 | } 233 | return []; 234 | } 235 | 236 | export function getGenres(): Genre[] { 237 | const sql = `select GenreId as genreId, Name as genreName from genres`; 238 | const stmnt = db.prepare(sql); 239 | const rows = stmnt.all(); 240 | return rows as Genre[]; 241 | } 242 | 243 | export function saveGridTracks(data: TracksGridSaveData) { 244 | if (data.deleted && data.deleted.length > 0) { 245 | const delSql = `delete from tracks where Trackid = $trackId`; 246 | const delStmnt = db.prepare(delSql); 247 | data.deleted.forEach((trackId) => delStmnt.run({ trackId })); 248 | } 249 | 250 | if (data.rows && data.rows.length > 0) { 251 | const genres = getGenres(); 252 | const rows = data.rows.map((track) => { 253 | const genre = genres.find((g) => g.genreName === track.genre); 254 | return { 255 | trackId: track.trackId > 0 ? track.trackId : null, 256 | trackName: track.trackName, 257 | trackMs: track.trackMs, 258 | composer: track.composer, 259 | genreId: genre ? genre.genreId : null 260 | }; 261 | }); 262 | 263 | const mergeSql = ` 264 | insert into tracks ( 265 | TrackId, 266 | Name, 267 | Milliseconds, 268 | Composer, 269 | GenreId, 270 | MediaTypeId, 271 | UnitPrice, 272 | AlbumId 273 | ) values ( 274 | $trackId, 275 | $trackName, 276 | $trackMs, 277 | $composer, 278 | $genreId, 279 | 1, 280 | 1, 281 | $albumId 282 | ) 283 | on conflict (TrackId) do 284 | update 285 | set Name = excluded.Name 286 | , GenreId = excluded.GenreId 287 | , Composer = excluded.Composer 288 | , Milliseconds = excluded.Milliseconds 289 | where TrackId = excluded.TrackId 290 | ;`; 291 | const mergeStmnt = db.prepare(mergeSql); 292 | rows.forEach((track) => mergeStmnt.run({ ...track, albumId: data.albumId })); 293 | } 294 | } 295 | 296 | export async function mergeAlbumImage(albumId: number, image: File) { 297 | const arrayBuffer = await image.arrayBuffer(); 298 | const buffer = Buffer.from(arrayBuffer); 299 | 300 | const sql = ` 301 | insert into album_images ( 302 | img_album_id 303 | , img_name 304 | , img_mime_type 305 | , img_last_modified 306 | , img_size 307 | , img_data 308 | ) 309 | values ( 310 | $albumId 311 | , $filename 312 | , $mimeType 313 | , $lastModified 314 | , $size 315 | , $data 316 | ) 317 | on conflict (img_album_id) do 318 | update 319 | set img_name = excluded.img_name 320 | , img_mime_type = excluded.img_mime_type 321 | , img_last_modified = excluded.img_last_modified 322 | , img_size = excluded.img_size 323 | , img_data = excluded.img_data 324 | where img_album_id = excluded.img_album_id 325 | `; 326 | const stmnt = db.prepare(sql); 327 | stmnt.run({ 328 | albumId, 329 | filename: image.name, 330 | mimeType: image.type, 331 | lastModified: image.lastModified, 332 | size: image.size, 333 | data: buffer 334 | }); 335 | } 336 | 337 | export function getAlbumImage(albumId: number, filename: string): AlbumImage { 338 | const sql = ` 339 | select img_name as filename 340 | , img_mime_type as mimeType 341 | , img_last_modified as lastModified 342 | , img_size as size 343 | , img_data as data 344 | from album_images 345 | where img_album_id = $albumId and img_name = $filename 346 | `; 347 | 348 | const stmnt = db.prepare(sql); 349 | const row = stmnt.get({ albumId, filename }) as AlbumImage; 350 | 351 | const img: AlbumImage = { 352 | filename: row.filename, 353 | mimeType: row.mimeType, 354 | lastModified: row.lastModified, 355 | size: row.size, 356 | data: new Blob([row.data], { type: row.mimeType }) 357 | }; 358 | 359 | return img; 360 | } 361 | 362 | export function otherAlbumsOfArtist(artistId: number, albumId: number): Album[] { 363 | const sql = ` 364 | select a.AlbumId as albumId 365 | , a.Title as albumTitle 366 | , at.ArtistId as artistId 367 | , at.Name as artistName 368 | , ai.img_name as imgName 369 | from albums a 370 | join artists at on a.ArtistId = at.ArtistId 371 | left join album_images ai on a.AlbumId = ai.img_album_id 372 | where a.ArtistId = $artistId 373 | and a.AlbumId != $albumId; 374 | `; 375 | const stmnt = db.prepare(sql); 376 | const rows = stmnt.all({ artistId, albumId }); 377 | return rows as Album[]; 378 | } 379 | 380 | export function getInvoices() { 381 | const sql = ` 382 | select i.InvoiceId as "id" 383 | , i.InvoiceDate as "date" 384 | , i.BillingAddress as "address" 385 | , i.BillingCity as "city" 386 | , i.BillingState as "state" 387 | , i.BillingCountry as "country" 388 | , i.BillingPostalCode as "postalCode" 389 | , i.Total as "total" 390 | , c.FirstName || ' ' || c.LastName as "customer" 391 | from invoices i 392 | join customers c 393 | on i.CustomerId = c.CustomerId 394 | order by i.InvoiceId desc 395 | `; 396 | const stmnt = db.prepare(sql); 397 | const rows = stmnt.all(); 398 | return rows as Invoice[]; 399 | } 400 | 401 | export function updateInvoice(invoice: Invoice) { 402 | const sql = `update invoices 403 | set BillingAddress = $address, 404 | Total = $total 405 | where InvoiceId = $id`; 406 | 407 | const stmnt = db.prepare(sql); 408 | stmnt.run({ 409 | id: invoice.id, 410 | address: invoice.address, 411 | total: invoice.total 412 | }); 413 | } 414 | 415 | export function getInvoiceEmailDetails(invoiceId: number) { 416 | const sql = ` 417 | select i.InvoiceId as "id" 418 | , i.InvoiceDate as "date" 419 | , i.BillingAddress as "address" 420 | , i.BillingCity as "city" 421 | , i.BillingState as "state" 422 | , i.BillingCountry as "country" 423 | , i.BillingPostalCode as "postalCode" 424 | , i.Total as "total" 425 | , c.FirstName || ' ' || c.LastName as "customer" 426 | , c.Email as "email" 427 | from invoices i 428 | join customers c 429 | on i.CustomerId = c.CustomerId 430 | where i.InvoiceId = $invoiceId 431 | `; 432 | 433 | let stmnt = db.prepare(sql); 434 | const order = stmnt.get({ invoiceId }) as { 435 | id: number; 436 | date: string; 437 | address: string; 438 | city: string; 439 | state: string; 440 | country: string; 441 | postalCode: string; 442 | total: number; 443 | customer: string; 444 | email: string; 445 | }; 446 | 447 | const sql2 = ` 448 | select t.Name as "track" 449 | , ii.UnitPrice as "price" 450 | , ii.Quantity as "quantity" 451 | , a.Title as "album" 452 | , ar.Name as "artist" 453 | from invoice_items ii 454 | join tracks t 455 | on ii.TrackId = t.TrackId 456 | join albums a 457 | on t.AlbumId = a.AlbumId 458 | join artists ar 459 | on a.ArtistId = ar.ArtistId 460 | where ii.InvoiceId = $invoiceId 461 | `; 462 | 463 | stmnt = db.prepare(sql2); 464 | const tracks = stmnt.all({ invoiceId }) as { 465 | track: string; 466 | price: number; 467 | quantity: number; 468 | album: string; 469 | artist: string; 470 | }[]; 471 | 472 | return { order, tracks }; 473 | } 474 | 475 | export type QueryPlaylistTracksArgs = { 476 | sort?: { col: string; dir: string }; 477 | pagination: { page: number; pageSize: number }; 478 | search?: string; 479 | colFilters?: { [key: string]: string }; 480 | }; 481 | 482 | export function queryPlaylistTracks({ 483 | sort, 484 | pagination, 485 | search, 486 | colFilters 487 | }: QueryPlaylistTracksArgs): PlaylistTrackResponse { 488 | let sql = ` 489 | select row_number() over () as "rowId" 490 | , p.PlaylistId as "playlistId" 491 | , p.name || ' (#' || p.PlaylistId || ')' as "playlistName" 492 | , t.name as "trackName" 493 | , a.Name as "artistName" 494 | , g.Name as "genre" 495 | from playlist_track pt 496 | join playlists p on p.PlaylistId = pt.PlaylistId 497 | join tracks t on pt.TrackId = t.TrackId 498 | join albums al on t.AlbumId = al.AlbumId 499 | join artists a on al.ArtistId = a.ArtistId 500 | left join genres g on t.GenreId = g.GenreId 501 | #WHERE# 502 | #ORDER# 503 | #LIMIT# 504 | `; 505 | 506 | console.log('colFilters', colFilters); 507 | 508 | if (sort) { 509 | sql = sql.replace('#ORDER#', `order by ${sort.col} ${sort.dir}`); 510 | } else { 511 | sql = sql.replace('#ORDER#', ''); 512 | } 513 | 514 | const whereClauses: string[] = []; 515 | 516 | if (search) { 517 | whereClauses.push( 518 | `( lower(p.name) like '%${search.toLowerCase()}%' or lower(t.name) like '%${search.toLowerCase()}%' or lower(a.name) like '%${search.toLowerCase()}%' or lower(g.name) like '%${search.toLowerCase()}%' )` 519 | ); 520 | } 521 | 522 | if (colFilters) { 523 | for (const [key, value] of Object.entries(colFilters)) { 524 | if (value) { 525 | whereClauses.push(`lower(${key}) like '%${value.toLowerCase()}%'`); 526 | } 527 | } 528 | } 529 | 530 | const where = whereClauses.length > 0 ? 'where ' + whereClauses.join(' and ') : ''; 531 | sql = sql.replace('#WHERE#', where); 532 | 533 | const withPagination = sql.replace( 534 | '#LIMIT#', 535 | `limit ${pagination.pageSize} offset ${(pagination.page - 1) * pagination.pageSize}` 536 | ); 537 | const withoutPagination = sql.replace('#LIMIT#', ''); 538 | 539 | const stmnt = db.prepare(withPagination); 540 | const rows = stmnt.all() as PlaylistTrack[]; 541 | 542 | const countSql = `select count(*) as "count" from (${withoutPagination})`; 543 | const stmnt2 = db.prepare(countSql); 544 | const countRes = stmnt2.get() as { count: number }; 545 | 546 | return { rows, count: countRes.count }; 547 | } 548 | 549 | export function getChartsData() { 550 | let sql = ` 551 | SELECT genres.Name as "genre", round(SUM(invoice_items.Quantity * invoice_items.UnitPrice)) AS "salesTotal" 552 | FROM genres 553 | JOIN tracks ON genres.GenreId = tracks.GenreId 554 | JOIN invoice_items ON tracks.TrackId = invoice_items.TrackId 555 | GROUP BY genres.GenreId 556 | order by "salesTotal" desc 557 | limit 5 558 | ; 559 | `; 560 | 561 | let stmnt = db.prepare(sql); 562 | const genreSales = stmnt.all() as GenreSales[]; 563 | 564 | sql = ` 565 | SELECT country as "country", COUNT(*) as "customerCount" 566 | FROM customers 567 | GROUP BY Country 568 | order by "customerCount" desc 569 | limit 5; 570 | `; 571 | 572 | stmnt = db.prepare(sql); 573 | const customerCount = stmnt.all() as CustomersByCountry[]; 574 | 575 | sql = ` 576 | SELECT media_types.Name as "mediaType", COUNT(*) as "trackCount" 577 | FROM media_types 578 | JOIN tracks ON media_types.MediaTypeId = tracks.MediaTypeId 579 | GROUP BY media_types.MediaTypeId; 580 | `; 581 | 582 | stmnt = db.prepare(sql); 583 | const tracksByMediaType = stmnt.all() as TracksByMediaType[]; 584 | 585 | sql = ` 586 | SELECT CASE strftime('%m', InvoiceDate) 587 | WHEN '01' THEN 'Jan' 588 | WHEN '02' THEN 'Feb' 589 | WHEN '03' THEN 'Mar' 590 | WHEN '04' THEN 'Apr' 591 | WHEN '05' THEN 'May' 592 | WHEN '06' THEN 'Jun' 593 | WHEN '07' THEN 'Jul' 594 | WHEN '08' THEN 'Aug' 595 | WHEN '09' THEN 'Sep' 596 | WHEN '10' THEN 'Oct' 597 | WHEN '11' THEN 'Nov' 598 | WHEN '12' THEN 'Dec' 599 | END as "month", round(SUM(Total)) as "salesTotal" 600 | FROM invoices 601 | where cast(strftime('%Y', InvoiceDate) as number) > 2008 602 | and invoiceid % 2 = 1 603 | GROUP BY Month 604 | ORDER BY strftime('%m', InvoiceDate) 605 | ; 606 | `; 607 | 608 | stmnt = db.prepare(sql); 609 | const salesTotalByMonth = stmnt.all() as SalesTotalByMonth[]; 610 | 611 | return { genreSales, customerCount, tracksByMediaType, salesTotalByMonth }; 612 | } 613 | -------------------------------------------------------------------------------- /src/lib/server/db/subscriptionDb.ts: -------------------------------------------------------------------------------- 1 | import { SUBSCRIPTION_DB_PATH, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY } from '$env/static/private'; 2 | import Database from 'better-sqlite3'; 3 | import webpush, { type PushSubscription } from 'web-push'; 4 | 5 | const subDb = new Database(SUBSCRIPTION_DB_PATH); 6 | subDb.pragma('foreign_keys = ON'); 7 | initTables(); 8 | initWebPush(); 9 | 10 | function initTables() { 11 | subDb.exec(` 12 | CREATE TABLE IF NOT EXISTS users ( 13 | username test not null, 14 | created_at timestamp not null default current_timestamp, 15 | constraint users_pk primary key (username) 16 | ); 17 | 18 | CREATE TABLE IF NOT EXISTS user_devices ( 19 | device_id integer primary key autoincrement, 20 | subscription json not null, 21 | username text not null, 22 | constraint user_devices_uk unique (subscription), 23 | constraint user_devices_users_username_fk foreign key (username) references users (username) on delete cascade 24 | ); 25 | 26 | CREATE INDEX IF NOT EXISTS user_devices_users_username_fk on user_devices (username); 27 | 28 | CREATE TABLE IF NOT EXISTS notif_channels ( 29 | channel_id text not null, 30 | created_at timestamp not null default current_timestamp, 31 | constraint notif_channels_pk primary key (channel_id) 32 | ); 33 | 34 | CREATE TABLE IF NOT EXISTS notif_channel_users ( 35 | username text not null, 36 | channel_id text not null, 37 | constraint user_notif_channels_pk primary key (username, channel_id), 38 | constraint user_notif_channels_notif_channels_channel_id_fk foreign key (channel_id) references notif_channels (channel_id) on delete cascade, 39 | constraint user_notif_channels_users_username_fk foreign key (username) references users (username) on delete cascade 40 | ); 41 | 42 | CREATE TABLE IF NOT EXISTS notif_log ( 43 | notif_id integer primary key autoincrement, 44 | created_at timestamp not null default current_timestamp, 45 | channel_id text, 46 | device_id integer not null, 47 | payload text not null, 48 | http_status_response integer, 49 | success boolean not null, 50 | error_message text, 51 | constraint notif_log_notif_channels_channel_id_fk foreign key (channel_id) references notif_channels (channel_id) on delete cascade, 52 | constraint notif_log_user_devices_device_id_fk foreign key (device_id) references user_devices (device_id) on delete cascade 53 | ); 54 | `); 55 | } 56 | 57 | function initWebPush() { 58 | webpush.setVapidDetails('mailto:webpush@hartenfeller.dev', VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY); 59 | } 60 | 61 | async function sendNotification(subscription: PushSubscription, payload: string) { 62 | try { 63 | const res = await webpush.sendNotification(subscription, payload); 64 | return { 65 | ok: res.statusCode === 201, 66 | status: res.statusCode, 67 | body: res.body 68 | }; 69 | } catch (err) { 70 | const msg = `Could not send notification: ${err}`; 71 | console.error(msg); 72 | return { 73 | ok: false, 74 | status: undefined, 75 | body: msg 76 | }; 77 | } 78 | } 79 | 80 | function deleteIfExpired(deviceId: number) { 81 | const last3Success = subDb 82 | .prepare( 83 | ` 84 | SELECT sum(success) as cnt 85 | FROM notif_log 86 | WHERE device_id = ? 87 | AND success = 0 88 | ORDER BY created_at DESC 89 | LIMIT 3 90 | ` 91 | ) 92 | .get(deviceId) as { cnt: number }; 93 | 94 | if (last3Success.cnt === 0) { 95 | console.log(`Removing expired subscription for device ${deviceId}`); 96 | subDb.prepare('DELETE FROM user_devices WHERE device_id = ?').run(deviceId); 97 | } 98 | } 99 | 100 | async function sendNotificationToDevices(devices: SubDevice[], payload: string) { 101 | devices.forEach(async (device) => { 102 | const subscription = JSON.parse(device.subscription); 103 | const res = await sendNotification(subscription, payload); 104 | 105 | if (!res.ok) { 106 | console.error( 107 | `Failed to send notification to device ${device.device_id}: ${res.body} (${res.status}). 108 | ${JSON.stringify(res)}` 109 | ); 110 | } 111 | 112 | subDb 113 | .prepare( 114 | ` 115 | INSERT INTO notif_log (device_id, payload, http_status_response, success, error_message) 116 | VALUES (?, ?, ?, ?, ?) 117 | ` 118 | ) 119 | .run(device.device_id, payload, res.status, res.ok ? 1 : 0, res.body); 120 | 121 | // remove expired subscription 122 | if (res.status === 410) { 123 | subDb.prepare('DELETE FROM user_devices WHERE device_id = ?').run(device.device_id); 124 | } else if (!res.ok) { 125 | deleteIfExpired(device.device_id); 126 | } 127 | }); 128 | } 129 | 130 | export function addUserDevice(username: string, subscription: PushSubscription) { 131 | const userNameCount = subDb 132 | .prepare('SELECT count(*) as cnt FROM users WHERE username = ?') 133 | .get(username) as { cnt: number }; 134 | 135 | if (userNameCount.cnt === 0) { 136 | subDb.prepare('INSERT INTO users (username) VALUES (?)').run(username); 137 | } 138 | 139 | const subCount = subDb 140 | .prepare( 141 | `SELECT count(*) as cnt FROM user_devices WHERE json_extract(subscription, '$.endpoint') = ? ` 142 | ) 143 | .get(subscription.endpoint) as { cnt: number }; 144 | 145 | if (subCount.cnt === 0) { 146 | subDb 147 | .prepare('INSERT INTO user_devices (subscription, username) VALUES (?, ?)') 148 | .run(JSON.stringify(subscription), username); 149 | } 150 | } 151 | 152 | export function addUserToChannel(username: string, channelId: string) { 153 | const channelCount = subDb 154 | .prepare('SELECT count(*) as cnt FROM notif_channels WHERE channel_id = ?') 155 | .get(channelId) as { cnt: number }; 156 | 157 | if (channelCount.cnt === 0) { 158 | subDb.prepare('INSERT INTO notif_channels (channel_id) VALUES (?)').run(channelId); 159 | } 160 | 161 | const subCount = subDb 162 | .prepare( 163 | `SELECT count(*) as cnt FROM notif_channel_users WHERE username = ? AND channel_id = ?` 164 | ) 165 | .get(username, channelId) as { cnt: number }; 166 | 167 | if (subCount.cnt === 0) { 168 | subDb 169 | .prepare('INSERT INTO notif_channel_users (username, channel_id) VALUES (?, ?)') 170 | .run(username, channelId); 171 | } 172 | } 173 | 174 | export async function notifUser(username: string, payload: string) { 175 | const devices = subDb 176 | .prepare('SELECT * FROM user_devices WHERE username = ?') 177 | .all(username) as SubDevice[]; 178 | 179 | await sendNotificationToDevices(devices, payload); 180 | } 181 | 182 | export async function notifChannel(channelId: string, payload: string) { 183 | const devices = subDb 184 | .prepare( 185 | ` 186 | SELECT ud.device_id, ud.subscription 187 | FROM user_devices ud 188 | JOIN notif_channel_users ncu ON ud.username = ncu.username 189 | WHERE ncu.channel_id = ? 190 | ` 191 | ) 192 | .all(channelId) as SubDevice[]; 193 | 194 | await sendNotificationToDevices(devices, payload); 195 | } 196 | 197 | type SubDevice = { 198 | device_id: number; 199 | subscription: string; 200 | }; 201 | -------------------------------------------------------------------------------- /src/lib/server/db/types.ts: -------------------------------------------------------------------------------- 1 | export type SessionInfo = { 2 | username: string; 3 | roles: string[]; 4 | }; 5 | 6 | export type SessionInfoCache = SessionInfo & { 7 | invalidAt: number; 8 | }; 9 | 10 | export type Track = { 11 | trackId: number; 12 | trackName: string; 13 | albumId: number; 14 | albumTitle: string; 15 | artistId: number; 16 | artistName: string; 17 | genre: string; 18 | }; 19 | 20 | export type Album = { 21 | albumId: number; 22 | albumTitle: string; 23 | artistId: number; 24 | artistName: string; 25 | imgName?: string; 26 | }; 27 | 28 | export type AlbumTrack = { 29 | trackId: number; 30 | trackName: string; 31 | trackMs: number; 32 | composer: string; 33 | genre: string; 34 | }; 35 | 36 | export type Genre = { genreId: number; genreName: string }; 37 | 38 | export type TracksGridSaveData = { 39 | deleted?: number[]; 40 | rows?: AlbumTrack[]; 41 | albumId: number; 42 | }; 43 | 44 | export type AlbumImage = { 45 | filename: string; 46 | mimeType: string; 47 | lastModified: number; 48 | size: number; 49 | data: Blob; 50 | }; 51 | 52 | export type Invoice = { 53 | id: number; 54 | date: string; 55 | address: string; 56 | city: string; 57 | state: string; 58 | country: string; 59 | postalCode: string; 60 | total: number; 61 | customer: string; 62 | }; 63 | 64 | export type PlaylistTrack = { 65 | rowId: number; 66 | playlistId: number; 67 | playlistName: string; 68 | trackName: string; 69 | artistName: string; 70 | genre?: string; 71 | }; 72 | 73 | export type PlaylistTrackResponse = { 74 | rows: PlaylistTrack[]; 75 | count: number; 76 | }; 77 | 78 | export type GenreSales = { genre: string; salesTotal: number }; 79 | 80 | export type CustomersByCountry = { country: string; customerCount: number }; 81 | 82 | export type TracksByMediaType = { mediaType: string; trackCount: number }; 83 | 84 | export type SalesTotalByMonth = { month: string; salesTotal: number }; 85 | -------------------------------------------------------------------------------- /src/lib/server/pdf/generateAlbumPdf.ts: -------------------------------------------------------------------------------- 1 | import PdfPrinter from 'pdfmake'; 2 | import blobStream, { type IBlobStream } from 'blob-stream'; 3 | import type { TDocumentDefinitions, TFontDictionary } from 'pdfmake/interfaces'; 4 | import { getAlbumById, getAlbumImage, getAlbumTracks, otherAlbumsOfArtist } from '../db'; 5 | import { SERVER_ASSETS } from '$env/static/private'; 6 | import path from 'node:path'; 7 | 8 | const fonts: TFontDictionary = { 9 | Inter: { 10 | normal: path.join(SERVER_ASSETS, 'fonts/Inter-Regular.ttf'), 11 | bold: path.join(SERVER_ASSETS, 'fonts/Inter-Bold.ttf') 12 | } 13 | }; 14 | const printer = new PdfPrinter(fonts); 15 | 16 | async function blobToBase64(blob: Blob) { 17 | const buffer = Buffer.from(await blob.arrayBuffer()); 18 | return `data:${blob.type};base64,${buffer.toString('base64')}`; 19 | } 20 | 21 | async function genAlbumPdf(albumId: number): Promise { 22 | const album = getAlbumById(albumId); 23 | const tracks = getAlbumTracks(albumId); 24 | const otherArtistAlbums = otherAlbumsOfArtist(album.artistId, albumId); 25 | 26 | let imgBase64: string | undefined; 27 | 28 | if (album.imgName) { 29 | const img = getAlbumImage(albumId, album.imgName); 30 | imgBase64 = await blobToBase64(img.data); 31 | } 32 | 33 | const file: TDocumentDefinitions = { 34 | content: [ 35 | { text: album.albumTitle, style: 'h1' }, 36 | `By ${album.artistName}`, 37 | 38 | imgBase64 ? { image: imgBase64, width: 100 } : 'no image', 39 | 40 | { text: 'Tracks', style: 'h2' }, 41 | { 42 | table: { 43 | headerRows: 1, 44 | body: [ 45 | [ 46 | { text: '#', style: 'tableHeader' }, 47 | { text: 'Name', style: 'tableHeader' }, 48 | { text: 'Seconds', style: 'tableHeader' } 49 | ], 50 | ...tracks.map((track, i) => [i + 1, track.trackName, (track.trackMs / 1000).toFixed(1)]) 51 | ] 52 | }, 53 | layout: 'headerLineOnly' 54 | }, 55 | { text: 'Other Albums by this Artist', style: 'h2' }, 56 | { 57 | ul: otherArtistAlbums.map((a) => ({ 58 | text: a.albumTitle, 59 | link: `http://localhost:5173/album/${a.albumId}`, 60 | style: 'link' 61 | })) 62 | } 63 | ], 64 | styles: { 65 | h1: { 66 | fontSize: 18, 67 | bold: true, 68 | margin: [0, 0, 0, 16] 69 | }, 70 | h2: { 71 | fontSize: 16, 72 | margin: [0, 10, 0, 10] 73 | }, 74 | tableHeader: { 75 | bold: true, 76 | fontSize: 13, 77 | color: 'black', 78 | fillColor: '#e7e7e7', 79 | margin: [0, 2, 0, 2] 80 | }, 81 | link: { 82 | decoration: 'underline', 83 | color: 'blue' 84 | } 85 | }, 86 | defaultStyle: { 87 | font: 'Inter' 88 | } 89 | }; 90 | 91 | return new Promise((resolve, reject) => { 92 | const pdf = printer.createPdfKitDocument(file); 93 | 94 | pdf 95 | .pipe(blobStream()) 96 | .on('finish', function (this: IBlobStream) { 97 | console.log('Finished generating PDF'); 98 | resolve(this.toBlob('application/pdf')); 99 | }) 100 | .on('error', (err) => { 101 | console.error('err', err); 102 | reject(err); 103 | }); 104 | 105 | pdf.end(); 106 | }); 107 | } 108 | 109 | export default genAlbumPdf; 110 | -------------------------------------------------------------------------------- /src/lib/server/sesstionStore/index.ts: -------------------------------------------------------------------------------- 1 | import { randomBytes } from 'node:crypto'; 2 | import { 3 | deleteDbSession, 4 | deleteExpiredDbSessions, 5 | getDbSession, 6 | getUserRoles, 7 | insertDbSession 8 | } from '../db'; 9 | import type { SessionInfo, SessionInfoCache } from '../db/types'; 10 | 11 | type Sid = string; 12 | 13 | const sessionStore = new Map(); 14 | let nextClean = Date.now() + 1000 * 60 * 60; // 1 hour 15 | 16 | function clean() { 17 | const now = Date.now(); 18 | for (const [sid, session] of sessionStore) { 19 | if (session.invalidAt < now) { 20 | sessionStore.delete(sid); 21 | } 22 | } 23 | deleteExpiredDbSessions(now); 24 | nextClean = Date.now() + 1000 * 60 * 60; // 1 hour 25 | } 26 | 27 | function getSid(): Sid { 28 | return randomBytes(32).toString('hex'); 29 | } 30 | 31 | export function createSession(username: string, maxAge: number): string { 32 | let sid: Sid = ''; 33 | 34 | do { 35 | sid = getSid(); 36 | } while (sessionStore.has(sid)); 37 | 38 | const roles = getUserRoles(username); 39 | 40 | const expiresAt = Date.now() + maxAge * 1000; 41 | 42 | const data: SessionInfo = { 43 | username, 44 | roles 45 | }; 46 | insertDbSession(sid, data, expiresAt); 47 | 48 | sessionStore.set(sid, { 49 | ...data, 50 | invalidAt: expiresAt 51 | }); 52 | 53 | if (Date.now() > nextClean) { 54 | setTimeout(() => { 55 | clean(); 56 | }, 5000); 57 | } 58 | 59 | return sid; 60 | } 61 | 62 | export function getSession(sid: Sid): SessionInfo | undefined { 63 | if (sessionStore.has(sid)) { 64 | return sessionStore.get(sid); 65 | } else { 66 | const session = getDbSession(sid); 67 | if (session) { 68 | sessionStore.set(sid, session); 69 | return session; 70 | } 71 | } 72 | 73 | console.log('session not found', sid); 74 | return undefined; 75 | } 76 | 77 | export function deleteSession(sid: string): void { 78 | sessionStore.delete(sid); 79 | deleteDbSession(sid); 80 | } 81 | -------------------------------------------------------------------------------- /src/routes/+layout.server.ts: -------------------------------------------------------------------------------- 1 | import type { LayoutServerLoad } from './$types'; 2 | 3 | export const load = (async ({ locals }) => { 4 | const { username } = locals; 5 | 6 | return { username }; 7 | }) satisfies LayoutServerLoad; 8 | -------------------------------------------------------------------------------- /src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 19 | 20 |
21 | 83 |
84 | 85 | 86 | 100 | -------------------------------------------------------------------------------- /src/routes/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { getInitialTracks } from '$lib/server/db'; 2 | import type { PageServerLoad } from './$types'; 3 | 4 | export const load = (({ locals }) => { 5 | const tracks = getInitialTracks(); 6 | const { username } = locals; 7 | 8 | return { 9 | tracks, 10 | loggedIn: !!username 11 | }; 12 | }) satisfies PageServerLoad; 13 | -------------------------------------------------------------------------------- /src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 33 | 34 |
35 | {#if !data.loggedIn} 36 |
Login to see protected routes...
37 | {/if} 38 |

Tracks

39 |
40 |
41 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | {#each tracks as track} 61 | 62 | 63 | 64 | 69 | 70 | 71 | {/each} 72 | 73 |
TrackArtistAlbumGenre
{track.trackName}{track.artistName}{track.albumTitle}{track.genre}
74 |
75 |
76 | 77 |
78 |
79 |
80 | -------------------------------------------------------------------------------- /src/routes/about/+page.svx: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Hello World 4 | --- 5 | 6 | Other pages: 7 | 8 | - [Advanced](/about/advanced) 9 | - [Components](/about/advanced/level3) 10 | 11 | ## MelodyMart: SvelteKit Music Store App 12 | 13 | Introducing MelodyMart, the revolutionary music store app built with SvelteKit, designed to enhance your music shopping experience like never before. MelodyMart combines the power of SvelteKit's efficient framework with a sleek and intuitive user interface, providing a seamless browsing and purchasing platform for music enthusiasts. 14 | 15 | With MelodyMart, you can explore a vast catalog of music from various genres, artists, and albums, all conveniently organized and easily accessible. The app offers a smooth and responsive interface, ensuring swift navigation and effortless browsing. Whether you're searching for the latest chart-toppers, classic hits, or hidden gems, MelodyMart's comprehensive search functionality and smart recommendations will help you discover your favorite tunes in no time. 16 | 17 | Once you've found your desired tracks or albums, MelodyMart offers secure and hassle-free purchasing options. The app integrates with popular payment gateways, allowing you to complete transactions quickly and securely. You can also create personalized playlists, share your favorite music with friends, and explore curated playlists crafted by music experts. 18 | 19 | MelodyMart goes beyond being just a music store. It also provides a platform for independent artists to showcase their talent. Musicians can upload their original tracks, gain exposure, and connect with their audience directly through the app. 20 | 21 | Thanks to SvelteKit's optimized performance, MelodyMart offers lightning-fast loading times, ensuring a smooth and delightful experience for users across various devices and network conditions. 22 | 23 | Experience the future of music shopping with MelodyMart, where cutting-edge technology and a love for music converge to create an unparalleled user experience. Download the app today and unlock a world of melodious possibilities! 24 | -------------------------------------------------------------------------------- /src/routes/about/advanced/+page.svx: -------------------------------------------------------------------------------- 1 | --- 2 | layout: fancy 3 | title: Advanced Markdown Demo 4 | --- 5 | 6 | Other pages: 7 | 8 | - [About](/about) 9 | - [Components](/about/advanced/level3) 10 | 11 | ## h2 Heading 12 | ### h3 Heading 13 | #### h4 Heading 14 | ##### h5 Heading 15 | ###### h6 Heading 16 | 17 | 18 | ## Horizontal Rules 19 | 20 | ___ 21 | 22 | --- 23 | 24 | *** 25 | 26 | 27 | 28 | ## Emphasis 29 | 30 | **This is bold text** 31 | 32 | __This is bold text__ 33 | 34 | *This is italic text* 35 | 36 | _This is italic text_ 37 | 38 | ~~Strikethrough~~ 39 | 40 | 41 | ## Blockquotes 42 | 43 | 44 | > Blockquotes can also be nested... 45 | >> ...by using additional greater-than signs right next to each other... 46 | > > > ...or with spaces between arrows. 47 | 48 | 49 | ## Lists 50 | 51 | Unordered 52 | 53 | + Create a list by starting a line with `+`, `-`, or `*` 54 | + Sub-lists are made by indenting 2 spaces: 55 | - Marker character change forces new list start: 56 | * Ac tristique libero volutpat at 57 | + Facilisis in pretium nisl aliquet 58 | - Nulla volutpat aliquam velit 59 | + Very easy! 60 | 61 | Ordered 62 | 63 | 1. Lorem ipsum dolor sit amet 64 | 2. Consectetur adipiscing elit 65 | 3. Integer molestie lorem at massa 66 | 67 | 68 | 1. You can use sequential numbers... 69 | 1. ...or keep all the numbers as `1.` 70 | 71 | Start numbering with offset: 72 | 73 | 57. foo 74 | 1. bar 75 | 76 | 77 | ## Code 78 | 79 | Inline `code` 80 | 81 | Indented code 82 | 83 | // Some comments 84 | line 1 of code 85 | line 2 of code 86 | line 3 of code 87 | 88 | 89 | Block code "fences" 90 | 91 | ``` 92 | Sample text here... 93 | ``` 94 | 95 | Syntax highlighting 96 | 97 | ``` js 98 | var foo = function (bar) { 99 | return bar++; 100 | }; 101 | 102 | console.log(foo(5)); 103 | ``` 104 | 105 | ## Tables 106 | 107 | | Option | Description | 108 | | ------ | ----------- | 109 | | data | path to data files to supply the data that will be passed into templates. | 110 | | engine | engine to be used for processing templates. Handlebars is the default. | 111 | | ext | extension to be used for dest files. | 112 | 113 | Right aligned columns 114 | 115 | | Option | Description | 116 | | ------:| -----------:| 117 | | data | path to data files to supply the data that will be passed into templates. | 118 | | engine | engine to be used for processing templates. Handlebars is the default. | 119 | | ext | extension to be used for dest files. | 120 | 121 | 122 | ## Links 123 | 124 | [link text](http://dev.nodeca.com) 125 | 126 | [link with title](http://nodeca.github.io/pica/demo/ "title text!") 127 | 128 | Autoconverted link https://github.com/nodeca/pica (enable linkify to see) 129 | 130 | 131 | ## Images 132 | 133 | ![Minion](https://octodex.github.com/images/minion.png) 134 | ![Stormtroopocat](https://octodex.github.com/images/stormtroopocat.jpg "The Stormtroopocat") 135 | 136 | Like links, Images also have a footnote style syntax 137 | 138 | ![Alt text][id] 139 | 140 | With a reference later in the document defining the URL location: 141 | 142 | [id]: https://octodex.github.com/images/dojocat.jpg "The Dojocat" 143 | 144 | -------------------------------------------------------------------------------- /src/routes/about/advanced/level3/+page.svx: -------------------------------------------------------------------------------- 1 | --- 2 | layout: components 3 | title: This has Components 4 | breadcrumb: [{label: 'Advanced Markdown Demo', href: '/about/advanced'}, {label: 'Components', href: '/about/advanced/level3'}] 5 | --- 6 | 7 | 13 | 14 | 15 | ## h2 Heading 16 | ### h3 Heading 17 | #### h4 Heading 18 | ##### h5 Heading 19 | ###### h6 Heading 20 | 21 | ## Quiz 22 | 23 | 31 | 32 | 33 | ## Horizontal Rules 34 | 35 | ___ 36 | 37 | --- 38 | 39 | *** 40 | 41 | 42 | 43 | ## Emphasis 44 | 45 | **This is bold text** 46 | 47 | __This is bold text__ 48 | 49 | *This is italic text* 50 | 51 | _This is italic text_ 52 | 53 | ~~Strikethrough~~ 54 | 55 | 56 | ## Blockquotes 57 | 58 | 59 | > Blockquotes can also be nested... 60 | >> ...by using additional greater-than signs right next to each other... 61 | > > > ...or with spaces between arrows. 62 | 63 | 64 | ## Lists 65 | 66 | Unordered 67 | 68 | + Create a list by starting a line with `+`, `-`, or `*` 69 | + Sub-lists are made by indenting 2 spaces: 70 | - Marker character change forces new list start: 71 | * Ac tristique libero volutpat at 72 | + Facilisis in pretium nisl aliquet 73 | - Nulla volutpat aliquam velit 74 | + Very easy! 75 | 76 | Ordered 77 | 78 | 1. Lorem ipsum dolor sit amet 79 | 2. Consectetur adipiscing elit 80 | 3. Integer molestie lorem at massa 81 | 82 | 83 | 1. You can use sequential numbers... 84 | 1. ...or keep all the numbers as `1.` 85 | 86 | Start numbering with offset: 87 | 88 | 57. foo 89 | 1. bar 90 | 91 | 92 | ## Code 93 | 94 | Inline `code` 95 | 96 | Indented code 97 | 98 | // Some comments 99 | line 1 of code 100 | line 2 of code 101 | line 3 of code 102 | 103 | 104 | Block code "fences" 105 | 106 | ``` 107 | Sample text here... 108 | ``` 109 | 110 | Syntax highlighting 111 | 112 | ``` js 113 | var foo = function (bar) { 114 | return bar++; 115 | }; 116 | 117 | console.log(foo(5)); 118 | ``` 119 | 120 | ## Tables 121 | 122 | | Option | Description | 123 | | ------ | ----------- | 124 | | data | path to data files to supply the data that will be passed into templates. | 125 | | engine | engine to be used for processing templates. Handlebars is the default. | 126 | | ext | extension to be used for dest files. | 127 | 128 | Right aligned columns 129 | 130 | | Option | Description | 131 | | ------:| -----------:| 132 | | data | path to data files to supply the data that will be passed into templates. | 133 | | engine | engine to be used for processing templates. Handlebars is the default. | 134 | | ext | extension to be used for dest files. | 135 | 136 | 137 | ## Links 138 | 139 | [link text](http://dev.nodeca.com) 140 | 141 | [link with title](http://nodeca.github.io/pica/demo/ "title text!") 142 | 143 | Autoconverted link https://github.com/nodeca/pica (enable linkify to see) 144 | 145 | 146 | ## Images 147 | 148 | ![Minion](https://octodex.github.com/images/minion.png) 149 | ![Stormtroopocat](https://octodex.github.com/images/stormtroopocat.jpg "The Stormtroopocat") 150 | 151 | Like links, Images also have a footnote style syntax 152 | 153 | ![Alt text][id] 154 | 155 | With a reference later in the document defining the URL location: 156 | 157 | [id]: https://octodex.github.com/images/dojocat.jpg "The Dojocat" 158 | 159 | -------------------------------------------------------------------------------- /src/routes/admin/charts_css/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { getChartsData } from '$lib/server/db'; 2 | import type { PageServerLoad } from './$types'; 3 | 4 | export const load = (() => { 5 | const chartData = getChartsData(); 6 | 7 | return { 8 | chartData 9 | }; 10 | }) satisfies PageServerLoad; 11 | -------------------------------------------------------------------------------- /src/routes/admin/charts_css/+page.svelte: -------------------------------------------------------------------------------- 1 | 17 | 18 |
19 |
20 |

Charts.css

21 |
22 |
23 | 24 |
25 |
26 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | {#each data.chartData.genreSales as g} 37 | 38 | 39 | 42 | 43 | {/each} 44 | 45 |
Sales Total by Genre
GenreSales Total
{g.genre} 40 | ${g.salesTotal} 41 |
46 |
47 | 48 |
49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | {#each data.chartData.customerCount as c} 57 | 58 | 59 | 62 | 63 | {/each} 64 | 65 |
Customer count By Country
CountryCustomer Count
{c.country} 60 | {c.customerCount} 61 |
66 |
67 | 68 |
69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | {#each data.chartData.salesTotalByMonth as m, i} 77 | 78 | 79 | 85 | 86 | {/each} 87 | 88 |
Sales Total by Month
CountryCustomer Count
{m.month} 83 | {m.salesTotal} 84 |
89 |
90 |
91 | -------------------------------------------------------------------------------- /src/routes/admin/invoices/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { getInvoices } from '$lib/server/db'; 2 | import { error } from '@sveltejs/kit'; 3 | import type { PageServerLoad } from './$types'; 4 | 5 | export const load = (({ locals, depends }) => { 6 | depends('invoice:load'); 7 | if (!locals?.roles?.includes('admin')) { 8 | throw error(404, 'Unauthorized'); 9 | } 10 | 11 | const invoices = getInvoices(); 12 | 13 | return { 14 | invoices 15 | }; 16 | }) satisfies PageServerLoad; 17 | -------------------------------------------------------------------------------- /src/routes/admin/invoices/+page.svelte: -------------------------------------------------------------------------------- 1 | 252 | 253 |
254 |
255 |

Invoices

256 |
257 | 258 | 259 |
260 |
261 | 262 |
263 |
264 |

Filters

265 | 266 | {#each headerGroups as headerGroup} 267 | {#each headerGroup.headers as header} 268 | {#if header.column.id === 'country'} 269 |
270 | 271 |

Countries

273 | 274 | 275 |
276 | {:else if header.column.id === 'state'} 277 |
278 |

State

279 | 280 | 281 |
282 | {:else if header.column.id === 'total'} 283 |
284 |

Total

285 | 286 | 287 |
288 | {/if} 289 | {/each} 290 | {/each} 291 |
292 |
293 | 301 | 302 | 303 | {#each headerGroups as headerGroup} 304 | 305 | {#each headerGroup.headers as header} 306 | 323 | {/each} 324 | 325 | {/each} 326 | 327 | 328 | 329 | {#each $table.getRowModel().rows as row} 330 | 331 | {#each row.getVisibleCells() as cell} 332 | 337 | {/each} 338 | 339 | {/each} 340 | 341 |
307 | {#if !header.isPlaceholder} 308 | 321 | {/if} 322 |
333 | 336 |
342 |
343 | 351 | 359 | Page 360 | 368 | 369 | {' '}of{' '} 370 | {$table.getPageCount()} 371 | 372 | 380 | 388 | | 389 | 400 | | 401 | {$table.getPrePaginationRowModel().rows.length} total Rows 402 |
403 |
404 |
405 |
406 | -------------------------------------------------------------------------------- /src/routes/admin/invoices/editRowBtn.svelte: -------------------------------------------------------------------------------- 1 | 22 | 23 | {#if isEdit} 24 | 25 | {:else} 26 | 27 | {/if} 28 | -------------------------------------------------------------------------------- /src/routes/admin/invoices/editRowInputs.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 | {#if rInEdit !== true} 25 | {initVal} 26 | {:else if editT === 'text'} 27 | 28 | {:else if editT === 'number'} 29 | 30 | {:else} 31 | {initVal} 32 | {/if} 33 | -------------------------------------------------------------------------------- /src/routes/admin/invoices/invEmailBtn.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/routes/admin/invoices/stores.ts: -------------------------------------------------------------------------------- 1 | import type { Invoice } from '$lib/server/db/types'; 2 | import { writable } from 'svelte/store'; 3 | 4 | export const rowChanges = writable<{ [key: number]: Invoice }>({}); 5 | 6 | export function resetTableChanges() { 7 | rowChanges.set({}); 8 | } 9 | -------------------------------------------------------------------------------- /src/routes/admin/invoices/types.ts: -------------------------------------------------------------------------------- 1 | export type TableEditT = 'text' | 'number'; 2 | 3 | export type InvoiceTableColMeta = { 4 | noExport?: boolean; 5 | }; 6 | -------------------------------------------------------------------------------- /src/routes/admin/playlistTracks/+page.svelte: -------------------------------------------------------------------------------- 1 | 160 | 161 |
162 |
163 |

Playlists

164 |
165 |
166 | 167 |
168 |
169 | setSearch(search)} 174 | placeholder="Search..." 175 | /> 176 |
177 | 178 | 179 | {#each $table.getHeaderGroups() as headerGroup} 180 | 181 | {#each headerGroup.headers as header} 182 | 212 | {/each} 213 | 214 | {/each} 215 | 216 | 217 | 218 | {#each $table.getRowModel().rows as row} 219 | 220 | {#each row.getVisibleCells() as cell} 221 | 226 | {/each} 227 | 228 | {/each} 229 | 230 |
183 | {#if !header.isPlaceholder} 184 | 200 | 201 | {#if header.column.columnDef.meta?.sort} 202 | setFilter(header.column.id, filters[header.column.id])} 207 | placeholder={`Filter by ${header.column.columnDef.header}...`} 208 | /> 209 | {/if} 210 | {/if} 211 |
222 | 225 |
231 | 232 | 250 |
251 |
252 | -------------------------------------------------------------------------------- /src/routes/album/[albumId]/+error.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |

Ooopsie Whooopsie

6 |

{$page.status}: {$page?.error?.message}

7 | -------------------------------------------------------------------------------- /src/routes/album/[albumId]/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { getAlbumById, getAlbumTracks, mergeAlbumImage, updateAlbumTitle } from '$lib/server/db'; 2 | import { error, type Actions } from '@sveltejs/kit'; 3 | import type { PageServerLoad } from './$types'; 4 | import { notifChannel } from '$lib/server/db/subscriptionDb'; 5 | 6 | export const load = (({ params, locals }) => { 7 | const albumId = parseInt(params.albumId); 8 | 9 | if (!albumId) { 10 | throw error(404, 'Album not found'); 11 | } 12 | 13 | const album = getAlbumById(albumId); 14 | 15 | if (!album) { 16 | throw error(404, 'Album not found'); 17 | } 18 | 19 | const tracks = getAlbumTracks(albumId); 20 | 21 | return { 22 | album, 23 | tracks, 24 | isAdmin: locals?.roles?.includes('admin') 25 | }; 26 | }) satisfies PageServerLoad; 27 | 28 | export const actions: Actions = { 29 | updateAlbumTitle: async ({ request, locals }) => { 30 | if (!locals.username || !locals?.roles?.includes('admin')) { 31 | throw error(401, { 32 | message: 'Unauthorized' 33 | }); 34 | } 35 | 36 | const data = await request.formData(); 37 | 38 | const albumIdStr = data.get('albumId')?.toString(); 39 | const albumId = albumIdStr ? parseInt(albumIdStr) : null; 40 | 41 | const albumTitle = data.get('albumTitle')?.toString(); 42 | 43 | if (!(albumId && albumTitle)) { 44 | throw error(400, 'AlbumId or AlbumTitle missing'); 45 | } 46 | 47 | updateAlbumTitle(albumId, albumTitle); 48 | notifChannel('album-updates', `Album "${albumId}" updated to "${albumTitle}"`); 49 | }, 50 | updateAlbumImage: async ({ request, locals }) => { 51 | if (!locals.username || !locals?.roles?.includes('admin')) { 52 | throw error(401, { 53 | message: 'Unauthorized' 54 | }); 55 | } 56 | 57 | const data = await request.formData(); 58 | 59 | const albumIdStr = data.get('albumId')?.toString(); 60 | const albumId = albumIdStr ? parseInt(albumIdStr) : null; 61 | 62 | if (!albumId) { 63 | throw error(400, 'AlbumId missing'); 64 | } 65 | 66 | const albumImage = data.get('albumImage')?.valueOf() as File; 67 | console.log( 68 | albumId, 69 | 'albumImage', 70 | albumImage, 71 | albumImage?.name, 72 | albumImage?.type, 73 | albumImage?.size, 74 | albumImage?.lastModified 75 | ); 76 | 77 | mergeAlbumImage(albumId, albumImage); 78 | } 79 | }; 80 | -------------------------------------------------------------------------------- /src/routes/album/[albumId]/+page.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 |
15 |
16 |
17 |

{data.album.albumTitle}

18 |

By {data.album.artistName}

19 | Download Sheet 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | {#each data.tracks as track, i} 36 | 37 | 38 | 39 | 40 | 41 | {/each} 42 | 43 |
#TrackDuration
{i + 1}{track.trackName}{Math.floor(track.trackMs / 1000)} s
44 |
45 |
46 | {#if data.album.imgName} 47 | 53 | {/if} 54 |
55 |
56 | 57 | {#if data.isAdmin} 58 | Edit Tracks 59 | 60 |

Update Album Name

61 |
62 | 69 | 70 | 72 |
73 | 74 |

Update Album Image

75 |
76 | 77 | 78 | 79 | {#if uploadedImage} 80 |
81 | 82 |
83 | {/if} 84 | 85 |
86 | 93 |
94 |
95 | {/if} 96 |
97 | 98 | 103 | -------------------------------------------------------------------------------- /src/routes/album/[albumId]/edit-tracks/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { getAlbumById, getAlbumTracks, getGenres } from '$lib/server/db'; 2 | import { error } from '@sveltejs/kit'; 3 | import type { PageServerLoad } from '../$types'; 4 | 5 | export const load = (({ params, locals }) => { 6 | if (!locals?.roles?.includes('admin')) { 7 | throw error(404, 'Unauthorized'); 8 | } 9 | 10 | const albumId = parseInt(params.albumId); 11 | 12 | if (!albumId) { 13 | throw error(404, 'Album not found'); 14 | } 15 | 16 | const album = getAlbumById(albumId); 17 | 18 | if (!album) { 19 | throw error(404, 'Album not found'); 20 | } 21 | 22 | const tracks = getAlbumTracks(albumId); 23 | const genres = getGenres(); 24 | 25 | return { 26 | album, 27 | tracks, 28 | genres 29 | }; 30 | }) satisfies PageServerLoad; 31 | -------------------------------------------------------------------------------- /src/routes/album/[albumId]/edit-tracks/+page.svelte: -------------------------------------------------------------------------------- 1 | 126 | 127 |
128 |

Tracks for {data.album.albumTitle}

129 | 130 |
131 |
132 |
133 |
134 | 135 |
136 |
137 | 138 |
139 |
140 | 141 |
142 | {#if error} 143 |
144 |
145 |
148 |
149 | {/if} 150 |
151 |
152 |
153 | -------------------------------------------------------------------------------- /src/routes/api/addSubscription/+server.ts: -------------------------------------------------------------------------------- 1 | import { addUserDevice, addUserToChannel } from '$lib/server/db/subscriptionDb'; 2 | import { error, json, type RequestHandler } from '@sveltejs/kit'; 3 | 4 | export const POST = (async ({ locals, request }) => { 5 | const username = locals?.username; 6 | 7 | if (!username) { 8 | console.log('No username passed to addSubscription'); 9 | throw error(401, 'Unauthorized'); 10 | } 11 | 12 | const data = await request.json(); 13 | 14 | if (!data.subscription) { 15 | console.log('No subscription passed to addSubscription', data); 16 | throw error(400, 'Bad Request'); 17 | } 18 | 19 | addUserDevice(username, data.subscription); 20 | addUserToChannel(username, 'album-updates'); 21 | 22 | return json({ success: true }); 23 | }) satisfies RequestHandler; 24 | -------------------------------------------------------------------------------- /src/routes/api/album/[albumId]/handleTrackGrid/+server.ts: -------------------------------------------------------------------------------- 1 | import { saveGridTracks } from '$lib/server/db'; 2 | import type { TracksGridSaveData } from '$lib/server/db/types'; 3 | import { error, json } from '@sveltejs/kit'; 4 | import type { RequestHandler } from './$types'; 5 | 6 | export const POST = (async ({ locals, request, params }) => { 7 | if (!locals?.roles?.includes('admin')) { 8 | throw error(401, 'Unauthorized'); 9 | } 10 | 11 | if (!params.albumId) { 12 | throw error(404, { 13 | message: 'Album not found' 14 | }); 15 | } 16 | 17 | const albumId = parseInt(params.albumId); 18 | console.log('albumId', albumId); 19 | if (!albumId) { 20 | throw error(404, { 21 | message: 'Album not found' 22 | }); 23 | } 24 | 25 | const data = await request.json(); 26 | data.albumId = albumId; 27 | 28 | try { 29 | saveGridTracks(data as TracksGridSaveData); 30 | 31 | return json({ success: true }); 32 | } catch (e) { 33 | console.log('error saving grid tracks', e); 34 | throw error(500, 'Error saving grid tracks'); 35 | } 36 | }) satisfies RequestHandler; 37 | -------------------------------------------------------------------------------- /src/routes/api/album/[albumId]/image/[imageName]/+server.ts: -------------------------------------------------------------------------------- 1 | import { getAlbumImage } from '$lib/server/db'; 2 | import { error } from '@sveltejs/kit'; 3 | import type { RequestHandler } from './$types'; 4 | 5 | export const GET = (async ({ params, setHeaders }) => { 6 | if (!params.albumId) { 7 | throw error(404, { 8 | message: 'Album not found' 9 | }); 10 | } 11 | 12 | const albumId = parseInt(params.albumId); 13 | console.log('albumId', albumId); 14 | if (!albumId) { 15 | throw error(404, { 16 | message: 'Album not found' 17 | }); 18 | } 19 | 20 | const img = getAlbumImage(albumId, params.imageName); 21 | if (!img || !img.data) { 22 | throw error(404, 'Image not found'); 23 | } 24 | 25 | setHeaders({ 26 | 'Content-Type': img.mimeType, 27 | 'Content-Length': img.size.toString(), 28 | 'Last-Modified': new Date(img.lastModified).toUTCString(), 29 | 'Cache-Control': 'public, max-age=600' 30 | }); 31 | 32 | return new Response(img.data); 33 | }) satisfies RequestHandler; 34 | -------------------------------------------------------------------------------- /src/routes/api/album/[albumId]/pdf/[pdfSheet]/+server.ts: -------------------------------------------------------------------------------- 1 | import genAlbumPdf from '$lib/server/pdf/generateAlbumPdf'; 2 | import { error } from '@sveltejs/kit'; 3 | import type { RequestHandler } from './$types'; 4 | 5 | export const GET = (async ({ params, setHeaders }) => { 6 | if (!params.albumId) { 7 | throw error(404, { 8 | message: 'Album not found' 9 | }); 10 | } 11 | 12 | const albumId = parseInt(params.albumId); 13 | console.log('albumId', albumId); 14 | if (!albumId) { 15 | throw error(404, { 16 | message: 'Album not found' 17 | }); 18 | } 19 | 20 | const pdf = await genAlbumPdf(albumId); 21 | 22 | setHeaders({ 23 | 'Content-Type': 'application/pdf', 24 | 'Content-Length': pdf.size.toString(), 25 | 'Last-Modified': new Date().toUTCString(), 26 | 'Cache-Control': 'public, max-age=600' 27 | }); 28 | 29 | return new Response(pdf); 30 | }) satisfies RequestHandler; 31 | -------------------------------------------------------------------------------- /src/routes/api/invoices/[id]/email/+server.ts: -------------------------------------------------------------------------------- 1 | import { error } from '@sveltejs/kit'; 2 | import type { RequestHandler } from './$types'; 3 | import { createTransport, type Transporter } from 'nodemailer'; 4 | import { getInvoiceEmailDetails } from '$lib/server/db'; 5 | 6 | let transport: Transporter; 7 | 8 | export const POST = (async ({ locals, params }) => { 9 | if (!locals?.roles?.includes('admin')) { 10 | throw error(401, 'Unauthorized'); 11 | } 12 | 13 | const { id } = params; 14 | 15 | const { order, tracks } = getInvoiceEmailDetails(parseInt(id)); 16 | 17 | if (!transport) { 18 | transport = createTransport({ 19 | host: 'localhost', 20 | port: 1025, 21 | secure: false 22 | }); 23 | } 24 | 25 | const text = ` 26 | Hello ${order.customer}, 27 | 28 | Thank you for your order! See below for details. 29 | 30 | Order #${order.id} 31 | ${tracks 32 | .map( 33 | (item) => 34 | ` - ${item.quantity} x ${item.track} - ${item.album} - ${item.artist} (${item.price}€)` 35 | ) 36 | .join('\n')} 37 | 38 | Total: ${order.total}€ 39 | 40 | Your order will be shipped to: ${order.address}, ${order.city}, ${order.country} 41 | 42 | Kind regards, 43 | SvelteKit Demo App Team 44 | `; 45 | 46 | const html = ` 47 |

Hello ${order.customer},

48 | 49 |

Thank you for your order! See below for details.

50 | 51 |

Order #${order.id}

52 | 53 |
    54 | ${tracks 55 | .map( 56 | (item) => 57 | `
  • ${item.quantity} x ${item.track} - ${item.album} - ${item.artist} (${item.price}€)
  • ` 58 | ) 59 | .join('\n')} 60 |
61 | 62 |

Total: ${order.total}€

63 | 64 |

Your order will be shipped to: ${order.address}, ${order.city}, ${order.country}

65 | 66 |

Kind regards,
67 | SvelteKit Demo App Team

`; 68 | 69 | const worked = await transport.sendMail({ 70 | from: '"SvelteKit Demo App" ', 71 | to: order.email, 72 | subject: `Order confirmation #${order.id}`, 73 | text, 74 | html 75 | }); 76 | 77 | return new Response(worked.response); 78 | }) satisfies RequestHandler; 79 | -------------------------------------------------------------------------------- /src/routes/api/invoices/saveChanges/+server.ts: -------------------------------------------------------------------------------- 1 | import { error, json } from '@sveltejs/kit'; 2 | import type { RequestHandler } from './$types'; 3 | import { updateInvoice } from '$lib/server/db'; 4 | import type { Invoice } from '$lib/server/db/types'; 5 | 6 | export const POST = (async ({ locals, request }) => { 7 | if (!locals?.roles?.includes('admin')) { 8 | throw error(401, 'Unauthorized'); 9 | } 10 | 11 | const data = await request.json(); 12 | 13 | // TODO some typechecking here 14 | 15 | try { 16 | for (const [key, value] of Object.entries(data)) { 17 | console.log(key, value); 18 | updateInvoice(value as Invoice); 19 | } 20 | } catch (e) { 21 | return json({ ok: false, error: e }, { status: 500 }); 22 | } 23 | 24 | return json({ ok: true }); 25 | }) satisfies RequestHandler; 26 | -------------------------------------------------------------------------------- /src/routes/api/playlistTracks/+server.ts: -------------------------------------------------------------------------------- 1 | import { queryPlaylistTracks, type QueryPlaylistTracksArgs } from '$lib/server/db'; 2 | import { json } from '@sveltejs/kit'; 3 | import type { RequestHandler } from './$types'; 4 | 5 | export const GET = (({ url }) => { 6 | const sortCol = url.searchParams.get('sortCol')?.toString(); 7 | const sortDir = url.searchParams.get('sortDir')?.toString(); 8 | console.log('sortCol', sortCol); 9 | 10 | const page = parseInt(url.searchParams.get('page')?.toString() ?? '-1'); 11 | const pageSize = parseInt(url.searchParams.get('pageSize')?.toString() ?? '-1'); 12 | 13 | if (!pageSize || !page || page < 1 || pageSize < 1) { 14 | return json({ error: 'Invalid page or pageSize' }, { status: 400 }); 15 | } 16 | 17 | const search = url.searchParams.get('search')?.toString(); 18 | 19 | const colFilters = url.searchParams.get('colFilters')?.toString(); 20 | 21 | const args: QueryPlaylistTracksArgs = { 22 | pagination: { page, pageSize } 23 | }; 24 | 25 | if (sortCol) { 26 | args.sort = { col: sortCol, dir: sortDir ?? 'asc' }; 27 | } 28 | 29 | if (search) { 30 | args.search = search; 31 | } 32 | 33 | if (colFilters) { 34 | args.colFilters = JSON.parse(colFilters); 35 | } 36 | 37 | const data = queryPlaylistTracks(args); 38 | return json(data); 39 | }) satisfies RequestHandler; 40 | -------------------------------------------------------------------------------- /src/routes/api/searchTracks/+server.ts: -------------------------------------------------------------------------------- 1 | import { getInitialTracks, searchTracks } from '$lib/server/db'; 2 | import type { Track } from '$lib/server/db/types'; 3 | import { json } from '@sveltejs/kit'; 4 | import type { RequestHandler } from './$types'; 5 | 6 | export const GET = (({ url }) => { 7 | const searchTerm = url.searchParams.get('searchTerm')?.toString(); 8 | console.log('searchTerm', searchTerm); 9 | 10 | let tracks: Track[] = []; 11 | 12 | if (!searchTerm) { 13 | tracks = getInitialTracks(); 14 | } else { 15 | tracks = searchTracks(searchTerm) ?? []; 16 | } 17 | 18 | return json(tracks); 19 | }) satisfies RequestHandler; 20 | -------------------------------------------------------------------------------- /src/routes/api/vapidPubKey/+server.ts: -------------------------------------------------------------------------------- 1 | import { VAPID_PUBLIC_KEY } from '$env/static/private'; 2 | import { json, type RequestHandler } from '@sveltejs/kit'; 3 | 4 | export const GET = (() => { 5 | return json({ data: VAPID_PUBLIC_KEY }); 6 | }) satisfies RequestHandler; 7 | -------------------------------------------------------------------------------- /src/routes/login/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { checkUserCredentials, createUser } from '$lib/server/db'; 2 | import { createSession } from '$lib/server/sesstionStore'; 3 | import { fail, redirect, type Actions, type Cookies } from '@sveltejs/kit'; 4 | 5 | function performLogin(cookies: Cookies, username: string) { 6 | const maxAge = 1000 * 60 * 60 * 24 * 30; // 30 days 7 | const sid = createSession(username, maxAge); 8 | cookies.set('sid', sid, { maxAge }); 9 | } 10 | 11 | export const actions: Actions = { 12 | register: async ({ request, cookies }) => { 13 | const data = await request.formData(); 14 | const username = data.get('username')?.toString(); 15 | const password = data.get('password')?.toString(); 16 | 17 | if (username && password) { 18 | createUser(username, password); 19 | performLogin(cookies, username); 20 | throw redirect(303, '/'); 21 | } else { 22 | console.log('Missing username or password', data); 23 | return fail(400, { errorMessage: 'Missing username or password' }); 24 | } 25 | }, 26 | 27 | login: async ({ request, cookies }) => { 28 | const data = await request.formData(); 29 | const username = data.get('username')?.toString(); 30 | const password = data.get('password')?.toString(); 31 | 32 | if (username && password) { 33 | const res = await checkUserCredentials(username, password); 34 | 35 | if (!res) { 36 | return fail(401, { errorMessage: 'Invalid username or password' }); 37 | } 38 | 39 | performLogin(cookies, username); 40 | throw redirect(303, '/'); 41 | } else { 42 | return fail(400, { errorMessage: 'Missing username or password' }); 43 | } 44 | } 45 | }; 46 | -------------------------------------------------------------------------------- /src/routes/login/+page.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
12 |

Login or Register

13 |
14 | 22 | 30 | {#if form?.errorMessage} 31 |
{form.errorMessage}
32 | {/if} 33 | 36 |
37 | 40 |
41 |
42 | -------------------------------------------------------------------------------- /src/routes/logout/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { deleteSession } from '$lib/server/sesstionStore'; 2 | import { redirect } from '@sveltejs/kit'; 3 | import type { PageServerLoad } from '../$types'; 4 | 5 | export const load = (({ cookies }) => { 6 | const sid = cookies.get('sid'); 7 | if (sid) { 8 | cookies.delete('sid'); 9 | deleteSession(sid); 10 | } 11 | 12 | throw redirect(303, '/'); 13 | }) satisfies PageServerLoad; 14 | -------------------------------------------------------------------------------- /src/routes/user/settings/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { notifUser } from '$lib/server/db/subscriptionDb'; 2 | import { error, type Actions } from '@sveltejs/kit'; 3 | 4 | export const actions: Actions = { 5 | testNotification: async ({ locals }) => { 6 | const username = locals.username; 7 | if (!username) { 8 | throw error(400, 'Not logged in'); 9 | } 10 | 11 | notifUser(username, 'This is a test notification'); 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /src/routes/user/settings/+page.svelte: -------------------------------------------------------------------------------- 1 | 90 | 91 |
92 |

Settings

93 | 94 |
95 |

Push Notifications

96 |

Receive notifications when albums are updated.

97 |

Subscriptions get reset every night!

98 |
99 | {#if nottifPermGranted === null} 100 |

Checking permissions...

101 | {:else if nottifPermGranted === false} 102 | 105 | {:else} 106 |

107 | You have enabled notification permissions. Remove the permission in your browser 108 | settings... 109 |

110 |

Subscribed to push notifications: {isSubscribed}

111 | {#if isSubscribed} 112 |
113 | 114 |
115 |
116 |
117 | 118 |
119 |
120 | {/if} 121 | {/if} 122 |
123 |
124 |
125 | -------------------------------------------------------------------------------- /src/service-worker.ts: -------------------------------------------------------------------------------- 1 | self.addEventListener('fetch', function () { 2 | return; 3 | }); 4 | 5 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 6 | self.addEventListener('push', function (event: any) { 7 | const payload = event.data?.text() ?? 'no payload'; 8 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 9 | const registration = (self as any).registration as ServiceWorkerRegistration; 10 | event.waitUntil( 11 | registration.showNotification('SvelteKit Music Store', { 12 | body: payload 13 | }) 14 | ); 15 | } as EventListener); 16 | -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phartenfeller/sveltekit-1.0-sqlite-demo-app/da869d6e0326e0388924aeef3bdd6c6d9a7269dc/static/favicon.png -------------------------------------------------------------------------------- /static/icons/icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phartenfeller/sveltekit-1.0-sqlite-demo-app/da869d6e0326e0388924aeef3bdd6c6d9a7269dc/static/icons/icon-192.png -------------------------------------------------------------------------------- /static/icons/icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phartenfeller/sveltekit-1.0-sqlite-demo-app/da869d6e0326e0388924aeef3bdd6c6d9a7269dc/static/icons/icon-512.png -------------------------------------------------------------------------------- /static/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "sqlite-sveltekit-tutorial-app", 3 | "name": "SvelteKit + SQLite Tutorial App", 4 | "short_name": "SvelteKit + SQLite", 5 | "start_url": "/", 6 | "scope": "/", 7 | "display": "standalone", 8 | "orientation": "any", 9 | "theme_color": "#00d1b2", 10 | "background_color": "#ffffff", 11 | "dir": "ltr", 12 | "lang": "en", 13 | "icons": [ 14 | { 15 | "src": "/icons/icon-192.png", 16 | "sizes": "192x192", 17 | "type": "image/png" 18 | }, 19 | { 20 | "src": "/icons/icon-512.png", 21 | "sizes": "512x512", 22 | "type": "image/png" 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-node'; 2 | import { vitePreprocess } from '@sveltejs/kit/vite'; 3 | import { mdsvex } from 'mdsvex'; 4 | import mdsvexConfig from './mdsvex.config.js'; 5 | 6 | /** @type {import('@sveltejs/kit').Config} */ 7 | const config = { 8 | extensions: ['.svelte', ...mdsvexConfig.extensions], 9 | // Consult https://kit.svelte.dev/docs/integrations#preprocessors 10 | // for more information about preprocessors 11 | preprocess: [vitePreprocess(), mdsvex(mdsvexConfig)], 12 | 13 | kit: { 14 | adapter: adapter() 15 | } 16 | }; 17 | 18 | export default config; 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true, 12 | "noEmit": true, 13 | "allowImportingTsExtensions": true 14 | } 15 | // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias 16 | // 17 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 18 | // from the referenced tsconfig.json - TypeScript does not merge them in 19 | } 20 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { sveltekit } from '@sveltejs/kit/vite'; 2 | import type { UserConfig } from 'vite'; 3 | import { imagetools } from 'vite-imagetools'; 4 | 5 | const config: UserConfig = { 6 | plugins: [sveltekit(), imagetools()] 7 | }; 8 | 9 | export default config; 10 | --------------------------------------------------------------------------------