.
All checks were successful
Test action / Print hello world (push) Successful in 6m38s

This commit is contained in:
Chris Kruining 2025-09-22 15:33:18 +02:00
parent 4f5bbac05e
commit a502a50176
No known key found for this signature in database
GPG key ID: EB894A3560CCCAD2
46 changed files with 2561 additions and 91 deletions

471
src/app.css Normal file
View file

@ -0,0 +1,471 @@
@layer reset, base, tokens, recipes, utilities;
@import "open-props/style" layer(tokens);
@import "open-props/normalize" layer(reset);
@import "open-props/durations" layer(base);
@import "open-props/theme.light.switch.min.css" layer(tokens);
@import "open-props/theme.dark.switch.min.css" layer(tokens);
@layer base {
html {
display: grid;
grid: 100% / 100%;
inline-size: 100%;
block-size: 100%;
overflow: clip;
/* font-size: clamp(1rem, -0.875rem + 8.333vw, 3.5rem); */
& > body {
display: grid;
grid: 100% / 100%;
inline-size: 100%;
block-size: 100%;
contain: layout style paint;
margin: 0;
font-family: sans-serif;
overflow: clip;
background-color: var(--surface-3);
color: var(--text-2);
accent-color: var(--primary-500);
* {
box-sizing: border-box;
&:focus-visible {
outline: 1px solid var(--info);
}
}
}
}
}
@layer reset {
@property --sibling-index {
syntax: "<integer>";
inherits: false;
initial-value: 1;
}
@property --sibling-count {
syntax: "<integer>";
inherits: false;
initial-value: 0;
}
:nth-child(1) {
--sibling-index: 1;
}
:nth-child(2) {
--sibling-index: 2;
}
:nth-child(3) {
--sibling-index: 3;
}
:nth-child(4) {
--sibling-index: 4;
}
:nth-child(5) {
--sibling-index: 5;
}
:nth-child(6) {
--sibling-index: 6;
}
:nth-child(7) {
--sibling-index: 7;
}
:nth-child(8) {
--sibling-index: 8;
}
:nth-child(9) {
--sibling-index: 9;
}
:nth-child(10) {
--sibling-index: 10;
}
:nth-child(11) {
--sibling-index: 11;
}
:nth-child(12) {
--sibling-index: 12;
}
:nth-child(13) {
--sibling-index: 13;
}
:nth-child(14) {
--sibling-index: 14;
}
:nth-child(15) {
--sibling-index: 15;
}
:nth-child(16) {
--sibling-index: 16;
}
:nth-child(17) {
--sibling-index: 17;
}
:nth-child(18) {
--sibling-index: 18;
}
:nth-child(19) {
--sibling-index: 19;
}
:nth-child(20) {
--sibling-index: 20;
}
:nth-child(21) {
--sibling-index: 21;
}
:nth-child(22) {
--sibling-index: 22;
}
:nth-child(23) {
--sibling-index: 23;
}
:nth-child(24) {
--sibling-index: 24;
}
:nth-child(25) {
--sibling-index: 25;
}
:nth-child(26) {
--sibling-index: 26;
}
:nth-child(27) {
--sibling-index: 27;
}
:nth-child(28) {
--sibling-index: 28;
}
:nth-child(29) {
--sibling-index: 29;
}
:nth-child(30) {
--sibling-index: 30;
}
:nth-child(31) {
--sibling-index: 31;
}
:nth-child(32) {
--sibling-index: 32;
}
:nth-child(33) {
--sibling-index: 33;
}
:nth-child(34) {
--sibling-index: 34;
}
:nth-child(35) {
--sibling-index: 35;
}
:nth-child(36) {
--sibling-index: 36;
}
:nth-child(37) {
--sibling-index: 37;
}
:nth-child(38) {
--sibling-index: 38;
}
:nth-child(39) {
--sibling-index: 39;
}
:nth-child(40) {
--sibling-index: 40;
}
:nth-child(41) {
--sibling-index: 41;
}
:nth-child(42) {
--sibling-index: 42;
}
:nth-child(43) {
--sibling-index: 43;
}
:nth-child(44) {
--sibling-index: 44;
}
:nth-child(45) {
--sibling-index: 45;
}
:nth-child(46) {
--sibling-index: 46;
}
:nth-child(47) {
--sibling-index: 47;
}
:nth-child(48) {
--sibling-index: 48;
}
:nth-child(49) {
--sibling-index: 49;
}
:nth-child(50) {
--sibling-index: 50;
}
:nth-child(51) {
--sibling-index: 51;
}
:nth-child(52) {
--sibling-index: 52;
}
:nth-child(53) {
--sibling-index: 53;
}
:nth-child(54) {
--sibling-index: 54;
}
:nth-child(55) {
--sibling-index: 55;
}
:nth-child(56) {
--sibling-index: 56;
}
:nth-child(57) {
--sibling-index: 57;
}
:nth-child(58) {
--sibling-index: 58;
}
:nth-child(59) {
--sibling-index: 59;
}
:has(> :last-child:nth-child(1)) > * {
--sibling-count: 1;
}
:has(> :last-child:nth-child(2)) > * {
--sibling-count: 2;
}
:has(> :last-child:nth-child(3)) > * {
--sibling-count: 3;
}
:has(> :last-child:nth-child(4)) > * {
--sibling-count: 4;
}
:has(> :last-child:nth-child(5)) > * {
--sibling-count: 5;
}
:has(> :last-child:nth-child(6)) > * {
--sibling-count: 6;
}
:has(> :last-child:nth-child(7)) > * {
--sibling-count: 7;
}
:has(> :last-child:nth-child(8)) > * {
--sibling-count: 8;
}
:has(> :last-child:nth-child(9)) > * {
--sibling-count: 9;
}
:has(> :last-child:nth-child(10)) > * {
--sibling-count: 10;
}
:has(> :last-child:nth-child(11)) > * {
--sibling-count: 11;
}
:has(> :last-child:nth-child(12)) > * {
--sibling-count: 12;
}
:has(> :last-child:nth-child(13)) > * {
--sibling-count: 13;
}
:has(> :last-child:nth-child(14)) > * {
--sibling-count: 14;
}
:has(> :last-child:nth-child(15)) > * {
--sibling-count: 15;
}
:has(> :last-child:nth-child(16)) > * {
--sibling-count: 16;
}
:has(> :last-child:nth-child(17)) > * {
--sibling-count: 17;
}
:has(> :last-child:nth-child(18)) > * {
--sibling-count: 18;
}
:has(> :last-child:nth-child(19)) > * {
--sibling-count: 19;
}
:has(> :last-child:nth-child(20)) > * {
--sibling-count: 20;
}
:has(> :last-child:nth-child(21)) > * {
--sibling-count: 21;
}
:has(> :last-child:nth-child(22)) > * {
--sibling-count: 22;
}
:has(> :last-child:nth-child(23)) > * {
--sibling-count: 23;
}
:has(> :last-child:nth-child(24)) > * {
--sibling-count: 24;
}
:has(> :last-child:nth-child(25)) > * {
--sibling-count: 25;
}
:has(> :last-child:nth-child(26)) > * {
--sibling-count: 26;
}
:has(> :last-child:nth-child(27)) > * {
--sibling-count: 27;
}
:has(> :last-child:nth-child(28)) > * {
--sibling-count: 28;
}
:has(> :last-child:nth-child(29)) > * {
--sibling-count: 29;
}
:has(> :last-child:nth-child(30)) > * {
--sibling-count: 30;
}
:has(> :last-child:nth-child(31)) > * {
--sibling-count: 31;
}
:has(> :last-child:nth-child(32)) > * {
--sibling-count: 32;
}
:has(> :last-child:nth-child(33)) > * {
--sibling-count: 33;
}
:has(> :last-child:nth-child(34)) > * {
--sibling-count: 34;
}
:has(> :last-child:nth-child(35)) > * {
--sibling-count: 35;
}
:has(> :last-child:nth-child(36)) > * {
--sibling-count: 36;
}
:has(> :last-child:nth-child(37)) > * {
--sibling-count: 37;
}
:has(> :last-child:nth-child(38)) > * {
--sibling-count: 38;
}
:has(> :last-child:nth-child(39)) > * {
--sibling-count: 39;
}
:has(> :last-child:nth-child(40)) > * {
--sibling-count: 40;
}
:has(> :last-child:nth-child(41)) > * {
--sibling-count: 41;
}
:has(> :last-child:nth-child(42)) > * {
--sibling-count: 42;
}
:has(> :last-child:nth-child(43)) > * {
--sibling-count: 43;
}
:has(> :last-child:nth-child(44)) > * {
--sibling-count: 44;
}
:has(> :last-child:nth-child(45)) > * {
--sibling-count: 45;
}
:has(> :last-child:nth-child(46)) > * {
--sibling-count: 46;
}
:has(> :last-child:nth-child(47)) > * {
--sibling-count: 47;
}
:has(> :last-child:nth-child(48)) > * {
--sibling-count: 48;
}
:has(> :last-child:nth-child(49)) > * {
--sibling-count: 49;
}
:has(> :last-child:nth-child(50)) > * {
--sibling-count: 50;
}
:has(> :last-child:nth-child(51)) > * {
--sibling-count: 51;
}
:has(> :last-child:nth-child(52)) > * {
--sibling-count: 52;
}
:has(> :last-child:nth-child(53)) > * {
--sibling-count: 53;
}
:has(> :last-child:nth-child(54)) > * {
--sibling-count: 54;
}
:has(> :last-child:nth-child(55)) > * {
--sibling-count: 55;
}
:has(> :last-child:nth-child(56)) > * {
--sibling-count: 56;
}
:has(> :last-child:nth-child(57)) > * {
--sibling-count: 57;
}
:has(> :last-child:nth-child(58)) > * {
--sibling-count: 58;
}
:has(> :last-child:nth-child(59)) > * {
--sibling-count: 59;
}
:has(> :last-child:nth-child(60)) > * {
--sibling-count: 60;
}
:has(> :last-child:nth-child(61)) > * {
--sibling-count: 61;
}
:has(> :last-child:nth-child(62)) > * {
--sibling-count: 62;
}
:has(> :last-child:nth-child(63)) > * {
--sibling-count: 63;
}
:has(> :last-child:nth-child(64)) > * {
--sibling-count: 64;
}
:has(> :last-child:nth-child(65)) > * {
--sibling-count: 65;
}
:has(> :last-child:nth-child(66)) > * {
--sibling-count: 66;
}
:has(> :last-child:nth-child(67)) > * {
--sibling-count: 67;
}
:has(> :last-child:nth-child(68)) > * {
--sibling-count: 68;
}
:has(> :last-child:nth-child(69)) > * {
--sibling-count: 69;
}
}

