diff options
62 files changed, 4394 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/.svelte-kit/ambient.d.ts b/.svelte-kit/ambient.d.ts new file mode 100644 index 0000000..338b8ec --- /dev/null +++ b/.svelte-kit/ambient.d.ts @@ -0,0 +1,326 @@ + +// this file is generated — do not edit it + + +/// <reference types="@sveltejs/kit" /> + +/** + * This module provides access to environment variables that are injected _statically_ into your bundle at build time and are limited to _private_ access. + * + * | | Runtime | Build time | + * | ------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ | + * | Private | [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private) | [`$env/static/private`](https://svelte.dev/docs/kit/$env-static-private) | + * | Public | [`$env/dynamic/public`](https://svelte.dev/docs/kit/$env-dynamic-public) | [`$env/static/public`](https://svelte.dev/docs/kit/$env-static-public) | + * + * Static environment variables are [loaded by Vite](https://vitejs.dev/guide/env-and-mode.html#env-files) from `.env` files and `process.env` at build time and then statically injected into your bundle at build time, enabling optimisations like dead code elimination. + * + * **_Private_ access:** + * + * - This module cannot be imported into client-side code + * - This module only includes variables that _do not_ begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) _and do_ start with [`config.kit.env.privatePrefix`](https://svelte.dev/docs/kit/configuration#env) (if configured) + * + * For example, given the following build time environment: + * + * ```env + * ENVIRONMENT=production + * PUBLIC_BASE_URL=http://site.com + * ``` + * + * With the default `publicPrefix` and `privatePrefix`: + * + * ```ts + * import { ENVIRONMENT, PUBLIC_BASE_URL } from '$env/static/private'; + * + * console.log(ENVIRONMENT); // => "production" + * console.log(PUBLIC_BASE_URL); // => throws error during build + * ``` + * + * The above values will be the same _even if_ different values for `ENVIRONMENT` or `PUBLIC_BASE_URL` are set at runtime, as they are statically replaced in your code with their build time values. + */ +declare module '$env/static/private' { + export const SHELL: string; + export const npm_command: string; + export const COREPACK_ENABLE_AUTO_PIN: string; + export const WINDOWID: string; + export const COLORTERM: string; + export const XDG_SESSION_PATH: string; + export const TERM_PROGRAM_VERSION: string; + export const TMUX: string; + export const NODE: string; + export const LC_ADDRESS: string; + export const LC_NAME: string; + export const npm_config_local_prefix: string; + export const DESKTOP_SESSION: string; + export const LC_MONETARY: string; + export const GTK_MODULES: string; + export const XDG_SEAT: string; + export const PWD: string; + export const XDG_SESSION_DESKTOP: string; + export const LOGNAME: string; + export const XDG_SESSION_TYPE: string; + export const XAUTHORITY: string; + export const NoDefaultCurrentDirectoryInExePath: string; + export const XDG_GREETER_DATA_DIR: string; + export const CLAUDECODE: string; + export const MOTD_SHOWN: string; + export const HOME: string; + export const LC_PAPER: string; + export const LANG: string; + export const LS_COLORS: string; + export const npm_package_version: string; + export const VTE_VERSION: string; + export const XDG_SEAT_PATH: string; + export const npm_lifecycle_script: string; + export const NVM_DIR: string; + export const XDG_SESSION_CLASS: string; + export const TERM: string; + export const LC_IDENTIFICATION: string; + export const npm_package_name: string; + export const USER: string; + export const TMUX_PANE: string; + export const PAM_KWALLET5_LOGIN: string; + export const DISPLAY: string; + export const npm_lifecycle_event: string; + export const SHLVL: string; + export const GIT_EDITOR: string; + export const LC_TELEPHONE: string; + export const LC_MEASUREMENT: string; + export const XDG_VTNR: string; + export const XDG_SESSION_ID: string; + export const npm_config_user_agent: string; + export const OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE: string; + export const npm_execpath: string; + export const LD_LIBRARY_PATH: string; + export const XDG_RUNTIME_DIR: string; + export const CLAUDE_CODE_ENTRYPOINT: string; + export const DEBUGINFOD_URLS: string; + export const npm_package_json: string; + export const LC_TIME: string; + export const CUDA_HOME: string; + export const GTK3_MODULES: string; + export const GCC_COLORS: string; + export const PATH: string; + export const GDMSESSION: string; + export const DBUS_SESSION_BUS_ADDRESS: string; + export const MAIL: string; + export const npm_node_execpath: string; + export const LC_NUMERIC: string; + export const OLDPWD: string; + export const TERM_PROGRAM: string; + export const _: string; + export const NODE_ENV: string; +} + +/** + * This module provides access to environment variables that are injected _statically_ into your bundle at build time and are _publicly_ accessible. + * + * | | Runtime | Build time | + * | ------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ | + * | Private | [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private) | [`$env/static/private`](https://svelte.dev/docs/kit/$env-static-private) | + * | Public | [`$env/dynamic/public`](https://svelte.dev/docs/kit/$env-dynamic-public) | [`$env/static/public`](https://svelte.dev/docs/kit/$env-static-public) | + * + * Static environment variables are [loaded by Vite](https://vitejs.dev/guide/env-and-mode.html#env-files) from `.env` files and `process.env` at build time and then statically injected into your bundle at build time, enabling optimisations like dead code elimination. + * + * **_Public_ access:** + * + * - This module _can_ be imported into client-side code + * - **Only** variables that begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) (which defaults to `PUBLIC_`) are included + * + * For example, given the following build time environment: + * + * ```env + * ENVIRONMENT=production + * PUBLIC_BASE_URL=http://site.com + * ``` + * + * With the default `publicPrefix` and `privatePrefix`: + * + * ```ts + * import { ENVIRONMENT, PUBLIC_BASE_URL } from '$env/static/public'; + * + * console.log(ENVIRONMENT); // => throws error during build + * console.log(PUBLIC_BASE_URL); // => "http://site.com" + * ``` + * + * The above values will be the same _even if_ different values for `ENVIRONMENT` or `PUBLIC_BASE_URL` are set at runtime, as they are statically replaced in your code with their build time values. + */ +declare module '$env/static/public' { + +} + +/** + * This module provides access to environment variables set _dynamically_ at runtime and that are limited to _private_ access. + * + * | | Runtime | Build time | + * | ------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ | + * | Private | [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private) | [`$env/static/private`](https://svelte.dev/docs/kit/$env-static-private) | + * | Public | [`$env/dynamic/public`](https://svelte.dev/docs/kit/$env-dynamic-public) | [`$env/static/public`](https://svelte.dev/docs/kit/$env-static-public) | + * + * Dynamic environment variables are defined by the platform you're running on. For example if you're using [`adapter-node`](https://github.com/sveltejs/kit/tree/main/packages/adapter-node) (or running [`vite preview`](https://svelte.dev/docs/kit/cli)), this is equivalent to `process.env`. + * + * **_Private_ access:** + * + * - This module cannot be imported into client-side code + * - This module includes variables that _do not_ begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) _and do_ start with [`config.kit.env.privatePrefix`](https://svelte.dev/docs/kit/configuration#env) (if configured) + * + * > [!NOTE] In `dev`, `$env/dynamic` includes environment variables from `.env`. In `prod`, this behavior will depend on your adapter. + * + * > [!NOTE] To get correct types, environment variables referenced in your code should be declared (for example in an `.env` file), even if they don't have a value until the app is deployed: + * > + * > ```env + * > MY_FEATURE_FLAG= + * > ``` + * > + * > You can override `.env` values from the command line like so: + * > + * > ```sh + * > MY_FEATURE_FLAG="enabled" npm run dev + * > ``` + * + * For example, given the following runtime environment: + * + * ```env + * ENVIRONMENT=production + * PUBLIC_BASE_URL=http://site.com + * ``` + * + * With the default `publicPrefix` and `privatePrefix`: + * + * ```ts + * import { env } from '$env/dynamic/private'; + * + * console.log(env.ENVIRONMENT); // => "production" + * console.log(env.PUBLIC_BASE_URL); // => undefined + * ``` + */ +declare module '$env/dynamic/private' { + export const env: { + SHELL: string; + npm_command: string; + COREPACK_ENABLE_AUTO_PIN: string; + WINDOWID: string; + COLORTERM: string; + XDG_SESSION_PATH: string; + TERM_PROGRAM_VERSION: string; + TMUX: string; + NODE: string; + LC_ADDRESS: string; + LC_NAME: string; + npm_config_local_prefix: string; + DESKTOP_SESSION: string; + LC_MONETARY: string; + GTK_MODULES: string; + XDG_SEAT: string; + PWD: string; + XDG_SESSION_DESKTOP: string; + LOGNAME: string; + XDG_SESSION_TYPE: string; + XAUTHORITY: string; + NoDefaultCurrentDirectoryInExePath: string; + XDG_GREETER_DATA_DIR: string; + CLAUDECODE: string; + MOTD_SHOWN: string; + HOME: string; + LC_PAPER: string; + LANG: string; + LS_COLORS: string; + npm_package_version: string; + VTE_VERSION: string; + XDG_SEAT_PATH: string; + npm_lifecycle_script: string; + NVM_DIR: string; + XDG_SESSION_CLASS: string; + TERM: string; + LC_IDENTIFICATION: string; + npm_package_name: string; + USER: string; + TMUX_PANE: string; + PAM_KWALLET5_LOGIN: string; + DISPLAY: string; + npm_lifecycle_event: string; + SHLVL: string; + GIT_EDITOR: string; + LC_TELEPHONE: string; + LC_MEASUREMENT: string; + XDG_VTNR: string; + XDG_SESSION_ID: string; + npm_config_user_agent: string; + OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE: string; + npm_execpath: string; + LD_LIBRARY_PATH: string; + XDG_RUNTIME_DIR: string; + CLAUDE_CODE_ENTRYPOINT: string; + DEBUGINFOD_URLS: string; + npm_package_json: string; + LC_TIME: string; + CUDA_HOME: string; + GTK3_MODULES: string; + GCC_COLORS: string; + PATH: string; + GDMSESSION: string; + DBUS_SESSION_BUS_ADDRESS: string; + MAIL: string; + npm_node_execpath: string; + LC_NUMERIC: string; + OLDPWD: string; + TERM_PROGRAM: string; + _: string; + NODE_ENV: string; + [key: `PUBLIC_${string}`]: undefined; + [key: `${string}`]: string | undefined; + } +} + +/** + * This module provides access to environment variables set _dynamically_ at runtime and that are _publicly_ accessible. + * + * | | Runtime | Build time | + * | ------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ | + * | Private | [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private) | [`$env/static/private`](https://svelte.dev/docs/kit/$env-static-private) | + * | Public | [`$env/dynamic/public`](https://svelte.dev/docs/kit/$env-dynamic-public) | [`$env/static/public`](https://svelte.dev/docs/kit/$env-static-public) | + * + * Dynamic environment variables are defined by the platform you're running on. For example if you're using [`adapter-node`](https://github.com/sveltejs/kit/tree/main/packages/adapter-node) (or running [`vite preview`](https://svelte.dev/docs/kit/cli)), this is equivalent to `process.env`. + * + * **_Public_ access:** + * + * - This module _can_ be imported into client-side code + * - **Only** variables that begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) (which defaults to `PUBLIC_`) are included + * + * > [!NOTE] In `dev`, `$env/dynamic` includes environment variables from `.env`. In `prod`, this behavior will depend on your adapter. + * + * > [!NOTE] To get correct types, environment variables referenced in your code should be declared (for example in an `.env` file), even if they don't have a value until the app is deployed: + * > + * > ```env + * > MY_FEATURE_FLAG= + * > ``` + * > + * > You can override `.env` values from the command line like so: + * > + * > ```sh + * > MY_FEATURE_FLAG="enabled" npm run dev + * > ``` + * + * For example, given the following runtime environment: + * + * ```env + * ENVIRONMENT=production + * PUBLIC_BASE_URL=http://example.com + * ``` + * + * With the default `publicPrefix` and `privatePrefix`: + * + * ```ts + * import { env } from '$env/dynamic/public'; + * console.log(env.ENVIRONMENT); // => undefined, not public + * console.log(env.PUBLIC_BASE_URL); // => "http://example.com" + * ``` + * + * ``` + * + * ``` + */ +declare module '$env/dynamic/public' { + export const env: { + [key: `PUBLIC_${string}`]: string | undefined; + } +} diff --git a/.svelte-kit/generated/client/app.js b/.svelte-kit/generated/client/app.js new file mode 100644 index 0000000..cb58f81 --- /dev/null +++ b/.svelte-kit/generated/client/app.js @@ -0,0 +1,41 @@ +export { matchers } from './matchers.js'; + +export const nodes = [ + () => import('./nodes/0'), + () => import('./nodes/1'), + () => import('./nodes/2'), + () => import('./nodes/3'), + () => import('./nodes/4'), + () => import('./nodes/5'), + () => import('./nodes/6'), + () => import('./nodes/7'), + () => import('./nodes/8') +]; + +export const server_loads = []; + +export const dictionary = { + "/": [2], + "/channel/[id]": [3], + "/playlists": [4], + "/playlists/[id]": [5], + "/search": [6], + "/subscriptions": [7], + "/watch/[id]": [8] + }; + +export const hooks = { + handleError: (({ error }) => { console.error(error) }), + + reroute: (() => {}), + transport: {} +}; + +export const decoders = Object.fromEntries(Object.entries(hooks.transport).map(([k, v]) => [k, v.decode])); +export const encoders = Object.fromEntries(Object.entries(hooks.transport).map(([k, v]) => [k, v.encode])); + +export const hash = false; + +export const decode = (type, value) => decoders[type](value); + +export { default as root } from '../root.js';
\ No newline at end of file diff --git a/.svelte-kit/generated/client/matchers.js b/.svelte-kit/generated/client/matchers.js new file mode 100644 index 0000000..f6bd30a --- /dev/null +++ b/.svelte-kit/generated/client/matchers.js @@ -0,0 +1 @@ +export const matchers = {};
\ No newline at end of file diff --git a/.svelte-kit/generated/client/nodes/0.js b/.svelte-kit/generated/client/nodes/0.js new file mode 100644 index 0000000..fed1375 --- /dev/null +++ b/.svelte-kit/generated/client/nodes/0.js @@ -0,0 +1 @@ +export { default as component } from "../../../../src/routes/+layout.svelte";
\ No newline at end of file diff --git a/.svelte-kit/generated/client/nodes/1.js b/.svelte-kit/generated/client/nodes/1.js new file mode 100644 index 0000000..bf58bad --- /dev/null +++ b/.svelte-kit/generated/client/nodes/1.js @@ -0,0 +1 @@ +export { default as component } from "../../../../node_modules/@sveltejs/kit/src/runtime/components/svelte-5/error.svelte";
\ No newline at end of file diff --git a/.svelte-kit/generated/client/nodes/2.js b/.svelte-kit/generated/client/nodes/2.js new file mode 100644 index 0000000..1cb4f85 --- /dev/null +++ b/.svelte-kit/generated/client/nodes/2.js @@ -0,0 +1 @@ +export { default as component } from "../../../../src/routes/+page.svelte";
\ No newline at end of file diff --git a/.svelte-kit/generated/client/nodes/3.js b/.svelte-kit/generated/client/nodes/3.js new file mode 100644 index 0000000..dc86f74 --- /dev/null +++ b/.svelte-kit/generated/client/nodes/3.js @@ -0,0 +1 @@ +export { default as component } from "../../../../src/routes/channel/[id]/+page.svelte";
\ No newline at end of file diff --git a/.svelte-kit/generated/client/nodes/4.js b/.svelte-kit/generated/client/nodes/4.js new file mode 100644 index 0000000..fc083af --- /dev/null +++ b/.svelte-kit/generated/client/nodes/4.js @@ -0,0 +1 @@ +export { default as component } from "../../../../src/routes/playlists/+page.svelte";
\ No newline at end of file diff --git a/.svelte-kit/generated/client/nodes/5.js b/.svelte-kit/generated/client/nodes/5.js new file mode 100644 index 0000000..9e322be --- /dev/null +++ b/.svelte-kit/generated/client/nodes/5.js @@ -0,0 +1 @@ +export { default as component } from "../../../../src/routes/playlists/[id]/+page.svelte";
\ No newline at end of file diff --git a/.svelte-kit/generated/client/nodes/6.js b/.svelte-kit/generated/client/nodes/6.js new file mode 100644 index 0000000..f0df5ad --- /dev/null +++ b/.svelte-kit/generated/client/nodes/6.js @@ -0,0 +1 @@ +export { default as component } from "../../../../src/routes/search/+page.svelte";
\ No newline at end of file diff --git a/.svelte-kit/generated/client/nodes/7.js b/.svelte-kit/generated/client/nodes/7.js new file mode 100644 index 0000000..c06163f --- /dev/null +++ b/.svelte-kit/generated/client/nodes/7.js @@ -0,0 +1 @@ +export { default as component } from "../../../../src/routes/subscriptions/+page.svelte";
\ No newline at end of file diff --git a/.svelte-kit/generated/client/nodes/8.js b/.svelte-kit/generated/client/nodes/8.js new file mode 100644 index 0000000..6e8ceff --- /dev/null +++ b/.svelte-kit/generated/client/nodes/8.js @@ -0,0 +1 @@ +export { default as component } from "../../../../src/routes/watch/[id]/+page.svelte";
\ No newline at end of file diff --git a/.svelte-kit/generated/root.js b/.svelte-kit/generated/root.js new file mode 100644 index 0000000..4d1e892 --- /dev/null +++ b/.svelte-kit/generated/root.js @@ -0,0 +1,3 @@ +import { asClassComponent } from 'svelte/legacy'; +import Root from './root.svelte'; +export default asClassComponent(Root);
\ No newline at end of file diff --git a/.svelte-kit/generated/root.svelte b/.svelte-kit/generated/root.svelte new file mode 100644 index 0000000..0795183 --- /dev/null +++ b/.svelte-kit/generated/root.svelte @@ -0,0 +1,68 @@ +<!-- This file is generated by @sveltejs/kit — do not edit it! --> +<svelte:options runes={true} /> +<script> + import { setContext, onMount, tick } from 'svelte'; + import { browser } from '$app/environment'; + + // stores + let { stores, page, constructors, components = [], form, data_0 = null, data_1 = null } = $props(); + + if (!browser) { + // svelte-ignore state_referenced_locally + setContext('__svelte__', stores); + } + + if (browser) { + $effect.pre(() => stores.page.set(page)); + } else { + // svelte-ignore state_referenced_locally + stores.page.set(page); + } + $effect(() => { + stores;page;constructors;components;form;data_0;data_1; + stores.page.notify(); + }); + + let mounted = $state(false); + let navigated = $state(false); + let title = $state(null); + + onMount(() => { + const unsubscribe = stores.page.subscribe(() => { + if (mounted) { + navigated = true; + tick().then(() => { + title = document.title || 'untitled page'; + }); + } + }); + + mounted = true; + return unsubscribe; + }); + + const Pyramid_1=$derived(constructors[1]) +</script> + +{#if constructors[1]} + {@const Pyramid_0 = constructors[0]} + <!-- svelte-ignore binding_property_non_reactive --> + <Pyramid_0 bind:this={components[0]} data={data_0} {form} params={page.params}> + <!-- svelte-ignore binding_property_non_reactive --> + <Pyramid_1 bind:this={components[1]} data={data_1} {form} params={page.params} /> + </Pyramid_0> + +{:else} + {@const Pyramid_0 = constructors[0]} + <!-- svelte-ignore binding_property_non_reactive --> + <Pyramid_0 bind:this={components[0]} data={data_0} {form} params={page.params} /> + +{/if} + +{#if mounted} + <div id="svelte-announcer" aria-live="assertive" aria-atomic="true" style="position: absolute; left: 0; top: 0; clip: rect(0 0 0 0); clip-path: inset(50%); overflow: hidden; white-space: nowrap; width: 1px; height: 1px"> + {#if navigated} + {title} + {/if} + </div> +{/if}
\ No newline at end of file diff --git a/.svelte-kit/generated/server/internal.js b/.svelte-kit/generated/server/internal.js new file mode 100644 index 0000000..bc65535 --- /dev/null +++ b/.svelte-kit/generated/server/internal.js @@ -0,0 +1,53 @@ + +import root from '../root.js'; +import { set_building, set_prerendering } from '__sveltekit/environment'; +import { set_assets } from '$app/paths/internal/server'; +import { set_manifest, set_read_implementation } from '__sveltekit/server'; +import { set_private_env, set_public_env } from '../../../node_modules/@sveltejs/kit/src/runtime/shared-server.js'; + +export const options = { + app_template_contains_nonce: false, + async: false, + csp: {"mode":"auto","directives":{"upgrade-insecure-requests":false,"block-all-mixed-content":false},"reportOnly":{"upgrade-insecure-requests":false,"block-all-mixed-content":false}}, + csrf_check_origin: true, + csrf_trusted_origins: [], + embedded: false, + env_public_prefix: 'PUBLIC_', + env_private_prefix: '', + hash_routing: false, + hooks: null, // added lazily, via `get_hooks` + preload_strategy: "modulepreload", + root, + service_worker: false, + service_worker_options: undefined, + templates: { + app: ({ head, body, assets, nonce, env }) => "<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <link rel=\"icon\" href=\"" + assets + "/favicon.png\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n " + head + "\n </head>\n <body data-sveltekit-preload-data=\"hover\">\n <div style=\"display: contents\">" + body + "</div>\n </body>\n</html>\n", + error: ({ status, message }) => "<!doctype html>\n<html lang=\"en\">\n\t<head>\n\t\t<meta charset=\"utf-8\" />\n\t\t<title>" + message + "</title>\n\n\t\t<style>\n\t\t\tbody {\n\t\t\t\t--bg: white;\n\t\t\t\t--fg: #222;\n\t\t\t\t--divider: #ccc;\n\t\t\t\tbackground: var(--bg);\n\t\t\t\tcolor: var(--fg);\n\t\t\t\tfont-family:\n\t\t\t\t\tsystem-ui,\n\t\t\t\t\t-apple-system,\n\t\t\t\t\tBlinkMacSystemFont,\n\t\t\t\t\t'Segoe UI',\n\t\t\t\t\tRoboto,\n\t\t\t\t\tOxygen,\n\t\t\t\t\tUbuntu,\n\t\t\t\t\tCantarell,\n\t\t\t\t\t'Open Sans',\n\t\t\t\t\t'Helvetica Neue',\n\t\t\t\t\tsans-serif;\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t\tjustify-content: center;\n\t\t\t\theight: 100vh;\n\t\t\t\tmargin: 0;\n\t\t\t}\n\n\t\t\t.error {\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t\tmax-width: 32rem;\n\t\t\t\tmargin: 0 1rem;\n\t\t\t}\n\n\t\t\t.status {\n\t\t\t\tfont-weight: 200;\n\t\t\t\tfont-size: 3rem;\n\t\t\t\tline-height: 1;\n\t\t\t\tposition: relative;\n\t\t\t\ttop: -0.05rem;\n\t\t\t}\n\n\t\t\t.message {\n\t\t\t\tborder-left: 1px solid var(--divider);\n\t\t\t\tpadding: 0 0 0 1rem;\n\t\t\t\tmargin: 0 0 0 1rem;\n\t\t\t\tmin-height: 2.5rem;\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t}\n\n\t\t\t.message h1 {\n\t\t\t\tfont-weight: 400;\n\t\t\t\tfont-size: 1em;\n\t\t\t\tmargin: 0;\n\t\t\t}\n\n\t\t\t@media (prefers-color-scheme: dark) {\n\t\t\t\tbody {\n\t\t\t\t\t--bg: #222;\n\t\t\t\t\t--fg: #ddd;\n\t\t\t\t\t--divider: #666;\n\t\t\t\t}\n\t\t\t}\n\t\t</style>\n\t</head>\n\t<body>\n\t\t<div class=\"error\">\n\t\t\t<span class=\"status\">" + status + "</span>\n\t\t\t<div class=\"message\">\n\t\t\t\t<h1>" + message + "</h1>\n\t\t\t</div>\n\t\t</div>\n\t</body>\n</html>\n" + }, + version_hash: "v2dt08" +}; + +export async function get_hooks() { + let handle; + let handleFetch; + let handleError; + let handleValidationError; + let init; + + + let reroute; + let transport; + + + return { + handle, + handleFetch, + handleError, + handleValidationError, + init, + reroute, + transport + }; +} + +export { set_assets, set_building, set_manifest, set_prerendering, set_private_env, set_public_env, set_read_implementation }; diff --git a/.svelte-kit/non-ambient.d.ts b/.svelte-kit/non-ambient.d.ts new file mode 100644 index 0000000..a76a2ec --- /dev/null +++ b/.svelte-kit/non-ambient.d.ts @@ -0,0 +1,64 @@ + +// this file is generated — do not edit it + + +declare module "svelte/elements" { + export interface HTMLAttributes<T> { + 'data-sveltekit-keepfocus'?: true | '' | 'off' | undefined | null; + 'data-sveltekit-noscroll'?: true | '' | 'off' | undefined | null; + 'data-sveltekit-preload-code'?: + | true + | '' + | 'eager' + | 'viewport' + | 'hover' + | 'tap' + | 'off' + | undefined + | null; + 'data-sveltekit-preload-data'?: true | '' | 'hover' | 'tap' | 'off' | undefined | null; + 'data-sveltekit-reload'?: true | '' | 'off' | undefined | null; + 'data-sveltekit-replacestate'?: true | '' | 'off' | undefined | null; + } +} + +export {}; + + +declare module "$app/types" { + export interface AppTypes { + RouteId(): "/" | "/api" | "/api/channel" | "/api/channel/[id]" | "/api/playlist" | "/api/related" | "/api/related/[id]" | "/api/search" | "/api/trending" | "/api/video" | "/api/video/[id]" | "/channel" | "/channel/[id]" | "/playlists" | "/playlists/[id]" | "/search" | "/subscriptions" | "/watch" | "/watch/[id]"; + RouteParams(): { + "/api/channel/[id]": { id: string }; + "/api/related/[id]": { id: string }; + "/api/video/[id]": { id: string }; + "/channel/[id]": { id: string }; + "/playlists/[id]": { id: string }; + "/watch/[id]": { id: string } + }; + LayoutParams(): { + "/": { id?: string }; + "/api": { id?: string }; + "/api/channel": { id?: string }; + "/api/channel/[id]": { id: string }; + "/api/playlist": Record<string, never>; + "/api/related": { id?: string }; + "/api/related/[id]": { id: string }; + "/api/search": Record<string, never>; + "/api/trending": Record<string, never>; + "/api/video": { id?: string }; + "/api/video/[id]": { id: string }; + "/channel": { id?: string }; + "/channel/[id]": { id: string }; + "/playlists": { id?: string }; + "/playlists/[id]": { id: string }; + "/search": Record<string, never>; + "/subscriptions": Record<string, never>; + "/watch": { id?: string }; + "/watch/[id]": { id: string } + }; + Pathname(): "/" | `/api/channel/${string}` & {} | "/api/playlist" | `/api/related/${string}` & {} | "/api/search" | "/api/trending" | `/api/video/${string}` & {} | `/channel/${string}` & {} | "/playlists" | `/playlists/${string}` & {} | "/search" | "/subscriptions" | `/watch/${string}` & {}; + ResolvedPathname(): `${"" | `/${string}`}${ReturnType<AppTypes['Pathname']>}`; + Asset(): string & {}; + } +}
\ No newline at end of file diff --git a/.svelte-kit/tsconfig.json b/.svelte-kit/tsconfig.json new file mode 100644 index 0000000..7692388 --- /dev/null +++ b/.svelte-kit/tsconfig.json @@ -0,0 +1,55 @@ +{ + "compilerOptions": { + "paths": { + "$lib": [ + "../src/lib" + ], + "$lib/*": [ + "../src/lib/*" + ], + "$app/types": [ + "./types/index.d.ts" + ] + }, + "rootDirs": [ + "..", + "./types" + ], + "verbatimModuleSyntax": true, + "isolatedModules": true, + "lib": [ + "esnext", + "DOM", + "DOM.Iterable" + ], + "moduleResolution": "bundler", + "module": "esnext", + "noEmit": true, + "target": "esnext" + }, + "include": [ + "ambient.d.ts", + "non-ambient.d.ts", + "./types/**/$types.d.ts", + "../vite.config.js", + "../vite.config.ts", + "../src/**/*.js", + "../src/**/*.ts", + "../src/**/*.svelte", + "../test/**/*.js", + "../test/**/*.ts", + "../test/**/*.svelte", + "../tests/**/*.js", + "../tests/**/*.ts", + "../tests/**/*.svelte" + ], + "exclude": [ + "../node_modules/**", + "../src/service-worker.js", + "../src/service-worker/**/*.js", + "../src/service-worker.ts", + "../src/service-worker/**/*.ts", + "../src/service-worker.d.ts", + "../src/service-worker/**/*.d.ts" + ] +}
\ No newline at end of file diff --git a/.svelte-kit/types/route_meta_data.json b/.svelte-kit/types/route_meta_data.json new file mode 100644 index 0000000..8069098 --- /dev/null +++ b/.svelte-kit/types/route_meta_data.json @@ -0,0 +1,27 @@ +{ + "/": [], + "/api/channel/[id]": [ + "src/routes/api/channel/[id]/+server.ts" + ], + "/api/playlist": [ + "src/routes/api/playlist/+server.ts" + ], + "/api/related/[id]": [ + "src/routes/api/related/[id]/+server.ts" + ], + "/api/search": [ + "src/routes/api/search/+server.ts" + ], + "/api/trending": [ + "src/routes/api/trending/+server.ts" + ], + "/api/video/[id]": [ + "src/routes/api/video/[id]/+server.ts" + ], + "/channel/[id]": [], + "/playlists": [], + "/playlists/[id]": [], + "/search": [], + "/subscriptions": [], + "/watch/[id]": [] +}
\ No newline at end of file diff --git a/.svelte-kit/types/src/routes/$types.d.ts b/.svelte-kit/types/src/routes/$types.d.ts new file mode 100644 index 0000000..8f940ba --- /dev/null +++ b/.svelte-kit/types/src/routes/$types.d.ts @@ -0,0 +1,24 @@ +import type * as Kit from '@sveltejs/kit'; + +type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam<M> = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { }; +type RouteId = '/'; +type MaybeWithVoid<T> = {} extends T ? T | void : T; +export type RequiredKeys<T> = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; +type OutputDataShape<T> = MaybeWithVoid<Omit<App.PageData, RequiredKeys<T>> & Partial<Pick<App.PageData, keyof T & keyof App.PageData>> & Record<string, any>> +type EnsureDefined<T> = T extends null | undefined ? {} : T; +type OptionalUnion<U extends Record<string, any>, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude<A, keyof U>]?: never } & U : never; +export type Snapshot<T = any> = Kit.Snapshot<T>; +type PageParentData = EnsureDefined<LayoutData>; +type LayoutRouteId = RouteId | "/" | "/channel/[id]" | "/playlists" | "/playlists/[id]" | "/search" | "/subscriptions" | "/watch/[id]" | null +type LayoutParams = RouteParams & { id?: string } +type LayoutParentData = EnsureDefined<{}>; + +export type PageServerData = null; +export type PageData = Expand<PageParentData>; +export type PageProps = { params: RouteParams; data: PageData } +export type LayoutServerData = null; +export type LayoutData = Expand<LayoutParentData>; +export type LayoutProps = { params: LayoutParams; data: LayoutData; children: import("svelte").Snippet }
\ No newline at end of file diff --git a/.svelte-kit/types/src/routes/api/channel/[id]/$types.d.ts b/.svelte-kit/types/src/routes/api/channel/[id]/$types.d.ts new file mode 100644 index 0000000..0b6f3de --- /dev/null +++ b/.svelte-kit/types/src/routes/api/channel/[id]/$types.d.ts @@ -0,0 +1,11 @@ +import type * as Kit from '@sveltejs/kit'; + +type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam<M> = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { id: string }; +type RouteId = '/api/channel/[id]'; + +export type EntryGenerator = () => Promise<Array<RouteParams>> | Array<RouteParams>; +export type RequestHandler = Kit.RequestHandler<RouteParams, RouteId>; +export type RequestEvent = Kit.RequestEvent<RouteParams, RouteId>;
\ No newline at end of file diff --git a/.svelte-kit/types/src/routes/api/playlist/$types.d.ts b/.svelte-kit/types/src/routes/api/playlist/$types.d.ts new file mode 100644 index 0000000..b91f2ee --- /dev/null +++ b/.svelte-kit/types/src/routes/api/playlist/$types.d.ts @@ -0,0 +1,10 @@ +import type * as Kit from '@sveltejs/kit'; + +type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam<M> = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { }; +type RouteId = '/api/playlist'; + +export type RequestHandler = Kit.RequestHandler<RouteParams, RouteId>; +export type RequestEvent = Kit.RequestEvent<RouteParams, RouteId>;
\ No newline at end of file diff --git a/.svelte-kit/types/src/routes/api/related/[id]/$types.d.ts b/.svelte-kit/types/src/routes/api/related/[id]/$types.d.ts new file mode 100644 index 0000000..a24dedb --- /dev/null +++ b/.svelte-kit/types/src/routes/api/related/[id]/$types.d.ts @@ -0,0 +1,11 @@ +import type * as Kit from '@sveltejs/kit'; + +type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam<M> = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { id: string }; +type RouteId = '/api/related/[id]'; + +export type EntryGenerator = () => Promise<Array<RouteParams>> | Array<RouteParams>; +export type RequestHandler = Kit.RequestHandler<RouteParams, RouteId>; +export type RequestEvent = Kit.RequestEvent<RouteParams, RouteId>;
\ No newline at end of file diff --git a/.svelte-kit/types/src/routes/api/search/$types.d.ts b/.svelte-kit/types/src/routes/api/search/$types.d.ts new file mode 100644 index 0000000..4a127e5 --- /dev/null +++ b/.svelte-kit/types/src/routes/api/search/$types.d.ts @@ -0,0 +1,10 @@ +import type * as Kit from '@sveltejs/kit'; + +type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam<M> = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { }; +type RouteId = '/api/search'; + +export type RequestHandler = Kit.RequestHandler<RouteParams, RouteId>; +export type RequestEvent = Kit.RequestEvent<RouteParams, RouteId>;
\ No newline at end of file diff --git a/.svelte-kit/types/src/routes/api/trending/$types.d.ts b/.svelte-kit/types/src/routes/api/trending/$types.d.ts new file mode 100644 index 0000000..c7b3fb9 --- /dev/null +++ b/.svelte-kit/types/src/routes/api/trending/$types.d.ts @@ -0,0 +1,10 @@ +import type * as Kit from '@sveltejs/kit'; + +type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam<M> = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { }; +type RouteId = '/api/trending'; + +export type RequestHandler = Kit.RequestHandler<RouteParams, RouteId>; +export type RequestEvent = Kit.RequestEvent<RouteParams, RouteId>;
\ No newline at end of file diff --git a/.svelte-kit/types/src/routes/api/video/[id]/$types.d.ts b/.svelte-kit/types/src/routes/api/video/[id]/$types.d.ts new file mode 100644 index 0000000..477abba --- /dev/null +++ b/.svelte-kit/types/src/routes/api/video/[id]/$types.d.ts @@ -0,0 +1,11 @@ +import type * as Kit from '@sveltejs/kit'; + +type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam<M> = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { id: string }; +type RouteId = '/api/video/[id]'; + +export type EntryGenerator = () => Promise<Array<RouteParams>> | Array<RouteParams>; +export type RequestHandler = Kit.RequestHandler<RouteParams, RouteId>; +export type RequestEvent = Kit.RequestEvent<RouteParams, RouteId>;
\ No newline at end of file diff --git a/.svelte-kit/types/src/routes/channel/[id]/$types.d.ts b/.svelte-kit/types/src/routes/channel/[id]/$types.d.ts new file mode 100644 index 0000000..46c79b1 --- /dev/null +++ b/.svelte-kit/types/src/routes/channel/[id]/$types.d.ts @@ -0,0 +1,19 @@ +import type * as Kit from '@sveltejs/kit'; + +type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam<M> = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { id: string }; +type RouteId = '/channel/[id]'; +type MaybeWithVoid<T> = {} extends T ? T | void : T; +export type RequiredKeys<T> = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; +type OutputDataShape<T> = MaybeWithVoid<Omit<App.PageData, RequiredKeys<T>> & Partial<Pick<App.PageData, keyof T & keyof App.PageData>> & Record<string, any>> +type EnsureDefined<T> = T extends null | undefined ? {} : T; +type OptionalUnion<U extends Record<string, any>, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude<A, keyof U>]?: never } & U : never; +export type Snapshot<T = any> = Kit.Snapshot<T>; +type PageParentData = EnsureDefined<import('../../$types.js').LayoutData>; + +export type EntryGenerator = () => Promise<Array<RouteParams>> | Array<RouteParams>; +export type PageServerData = null; +export type PageData = Expand<PageParentData>; +export type PageProps = { params: RouteParams; data: PageData }
\ No newline at end of file diff --git a/.svelte-kit/types/src/routes/playlists/$types.d.ts b/.svelte-kit/types/src/routes/playlists/$types.d.ts new file mode 100644 index 0000000..c58d33a --- /dev/null +++ b/.svelte-kit/types/src/routes/playlists/$types.d.ts @@ -0,0 +1,18 @@ +import type * as Kit from '@sveltejs/kit'; + +type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam<M> = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { }; +type RouteId = '/playlists'; +type MaybeWithVoid<T> = {} extends T ? T | void : T; +export type RequiredKeys<T> = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; +type OutputDataShape<T> = MaybeWithVoid<Omit<App.PageData, RequiredKeys<T>> & Partial<Pick<App.PageData, keyof T & keyof App.PageData>> & Record<string, any>> +type EnsureDefined<T> = T extends null | undefined ? {} : T; +type OptionalUnion<U extends Record<string, any>, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude<A, keyof U>]?: never } & U : never; +export type Snapshot<T = any> = Kit.Snapshot<T>; +type PageParentData = EnsureDefined<import('../$types.js').LayoutData>; + +export type PageServerData = null; +export type PageData = Expand<PageParentData>; +export type PageProps = { params: RouteParams; data: PageData }
\ No newline at end of file diff --git a/.svelte-kit/types/src/routes/playlists/[id]/$types.d.ts b/.svelte-kit/types/src/routes/playlists/[id]/$types.d.ts new file mode 100644 index 0000000..34da3e5 --- /dev/null +++ b/.svelte-kit/types/src/routes/playlists/[id]/$types.d.ts @@ -0,0 +1,19 @@ +import type * as Kit from '@sveltejs/kit'; + +type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam<M> = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { id: string }; +type RouteId = '/playlists/[id]'; +type MaybeWithVoid<T> = {} extends T ? T | void : T; +export type RequiredKeys<T> = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; +type OutputDataShape<T> = MaybeWithVoid<Omit<App.PageData, RequiredKeys<T>> & Partial<Pick<App.PageData, keyof T & keyof App.PageData>> & Record<string, any>> +type EnsureDefined<T> = T extends null | undefined ? {} : T; +type OptionalUnion<U extends Record<string, any>, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude<A, keyof U>]?: never } & U : never; +export type Snapshot<T = any> = Kit.Snapshot<T>; +type PageParentData = EnsureDefined<import('../../$types.js').LayoutData>; + +export type EntryGenerator = () => Promise<Array<RouteParams>> | Array<RouteParams>; +export type PageServerData = null; +export type PageData = Expand<PageParentData>; +export type PageProps = { params: RouteParams; data: PageData }
\ No newline at end of file diff --git a/.svelte-kit/types/src/routes/search/$types.d.ts b/.svelte-kit/types/src/routes/search/$types.d.ts new file mode 100644 index 0000000..1edc981 --- /dev/null +++ b/.svelte-kit/types/src/routes/search/$types.d.ts @@ -0,0 +1,18 @@ +import type * as Kit from '@sveltejs/kit'; + +type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam<M> = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { }; +type RouteId = '/search'; +type MaybeWithVoid<T> = {} extends T ? T | void : T; +export type RequiredKeys<T> = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; +type OutputDataShape<T> = MaybeWithVoid<Omit<App.PageData, RequiredKeys<T>> & Partial<Pick<App.PageData, keyof T & keyof App.PageData>> & Record<string, any>> +type EnsureDefined<T> = T extends null | undefined ? {} : T; +type OptionalUnion<U extends Record<string, any>, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude<A, keyof U>]?: never } & U : never; +export type Snapshot<T = any> = Kit.Snapshot<T>; +type PageParentData = EnsureDefined<import('../$types.js').LayoutData>; + +export type PageServerData = null; +export type PageData = Expand<PageParentData>; +export type PageProps = { params: RouteParams; data: PageData }
\ No newline at end of file diff --git a/.svelte-kit/types/src/routes/subscriptions/$types.d.ts b/.svelte-kit/types/src/routes/subscriptions/$types.d.ts new file mode 100644 index 0000000..ffccbab --- /dev/null +++ b/.svelte-kit/types/src/routes/subscriptions/$types.d.ts @@ -0,0 +1,18 @@ +import type * as Kit from '@sveltejs/kit'; + +type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam<M> = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { }; +type RouteId = '/subscriptions'; +type MaybeWithVoid<T> = {} extends T ? T | void : T; +export type RequiredKeys<T> = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; +type OutputDataShape<T> = MaybeWithVoid<Omit<App.PageData, RequiredKeys<T>> & Partial<Pick<App.PageData, keyof T & keyof App.PageData>> & Record<string, any>> +type EnsureDefined<T> = T extends null | undefined ? {} : T; +type OptionalUnion<U extends Record<string, any>, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude<A, keyof U>]?: never } & U : never; +export type Snapshot<T = any> = Kit.Snapshot<T>; +type PageParentData = EnsureDefined<import('../$types.js').LayoutData>; + +export type PageServerData = null; +export type PageData = Expand<PageParentData>; +export type PageProps = { params: RouteParams; data: PageData }
\ No newline at end of file diff --git a/.svelte-kit/types/src/routes/watch/[id]/$types.d.ts b/.svelte-kit/types/src/routes/watch/[id]/$types.d.ts new file mode 100644 index 0000000..5b87eac --- /dev/null +++ b/.svelte-kit/types/src/routes/watch/[id]/$types.d.ts @@ -0,0 +1,19 @@ +import type * as Kit from '@sveltejs/kit'; + +type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never; +// @ts-ignore +type MatcherParam<M> = M extends (param : string) => param is infer U ? U extends string ? U : string : string; +type RouteParams = { id: string }; +type RouteId = '/watch/[id]'; +type MaybeWithVoid<T> = {} extends T ? T | void : T; +export type RequiredKeys<T> = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; +type OutputDataShape<T> = MaybeWithVoid<Omit<App.PageData, RequiredKeys<T>> & Partial<Pick<App.PageData, keyof T & keyof App.PageData>> & Record<string, any>> +type EnsureDefined<T> = T extends null | undefined ? {} : T; +type OptionalUnion<U extends Record<string, any>, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude<A, keyof U>]?: never } & U : never; +export type Snapshot<T = any> = Kit.Snapshot<T>; +type PageParentData = EnsureDefined<import('../../$types.js').LayoutData>; + +export type EntryGenerator = () => Promise<Array<RouteParams>> | Array<RouteParams>; +export type PageServerData = null; +export type PageData = Expand<PageParentData>; +export type PageProps = { params: RouteParams; data: PageData }
\ No newline at end of file diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..3cee9bb --- /dev/null +++ b/bun.lock @@ -0,0 +1,241 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "actualyt", + "dependencies": { + "idb-keyval": "^6.2.1", + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^3.0.0", + "@sveltejs/kit": "^2.0.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@types/node": "^25.3.5", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "typescript": "^5.0.0", + "vite": "^6.0.0", + }, + }, + }, + "packages": { + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.9", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA=="], + + "@sveltejs/adapter-auto": ["@sveltejs/adapter-auto@3.3.1", "", { "dependencies": { "import-meta-resolve": "^4.1.0" }, "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-5Sc7WAxYdL6q9j/+D0jJKjGREGlfIevDyHSQ2eNETHcB1TKlQWHcAo8AS8H1QdjNvSXpvOwNjykDUHPEAyGgdQ=="], + + "@sveltejs/kit": ["@sveltejs/kit@2.53.4", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.6.3", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-iAIPEahFgDJJyvz8g0jP08KvqnM6JvdW8YfsygZ+pMeMvyM2zssWMltcsotETvjSZ82G3VlitgDtBIvpQSZrTA=="], + + "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@5.1.1", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.1", "deepmerge": "^4.3.1", "kleur": "^4.1.5", "magic-string": "^0.30.17", "vitefu": "^1.0.6" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ=="], + + "@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@4.0.1", "", { "dependencies": { "debug": "^4.3.7" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^5.0.0", "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw=="], + + "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/node": ["@types/node@25.3.5", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA=="], + + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + + "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], + + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + + "devalue": ["devalue@5.6.3", "", {}, "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg=="], + + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], + + "esrap": ["esrap@2.2.3", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "idb-keyval": ["idb-keyval@6.2.2", "", {}, "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg=="], + + "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], + + "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], + + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], + + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], + + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + + "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="], + + "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], + + "set-cookie-parser": ["set-cookie-parser@3.0.1", "", {}, "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q=="], + + "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "svelte": ["svelte@5.53.7", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.3", "esm-env": "^1.2.1", "esrap": "^2.2.2", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-uxck1KI7JWtlfP3H6HOWi/94soAl23jsGJkBzN2BAWcQng0+lTrRNhxActFqORgnO9BHVd1hKJhG+ljRuIUWfQ=="], + + "svelte-check": ["svelte-check@4.4.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-1bSwIRCvvmSHrlK52fOlZmVtUZgil43jNL/2H18pRpa+eQjzGt6e3zayxhp1S7GajPFKNM/2PMCG+DZFHlG9fw=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], + + "vitefu": ["vitefu@1.1.2", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw=="], + + "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d255b18 --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "actualyt", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^3.0.0", + "@sveltejs/kit": "^2.0.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@types/node": "^25.3.5", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "typescript": "^5.0.0", + "vite": "^6.0.0" + }, + "dependencies": { + "idb-keyval": "^6.2.1" + }, + "type": "module" +} diff --git a/src/app.css b/src/app.css new file mode 100644 index 0000000..dd23a9e --- /dev/null +++ b/src/app.css @@ -0,0 +1,159 @@ +:root { + --bg-primary: #0f0f0f; + --bg-secondary: #1a1a1a; + --bg-hover: #252525; + --text-primary: #f1f1f1; + --text-secondary: #aaa; + --text-muted: #717171; + --border-color: #303030; + --accent-color: #3ea6ff; + --error-color: #ff4444; + --max-width: 1400px; +} + +[data-theme="light"] { + --bg-primary: #ffffff; + --bg-secondary: #f9f9f9; + --bg-hover: #f0f0f0; + --text-primary: #0f0f0f; + --text-secondary: #606060; + --text-muted: #909090; + --border-color: #e5e5e5; +} + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + background: var(--bg-primary); + color: var(--text-primary); + min-height: 100vh; + line-height: 1.5; +} + +a { + color: inherit; +} + +button { + font-family: inherit; +} + +img { + max-width: 100%; + height: auto; +} + +.container { + max-width: var(--max-width); + margin: 0 auto; + padding: 0 1rem; +} + +.video-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1.5rem; +} + +.section-title { + font-size: 1.25rem; + font-weight: 600; + margin-bottom: 1.5rem; + color: var(--text-primary); +} + +.loading { + display: flex; + align-items: center; + justify-content: center; + padding: 4rem 0; + color: var(--text-muted); +} + +.loading::after { + content: ''; + width: 24px; + height: 24px; + margin-left: 0.75rem; + border: 2px solid var(--border-color); + border-top-color: var(--accent-color); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.error { + padding: 2rem; + text-align: center; + color: var(--error-color); +} + +.empty { + padding: 4rem 2rem; + text-align: center; + color: var(--text-muted); +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.5rem 1rem; + border: none; + border-radius: 4px; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + text-decoration: none; + transition: opacity 0.15s ease; +} + +.btn:hover { + opacity: 0.9; +} + +.btn-primary { + background: var(--accent-color); + color: #fff; +} + +.btn-secondary { + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border-color); +} + +.btn-danger { + background: var(--error-color); + color: #fff; +} + +@media (max-width: 768px) { + .video-grid { + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 1rem; + } +} + +@media (max-width: 480px) { + .video-grid { + grid-template-columns: 1fr; + } +} diff --git a/src/app.d.ts b/src/app.d.ts new file mode 100644 index 0000000..e6bfd9f --- /dev/null +++ b/src/app.d.ts @@ -0,0 +1,13 @@ +/// <reference types="@sveltejs/kit" /> + +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/src/app.html b/src/app.html new file mode 100644 index 0000000..84ffad1 --- /dev/null +++ b/src/app.html @@ -0,0 +1,12 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="utf-8" /> + <link rel="icon" href="%sveltekit.assets%/favicon.png" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + %sveltekit.head% + </head> + <body data-sveltekit-preload-data="hover"> + <div style="display: contents">%sveltekit.body%</div> + </body> +</html> diff --git a/src/lib/api/youtube.ts b/src/lib/api/youtube.ts new file mode 100644 index 0000000..8ade3c5 --- /dev/null +++ b/src/lib/api/youtube.ts @@ -0,0 +1,253 @@ +export interface VideoThumbnail { + url: string; + width?: number; + height?: number; +} + +export interface VideoInfo { + videoId: string; + title: string; + author: string; + authorId: string; + videoThumbnails: VideoThumbnail[]; + description: string; + viewCount: number; + publishedText: string; + lengthSeconds: number; +} + +export interface VideoFormat { + formatId: string; + url: string; + ext: string; + resolution?: string; + height?: number; + width?: number; + qualityLabel?: string; +} + +export interface VideoDetails extends VideoInfo { + likeCount?: number; + formats: VideoFormat[]; +} + +export interface ChannelInfo { + author: string; + authorId: string; + authorThumbnails: VideoThumbnail[]; + subCount: number; + description: string; + videos: VideoInfo[]; +} + +interface YtdlpSearchResult { + id: string; + title: string; + channel: string; + channel_id: string; + thumbnail: string; + thumbnails: { url: string; width?: number; height?: number }[]; + duration: number; + view_count: number; + upload_date: string; +} + +interface YtdlpVideo { + id: string; + title: string; + description: string; + channel: string; + channel_id: string; + thumbnail: string; + thumbnails: { url: string; width?: number; height?: number }[]; + duration: number; + view_count: number; + upload_date: string; + like_count?: number; + formats: { + format_id: string; + url: string; + ext: string; + resolution?: string; + height?: number; + width?: number; + format_note?: string; + }[]; +} + +interface YtdlpChannel { + id: string; + channel: string; + channel_id: string; + description: string; + channel_follower_count: number; + thumbnails: { url: string; width?: number; height?: number }[]; + entries?: YtdlpSearchResult[]; +} + +function formatUploadDate(dateStr: string): string { + if (!dateStr || dateStr.length !== 8) return ''; + const year = dateStr.slice(0, 4); + const month = dateStr.slice(4, 6); + const day = dateStr.slice(6, 8); + const date = new Date(`${year}-${month}-${day}`); + const now = new Date(); + const diff = now.getTime() - date.getTime(); + const days = Math.floor(diff / (1000 * 60 * 60 * 24)); + + if (days === 0) return 'Today'; + if (days === 1) return 'Yesterday'; + if (days < 7) return `${days} days ago`; + if (days < 30) return `${Math.floor(days / 7)} weeks ago`; + if (days < 365) return `${Math.floor(days / 30)} months ago`; + return `${Math.floor(days / 365)} years ago`; +} + +function transformSearchResult(item: YtdlpSearchResult): VideoInfo { + return { + videoId: item.id, + title: item.title, + author: item.channel, + authorId: item.channel_id, + videoThumbnails: item.thumbnails?.length + ? item.thumbnails + : [{ url: item.thumbnail }], + description: '', + viewCount: item.view_count || 0, + publishedText: formatUploadDate(item.upload_date), + lengthSeconds: item.duration || 0 + }; +} + +function transformVideo(item: YtdlpVideo): VideoDetails { + return { + videoId: item.id, + title: item.title, + author: item.channel, + authorId: item.channel_id, + videoThumbnails: item.thumbnails?.length + ? item.thumbnails + : [{ url: item.thumbnail }], + description: item.description || '', + viewCount: item.view_count || 0, + publishedText: formatUploadDate(item.upload_date), + lengthSeconds: item.duration || 0, + likeCount: item.like_count, + formats: item.formats.map(f => ({ + formatId: f.format_id, + url: f.url, + ext: f.ext, + resolution: f.resolution, + height: f.height, + width: f.width, + qualityLabel: f.format_note || (f.height ? `${f.height}p` : undefined) + })) + }; +} + +function transformChannel(item: YtdlpChannel): ChannelInfo { + return { + author: item.channel, + authorId: item.channel_id, + authorThumbnails: item.thumbnails || [], + subCount: item.channel_follower_count || 0, + description: item.description || '', + videos: (item.entries || []).map(transformSearchResult) + }; +} + +export async function search(query: string): Promise<VideoInfo[]> { + const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`); + if (!response.ok) { + throw new Error('Search failed'); + } + const results: YtdlpSearchResult[] = await response.json(); + return results.map(transformSearchResult); +} + +export async function getVideo(videoId: string): Promise<VideoDetails> { + const response = await fetch(`/api/video/${videoId}`); + if (!response.ok) { + throw new Error('Failed to fetch video'); + } + const video: YtdlpVideo = await response.json(); + return transformVideo(video); +} + +export async function getChannel(channelId: string): Promise<ChannelInfo> { + const response = await fetch(`/api/channel/${encodeURIComponent(channelId)}`); + if (!response.ok) { + throw new Error('Failed to fetch channel'); + } + const channel: YtdlpChannel = await response.json(); + return transformChannel(channel); +} + +export async function getTrending(): Promise<VideoInfo[]> { + const response = await fetch('/api/trending'); + if (!response.ok) { + throw new Error('Failed to fetch trending'); + } + const results: YtdlpSearchResult[] = await response.json(); + return results.map(transformSearchResult); +} + +export async function getRelatedVideos(videoId: string): Promise<VideoInfo[]> { + const response = await fetch(`/api/related/${videoId}`); + if (!response.ok) { + throw new Error('Failed to fetch related videos'); + } + const results: YtdlpSearchResult[] = await response.json(); + return results.map(transformSearchResult); +} + +export interface ImportedPlaylist { + id: string; + title: string; + channel: string; + channelId: string; + videos: VideoInfo[]; +} + +export async function importPlaylist(playlistUrl: string): Promise<ImportedPlaylist> { + const response = await fetch(`/api/playlist?url=${encodeURIComponent(playlistUrl)}`); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to import playlist'); + } + const data = await response.json(); + return { + id: data.id, + title: data.title, + channel: data.channel, + channelId: data.channel_id, + videos: data.entries.map(transformSearchResult) + }; +} + +export function formatDuration(seconds: number): string { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + } + return `${minutes}:${secs.toString().padStart(2, '0')}`; +} + +export function formatViews(views: number): string { + if (views >= 1000000) { + return `${(views / 1000000).toFixed(1)}M views`; + } + if (views >= 1000) { + return `${(views / 1000).toFixed(1)}K views`; + } + return `${views} views`; +} + +export function getBestThumbnail(thumbnails: VideoThumbnail[]): string { + if (!thumbnails || thumbnails.length === 0) return ''; + const sorted = [...thumbnails].sort((a, b) => (b.width || 0) - (a.width || 0)); + return sorted[0]?.url || ''; +} diff --git a/src/lib/components/ChannelCard.svelte b/src/lib/components/ChannelCard.svelte new file mode 100644 index 0000000..8747efa --- /dev/null +++ b/src/lib/components/ChannelCard.svelte @@ -0,0 +1,160 @@ +<script lang="ts"> + import type { VideoThumbnail } from '$lib/api/youtube'; + import { subscriptions } from '$lib/stores/subscriptions'; + + interface Props { + channelId: string; + channelName: string; + thumbnails: VideoThumbnail[]; + subCount?: number; + videoCount?: number; + showSubscribeButton?: boolean; + } + + let { + channelId, + channelName, + thumbnails, + subCount, + videoCount, + showSubscribeButton = true + }: Props = $props(); + + const thumbnail = $derived(thumbnails[0]?.url || ''); + + const isSubscribed = $derived(subscriptions.isSubscribed($subscriptions, channelId)); + + function formatSubCount(count: number | undefined): string { + if (!count) return ''; + if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M subscribers`; + if (count >= 1000) return `${(count / 1000).toFixed(1)}K subscribers`; + return `${count} subscribers`; + } + + function handleSubscribe(e: Event) { + e.preventDefault(); + e.stopPropagation(); + if (isSubscribed) { + subscriptions.remove(channelId); + } else { + subscriptions.add(channelId, channelName, thumbnails); + } + } +</script> + +<a href="/channel/{channelId}" class="channel-card"> + <div class="thumbnail"> + {#if thumbnail} + <img src={thumbnail} alt={channelName} loading="lazy" /> + {:else} + <div class="placeholder">{channelName[0]}</div> + {/if} + </div> + <div class="info"> + <h3 class="name">{channelName}</h3> + <div class="meta"> + {#if subCount} + <span>{formatSubCount(subCount)}</span> + {/if} + {#if videoCount} + <span class="separator">•</span> + <span>{videoCount} videos</span> + {/if} + </div> + </div> + {#if showSubscribeButton} + <button + class="subscribe-btn" + class:subscribed={isSubscribed} + onclick={handleSubscribe} + > + {isSubscribed ? 'Subscribed' : 'Subscribe'} + </button> + {/if} +</a> + +<style> + .channel-card { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem; + text-decoration: none; + color: inherit; + border-radius: 8px; + transition: background 0.15s ease; + } + + .channel-card:hover { + background: var(--bg-hover); + } + + .thumbnail { + width: 80px; + height: 80px; + border-radius: 50%; + overflow: hidden; + background: var(--bg-secondary); + flex-shrink: 0; + } + + .thumbnail img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: 2rem; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + } + + .info { + flex: 1; + min-width: 0; + } + + .name { + font-size: 1rem; + font-weight: 500; + margin: 0 0 0.25rem; + color: var(--text-primary); + } + + .meta { + font-size: 0.85rem; + color: var(--text-muted); + } + + .separator { + margin: 0 0.25rem; + } + + .subscribe-btn { + padding: 0.5rem 1rem; + border-radius: 20px; + border: none; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + background: var(--accent-color); + color: #fff; + transition: opacity 0.15s ease; + } + + .subscribe-btn:hover { + opacity: 0.9; + } + + .subscribe-btn.subscribed { + background: var(--bg-secondary); + color: var(--text-secondary); + } +</style> diff --git a/src/lib/components/SearchBar.svelte b/src/lib/components/SearchBar.svelte new file mode 100644 index 0000000..d81ba14 --- /dev/null +++ b/src/lib/components/SearchBar.svelte @@ -0,0 +1,76 @@ +<script lang="ts"> + import { goto } from '$app/navigation'; + + let query = $state(''); + + function handleSubmit(e: Event) { + e.preventDefault(); + if (query.trim()) { + goto(`/search?q=${encodeURIComponent(query.trim())}`); + } + } + + function handleKeydown(e: KeyboardEvent) { + if (e.key === 'Escape') { + query = ''; + } + } +</script> + +<form class="search-bar" onsubmit={handleSubmit}> + <input + type="text" + bind:value={query} + placeholder="Search videos..." + onkeydown={handleKeydown} + /> + <button type="submit" aria-label="Search"> + <svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"> + <path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/> + </svg> + </button> +</form> + +<style> + .search-bar { + display: flex; + max-width: 600px; + width: 100%; + } + + input { + flex: 1; + padding: 0.75rem 1rem; + font-size: 1rem; + border: 1px solid var(--border-color); + border-right: none; + border-radius: 4px 0 0 4px; + background: var(--bg-secondary); + color: var(--text-primary); + outline: none; + } + + input:focus { + border-color: var(--accent-color); + } + + input::placeholder { + color: var(--text-muted); + } + + button { + padding: 0.75rem 1.25rem; + border: 1px solid var(--border-color); + border-radius: 0 4px 4px 0; + background: var(--bg-secondary); + color: var(--text-primary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + } + + button:hover { + background: var(--bg-hover); + } +</style> diff --git a/src/lib/components/VideoCard.svelte b/src/lib/components/VideoCard.svelte new file mode 100644 index 0000000..61bf975 --- /dev/null +++ b/src/lib/components/VideoCard.svelte @@ -0,0 +1,133 @@ +<script lang="ts"> + import { formatDuration, formatViews, getBestThumbnail, type VideoThumbnail } from '$lib/api/youtube'; + + interface Props { + videoId: string; + title: string; + author: string; + authorId: string; + thumbnails: VideoThumbnail[]; + viewCount: number; + publishedText: string; + lengthSeconds: number; + } + + let { + videoId, + title, + author, + authorId, + thumbnails, + viewCount, + publishedText, + lengthSeconds + }: Props = $props(); + + const thumbnail = $derived(getBestThumbnail(thumbnails)); + const duration = $derived(formatDuration(lengthSeconds)); + const views = $derived(formatViews(viewCount)); +</script> + +<a href="/watch/{videoId}" class="video-card"> + <div class="thumbnail"> + <img src={thumbnail} alt={title} loading="lazy" /> + <span class="duration">{duration}</span> + </div> + <div class="info"> + <h3 class="title">{title}</h3> + <span + class="author" + role="link" + tabindex="0" + onclick={(e) => { e.preventDefault(); e.stopPropagation(); window.location.href = `/channel/${authorId}`; }} + onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); window.location.href = `/channel/${authorId}`; } }} + > + {author} + </span> + <div class="meta"> + <span>{views}</span> + <span class="separator">•</span> + <span>{publishedText}</span> + </div> + </div> +</a> + +<style> + .video-card { + display: block; + text-decoration: none; + color: inherit; + border-radius: 8px; + overflow: hidden; + transition: transform 0.15s ease; + } + + .video-card:hover { + transform: translateY(-2px); + } + + .thumbnail { + position: relative; + aspect-ratio: 16 / 9; + background: var(--bg-secondary); + border-radius: 8px; + overflow: hidden; + } + + .thumbnail img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .duration { + position: absolute; + bottom: 8px; + right: 8px; + background: rgba(0, 0, 0, 0.8); + color: #fff; + padding: 2px 6px; + border-radius: 4px; + font-size: 0.8rem; + font-weight: 500; + } + + .info { + padding: 0.75rem 0; + } + + .title { + font-size: 0.95rem; + font-weight: 500; + line-height: 1.3; + margin: 0 0 0.5rem; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + color: var(--text-primary); + } + + .author { + font-size: 0.85rem; + color: var(--text-secondary); + text-decoration: none; + display: block; + margin-bottom: 0.25rem; + cursor: pointer; + } + + .author:hover { + color: var(--text-primary); + } + + .meta { + font-size: 0.8rem; + color: var(--text-muted); + } + + .separator { + margin: 0 0.25rem; + } +</style> diff --git a/src/lib/components/VideoPlayer.svelte b/src/lib/components/VideoPlayer.svelte new file mode 100644 index 0000000..0829c8b --- /dev/null +++ b/src/lib/components/VideoPlayer.svelte @@ -0,0 +1,185 @@ +<script lang="ts"> + import type { VideoFormat } from '$lib/api/youtube'; + + interface Props { + formats: VideoFormat[]; + title: string; + } + + let { formats, title }: Props = $props(); + + let videoElement: HTMLVideoElement | undefined = $state(); + let currentQuality = $state(''); + + const sortedFormats = $derived( + [...formats] + .filter(f => f.url && f.height) + .sort((a, b) => (b.height || 0) - (a.height || 0)) + ); + + const selectedFormat = $derived( + currentQuality + ? sortedFormats.find(f => f.qualityLabel === currentQuality) || sortedFormats[0] + : sortedFormats[0] + ); + + function handleQualityChange(e: Event) { + const select = e.target as HTMLSelectElement; + const newQuality = select.value; + const currentTime = videoElement?.currentTime || 0; + const wasPlaying = videoElement && !videoElement.paused; + + currentQuality = newQuality; + + if (videoElement) { + videoElement.load(); + videoElement.currentTime = currentTime; + if (wasPlaying) { + videoElement.play(); + } + } + } + + function handleKeydown(e: KeyboardEvent) { + if (!videoElement) return; + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; + + switch (e.key) { + case ' ': + case 'k': + e.preventDefault(); + if (videoElement.paused) { + videoElement.play(); + } else { + videoElement.pause(); + } + break; + case 'f': + e.preventDefault(); + if (document.fullscreenElement) { + document.exitFullscreen(); + } else { + videoElement.requestFullscreen(); + } + break; + case 'm': + e.preventDefault(); + videoElement.muted = !videoElement.muted; + break; + case 'ArrowLeft': + e.preventDefault(); + videoElement.currentTime -= 5; + break; + case 'ArrowRight': + e.preventDefault(); + videoElement.currentTime += 5; + break; + case 'ArrowUp': + e.preventDefault(); + videoElement.volume = Math.min(1, videoElement.volume + 0.1); + break; + case 'ArrowDown': + e.preventDefault(); + videoElement.volume = Math.max(0, videoElement.volume - 0.1); + break; + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': + e.preventDefault(); + videoElement.currentTime = (parseInt(e.key) / 10) * videoElement.duration; + break; + } + } + + function getMimeType(ext: string): string { + const types: Record<string, string> = { + mp4: 'video/mp4', + webm: 'video/webm', + mkv: 'video/x-matroska' + }; + return types[ext] || 'video/mp4'; + } +</script> + +<svelte:window onkeydown={handleKeydown} /> + +<div class="player-container"> + {#if selectedFormat} + <video + bind:this={videoElement} + controls + autoplay + {title} + > + <source src={selectedFormat.url} type={getMimeType(selectedFormat.ext)} /> + Your browser does not support the video tag. + </video> + {:else} + <div class="no-video"> + <p>No playable video format found</p> + </div> + {/if} + + {#if sortedFormats.length > 1} + <div class="quality-selector"> + <label for="quality">Quality:</label> + <select id="quality" onchange={handleQualityChange} value={selectedFormat?.qualityLabel}> + {#each sortedFormats as format} + <option value={format.qualityLabel}>{format.qualityLabel || format.height + 'p'}</option> + {/each} + </select> + </div> + {/if} +</div> + +<style> + .player-container { + width: 100%; + background: #000; + border-radius: 8px; + overflow: hidden; + } + + video { + width: 100%; + aspect-ratio: 16 / 9; + display: block; + } + + .no-video { + aspect-ratio: 16 / 9; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + } + + .quality-selector { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem; + background: var(--bg-secondary); + } + + .quality-selector label { + font-size: 0.9rem; + color: var(--text-secondary); + } + + .quality-selector select { + padding: 0.25rem 0.5rem; + border-radius: 4px; + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-primary); + font-size: 0.9rem; + } +</style> diff --git a/src/lib/server/ytdlp.ts b/src/lib/server/ytdlp.ts new file mode 100644 index 0000000..2f6f47e --- /dev/null +++ b/src/lib/server/ytdlp.ts @@ -0,0 +1,329 @@ +import { spawn } from 'child_process'; + +export interface YtdlpVideo { + id: string; + title: string; + description: string; + channel: string; + channel_id: string; + channel_url: string; + thumbnail: string; + thumbnails: { url: string; width?: number; height?: number }[]; + duration: number; + view_count: number; + upload_date: string; + like_count?: number; + formats: YtdlpFormat[]; +} + +export interface YtdlpFormat { + format_id: string; + url: string; + ext: string; + resolution?: string; + height?: number; + width?: number; + vcodec?: string; + acodec?: string; + format_note?: string; +} + +export interface YtdlpSearchResult { + id: string; + title: string; + channel: string; + channel_id: string; + channel_url: string; + thumbnail: string; + thumbnails: { url: string; width?: number; height?: number }[]; + duration: number; + view_count: number; + upload_date: string; +} + +export interface YtdlpChannel { + id: string; + channel: string; + channel_id: string; + description: string; + channel_follower_count: number; + thumbnails: { url: string; width?: number; height?: number }[]; + entries?: YtdlpSearchResult[]; +} + +function runYtdlp(args: string[]): Promise<string> { + return new Promise((resolve, reject) => { + const proc = spawn('yt-dlp', args); + let stdout = ''; + let stderr = ''; + + proc.stdout.on('data', (data: Buffer) => { + stdout += data.toString(); + }); + + proc.stderr.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + + proc.on('close', (code: number | null) => { + if (code === 0) { + resolve(stdout); + } else { + reject(new Error(stderr || `yt-dlp exited with code ${code}`)); + } + }); + + proc.on('error', (err: Error) => { + reject(err); + }); + }); +} + +export async function search(query: string, limit = 20): Promise<YtdlpSearchResult[]> { + const output = await runYtdlp([ + `ytsearch${limit}:${query}`, + '--dump-json', + '--flat-playlist', + '--no-warnings', + '--ignore-errors' + ]); + + const results: YtdlpSearchResult[] = []; + for (const line of output.trim().split('\n')) { + if (!line) continue; + try { + const item = JSON.parse(line); + if (item.duration && item.duration >= 60) { + results.push({ + id: item.id, + title: item.title, + channel: item.channel || item.uploader || '', + channel_id: item.channel_id || item.uploader_id || '', + channel_url: item.channel_url || item.uploader_url || '', + thumbnail: item.thumbnail || item.thumbnails?.[0]?.url || '', + thumbnails: item.thumbnails || [], + duration: item.duration || 0, + view_count: item.view_count || 0, + upload_date: item.upload_date || '' + }); + } + } catch { + // Skip invalid JSON lines + } + } + + return results; +} + +export async function getVideo(videoId: string): Promise<YtdlpVideo> { + const output = await runYtdlp([ + `https://www.youtube.com/watch?v=${videoId}`, + '--dump-json', + '--no-warnings' + ]); + + const data = JSON.parse(output); + return { + id: data.id, + title: data.title, + description: data.description || '', + channel: data.channel || data.uploader || '', + channel_id: data.channel_id || data.uploader_id || '', + channel_url: data.channel_url || data.uploader_url || '', + thumbnail: data.thumbnail || data.thumbnails?.[0]?.url || '', + thumbnails: data.thumbnails || [], + duration: data.duration || 0, + view_count: data.view_count || 0, + upload_date: data.upload_date || '', + like_count: data.like_count, + formats: (data.formats || []).filter((f: YtdlpFormat) => + f.vcodec !== 'none' && f.acodec !== 'none' && f.url + ).map((f: YtdlpFormat) => ({ + format_id: f.format_id, + url: f.url, + ext: f.ext, + resolution: f.resolution, + height: f.height, + width: f.width, + vcodec: f.vcodec, + acodec: f.acodec, + format_note: f.format_note + })) + }; +} + +export async function getChannel(channelId: string): Promise<YtdlpChannel> { + const url = channelId.startsWith('@') + ? `https://www.youtube.com/${channelId}` + : `https://www.youtube.com/channel/${channelId}`; + + const output = await runYtdlp([ + `${url}/videos`, + '--dump-json', + '--flat-playlist', + '--playlist-end', '30', + '--no-warnings', + '--ignore-errors' + ]); + + const lines = output.trim().split('\n').filter(Boolean); + const entries: YtdlpSearchResult[] = []; + let channelInfo: Partial<YtdlpChannel> = {}; + + for (const line of lines) { + try { + const item = JSON.parse(line); + if (item.channel_id || item.uploader_id) { + channelInfo = { + id: item.channel_id || item.uploader_id, + channel: item.channel || item.uploader || '', + channel_id: item.channel_id || item.uploader_id || '', + description: item.description || '', + channel_follower_count: item.channel_follower_count || 0, + thumbnails: item.thumbnails || [] + }; + } + if (item.id && item.title && item.duration && item.duration >= 60) { + entries.push({ + id: item.id, + title: item.title, + channel: item.channel || item.uploader || '', + channel_id: item.channel_id || item.uploader_id || '', + channel_url: item.channel_url || item.uploader_url || '', + thumbnail: item.thumbnail || item.thumbnails?.[0]?.url || '', + thumbnails: item.thumbnails || [], + duration: item.duration || 0, + view_count: item.view_count || 0, + upload_date: item.upload_date || '' + }); + } + } catch { + // Skip invalid JSON + } + } + + return { + id: channelInfo.channel_id || channelId, + channel: channelInfo.channel || '', + channel_id: channelInfo.channel_id || channelId, + description: channelInfo.description || '', + channel_follower_count: channelInfo.channel_follower_count || 0, + thumbnails: channelInfo.thumbnails || [], + entries + }; +} + +export interface YtdlpPlaylist { + id: string; + title: string; + channel: string; + channel_id: string; + entries: YtdlpSearchResult[]; +} + +export async function getRelatedVideos(title: string, excludeId: string): Promise<YtdlpSearchResult[]> { + // Extract key words from title for search + const keywords = title + .replace(/[^\w\s]/g, '') + .split(/\s+/) + .filter(w => w.length > 3) + .slice(0, 5) + .join(' '); + + if (!keywords) return []; + + const results = await search(keywords, 15); + return results.filter(r => r.id !== excludeId).slice(0, 10); +} + +export async function getPlaylist(playlistUrl: string): Promise<YtdlpPlaylist> { + const output = await runYtdlp([ + playlistUrl, + '--dump-json', + '--flat-playlist', + '--no-warnings', + '--ignore-errors' + ]); + + const lines = output.trim().split('\n').filter(Boolean); + const entries: YtdlpSearchResult[] = []; + let playlistInfo: Partial<YtdlpPlaylist> = {}; + + for (const line of lines) { + try { + const item = JSON.parse(line); + + // Capture playlist metadata from first item + if (!playlistInfo.id && item.playlist_id) { + playlistInfo = { + id: item.playlist_id, + title: item.playlist_title || item.playlist || 'Imported Playlist', + channel: item.playlist_uploader || item.channel || item.uploader || '', + channel_id: item.playlist_uploader_id || item.channel_id || item.uploader_id || '' + }; + } + + if (item.id && item.title) { + entries.push({ + id: item.id, + title: item.title, + channel: item.channel || item.uploader || '', + channel_id: item.channel_id || item.uploader_id || '', + channel_url: item.channel_url || item.uploader_url || '', + thumbnail: item.thumbnail || item.thumbnails?.[0]?.url || `https://i.ytimg.com/vi/${item.id}/hqdefault.jpg`, + thumbnails: item.thumbnails || [], + duration: item.duration || 0, + view_count: item.view_count || 0, + upload_date: item.upload_date || '' + }); + } + } catch { + // Skip invalid JSON + } + } + + return { + id: playlistInfo.id || '', + title: playlistInfo.title || 'Imported Playlist', + channel: playlistInfo.channel || '', + channel_id: playlistInfo.channel_id || '', + entries + }; +} + +export async function getTrending(): Promise<YtdlpSearchResult[]> { + const output = await runYtdlp([ + 'https://www.youtube.com/feed/trending', + '--dump-json', + '--flat-playlist', + '--playlist-end', '30', + '--no-warnings', + '--ignore-errors' + ]); + + const results: YtdlpSearchResult[] = []; + for (const line of output.trim().split('\n')) { + if (!line) continue; + try { + const item = JSON.parse(line); + if (item.duration && item.duration >= 60) { + results.push({ + id: item.id, + title: item.title, + channel: item.channel || item.uploader || '', + channel_id: item.channel_id || item.uploader_id || '', + channel_url: item.channel_url || item.uploader_url || '', + thumbnail: item.thumbnail || item.thumbnails?.[0]?.url || '', + thumbnails: item.thumbnails || [], + duration: item.duration || 0, + view_count: item.view_count || 0, + upload_date: item.upload_date || '' + }); + } + } catch { + // Skip invalid JSON + } + } + + return results; +} diff --git a/src/lib/stores/playlists.ts b/src/lib/stores/playlists.ts new file mode 100644 index 0000000..ee516dc --- /dev/null +++ b/src/lib/stores/playlists.ts @@ -0,0 +1,123 @@ +import { writable } from 'svelte/store'; +import { get, set } from 'idb-keyval'; + +export interface PlaylistVideo { + videoId: string; + title: string; + author: string; + authorId: string; + thumbnail: string; + lengthSeconds: number; +} + +export interface Playlist { + id: string; + name: string; + videos: PlaylistVideo[]; + createdAt: number; + updatedAt: number; +} + +const STORAGE_KEY = 'actualyt-playlists'; + +function generateId(): string { + return Date.now().toString(36) + Math.random().toString(36).substring(2); +} + +function createPlaylistStore() { + const { subscribe, set: setStore, update } = writable<Playlist[]>([]); + let initialized = false; + + async function init() { + if (initialized) return; + try { + const stored = await get<Playlist[]>(STORAGE_KEY); + if (stored) { + setStore(stored); + } + initialized = true; + } catch (e) { + console.error('Failed to load playlists:', e); + } + } + + async function persist(playlists: Playlist[]) { + try { + await set(STORAGE_KEY, playlists); + } catch (e) { + console.error('Failed to save playlists:', e); + } + } + + return { + subscribe, + init, + create: async (name: string): Promise<string> => { + const id = generateId(); + const now = Date.now(); + const newPlaylist: Playlist = { + id, + name, + videos: [], + createdAt: now, + updatedAt: now + }; + update(playlists => { + const newPlaylists = [...playlists, newPlaylist]; + persist(newPlaylists); + return newPlaylists; + }); + return id; + }, + delete: async (id: string) => { + update(playlists => { + const newPlaylists = playlists.filter(p => p.id !== id); + persist(newPlaylists); + return newPlaylists; + }); + }, + rename: async (id: string, name: string) => { + update(playlists => { + const newPlaylists = playlists.map(p => + p.id === id ? { ...p, name, updatedAt: Date.now() } : p + ); + persist(newPlaylists); + return newPlaylists; + }); + }, + addVideo: async (playlistId: string, video: PlaylistVideo) => { + update(playlists => { + const newPlaylists = playlists.map(p => { + if (p.id !== playlistId) return p; + if (p.videos.some(v => v.videoId === video.videoId)) return p; + return { + ...p, + videos: [...p.videos, video], + updatedAt: Date.now() + }; + }); + persist(newPlaylists); + return newPlaylists; + }); + }, + removeVideo: async (playlistId: string, videoId: string) => { + update(playlists => { + const newPlaylists = playlists.map(p => { + if (p.id !== playlistId) return p; + return { + ...p, + videos: p.videos.filter(v => v.videoId !== videoId), + updatedAt: Date.now() + }; + }); + persist(newPlaylists); + return newPlaylists; + }); + }, + getById: (playlists: Playlist[], id: string): Playlist | undefined => { + return playlists.find(p => p.id === id); + } + }; +} + +export const playlists = createPlaylistStore(); diff --git a/src/lib/stores/subscriptions.ts b/src/lib/stores/subscriptions.ts new file mode 100644 index 0000000..91f3d36 --- /dev/null +++ b/src/lib/stores/subscriptions.ts @@ -0,0 +1,65 @@ +import { writable } from 'svelte/store'; +import { get, set } from 'idb-keyval'; +import type { VideoThumbnail } from '$lib/api/youtube'; + +export interface Subscription { + channelId: string; + channelName: string; + thumbnail: string; +} + +const STORAGE_KEY = 'actualyt-subscriptions'; + +function createSubscriptionStore() { + const { subscribe, set: setStore, update } = writable<Subscription[]>([]); + let initialized = false; + + async function init() { + if (initialized) return; + try { + const stored = await get<Subscription[]>(STORAGE_KEY); + if (stored) { + setStore(stored); + } + initialized = true; + } catch (e) { + console.error('Failed to load subscriptions:', e); + } + } + + async function persist(subs: Subscription[]) { + try { + await set(STORAGE_KEY, subs); + } catch (e) { + console.error('Failed to save subscriptions:', e); + } + } + + return { + subscribe, + init, + add: async (channelId: string, channelName: string, thumbnails: VideoThumbnail[]) => { + update(subs => { + if (subs.some(s => s.channelId === channelId)) { + return subs; + } + const thumbnail = thumbnails[0]?.url || ''; + const newSubs = [...subs, { channelId, channelName, thumbnail }]; + persist(newSubs); + return newSubs; + }); + }, + remove: async (channelId: string) => { + update(subs => { + const newSubs = subs.filter(s => s.channelId !== channelId); + persist(newSubs); + return newSubs; + }); + }, + isSubscribed: (subs: Subscription[], channelId: string): boolean => { + return subs.some(s => s.channelId === channelId); + } + }; +} + +export const subscriptions = createSubscriptionStore(); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte new file mode 100644 index 0000000..24c72c6 --- /dev/null +++ b/src/routes/+layout.svelte @@ -0,0 +1,151 @@ +<script lang="ts"> + import '../app.css'; + import SearchBar from '$lib/components/SearchBar.svelte'; + import { subscriptions } from '$lib/stores/subscriptions'; + import { playlists } from '$lib/stores/playlists'; + import { onMount } from 'svelte'; + import { browser } from '$app/environment'; + + let { children } = $props(); + let theme = $state<'dark' | 'light'>('dark'); + + onMount(() => { + subscriptions.init(); + playlists.init(); + + const stored = localStorage.getItem('actualyt-theme'); + if (stored === 'light' || stored === 'dark') { + theme = stored; + } + }); + + function toggleTheme() { + theme = theme === 'dark' ? 'light' : 'dark'; + if (browser) { + localStorage.setItem('actualyt-theme', theme); + } + } +</script> + +<svelte:head> + <title>ActualYT</title> +</svelte:head> + +<div class="app" data-theme={theme}> + <header class="header"> + <div class="header-content container"> + <a href="/" class="logo">ActualYT</a> + <SearchBar /> + <nav class="nav"> + <a href="/subscriptions" class="nav-link">Subscriptions</a> + <a href="/playlists" class="nav-link">Playlists</a> + <button class="theme-toggle" onclick={toggleTheme} aria-label="Toggle theme"> + {#if theme === 'dark'} + <svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"> + <path d="M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.99 4.58a.996.996 0 0 0-1.41 0 .996.996 0 0 0 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0s.39-1.03 0-1.41L5.99 4.58zm12.37 12.37a.996.996 0 0 0-1.41 0 .996.996 0 0 0 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0a.996.996 0 0 0 0-1.41l-1.06-1.06zm1.06-10.96a.996.996 0 0 0 0-1.41.996.996 0 0 0-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06zM7.05 18.36a.996.996 0 0 0 0-1.41.996.996 0 0 0-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06z"/> + </svg> + {:else} + <svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"> + <path d="M12 3a9 9 0 1 0 9 9c0-.46-.04-.92-.1-1.36a5.389 5.389 0 0 1-4.4 2.26 5.403 5.403 0 0 1-3.14-9.8c-.44-.06-.9-.1-1.36-.1z"/> + </svg> + {/if} + </button> + </nav> + </div> + </header> + + <main class="main"> + {@render children()} + </main> +</div> + +<style> + .app { + min-height: 100vh; + background: var(--bg-primary); + } + + .header { + position: sticky; + top: 0; + z-index: 100; + background: var(--bg-primary); + border-bottom: 1px solid var(--border-color); + } + + .header-content { + display: flex; + align-items: center; + gap: 2rem; + padding-top: 0.75rem; + padding-bottom: 0.75rem; + } + + .logo { + font-size: 1.25rem; + font-weight: 700; + text-decoration: none; + color: var(--accent-color); + flex-shrink: 0; + } + + .nav { + display: flex; + align-items: center; + gap: 1rem; + margin-left: auto; + } + + .nav-link { + text-decoration: none; + color: var(--text-secondary); + font-size: 0.9rem; + padding: 0.5rem; + border-radius: 4px; + transition: color 0.15s ease; + } + + .nav-link:hover { + color: var(--text-primary); + } + + .theme-toggle { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: none; + border-radius: 50%; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; + } + + .theme-toggle:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + + .main { + padding: 2rem 0; + } + + @media (max-width: 768px) { + .header-content { + flex-wrap: wrap; + gap: 1rem; + } + + .nav { + order: -1; + width: 100%; + justify-content: flex-end; + } + + .logo { + order: -2; + } + } +</style> diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte new file mode 100644 index 0000000..8477c7c --- /dev/null +++ b/src/routes/+page.svelte @@ -0,0 +1,50 @@ +<script lang="ts"> + import { onMount } from 'svelte'; + import { getTrending, type VideoInfo } from '$lib/api/youtube'; + import VideoCard from '$lib/components/VideoCard.svelte'; + + let videos = $state<VideoInfo[]>([]); + let loading = $state(true); + let error = $state(''); + + onMount(async () => { + try { + videos = await getTrending(); + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to load videos'; + } finally { + loading = false; + } + }); +</script> + +<svelte:head> + <title>ActualYT - Home</title> +</svelte:head> + +<div class="container"> + <h1 class="section-title">Trending</h1> + + {#if loading} + <div class="loading">Loading videos</div> + {:else if error} + <div class="error">{error}</div> + {:else if videos.length === 0} + <div class="empty">No videos found</div> + {:else} + <div class="video-grid"> + {#each videos as video (video.videoId)} + <VideoCard + videoId={video.videoId} + title={video.title} + author={video.author} + authorId={video.authorId} + thumbnails={video.videoThumbnails} + viewCount={video.viewCount} + publishedText={video.publishedText} + lengthSeconds={video.lengthSeconds} + /> + {/each} + </div> + {/if} +</div> diff --git a/src/routes/api/channel/[id]/+server.ts b/src/routes/api/channel/[id]/+server.ts new file mode 100644 index 0000000..c9aa67f --- /dev/null +++ b/src/routes/api/channel/[id]/+server.ts @@ -0,0 +1,18 @@ +import { json } from '@sveltejs/kit'; +import { getChannel } from '$lib/server/ytdlp'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ params }) => { + const { id } = params; + if (!id) { + return json({ error: 'Missing channel ID' }, { status: 400 }); + } + + try { + const channel = await getChannel(id); + return json(channel); + } catch (e) { + console.error('Channel fetch error:', e); + return json({ error: 'Failed to fetch channel' }, { status: 500 }); + } +}; diff --git a/src/routes/api/playlist/+server.ts b/src/routes/api/playlist/+server.ts new file mode 100644 index 0000000..b70ac26 --- /dev/null +++ b/src/routes/api/playlist/+server.ts @@ -0,0 +1,23 @@ +import { json } from '@sveltejs/kit'; +import { getPlaylist } from '$lib/server/ytdlp'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ url }) => { + const playlistUrl = url.searchParams.get('url'); + if (!playlistUrl) { + return json({ error: 'Missing playlist URL' }, { status: 400 }); + } + + // Validate it's a YouTube playlist URL + if (!playlistUrl.includes('youtube.com/playlist') && !playlistUrl.includes('list=')) { + return json({ error: 'Invalid YouTube playlist URL' }, { status: 400 }); + } + + try { + const playlist = await getPlaylist(playlistUrl); + return json(playlist); + } catch (e) { + console.error('Playlist fetch error:', e); + return json({ error: 'Failed to fetch playlist' }, { status: 500 }); + } +}; diff --git a/src/routes/api/related/[id]/+server.ts b/src/routes/api/related/[id]/+server.ts new file mode 100644 index 0000000..9fcd9c3 --- /dev/null +++ b/src/routes/api/related/[id]/+server.ts @@ -0,0 +1,19 @@ +import { json } from '@sveltejs/kit'; +import { getVideo, getRelatedVideos } from '$lib/server/ytdlp'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ params }) => { + const { id } = params; + if (!id) { + return json({ error: 'Missing video ID' }, { status: 400 }); + } + + try { + const video = await getVideo(id); + const related = await getRelatedVideos(video.title, id); + return json(related); + } catch (e) { + console.error('Related videos fetch error:', e); + return json({ error: 'Failed to fetch related videos' }, { status: 500 }); + } +}; diff --git a/src/routes/api/search/+server.ts b/src/routes/api/search/+server.ts new file mode 100644 index 0000000..57bf347 --- /dev/null +++ b/src/routes/api/search/+server.ts @@ -0,0 +1,18 @@ +import { json } from '@sveltejs/kit'; +import { search } from '$lib/server/ytdlp'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ url }) => { + const query = url.searchParams.get('q'); + if (!query) { + return json({ error: 'Missing query parameter' }, { status: 400 }); + } + + try { + const results = await search(query); + return json(results); + } catch (e) { + console.error('Search error:', e); + return json({ error: 'Search failed' }, { status: 500 }); + } +}; diff --git a/src/routes/api/trending/+server.ts b/src/routes/api/trending/+server.ts new file mode 100644 index 0000000..b19ed21 --- /dev/null +++ b/src/routes/api/trending/+server.ts @@ -0,0 +1,13 @@ +import { json } from '@sveltejs/kit'; +import { getTrending } from '$lib/server/ytdlp'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async () => { + try { + const videos = await getTrending(); + return json(videos); + } catch (e) { + console.error('Trending fetch error:', e); + return json({ error: 'Failed to fetch trending' }, { status: 500 }); + } +}; diff --git a/src/routes/api/video/[id]/+server.ts b/src/routes/api/video/[id]/+server.ts new file mode 100644 index 0000000..9a64c36 --- /dev/null +++ b/src/routes/api/video/[id]/+server.ts @@ -0,0 +1,18 @@ +import { json } from '@sveltejs/kit'; +import { getVideo } from '$lib/server/ytdlp'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ params }) => { + const { id } = params; + if (!id) { + return json({ error: 'Missing video ID' }, { status: 400 }); + } + + try { + const video = await getVideo(id); + return json(video); + } catch (e) { + console.error('Video fetch error:', e); + return json({ error: 'Failed to fetch video' }, { status: 500 }); + } +}; diff --git a/src/routes/channel/[id]/+page.svelte b/src/routes/channel/[id]/+page.svelte new file mode 100644 index 0000000..05ba2d7 --- /dev/null +++ b/src/routes/channel/[id]/+page.svelte @@ -0,0 +1,175 @@ +<script lang="ts"> + import { page } from '$app/stores'; + import { getChannel, type ChannelInfo } from '$lib/api/youtube'; + import { subscriptions } from '$lib/stores/subscriptions'; + import VideoCard from '$lib/components/VideoCard.svelte'; + + let channel = $state<ChannelInfo | null>(null); + let loading = $state(true); + let error = $state(''); + + const channelId = $derived($page.params.id); + const isSubscribed = $derived( + channel ? subscriptions.isSubscribed($subscriptions, channel.authorId) : false + ); + + $effect(() => { + const id = $page.params.id; + if (id) loadChannel(id); + }); + + async function loadChannel(id: string) { + loading = true; + error = ''; + + try { + channel = await getChannel(id); + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to load channel'; + channel = null; + } finally { + loading = false; + } + } + + function handleSubscribe() { + if (!channel) return; + if (isSubscribed) { + subscriptions.remove(channel.authorId); + } else { + subscriptions.add(channel.authorId, channel.author, channel.authorThumbnails); + } + } + + function formatSubCount(count: number): string { + if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M subscribers`; + if (count >= 1000) return `${(count / 1000).toFixed(1)}K subscribers`; + return `${count} subscribers`; + } +</script> + +<svelte:head> + <title>{channel?.author || 'Channel'} - ActualYT</title> +</svelte:head> + +<div class="container"> + {#if loading} + <div class="loading">Loading channel</div> + {:else if error} + <div class="error">{error}</div> + {:else if channel} + <div class="channel-page"> + <div class="channel-header"> + <div class="channel-info"> + {#if channel.authorThumbnails && channel.authorThumbnails.length > 0} + <img + src={channel.authorThumbnails[0].url} + alt={channel.author} + class="avatar" + /> + {/if} + <div class="channel-text"> + <h1 class="channel-name">{channel.author}</h1> + {#if channel.subCount > 0} + <p class="sub-count">{formatSubCount(channel.subCount)}</p> + {/if} + </div> + </div> + <button + class="btn" + class:btn-primary={!isSubscribed} + class:btn-secondary={isSubscribed} + onclick={handleSubscribe} + > + {isSubscribed ? 'Subscribed' : 'Subscribe'} + </button> + </div> + + {#if channel.description} + <p class="description">{channel.description}</p> + {/if} + + <h2 class="section-title">Videos</h2> + + {#if channel.videos.length === 0} + <div class="empty">No videos found</div> + {:else} + <div class="video-grid"> + {#each channel.videos as video (video.videoId)} + <VideoCard + videoId={video.videoId} + title={video.title} + author={video.author} + authorId={video.authorId} + thumbnails={video.videoThumbnails} + viewCount={video.viewCount} + publishedText={video.publishedText} + lengthSeconds={video.lengthSeconds} + /> + {/each} + </div> + {/if} + </div> + {/if} +</div> + +<style> + .channel-page { + max-width: 1200px; + } + + .channel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1.5rem; + } + + .channel-info { + display: flex; + align-items: center; + gap: 1rem; + } + + .avatar { + width: 80px; + height: 80px; + border-radius: 50%; + object-fit: cover; + } + + .channel-name { + font-size: 1.5rem; + font-weight: 600; + margin: 0 0 0.25rem; + } + + .sub-count { + color: var(--text-muted); + margin: 0; + } + + .description { + color: var(--text-secondary); + margin-bottom: 2rem; + white-space: pre-wrap; + line-height: 1.6; + } + + @media (max-width: 600px) { + .channel-header { + flex-direction: column; + align-items: flex-start; + } + + .avatar { + width: 60px; + height: 60px; + } + + .channel-name { + font-size: 1.25rem; + } + } +</style> diff --git a/src/routes/playlists/+page.svelte b/src/routes/playlists/+page.svelte new file mode 100644 index 0000000..eb06e69 --- /dev/null +++ b/src/routes/playlists/+page.svelte @@ -0,0 +1,353 @@ +<script lang="ts"> + import { playlists } from '$lib/stores/playlists'; + import { importPlaylist, getBestThumbnail } from '$lib/api/youtube'; + + let newPlaylistName = $state(''); + let showCreateForm = $state(false); + let showImportForm = $state(false); + let importUrl = $state(''); + let importing = $state(false); + let importError = $state(''); + + async function createPlaylist() { + if (!newPlaylistName.trim()) return; + await playlists.create(newPlaylistName.trim()); + newPlaylistName = ''; + showCreateForm = false; + } + + function handleCreateKeydown(e: KeyboardEvent) { + if (e.key === 'Enter') { + createPlaylist(); + } else if (e.key === 'Escape') { + showCreateForm = false; + newPlaylistName = ''; + } + } + + function handleImportKeydown(e: KeyboardEvent) { + if (e.key === 'Enter') { + handleImport(); + } else if (e.key === 'Escape') { + showImportForm = false; + importUrl = ''; + importError = ''; + } + } + + async function handleImport() { + if (!importUrl.trim()) return; + + importing = true; + importError = ''; + + try { + const imported = await importPlaylist(importUrl.trim()); + + // Create the playlist locally + const playlistId = await playlists.create(imported.title); + + // Add all videos to it + for (const video of imported.videos) { + await playlists.addVideo(playlistId, { + videoId: video.videoId, + title: video.title, + author: video.author, + authorId: video.authorId, + thumbnail: getBestThumbnail(video.videoThumbnails) || `https://i.ytimg.com/vi/${video.videoId}/hqdefault.jpg`, + lengthSeconds: video.lengthSeconds + }); + } + + importUrl = ''; + showImportForm = false; + } catch (e) { + importError = e instanceof Error ? e.message : 'Failed to import playlist'; + } finally { + importing = false; + } + } + + async function deletePlaylist(id: string, name: string) { + if (confirm(`Delete playlist "${name}"?`)) { + await playlists.delete(id); + } + } +</script> + +<svelte:head> + <title>Playlists - ActualYT</title> +</svelte:head> + +<div class="container"> + <div class="header"> + <h1 class="section-title">Playlists</h1> + <div class="header-actions"> + {#if !showCreateForm && !showImportForm} + <button class="btn btn-secondary" onclick={() => showImportForm = true}> + Import from YouTube + </button> + <button class="btn btn-primary" onclick={() => showCreateForm = true}> + + New Playlist + </button> + {/if} + </div> + </div> + + {#if showCreateForm} + <div class="form-section"> + <h3 class="form-title">Create New Playlist</h3> + <div class="form-row"> + <input + type="text" + bind:value={newPlaylistName} + placeholder="Playlist name" + onkeydown={handleCreateKeydown} + /> + <button class="btn btn-primary" onclick={createPlaylist}>Create</button> + <button class="btn btn-secondary" onclick={() => { showCreateForm = false; newPlaylistName = ''; }}> + Cancel + </button> + </div> + </div> + {/if} + + {#if showImportForm} + <div class="form-section"> + <h3 class="form-title">Import YouTube Playlist</h3> + <p class="form-hint">Paste a YouTube playlist URL to import all its videos</p> + <div class="form-row"> + <input + type="text" + bind:value={importUrl} + placeholder="https://www.youtube.com/playlist?list=..." + onkeydown={handleImportKeydown} + disabled={importing} + /> + <button class="btn btn-primary" onclick={handleImport} disabled={importing}> + {importing ? 'Importing...' : 'Import'} + </button> + <button class="btn btn-secondary" onclick={() => { showImportForm = false; importUrl = ''; importError = ''; }} disabled={importing}> + Cancel + </button> + </div> + {#if importError} + <p class="form-error">{importError}</p> + {/if} + </div> + {/if} + + {#if $playlists.length === 0 && !showCreateForm && !showImportForm} + <div class="empty"> + <p>You haven't created any playlists yet.</p> + <p>Create a playlist or import one from YouTube.</p> + </div> + {:else if $playlists.length > 0} + <div class="playlist-grid"> + {#each $playlists as playlist (playlist.id)} + <a href="/playlists/{playlist.id}" class="playlist-card"> + <div class="thumbnail"> + {#if playlist.videos.length > 0} + <img src={playlist.videos[0].thumbnail} alt="" /> + <span class="count">{playlist.videos.length} videos</span> + {:else} + <div class="empty-thumb"> + <svg viewBox="0 0 24 24" width="48" height="48" fill="currentColor"> + <path d="M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z"/> + </svg> + </div> + {/if} + </div> + <div class="info"> + <h3 class="name">{playlist.name}</h3> + <p class="meta">{playlist.videos.length} videos</p> + </div> + <button + class="delete-btn" + onclick={(e) => { e.preventDefault(); deletePlaylist(playlist.id, playlist.name); }} + aria-label="Delete playlist" + > + <svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"> + <path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/> + </svg> + </button> + </a> + {/each} + </div> + {/if} +</div> + +<style> + .header { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem; + margin-bottom: 1.5rem; + } + + .section-title { + margin-bottom: 0; + } + + .header-actions { + display: flex; + gap: 0.5rem; + } + + .form-section { + background: var(--bg-secondary); + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 2rem; + } + + .form-title { + font-size: 1rem; + font-weight: 600; + margin: 0 0 0.5rem; + } + + .form-hint { + font-size: 0.9rem; + color: var(--text-muted); + margin: 0 0 1rem; + } + + .form-row { + display: flex; + gap: 0.5rem; + } + + .form-row input { + flex: 1; + padding: 0.5rem 1rem; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--bg-primary); + color: var(--text-primary); + font-size: 1rem; + } + + .form-row input:focus { + outline: none; + border-color: var(--accent-color); + } + + .form-row input:disabled { + opacity: 0.6; + } + + .form-error { + color: var(--error-color); + font-size: 0.9rem; + margin: 0.75rem 0 0; + } + + .playlist-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1.5rem; + } + + .playlist-card { + display: block; + text-decoration: none; + color: inherit; + border-radius: 8px; + overflow: hidden; + position: relative; + transition: transform 0.15s ease; + } + + .playlist-card:hover { + transform: translateY(-2px); + } + + .thumbnail { + position: relative; + aspect-ratio: 16 / 9; + background: var(--bg-secondary); + border-radius: 8px; + overflow: hidden; + } + + .thumbnail img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .count { + position: absolute; + bottom: 0; + right: 0; + background: rgba(0, 0, 0, 0.8); + color: #fff; + padding: 4px 8px; + font-size: 0.8rem; + } + + .empty-thumb { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + } + + .info { + padding: 0.75rem 0; + } + + .name { + font-size: 1rem; + font-weight: 500; + margin: 0 0 0.25rem; + } + + .meta { + font-size: 0.85rem; + color: var(--text-muted); + margin: 0; + } + + .delete-btn { + position: absolute; + top: 8px; + right: 8px; + width: 32px; + height: 32px; + border: none; + border-radius: 50%; + background: rgba(0, 0, 0, 0.7); + color: #fff; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.15s ease; + } + + .playlist-card:hover .delete-btn { + opacity: 1; + } + + .delete-btn:hover { + background: var(--error-color); + } + + @media (max-width: 600px) { + .header { + flex-direction: column; + align-items: flex-start; + } + + .form-row { + flex-direction: column; + } + } +</style> diff --git a/src/routes/playlists/[id]/+page.svelte b/src/routes/playlists/[id]/+page.svelte new file mode 100644 index 0000000..21de34f --- /dev/null +++ b/src/routes/playlists/[id]/+page.svelte @@ -0,0 +1,294 @@ +<script lang="ts"> + import { page } from '$app/stores'; + import { goto } from '$app/navigation'; + import { playlists, type Playlist } from '$lib/stores/playlists'; + import { formatDuration } from '$lib/api/youtube'; + + const playlistId = $derived($page.params.id ?? ''); + const playlist = $derived(playlists.getById($playlists, playlistId)); + + let editing = $state(false); + let editName = $state(''); + + function startEditing() { + if (!playlist) return; + editName = playlist.name; + editing = true; + } + + function saveEdit() { + if (!editName.trim() || !playlist) return; + playlists.rename(playlist.id, editName.trim()); + editing = false; + } + + function cancelEdit() { + editing = false; + editName = ''; + } + + function handleKeydown(e: KeyboardEvent) { + if (e.key === 'Enter') saveEdit(); + else if (e.key === 'Escape') cancelEdit(); + } + + function removeVideo(videoId: string) { + if (!playlist) return; + playlists.removeVideo(playlist.id, videoId); + } + + async function deletePlaylist() { + if (!playlist) return; + if (confirm(`Delete playlist "${playlist.name}"?`)) { + await playlists.delete(playlist.id); + goto('/playlists'); + } + } +</script> + +<svelte:head> + <title>{playlist?.name || 'Playlist'} - ActualYT</title> +</svelte:head> + +<div class="container"> + {#if !playlist} + <div class="error">Playlist not found</div> + {:else} + <div class="playlist-page"> + <div class="playlist-header"> + {#if editing} + <input + type="text" + bind:value={editName} + onkeydown={handleKeydown} + class="edit-input" + autofocus + /> + <button class="btn btn-primary" onclick={saveEdit}>Save</button> + <button class="btn btn-secondary" onclick={cancelEdit}>Cancel</button> + {:else} + <h1 class="title">{playlist.name}</h1> + <div class="actions"> + <button class="btn btn-secondary" onclick={startEditing}>Rename</button> + <button class="btn btn-danger" onclick={deletePlaylist}>Delete</button> + </div> + {/if} + </div> + + <p class="meta">{playlist.videos.length} videos</p> + + {#if playlist.videos.length === 0} + <div class="empty"> + <p>This playlist is empty.</p> + <p>Add videos from the watch page using the "Save" button.</p> + </div> + {:else} + <div class="video-list"> + {#each playlist.videos as video, index (video.videoId)} + <div class="video-item"> + <span class="index">{index + 1}</span> + <a href="/watch/{video.videoId}" class="video-link"> + <div class="thumbnail"> + <img src={video.thumbnail} alt="" /> + <span class="duration">{formatDuration(video.lengthSeconds)}</span> + </div> + <div class="info"> + <h3 class="video-title">{video.title}</h3> + <span + class="author" + role="link" + tabindex="0" + onclick={(e) => { e.preventDefault(); e.stopPropagation(); window.location.href = `/channel/${video.authorId}`; }} + onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); window.location.href = `/channel/${video.authorId}`; } }} + > + {video.author} + </span> + </div> + </a> + <button + class="remove-btn" + onclick={() => removeVideo(video.videoId)} + aria-label="Remove from playlist" + > + <svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"> + <path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/> + </svg> + </button> + </div> + {/each} + </div> + {/if} + </div> + {/if} +</div> + +<style> + .playlist-page { + max-width: 900px; + } + + .playlist-header { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 0.5rem; + } + + .title { + font-size: 1.5rem; + font-weight: 600; + margin: 0; + flex: 1; + } + + .edit-input { + flex: 1; + padding: 0.5rem 1rem; + font-size: 1.25rem; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--bg-secondary); + color: var(--text-primary); + } + + .edit-input:focus { + outline: none; + border-color: var(--accent-color); + } + + .actions { + display: flex; + gap: 0.5rem; + } + + .meta { + color: var(--text-muted); + margin-bottom: 2rem; + } + + .video-list { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + .video-item { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.5rem; + border-radius: 8px; + transition: background 0.15s ease; + } + + .video-item:hover { + background: var(--bg-hover); + } + + .index { + width: 24px; + text-align: center; + color: var(--text-muted); + font-size: 0.9rem; + flex-shrink: 0; + } + + .video-link { + display: flex; + align-items: center; + gap: 1rem; + flex: 1; + text-decoration: none; + color: inherit; + min-width: 0; + } + + .thumbnail { + position: relative; + width: 120px; + aspect-ratio: 16 / 9; + background: var(--bg-secondary); + border-radius: 4px; + overflow: hidden; + flex-shrink: 0; + } + + .thumbnail img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .duration { + position: absolute; + bottom: 4px; + right: 4px; + background: rgba(0, 0, 0, 0.8); + color: #fff; + padding: 1px 4px; + border-radius: 2px; + font-size: 0.75rem; + } + + .info { + flex: 1; + min-width: 0; + } + + .video-title { + font-size: 0.95rem; + font-weight: 500; + margin: 0 0 0.25rem; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + + .author { + font-size: 0.85rem; + color: var(--text-secondary); + text-decoration: none; + cursor: pointer; + } + + .author:hover { + color: var(--text-primary); + } + + .remove-btn { + width: 36px; + height: 36px; + border: none; + border-radius: 50%; + background: transparent; + color: var(--text-muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.15s ease, background 0.15s ease; + flex-shrink: 0; + } + + .video-item:hover .remove-btn { + opacity: 1; + } + + .remove-btn:hover { + background: var(--bg-secondary); + color: var(--error-color); + } + + @media (max-width: 600px) { + .thumbnail { + width: 100px; + } + + .index { + display: none; + } + } +</style> diff --git a/src/routes/search/+page.svelte b/src/routes/search/+page.svelte new file mode 100644 index 0000000..29bf7dc --- /dev/null +++ b/src/routes/search/+page.svelte @@ -0,0 +1,77 @@ +<script lang="ts"> + import { page } from '$app/stores'; + import { search, type VideoInfo } from '$lib/api/youtube'; + import VideoCard from '$lib/components/VideoCard.svelte'; + + let results = $state<VideoInfo[]>([]); + let loading = $state(true); + let error = $state(''); + let currentQuery = $state(''); + + $effect(() => { + const query = $page.url.searchParams.get('q') || ''; + if (query !== currentQuery) { + currentQuery = query; + loadResults(query); + } + }); + + async function loadResults(query: string) { + if (!query) { + results = []; + loading = false; + return; + } + + loading = true; + error = ''; + + try { + results = await search(query); + } catch (e) { + error = e instanceof Error ? e.message : 'Search failed'; + results = []; + } finally { + loading = false; + } + } +</script> + +<svelte:head> + <title>{currentQuery ? `${currentQuery} - Search` : 'Search'} - ActualYT</title> +</svelte:head> + +<div class="container"> + {#if currentQuery} + <h1 class="section-title">Results for "{currentQuery}"</h1> + {:else} + <h1 class="section-title">Search</h1> + {/if} + + {#if loading} + <div class="loading">Searching</div> + {:else if error} + <div class="error">{error}</div> + {:else if !currentQuery} + <div class="empty">Enter a search term to find videos</div> + {:else if results.length === 0} + <div class="empty">No results found for "{currentQuery}"</div> + {:else} + <div class="video-grid"> + {#each results as result (result.videoId)} + {#if result.videoId && result.title} + <VideoCard + videoId={result.videoId} + title={result.title} + author={result.author || ''} + authorId={result.authorId || ''} + thumbnails={result.videoThumbnails || []} + viewCount={result.viewCount || 0} + publishedText={result.publishedText || ''} + lengthSeconds={result.lengthSeconds || 0} + /> + {/if} + {/each} + </div> + {/if} +</div> diff --git a/src/routes/subscriptions/+page.svelte b/src/routes/subscriptions/+page.svelte new file mode 100644 index 0000000..9f318ac --- /dev/null +++ b/src/routes/subscriptions/+page.svelte @@ -0,0 +1,151 @@ +<script lang="ts"> + import { subscriptions } from '$lib/stores/subscriptions'; + import { getChannel, type VideoInfo } from '$lib/api/youtube'; + import VideoCard from '$lib/components/VideoCard.svelte'; + import ChannelCard from '$lib/components/ChannelCard.svelte'; + + let videos = $state<VideoInfo[]>([]); + let loading = $state(true); + let error = $state(''); + let activeTab = $state<'feed' | 'channels'>('feed'); + + $effect(() => { + if ($subscriptions.length > 0 && activeTab === 'feed') { + loadFeed(); + } else if ($subscriptions.length === 0) { + loading = false; + } + }); + + async function loadFeed() { + loading = true; + error = ''; + + try { + const results = await Promise.all( + $subscriptions.map(sub => + getChannel(sub.channelId).catch(() => ({ videos: [] })) + ) + ); + + const allVideos = results.flatMap(r => r.videos); + videos = allVideos.slice(0, 50); + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to load feed'; + } finally { + loading = false; + } + } +</script> + +<svelte:head> + <title>Subscriptions - ActualYT</title> +</svelte:head> + +<div class="container"> + <div class="header"> + <h1 class="section-title">Subscriptions</h1> + <div class="tabs"> + <button + class="tab" + class:active={activeTab === 'feed'} + onclick={() => activeTab = 'feed'} + > + Feed + </button> + <button + class="tab" + class:active={activeTab === 'channels'} + onclick={() => activeTab = 'channels'} + > + Channels ({$subscriptions.length}) + </button> + </div> + </div> + + {#if $subscriptions.length === 0} + <div class="empty"> + <p>You haven't subscribed to any channels yet.</p> + <p>Find channels you like and click Subscribe to see their videos here.</p> + </div> + {:else if activeTab === 'feed'} + {#if loading} + <div class="loading">Loading your feed</div> + {:else if error} + <div class="error">{error}</div> + {:else if videos.length === 0} + <div class="empty">No recent videos from your subscriptions</div> + {:else} + <div class="video-grid"> + {#each videos as video (video.videoId)} + <VideoCard + videoId={video.videoId} + title={video.title} + author={video.author} + authorId={video.authorId} + thumbnails={video.videoThumbnails} + viewCount={video.viewCount} + publishedText={video.publishedText} + lengthSeconds={video.lengthSeconds} + /> + {/each} + </div> + {/if} + {:else} + <div class="channel-list"> + {#each $subscriptions as sub (sub.channelId)} + <ChannelCard + channelId={sub.channelId} + channelName={sub.channelName} + thumbnails={[{ url: sub.thumbnail, width: 88, height: 88 }]} + /> + {/each} + </div> + {/if} +</div> + +<style> + .header { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem; + margin-bottom: 1.5rem; + } + + .section-title { + margin-bottom: 0; + } + + .tabs { + display: flex; + gap: 0.5rem; + } + + .tab { + padding: 0.5rem 1rem; + border: none; + border-radius: 20px; + background: var(--bg-secondary); + color: var(--text-secondary); + font-size: 0.9rem; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; + } + + .tab:hover { + background: var(--bg-hover); + } + + .tab.active { + background: var(--text-primary); + color: var(--bg-primary); + } + + .channel-list { + display: flex; + flex-direction: column; + gap: 0.5rem; + } +</style> diff --git a/src/routes/watch/[id]/+page.svelte b/src/routes/watch/[id]/+page.svelte new file mode 100644 index 0000000..f96d54a --- /dev/null +++ b/src/routes/watch/[id]/+page.svelte @@ -0,0 +1,381 @@ +<script lang="ts"> + import { page } from '$app/stores'; + import { getVideo, getRelatedVideos, formatViews, getBestThumbnail, type VideoDetails, type VideoInfo } from '$lib/api/youtube'; + import { subscriptions } from '$lib/stores/subscriptions'; + import { playlists, type PlaylistVideo } from '$lib/stores/playlists'; + import VideoPlayer from '$lib/components/VideoPlayer.svelte'; + import VideoCard from '$lib/components/VideoCard.svelte'; + + let video = $state<VideoDetails | null>(null); + let relatedVideos = $state<VideoInfo[]>([]); + let loading = $state(true); + let loadingRelated = $state(false); + let error = $state(''); + let showDescription = $state(false); + let showPlaylistMenu = $state(false); + + const videoId = $derived($page.params.id); + const isSubscribed = $derived( + video ? subscriptions.isSubscribed($subscriptions, video.authorId) : false + ); + + $effect(() => { + const id = $page.params.id; + if (id) loadVideo(id); + }); + + async function loadVideo(id: string) { + loading = true; + error = ''; + showDescription = false; + relatedVideos = []; + + try { + video = await getVideo(id); + loadRelated(id); + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to load video'; + video = null; + } finally { + loading = false; + } + } + + async function loadRelated(id: string) { + loadingRelated = true; + try { + relatedVideos = await getRelatedVideos(id); + } catch (e) { + console.error('Failed to load related videos:', e); + } finally { + loadingRelated = false; + } + } + + function handleSubscribe() { + if (!video) return; + if (isSubscribed) { + subscriptions.remove(video.authorId); + } else { + subscriptions.add(video.authorId, video.author, video.videoThumbnails); + } + } + + function addToPlaylist(playlistId: string) { + if (!video) return; + const playlistVideo: PlaylistVideo = { + videoId: video.videoId, + title: video.title, + author: video.author, + authorId: video.authorId, + thumbnail: getBestThumbnail(video.videoThumbnails), + lengthSeconds: video.lengthSeconds + }; + playlists.addVideo(playlistId, playlistVideo); + showPlaylistMenu = false; + } + + async function createAndAddToPlaylist() { + if (!video) return; + const name = prompt('Playlist name:'); + if (!name) return; + const id = await playlists.create(name); + addToPlaylist(id); + } +</script> + +<svelte:head> + <title>{video?.title || 'Watch'} - ActualYT</title> +</svelte:head> + +<div class="container"> + {#if loading} + <div class="loading">Loading video</div> + {:else if error} + <div class="error">{error}</div> + {:else if video} + <div class="watch-page"> + <div class="main-content"> + <div class="player-section"> + <VideoPlayer + formats={video.formats} + title={video.title} + /> + + <div class="video-info"> + <h1 class="title">{video.title}</h1> + + <div class="meta-row"> + <div class="stats"> + <span>{formatViews(video.viewCount)}</span> + <span class="separator">-</span> + <span>{video.publishedText}</span> + {#if video.likeCount} + <span class="separator">-</span> + <span>{formatViews(video.likeCount).replace('views', 'likes')}</span> + {/if} + </div> + + <div class="actions"> + <div class="playlist-dropdown"> + <button + class="btn btn-secondary" + onclick={() => showPlaylistMenu = !showPlaylistMenu} + > + + Save + </button> + {#if showPlaylistMenu} + <div class="playlist-menu"> + {#each $playlists as playlist (playlist.id)} + <button onclick={() => addToPlaylist(playlist.id)}> + {playlist.name} + </button> + {/each} + <button class="new-playlist" onclick={createAndAddToPlaylist}> + + Create new playlist + </button> + </div> + {/if} + </div> + </div> + </div> + + <div class="channel-row"> + <a href="/channel/{video.authorId}" class="channel"> + <span class="channel-name">{video.author}</span> + </a> + <button + class="btn" + class:btn-primary={!isSubscribed} + class:btn-secondary={isSubscribed} + onclick={handleSubscribe} + > + {isSubscribed ? 'Subscribed' : 'Subscribe'} + </button> + </div> + + <div class="description" class:expanded={showDescription}> + <p>{video.description || 'No description'}</p> + {#if video.description && video.description.length > 200} + <button class="show-more" onclick={() => showDescription = !showDescription}> + {showDescription ? 'Show less' : 'Show more'} + </button> + {/if} + </div> + </div> + </div> + </div> + + <aside class="related"> + <h2 class="related-title">Related Videos</h2> + {#if loadingRelated} + <div class="loading-small">Loading related videos...</div> + {:else if relatedVideos.length > 0} + <div class="related-list"> + {#each relatedVideos as related (related.videoId)} + <VideoCard + videoId={related.videoId} + title={related.title} + author={related.author} + authorId={related.authorId} + thumbnails={related.videoThumbnails} + viewCount={related.viewCount} + publishedText={related.publishedText} + lengthSeconds={related.lengthSeconds} + /> + {/each} + </div> + {:else} + <p class="no-related">No related videos found</p> + {/if} + </aside> + </div> + {/if} +</div> + +<style> + .watch-page { + display: grid; + grid-template-columns: 1fr 350px; + gap: 2rem; + align-items: start; + } + + .main-content { + min-width: 0; + } + + .player-section { + width: 100%; + } + + .video-info { + margin-top: 1rem; + } + + .title { + font-size: 1.25rem; + font-weight: 600; + line-height: 1.4; + margin-bottom: 0.75rem; + } + + .meta-row { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border-color); + } + + .stats { + color: var(--text-secondary); + font-size: 0.9rem; + } + + .separator { + margin: 0 0.5rem; + } + + .actions { + display: flex; + gap: 0.5rem; + } + + .playlist-dropdown { + position: relative; + } + + .playlist-menu { + position: absolute; + top: 100%; + right: 0; + margin-top: 0.5rem; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; + min-width: 200px; + z-index: 10; + overflow: hidden; + } + + .playlist-menu button { + display: block; + width: 100%; + padding: 0.75rem 1rem; + text-align: left; + border: none; + background: transparent; + color: var(--text-primary); + font-size: 0.9rem; + cursor: pointer; + } + + .playlist-menu button:hover { + background: var(--bg-hover); + } + + .playlist-menu .new-playlist { + border-top: 1px solid var(--border-color); + color: var(--accent-color); + } + + .channel-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem 0; + } + + .channel { + text-decoration: none; + } + + .channel-name { + font-weight: 500; + color: var(--text-primary); + } + + .description { + padding: 1rem; + background: var(--bg-secondary); + border-radius: 8px; + font-size: 0.9rem; + line-height: 1.6; + } + + .description:not(.expanded) p { + display: -webkit-box; + -webkit-line-clamp: 3; + line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + } + + .description p { + white-space: pre-wrap; + word-break: break-word; + } + + .show-more { + display: block; + margin-top: 0.5rem; + padding: 0; + border: none; + background: transparent; + color: var(--text-secondary); + font-size: 0.85rem; + cursor: pointer; + } + + .show-more:hover { + color: var(--text-primary); + } + + .related { + position: sticky; + top: 80px; + } + + .related-title { + font-size: 1rem; + font-weight: 600; + margin-bottom: 1rem; + } + + .related-list { + display: flex; + flex-direction: column; + gap: 1rem; + } + + .loading-small { + color: var(--text-muted); + font-size: 0.9rem; + padding: 1rem 0; + } + + .no-related { + color: var(--text-muted); + font-size: 0.9rem; + } + + @media (max-width: 1024px) { + .watch-page { + grid-template-columns: 1fr; + } + + .related { + position: static; + margin-top: 2rem; + } + + .related-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + } + } +</style> diff --git a/svelte.config.js b/svelte.config.js new file mode 100644 index 0000000..e7f0869 --- /dev/null +++ b/svelte.config.js @@ -0,0 +1,12 @@ +import adapter from '@sveltejs/adapter-auto'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter() + } +}; + +export default config; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4344710 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..3406f32 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,6 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [sveltekit()] +}); |
