too lazy to think of a message, so enjoy this pointless text. Good luck future me...
All checks were successful
Test action / Print hello world (push) Successful in 6m12s

This commit is contained in:
Chris Kruining 2025-09-22 14:25:07 +00:00
parent a502a50176
commit ff31c28d38
46 changed files with 1403 additions and 1397 deletions

0
bun.lock Normal file → Executable file
View file

12
bunfig.toml Normal file → Executable file
View file

@ -1,6 +1,6 @@
[test]
coverage = true
coverageSkipTestFiles = true
coverageReporter = ['text', 'lcov']
coverageDir = './.coverage'
preload = "./test.config.ts"
[test]
coverage = true
coverageSkipTestFiles = true
coverageReporter = ['text', 'lcov']
coverageDir = './.coverage'
preload = "./test.config.ts"

0
flake.nix Normal file → Executable file
View file

6
justfile Executable file
View file

@ -0,0 +1,6 @@
push:
git add .
git commit -m 'too lazy to think of a message, so enjoy this pointless text. Good luck future me...'
git push

0
nix/devShells/flake-module.nix Normal file → Executable file
View file

2
nix/modules/customer-portal/default.nix Normal file → Executable file
View file

@ -77,7 +77,7 @@ in
config = mkIf cfg.enable {
systemd = {
servces.amarthCustomerPortal = {
services.amarthCustomerPortal = {
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];

0
nix/modules/customer-portal/flake-module.nix Normal file → Executable file
View file

0
nix/modules/flake-module.nix Normal file → Executable file
View file

0
nix/packages/flake-module.nix Normal file → Executable file
View file

0
src/app.css Normal file → Executable file
View file

10
src/auth.client.ts Normal file → Executable file
View file

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

102
src/auth.server.ts Normal file → Executable file
View file

@ -1,51 +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 }),
},
],
}),
],
});
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 }),
},
],
}),
],
});

66
src/components/details/details.module.css Normal file → Executable file
View file

@ -1,33 +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;
}
}
.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;
}
}

70
src/components/details/details.tsx Normal file → Executable file
View file

@ -1,35 +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>
);
};
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>
);
};

4
src/components/details/index.ts Normal file → Executable file
View file

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

190
src/components/dropdown/dropdown.module.css Normal file → Executable file
View file

@ -1,96 +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);
}
.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);
}
}

102
src/components/dropdown/dropdown.tsx Normal file → Executable file
View file

@ -1,52 +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>;
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>;
}

6
src/components/dropdown/index.ts Normal file → Executable file
View file

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

348
src/components/hero/hero.module.css Normal file → Executable file
View file

@ -1,174 +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;
}
}
@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;
}
}

122
src/components/hero/hero.tsx Normal file → Executable file
View file

@ -1,61 +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>
);
};
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>
);
};

2
src/components/hero/index.ts Normal file → Executable file
View file

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

2
src/components/list/index.ts Normal file → Executable file
View file

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

184
src/components/list/list.module.css Normal file → Executable file
View file

@ -1,92 +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);
}
}
.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);
}
}

54
src/components/list/list.tsx Normal file → Executable file
View file

@ -1,27 +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>
);
}
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>
);
}

4
src/components/select/index.ts Normal file → Executable file
View file

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

350
src/components/select/select.module.css Normal file → Executable file
View file

@ -1,176 +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);
}
.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);
}
} */

132
src/components/select/select.tsx Normal file → Executable file
View file

@ -1,67 +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>
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>
}

2
src/features/shell/index.tsx Normal file → Executable file
View file

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

336
src/features/shell/nav.module.css Normal file → Executable file
View file

@ -1,168 +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;
}
}
.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;
}
}

50
src/features/shell/nav.tsx Normal file → Executable file
View file

@ -1,25 +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>
);
};
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>
);
};

102
src/features/shell/shell.module.css Normal file → Executable file
View file

@ -1,52 +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%;
}
.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%;
}
}

44
src/features/shell/shell.tsx Normal file → Executable file
View file

@ -1,22 +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>
);
};
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>
);
};

74
src/features/shell/top.module.css Normal file → Executable file
View file

@ -1,37 +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;
}
}
.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;
}
}

118
src/features/shell/top.tsx Normal file → Executable file
View file

@ -1,59 +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>
);
};
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>
);
};

0
src/features/theme/context.ts Normal file → Executable file
View file

0
src/features/theme/index.ts Normal file → Executable file
View file

0
src/features/theme/picker.module.css Normal file → Executable file
View file

0
src/features/theme/picker.tsx Normal file → Executable file
View file

14
src/features/user/avatar.module.css Normal file → Executable file
View file

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

54
src/features/user/avatar.tsx Normal file → Executable file
View file

@ -1,27 +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} />;
};
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} />;
};

8
src/features/user/index.ts Normal file → Executable file
View file

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

44
src/features/user/profile.module.css Normal file → Executable file
View file

@ -1,22 +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));
}
}
.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));
}
}

36
src/features/user/profile.tsx Normal file → Executable file
View file

@ -1,18 +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>
);
};
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>
);
};

12
src/features/user/user.ts Normal file → Executable file
View file

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

0
src/routes/(shell).tsx Normal file → Executable file
View file

138
src/utilities.ts Normal file → Executable file
View file

@ -1,69 +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>;
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>;