6
src/auth.client.ts Normal file
View file

@ -0,0 +1,6 @@
import { createAuthClient } from "better-auth/solid";
import { genericOAuthClient } from "better-auth/client/plugins";
export const { signIn, signOut, useSession, ...client } = createAuthClient({
plugins: [genericOAuthClient()],
});

51
src/auth.server.ts Normal file
View file

@ -0,0 +1,51 @@
import { betterAuth } from "better-auth";
import { genericOAuth } from "better-auth/plugins";
import { Database } from "bun:sqlite";
export const auth = betterAuth({
appName: "Streamarr",
basePath: "/api/auth",
database: new Database('auth.sqlite', { create: true }),
logger: {
level: "debug",
log(level, message, ...args) {
console.log(level, message, {args});
},
},
user: {
additionalFields: {
name: {
type: "string",
nullable: true,
},
username: {
type: "string",
nullable: true,
},
},
},
plugins: [
genericOAuth({
config: [
{
providerId: "zitadel",
clientId: "",
clientSecret: "",
discoveryUrl: "https://auth.amarth.cloud/.well-known/openid-configuration",
scopes: [
"offline_access",
"openid",
"email",
"picture",
"profile",
"groups",
],
accessType: "offline",
pkce: true,
mapProfileToUser: ({ id, name, email, image, preferred_username, emailVerified }) =>
({ id, name, email, emailVerified, image, username: preferred_username }),
},
],
}),
],
});

View file

@ -1,21 +0,0 @@
.increment {
font-family: inherit;
font-size: inherit;
padding: 1em 2em;
color: #335d92;
background-color: rgba(68, 107, 158, 0.1);
border-radius: 2em;
border: 2px solid rgba(68, 107, 158, 0);
outline: none;
width: 200px;
font-variant-numeric: tabular-nums;
cursor: pointer;
}
.increment:focus {
border: 2px solid #335d92;
}
.increment:active {
background-color: rgba(68, 107, 158, 0.2);
}

View file

@ -1,11 +0,0 @@
import { createSignal } from "solid-js";
import "./Counter.css";
export default function Counter() {
const [count, setCount] = createSignal(0);
return (
<button class="increment" onClick={() => setCount(count() + 1)} type="button">
Clicks: {count()}
</button>
);
}

View file

@ -0,0 +1,33 @@
.container {
isolation: isolate;
display: block grid;
container-type: inline-size;
}
.header {
position: relative;
block-size: 80cqb;
&::after {
content: "";
position: absolute;
inset: 0;
display: block;
background: linear-gradient(
atan(var(--ratio, .2)),
var(--surface-2) 20em,
transparent 90%
);
}
& > .background {
position: absolute;
inset: 0;
block-size: 100%;
inline-size: 100%;
object-fit: cover;
object-position: center;
z-index: 0;
}
}

View file

@ -0,0 +1,35 @@
import { Component, createSignal, onCleanup, onMount } from "solid-js";
import { Entry } from "~/features/content";
import css from "./details.module.css";
interface DetailsProps {
entry: Entry;
}
export const Details: Component<DetailsProps> = (props) => {
const [header, setHeader] = createSignal<HTMLElement>();
onMount(() => {
const observer = new ResizeObserver(([entry]) => {
const { inlineSize, blockSize } = entry.contentBoxSize[0];
(entry.target as HTMLElement).style.setProperty(
"--ratio",
String((blockSize * 0.2) / inlineSize)
);
});
observer.observe(header()!);
onCleanup(() => observer.disconnect());
});
return (
<div class={css.container}>
<header ref={setHeader} class={css.header}>
<img class={css.background} src={props.entry.image} />
<h1>{props.entry.title}</h1>
</header>
</div>
);
};

View file

@ -0,0 +1,3 @@
export { Details } from './details';

View file

@ -0,0 +1,96 @@
.box {
display: contents;
&:has(> :popover-open) > .button {
background-color: var(--surface-500);
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
}
.button {
position: relative;
display: grid;
grid-template-columns: inherit;
place-items: center start;
/* Make sure the height of the button does not collapse when it is empty */
block-size: 1em;
box-sizing: content-box;
padding: var(--size-2);
background-color: transparent;
border: none;
border-radius: var(--radius-2);
font-size: 1rem;
line-height: 1;
cursor: pointer;
&:hover {
background-color: var(--surface-4);
}
&:has(> .caret) {
padding-inline-end: calc(1em + (2 * var(--size-2)));
}
& > .caret {
position: absolute;
inset-inline-end: var(--size-2);
inset-block-start: 50%;
translate: 0 -50%;
inline-size: 1em;
}
}
.dialog {
display: none;
position: relative;
grid-template-columns: inherit;
inset-inline-start: anchor(start);
inset-block-start: anchor(end);
position-try-fallbacks: flip-block, flip-inline;
/* inline-size: anchor-size(self-inline); */
background-color: var(--surface-4);
padding: var(--size-2);
border: none;
box-shadow: var(--shadow-2);
&:popover-open {
display: grid;
}
& > header {
display: grid;
grid-column: 1 / -1;
gap: var(--size-1);
}
& > main {
display: grid;
grid-template-columns: subgrid;
grid-column: 1 / -1;
row-gap: var(--size-1);
}
}
.option {
display: grid;
grid-template-columns: subgrid;
grid-column: 1 / -1;
place-items: center start;
border-radius: var(--radius-2);
padding: var(--size-1);
margin-inline: calc(-1 * var(--size-1));
cursor: pointer;
&.selected {
background-color: color(from var(--cyan-4) srgb r g b / .1);
}
}

View file

@ -0,0 +1,52 @@
import { createSignal, JSX, createEffect, Show } from "solid-js";
import { FaSolidAngleDown } from "solid-icons/fa";
import css from './dropdown.module.css';
export interface DropdownApi {
show(): void;
hide(): void;
}
interface DropdownProps {
api?: (api: DropdownApi) => any,
id: string;
class?: string;
open?: boolean;
showCaret?: boolean;
text: JSX.Element;
children: JSX.Element;
}
export function Dropdown(props: DropdownProps) {
const [dialog, setDialog] = createSignal<HTMLDialogElement>();
const [open, setOpen] = createSignal<boolean>(props.open ?? false);
createEffect(() => {
dialog()?.[open() ? 'showPopover' : 'hidePopover']();
});
createEffect(() => {
props.api?.({
show() {
dialog()?.showPopover();
},
hide() {
dialog()?.hidePopover();
},
});
});
return <section class={`${css.box} ${props.class}`}>
<button id={`${props.id}_button`} popoverTarget={`${props.id}_dialog`} class={css.button}>
{props.text}
<Show when={props.showCaret}>
<FaSolidAngleDown class={css.caret} />
</Show>
</button>
<dialog ref={setDialog} id={`${props.id}_dialog`} anchor={`${props.id}_button`} popover class={css.dialog} onToggle={e => setOpen(e.newState === 'open')}>
{props.children}
</dialog>
</section>;
}

View file

@ -0,0 +1,4 @@
export type { DropdownApi } from './dropdown';
export { Dropdown } from './dropdown';

View file

@ -0,0 +1,174 @@
@property --thumb-image {
syntax: "<image>";
inherits: true;
}
.container {
isolation: isolate;
display: block grid;
grid-auto-flow: column;
grid-auto-columns: 100%;
container-type: inline-size;
overflow: hidden visible;
scroll-snap-type: inline mandatory;
overscroll-behavior-inline: contain;
@media (prefers-reduced-motion: no-preference) {
scroll-behavior: smooth;
}
scroll-marker-group: after;
&::scroll-marker-group {
display: block grid;
grid-auto-flow: column;
grid-auto-columns: 5em;
gap: 1rem;
justify-content: start;
padding-inline: var(--size-6);
inline-size: 100%;
block-size: 8.333333em;
z-index: 1;
}
}
.page {
--__i: var(--sibling-index);
--__c: var(--sibling-count);
scroll-snap-align: center;
position: relative;
display: grid;
grid: repeat(3, auto) / 15em 1fr;
grid-template-areas:
"thumbnail . ."
"thumbnail title cta"
"thumbnail detail detail"
"thumbnail summary summary";
align-content: end;
align-items: center;
gap: 1rem;
padding: var(--size-6);
block-size: 80vh;
overflow: clip;
container-type: scroll-state;
animation:
animate-in linear forwards,
animate-out linear forwards;
animation-timeline: view(inline);
animation-range: entry, exit;
color: var(--gray-0);
&::after {
content: "";
position: absolute;
inset: 0;
display: block;
background: linear-gradient(182.5deg, transparent 20%, var(--surface-2) 90%),
linear-gradient(transparent 50%, #0007 75%);
}
&::scroll-marker {
display: block;
content: " ";
inline-size: 5rem;
aspect-ratio: 3 / 5;
background: var(--thumb-image) center / cover no-repeat;
background-color: cornflowerblue;
border-radius: var(--radius-2);
transform: scale(1);
transform-origin: top left;
transition: 0.3s;
}
&::scroll-marker:target-current {
/* outline: 1px solid white; */
transform: translate(calc(-0cqi - (6rem * (var(--__i) - 1))), -29rem)
scale(3);
}
}
.title {
grid-area: title;
font-size: 2.5em;
z-index: 1;
filter: contrast(9);
}
.cta {
grid-area: cta;
z-index: 1;
border-radius: var(--radius-2);
background-color: var(--gray-2);
color: var(--gray-8);
text-decoration-color: var(--gray-8);
padding: var(--size-3);
font-weight: var(--font-weight-9);
outline-offset: var(--size-1);
&:focus-visible {
outline: 1px solid var(--gray-2);
}
}
.thumbnail {
grid-area: thumbnail;
inline-size: 15em;
aspect-ratio: 3 / 5;
border-radius: var(--radius-3);
object-fit: cover;
object-position: center;
z-index: 1;
opacity: 0 !important;
}
.background {
position: absolute;
inset: 0;
block-size: 100%;
inline-size: 100%;
object-fit: cover;
object-position: center;
z-index: 0;
}
.detail {
grid-area: detail;
z-index: 1;
}
.summary {
grid-area: summary;
text-wrap: balance;
z-index: 1;
}
@keyframes animate-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes animate-out {
0% {
opacity: 1;
}
20% {
opacity: 0;
}
100% {
opacity: 0;
}
}

View file

@ -0,0 +1,61 @@
import { Component, createEffect, createMemo, For, Index } from "solid-js";
import { createSlug, Entry } from "~/features/content";
import css from "./hero.module.css";
type HeroProps = {
entries: Entry[];
class?: string;
};
export function Hero(props: HeroProps) {
return (
<div class={`${css.container} ${props.class ?? ""}`}>
<For each={props.entries}>{(entry) => <Page entry={entry} />}</For>
</div>
);
}
const Page: Component<{ entry: Entry }> = (props) => {
const slug = createMemo(() => createSlug(props.entry));
return (
<div
class={`${css.page}`}
style={{ "--thumb-image": `url(${props.entry.thumbnail})` }}
>
<h2 class={css.title}>{props.entry.title}</h2>
<a class={css.cta} href={`/play/${slug()}`}>
Continue
</a>
<img src={props.entry.thumbnail} class={css.thumbnail} />
{/* <img src={props.entry.image} class={css.background} /> */}
<video
class={css.background}
src={props.entry.trailer}
poster={props.entry.image}
muted
autoplay
/>
<span class={css.detail}>
{props.entry.releaseDate}
<Index each={props.entry.sources ?? []}>
{(source) => (
<>
&nbsp;&nbsp;
<a href={source().url.toString()} target="_blank">
{source().rating.score} {source().label}
</a>
</>
)}
</Index>
</span>
<p class={css.summary}>{props.entry.overview}</p>
</div>
);
};

View file

@ -0,0 +1 @@
export { Hero } from "./hero";

View file

@ -0,0 +1 @@
export { List } from "./list";

View file

@ -0,0 +1,92 @@
.container {
--_space: var(--size-6);
display: grid;
grid: auto auto / auto auto;
grid-template-areas:
"heading metadata"
"list list";
justify-content: space-between;
inline-size: 100%;
padding-inline: var(--_space);
}
.heading {
grid-area: heading;
font-size: var(--size-7);
color: var(--text-1);
padding-inline: var(--_space);
}
.metadata {
grid-area: metadata;
color: var(--text-2);
}
.list {
grid-area: list;
list-style-type: none;
container-type: inline-size;
display: grid;
grid-auto-flow: column;
gap: var(--_space);
padding: calc(8 * var(--_space)) calc(2 * var(--_space)) calc(2.5 * var(--_space));
scroll-padding: calc(2 * var(--_space));
margin: calc(-7 * var(--_space)) calc(-1 * var(--_space)) 0em;
overflow: visible auto;
scroll-snap-type: inline mandatory;
overscroll-behavior-inline: contain;
@media (prefers-reduced-motion: no-preference) {
scroll-behavior: smooth;
}
/* the before and afters have unsnappable elements that create bouncy edges to the scroll */
&::before,
&::after {
content: "";
display: block;
}
&::before {
inline-size: 15cqi;
}
&::after {
inline-size: 100cqi;
}
& > li {
scroll-snap-align: start;
container-type: scroll-state;
padding: 0;
position: relative;
isolation: isolate;
z-index: calc(var(--sibling-count) - var(--sibling-index));
&:has(> :hover, > :focus-within) {
z-index: calc(var(--sibling-count) + 1);
}
& > * {
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
animation: slide-in linear both;
animation-timeline: view(inline);
animation-range: cover -100cqi contain 15cqi;
}
}
}
}
}
@keyframes slide-in {
from {
transform: translateX(-100cqi) scale(0.5);
}
}

View file

@ -0,0 +1,27 @@
import { Accessor, Index, JSX } from "solid-js";
import css from "./list.module.css";
interface ListProps<T> {
label: string;
items: T[];
class?: string;
children: (item: Accessor<T>) => JSX.Element;
}
export function List<T>(props: ListProps<T>) {
return (
<section class={`${css.container} ${props.class ?? ""}`}>
<b role="heading" class={css.heading}>
{props.label}
</b>
<sub class={css.metadata}>{props.items.length} result(s)</sub>
<ul class={css.list}>
<Index each={props.items}>
{(item) => <li>{props.children(item)}</li>}
</Index>
</ul>
</section>
);
}

View file

@ -0,0 +1,3 @@
export { Select } from './select';

View file

@ -0,0 +1,176 @@
.box {
appearance: none;
display: block grid;
place-items: center start;
padding: var(--size-2);
background-color: transparent;
border: none;
border-radius: var(--radius-2);
font-size: 1rem;
&:hover {
background-color: var(--surface-700);
}
@supports (appearance: base-select) {
&,
&::picker(select) {
appearance: base-select;
}
&::picker(select) {
/* display: block grid;
row-gap: var(--size-2); */
background-color: var(--surface-3);
padding: var(--size-2) 0;
border: none;
box-shadow: var(--shadow-2);
opacity: 0;
block-size: 0;
overflow: clip;
transition:
height 0.5s ease-out,
opacity 0.5s ease-out,
overlay 0.5s,
display 0.5s,
overflow 0.5s;
transition-behavior: allow-discrete;
}
&:open {
background-color: var(--surface-3);
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
&::picker(select) {
opacity: 1;
block-size: calc-size(auto, size);
overflow: auto;
@starting-style {
opacity: 0;
block-size: 0;
}
}
}
& > option {
display: block grid;
grid-auto-flow: column;
place-items: center start;
border-radius: var(--radius-2);
padding: var(--size-2);
cursor: pointer;
&:checked {
background-color: var(--surface-4);
}
&::checkmark {
display: none;
}
}
}
}
/* .box {
display: contents;
&:has(> :popover-open) > .button {
background-color: var(--surface-500);
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
}
.button {
position: relative;
display: grid;
grid-template-columns: inherit;
place-items: center start;
block-size: 1em;
box-sizing: content-box;
padding: var(--size-2);
background-color: transparent;
border: none;
border-radius: var(--radius-2);
font-size: 1rem;
cursor: pointer;
&:hover {
background-color: var(--surface-700);
}
&:has(> .caret) {
padding-inline-end: calc(1em + (2 * var(--size-2)));
}
& > .caret {
position: absolute;
inset-inline-end: var(--size-2);
inset-block-start: 50%;
translate: 0 -50%;
inline-size: 1em;
}
}
.dialog {
display: none;
position: relative;
grid-template-columns: inherit;
inset-inline-start: anchor(start);
inset-block-start: anchor(end);
position-try-fallbacks: flip-block, flip-inline;
background-color: var(--surface-3);
padding: var(--size-2);
border: none;
box-shadow: var(--shadow-2);
&:popover-open {
display: grid;
}
& > header {
display: grid;
grid-column: 1 / -1;
gap: var(--padding-s);
}
& > main {
display: grid;
grid-template-columns: subgrid;
grid-column: 1 / -1;
row-gap: var(--padding-s);
}
}
.option {
display: grid;
grid-template-columns: subgrid;
grid-column: 1 / -1;
place-items: center start;
border-radius: var(--radii-m);
padding: var(--padding-s);
margin-inline: calc(-1 * var(--padding-s));
cursor: pointer;
&.selected {
background-color: oklch(from var(--info) l c h / .1);
}
} */

View file

@ -0,0 +1,67 @@
import { createMemo, createSignal, For, JSX, Setter, createEffect, Show } from "solid-js";
import { Dropdown, DropdownApi } from "../dropdown";
import css from './select.module.css';
interface SelectProps<T, K extends string> {
id: string;
class?: string;
value: K;
setValue?: Setter<K>;
values: Record<K, T>;
open?: boolean;
showCaret?: boolean;
children: (key: K, value: T) => JSX.Element;
filter?: (query: string, key: K, value: T) => boolean;
}
export function Select<T, K extends string>(props: SelectProps<T, K>) {
const [dropdown, setDropdown] = createSignal<DropdownApi>();
const [key, setKey] = createSignal<K>(props.value);
const [query, setQuery] = createSignal<string>('');
const showCaret = createMemo(() => props.showCaret ?? true);
const values = createMemo(() => {
let entries = Object.entries<T>(props.values) as [K, T][];
const filter = props.filter;
const q = query();
if (filter) {
entries = entries.filter(([k, v]) => filter(q, k, v));
}
return entries;
});
createEffect(() => {
props.setValue?.(() => key());
});
const text = <Show when={key()}>{
key => {
const value = createMemo(() => props.values[key()]);
return <>{props.children(key(), value())}</>;
}
}</Show>
return <Dropdown api={setDropdown} id={props.id} class={`${css.box} ${props.class}`} showCaret={showCaret()} open={props.open} text={text}>
<Show when={props.filter !== undefined}>
<header>
<input value={query()} onInput={e => setQuery(e.target.value)} />
</header>
</Show>
<main>
<For each={values()}>{
([k, v]) => {
const selected = createMemo(() => key() === k);
return <span class={`${css.option} ${selected() ? css.selected : ''}`} onpointerdown={() => {
setKey(() => k);
dropdown()?.hide();
}}>{props.children(k, v)}</span>;
}
}</For>
</main>
</Dropdown>
}

View file

@ -0,0 +1 @@
export { Shell } from './shell';

View file

@ -0,0 +1,168 @@
.nav {
grid-area: 2 / 1 / 3 / 2;
display: block grid;
grid-auto-flow: row;
justify-content: space-between;
inline-size: 5em;
block-size: 100%;
padding: 1em;
background: inherit;
z-index: 0;
transition: z-index 0.3s step-end;
& > ul {
position: relative;
display: block grid;
grid-template-columns: 2.5rem auto;
align-content: center;
inline-size: 4rem;
gap: 1rem;
transform-origin: left center;
padding: 0;
padding-inline-start: 0.5rem;
margin: 0;
&::before {
content: "";
position: absolute;
inset-inline-start: 100%;
inset-block: -1em;
inline-size: 40vw;
background-image: linear-gradient(to right, rgb(from var(--surface-1) r g b / .9) 50%, transparent);
mask: radial-gradient(
ellipse 40vw 100% at left center,
black 25%,
transparent
);
backdrop-filter: blur(5px);
opacity: 0;
transition: opacity 0.3s var(--ease-3);
}
& > a {
position: relative;
grid-column: span 2;
display: block grid;
grid-template-columns: subgrid;
align-items: center;
text-decoration: none;
transform-origin: center left;
transition:
transform 2s var(--ease-spring-5),
opacity 0.3s var(--ease-3);
color: var(--text-2);
font-size: 2rem;
line-height: 1.5;
& > span {
opacity: 0;
transition: opacity 0.3s var(--ease-3);
text-shadow: 0 0 .5em var(--surface-1);
}
& > svg {
fill: var(--text-2);
inline-size: 2.5rem;
block-size: 2.5rem;
}
&.active {
color: var(--yellow-5);
list-style: disc;
&::before {
content: "•";
position: absolute;
inset-inline-start: -1rem;
}
& > svg {
fill: var(--yellow-5);
}
}
}
&:has(a:is(:hover, :focus)) {
&::before {
opacity: 1;
}
& > a {
transform: scale(max(1, calc(1.5 - (0.2 * abs(var(--target) - var(--sibling-index))))));
& > span {
opacity: 1;
}
}
}
&:has(a:is(:hover, :focus)) > a:not(:is(:hover, :focus)) {
opacity: 0.25;
}
&:has(a:is(:hover, :focus):nth-child(1)) {
--target: 1;
}
&:has(a:is(:hover, :focus):nth-child(2)) {
--target: 2;
}
&:has(a:is(:hover, :focus):nth-child(3)) {
--target: 3;
}
&:has(a:is(:hover, :focus):nth-child(4)) {
--target: 4;
}
&:has(a:is(:hover, :focus):nth-child(5)) {
--target: 5;
}
&:has(a:is(:hover, :focus):nth-child(6)) {
--target: 6;
}
&:has(a:is(:hover, :focus):nth-child(7)) {
--target: 7;
}
&:has(a:is(:hover, :focus):nth-child(8)) {
--target: 8;
}
&:has(a:is(:hover, :focus):nth-child(9)) {
--target: 9;
}
&:has(a:is(:hover, :focus):nth-child(10)) {
--target: 10;
}
&:has(a:is(:hover, :focus):nth-child(11)) {
--target: 11;
}
&:has(a:is(:hover, :focus):nth-child(12)) {
--target: 12;
}
&:has(a:is(:hover, :focus):nth-child(13)) {
--target: 13;
}
&:has(a:is(:hover, :focus):nth-child(14)) {
--target: 14;
}
&:has(a:is(:hover, :focus):nth-child(15)) {
--target: 15;
}
}
&:has(a:hover, :focus-within) {
z-index: 1;
transition: z-index 0.3s step-start;
}
}

View file

@ -0,0 +1,25 @@
import { A } from "@solidjs/router";
import { AiOutlineHome, AiOutlineStar, AiOutlineSearch } from "solid-icons/ai";
import { Component } from "solid-js";
import css from "./nav.module.css";
export const Nav: Component = (props) => {
return (
<nav class={css.nav}>
<ul>
<A href="/" activeClass={css.active} end={true}>
<AiOutlineHome />
<span>Home</span>
</A>
<A href="/library" activeClass={css.active}>
<AiOutlineStar />
<span>Library</span>
</A>
<A href="/search" activeClass={css.active}>
<AiOutlineSearch />
<span>Search</span>
</A>
</ul>
</nav>
);
};

View file

@ -0,0 +1,52 @@
.container {
position: relative;
display: block grid;
grid: auto 1fr / 5em 1fr;
grid-template-areas:
"top top"
"nav content";
inline-size: 100%;
block-size: 100%;
z-index: 0;
overflow: clip;
container-type: inline-size;
background-color: var(--surface-1);
contain: layout style paint;
&::after {
content: '';
grid-area: content;
display: block;
position: absolute;
inset-inline-start: 0;
inset-block-start: 0;
inline-size: var(--radius-4);
block-size: var(--radius-4);
background: radial-gradient(circle at bottom right, transparent var(--radius-4), var(--surface-1) var(--radius-4));
pointer-events: none;
}
}
.body {
grid-area: 2 / 1 / 3 / 3;
inline-size: 100%;
block-size: 100%;
background: linear-gradient(180deg,
transparent,
transparent 90vh,
var(--surface-500) 90vh,
var(--surface-500));
overflow: clip auto;
padding-inline-start: 5em;
transition: filter var(--duration-moderate-1) var(--ease-3);
container-type: size;
& > div {
background-color: var(--surface-2);
container-type: inline-size;
contain: layout style paint;
inline-size: 100%;
block-size: fit-content;
min-block-size: 100%;
}
}

View file

@ -0,0 +1,22 @@
import { ParentComponent } from "solid-js";
import { Top } from "./top";
import { Nav } from "./nav";
import css from "./shell.module.css";
import { User } from "../user";
interface ShellProps {
user: User | undefined;
}
export const Shell: ParentComponent<ShellProps> = (props) => {
return (
<main class={css.container}>
<Top user={props.user} />
<Nav />
<div class={css.body}>
<div>{props.children}</div>
</div>
</main>
);
};

View file

@ -0,0 +1,37 @@
.top {
grid-area: 1 / 1 / 2 / 3;
display: block grid;
grid-auto-flow: column;
justify-content: end;
z-index: 1;
background-color: inherit;
padding: 0.5em;
}
.accountTrigger {
anchor-name: --account-trigger;
background: transparent;
padding: 0;
margin: 0;
border-radius: var(--radius-round);
}
.accountMenu {
position-anchor: --account-trigger;
position: absolute;
inset: auto;
inset-inline-end: anchor(end);
inset-block-start: anchor(start);
display: block grid;
grid-auto-flow: row;
gap: var(--size-3);
padding: var(--size-3);
background-color: light-dark(var(--gray-1), var(--gray-9));
border-radius: var(--radius-2);
box-shadow: var(--shadow-2);
&:not(:popover-open) {
display: none;
}
}

View file

@ -0,0 +1,59 @@
import { Component, Show } from "solid-js";
import { signIn, signOut, client } from "~/auth.client";
import { Avatar, Profile, User } from "../user";
import { ColorSchemePicker } from "../theme";
import css from "./top.module.css";
interface TopProps {
user: User | undefined;
}
export const Top: Component<TopProps> = (props) => {
const login = async (e: SubmitEvent) => {
e.preventDefault();
await signIn.oauth2({
providerId: "authelia",
callbackURL: "/",
});
};
const logout = async (e: SubmitEvent) => {
e.preventDefault();
await signOut();
};
return (
<aside class={css.top}>
<Show
when={props.user}
fallback={
<form method="post" onSubmit={login}>
<button type="submit">Sign in</button>
</form>
}
>
{(user) => (
<>
<button
class={css.accountTrigger}
id="account-menu-trigger"
popovertarget="account-menu-popover"
>
<Avatar user={user()} />
</button>
<div class={css.accountMenu} id="account-menu-popover" popover>
<Profile user={user()} />
<a href="/settings">Settings</a>
<form method="post" onSubmit={logout}>
<button type="submit">Log out</button>
</form>
</div>
</>
)}
</Show>
<ColorSchemePicker />
</aside>
);
};

View file

@ -0,0 +1,7 @@
.avatar {
inline-size: var(--size-8);
border-radius: var(--radius-round);
aspect-ratio: 1;
object-fit: cover;
object-position: center;
}

View file

@ -0,0 +1,27 @@
import { Component, createMemo, Show } from "solid-js";
import { User } from "./user";
import { hash } from "~/utilities";
import css from "./avatar.module.css";
interface AvatarProps {
user: User | undefined;
}
export const Avatar: Component<AvatarProps> = (props) => {
const hashedEmail = hash("SHA-256", () => props.user?.email);
const src = createMemo(() => {
const user = props.user;
if (user === undefined) {
return "";
}
if (user.image === null) {
return `https://www.gravatar.com/avatar/${hashedEmail()}`;
}
return user.image;
});
return <img src={src()} class={css.avatar} />;
};

View file

@ -0,0 +1,4 @@
export type { User } from "./user";
export { Avatar } from "./avatar";
export { Profile } from "./profile";

View file

@ -0,0 +1,22 @@
.profile {
display: block grid;
grid: auto 1fr / auto 1fr;
gap: var(--size-2);
place-content: start;
background-color: light-dark(var(--gray-1), var(--gray-9));
& > img {
grid-area: span 2 / 1;
}
& > strong {
font-size: var(--size-4);
line-height: 1;
color: light-dark(var(--gray-7), var(--gray-3));
}
& > span {
line-height: 1;
color: light-dark(var(--gray-4), var(--gray-6));
}
}

View file

@ -0,0 +1,18 @@
import { Component } from "solid-js";
import { User } from "./user";
import { Avatar } from "./avatar";
import css from "./profile.module.css";
interface ProfileProps {
user: User | undefined;
}
export const Profile: Component<ProfileProps> = (props) => {
return (
<div class={css.profile}>
<Avatar user={props.user} />
<strong>{props.user?.name ?? ""}</strong>
<span>{props.user?.email ?? ""}</span>
</div>
);
};

View file

@ -0,0 +1,6 @@
export interface User {
username: string;
name: string;
email: string;
image: string | null;
}

View file

@ -1,6 +1,6 @@
import { Meta } from "@solidjs/meta";
import { query, createAsync } from "@solidjs/router";
import { createEffect, on, ParentProps } from "solid-js";
import { ParentProps } from "solid-js";
import { getRequestEvent } from "solid-js/web";
import { auth } from "~/auth.server";
import { Shell } from "~/features/shell";

69
src/utilities.ts Normal file
View file

@ -0,0 +1,69 @@
import { Accessor, createEffect, createSignal, on } from "solid-js";
export const splitAt = (
subject: string,
index: number,
): readonly [string, string] => {
if (index < 0) {
return [subject, ""];
}
if (index > subject.length) {
return [subject, ""];
}
return [subject.slice(0, index), subject.slice(index + 1)];
};
export const toSlug = (subject: string) =>
subject.toLowerCase().replaceAll(" ", "-").replaceAll(/[^\w-]/gi, "");
export const toHex = (subject: number) => subject.toString(16).padStart(2, "0");
const encoder = new TextEncoder();
export const hash = (
algorithm: AlgorithmIdentifier,
subject: Accessor<string | null | undefined>,
) => {
const [hash, setHash] = createSignal<string>();
createEffect(
on(subject, async (subject) => {
if (subject === null || subject === undefined || subject.length === 0) {
setHash(undefined);
return;
}
const buffer = new Uint8Array(
await crypto.subtle.digest(algorithm, encoder.encode(subject)),
);
setHash(Array.from(buffer).map(toHex).join(""));
}),
);
return hash;
};
export const merge = (...objects: Record<string, any>[]): Record<string, any> => {
if (objects.length === 0) {
return {};
}
const target = objects[0];
for (const key of new Set(objects.map(o => Object.keys(o)).flat())) {
const values = objects.filter(o => Object.hasOwn(o, key)).map(o => o[key]);
target[key] = values.every(v => v && typeof v === 'object' && !Array.isArray(v)) ? merge(...values) : values.at(-1);
}
return target;
};
type CamelCase<S extends string> = S extends `${infer First}${infer Rest}` ? `${Lowercase<First>}${Rest}` : Lowercase<S>;
export type CamelCased<T extends Record<string, any>> = {
[ K in keyof T as CamelCase<string&K>]: T[K];
} & {};
export const mapKeysToCamelCase = <T extends Record<string, any>>(subject: T): CamelCased<T> => Object.fromEntries(Object.entries(subject).map(([k, v]) => [`${k[0].toLowerCase()}${k.slice(1)}`, v])) as CamelCased<T>;