did a lot of syle work and started search and detail pages

This commit is contained in:
Chris Kruining 2025-05-19 17:00:18 +02:00
parent 7c5d2a25ff
commit 275fb87eeb
No known key found for this signature in database
GPG key ID: EB894A3560CCCAD2
23 changed files with 301155 additions and 243 deletions

View file

@ -1 +1,16 @@
# Notes
## APIS
### Generate openapi client
- path to source yml or json
- path to output, will create a typescript file
example
```bash
bunx openapi-typescript .\src\features\content\apis\api.yml -o .\src\features\content\apis\api.generated.ts
```

View file

@ -7,6 +7,8 @@
"@solid-primitives/context": "^0.3.1",
"@solid-primitives/deep": "^0.3.2",
"@solid-primitives/event-listener": "^2.4.1",
"@solid-primitives/pagination": "^0.4.1",
"@solid-primitives/scheduled": "^1.5.1",
"@solidjs/meta": "^0.29.4",
"@solidjs/router": "^0.15.3",
"@solidjs/start": "^1.1.4",
@ -348,6 +350,8 @@
"@solid-primitives/memo": ["@solid-primitives/memo@1.4.2", "", { "dependencies": { "@solid-primitives/scheduled": "^1.5.1", "@solid-primitives/utils": "^6.3.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-1w2MoD/25tZOImCI+dEL08n8dyyc6sg6o0zc14sZXBBa4XSz6TDuPYgQ24r+dQerXWoP6OgZ1VZz+Mo7c1Lmvg=="],
"@solid-primitives/pagination": ["@solid-primitives/pagination@0.4.1", "", { "dependencies": { "@solid-primitives/utils": "^6.3.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Q/ZDa8qjKUCRW4Fvdunk8qlgf1geUAz5nfcJbI0sbIYf/9dhURMvESugpqFeF+/GJo784jNcfUwg5253/I7tOA=="],
"@solid-primitives/platform": ["@solid-primitives/platform@0.1.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-sSxcZfuUrtxcwV0vdjmGnZQcflACzMfLriVeIIWXKp8hzaS3Or3tO6EFQkTd3L8T5dTq+kTtLvPscXIpL0Wzdg=="],
"@solid-primitives/refs": ["@solid-primitives/refs@1.1.0", "", { "dependencies": { "@solid-primitives/utils": "^6.3.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-QJ3bTSQOlPdHBP2m6llrT13FvVzAwZfx41lTN8lQrRwwcZoWb7kfCAjhaohPnwkAsQ6nJpLjtGfT5GOyuCA4tA=="],
@ -356,7 +360,7 @@
"@solid-primitives/rootless": ["@solid-primitives/rootless@1.5.0", "", { "dependencies": { "@solid-primitives/utils": "^6.3.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-YJ+EveQeDv9DLqfDKfsPAAGy2x3vBruoD23yn+nD2dT84QjoBxWT1T0qA0TMFjek6/xuN3flqnHtQ4r++4zdjg=="],
"@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.0", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-RVw24IRNh1FQ4DCMb3OahB70tXIwc5vH8nhR4nNPsXwUPQeuOkLsDI5BlxaPk0vyZgqw9lDpufgI3HnPwplgDw=="],
"@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.1", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-WKg/zvAyDIgQ/Xo48YaUY7ISaPyWTZNDzIVWP2R84CuLH+nZN/2O0aFn/gQlWY6y/Bfi/LdDt6Og2/PRzPY7mA=="],
"@solid-primitives/static-store": ["@solid-primitives/static-store@0.0.8", "", { "dependencies": { "@solid-primitives/utils": "^6.2.3" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-ZecE4BqY0oBk0YG00nzaAWO5Mjcny8Fc06CdbXadH9T9lzq/9GefqcSe/5AtdXqjvY/DtJ5C6CkcjPZO0o/eqg=="],
@ -1722,10 +1726,14 @@
"@solid-devtools/debugger/@solid-primitives/event-listener": ["@solid-primitives/event-listener@2.4.0", "", { "dependencies": { "@solid-primitives/utils": "^6.3.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-TSfR1PNTfojFEYGSxSMCnUhXsaYWBo4p+cm73QmWODa9YnaQAk6PB7VjzG2bOT2D817VlvuOqTj0Qdq+MZrdGg=="],
"@solid-devtools/debugger/@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.0", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-RVw24IRNh1FQ4DCMb3OahB70tXIwc5vH8nhR4nNPsXwUPQeuOkLsDI5BlxaPk0vyZgqw9lDpufgI3HnPwplgDw=="],
"@solid-devtools/debugger/@solid-primitives/utils": ["@solid-primitives/utils@6.3.0", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-e7hTlJ1Ywh2+g/Qug+n4L1mpfxsikoIS4/sHE2EK9WatQt8UJqop/vE6bsLnXlU1xuhb/jo94Ah5Y27rd4wP7A=="],
"@solid-devtools/shared/@solid-primitives/event-listener": ["@solid-primitives/event-listener@2.4.0", "", { "dependencies": { "@solid-primitives/utils": "^6.3.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-TSfR1PNTfojFEYGSxSMCnUhXsaYWBo4p+cm73QmWODa9YnaQAk6PB7VjzG2bOT2D817VlvuOqTj0Qdq+MZrdGg=="],
"@solid-devtools/shared/@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.0", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-RVw24IRNh1FQ4DCMb3OahB70tXIwc5vH8nhR4nNPsXwUPQeuOkLsDI5BlxaPk0vyZgqw9lDpufgI3HnPwplgDw=="],
"@solid-devtools/shared/@solid-primitives/utils": ["@solid-primitives/utils@6.3.0", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-e7hTlJ1Ywh2+g/Qug+n4L1mpfxsikoIS4/sHE2EK9WatQt8UJqop/vE6bsLnXlU1xuhb/jo94Ah5Y27rd4wP7A=="],
"@solid-primitives/bounds/@solid-primitives/event-listener": ["@solid-primitives/event-listener@2.4.0", "", { "dependencies": { "@solid-primitives/utils": "^6.3.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-TSfR1PNTfojFEYGSxSMCnUhXsaYWBo4p+cm73QmWODa9YnaQAk6PB7VjzG2bOT2D817VlvuOqTj0Qdq+MZrdGg=="],
@ -1744,8 +1752,6 @@
"@solid-primitives/media/@solid-primitives/utils": ["@solid-primitives/utils@6.3.0", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-e7hTlJ1Ywh2+g/Qug+n4L1mpfxsikoIS4/sHE2EK9WatQt8UJqop/vE6bsLnXlU1xuhb/jo94Ah5Y27rd4wP7A=="],
"@solid-primitives/memo/@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.1", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-WKg/zvAyDIgQ/Xo48YaUY7ISaPyWTZNDzIVWP2R84CuLH+nZN/2O0aFn/gQlWY6y/Bfi/LdDt6Og2/PRzPY7mA=="],
"@solid-primitives/refs/@solid-primitives/utils": ["@solid-primitives/utils@6.3.0", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-e7hTlJ1Ywh2+g/Qug+n4L1mpfxsikoIS4/sHE2EK9WatQt8UJqop/vE6bsLnXlU1xuhb/jo94Ah5Y27rd4wP7A=="],
"@solid-primitives/resize-observer/@solid-primitives/event-listener": ["@solid-primitives/event-listener@2.4.0", "", { "dependencies": { "@solid-primitives/utils": "^6.3.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-TSfR1PNTfojFEYGSxSMCnUhXsaYWBo4p+cm73QmWODa9YnaQAk6PB7VjzG2bOT2D817VlvuOqTj0Qdq+MZrdGg=="],

View file

@ -17,6 +17,8 @@
"@solid-primitives/context": "^0.3.1",
"@solid-primitives/deep": "^0.3.2",
"@solid-primitives/event-listener": "^2.4.1",
"@solid-primitives/pagination": "^0.4.1",
"@solid-primitives/scheduled": "^1.5.1",
"@solidjs/meta": "^0.29.4",
"@solidjs/router": "^0.15.3",
"@solidjs/start": "^1.1.4",

View file

@ -0,0 +1,31 @@
.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(182.5deg, transparent 20%, var(--surface-2) 90%),
linear-gradient(transparent 50%, #0007 75%);
}
& > .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,19 @@
import { Component } from 'solid-js';
import { Entry } from '~/features/content';
import css from './details.module.css';
interface DetailsProps {
entry: Entry
}
export const Details: Component<DetailsProps> = (props) => {
return (
<div class={css.container}>
<header 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

@ -29,7 +29,7 @@
gap: 1rem;
justify-content: start;
padding-inline: 2rem;
padding-inline: var(--size-6);
inline-size: 100%;
block-size: 8.333333em;
@ -53,7 +53,7 @@
align-content: end;
align-items: center;
gap: 1rem;
padding: 2rem;
padding: var(--size-6);
block-size: 80vh;
overflow: clip;
container-type: scroll-state;
@ -114,6 +114,11 @@
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 {
@ -152,9 +157,6 @@
0% {
opacity: 0;
}
/* 80% {
opacity: 0;
} */
100% {
opacity: 1;
}

View file

@ -1,4 +1,5 @@
.container {
--_space: var(--size-6);
display: grid;
grid: auto auto / auto auto;
grid-template-areas:
@ -6,16 +7,21 @@
"list list";
justify-content: space-between;
inline-size: 100%;
padding-inline: var(--_space);
}
.heading {
grid-area: heading;
font-size: 2em;
font-size: var(--size-7);
color: var(--text-1);
padding-inline: var(--_space);
}
.metadata {
grid-area: metadata;
opacity: 0.6;
color: var(--text-2);
}
.list {
@ -26,10 +32,10 @@
display: grid;
grid-auto-flow: column;
gap: 2em;
padding: 12em 4em 5em;
scroll-padding: 4em;
margin: -10em -4em 0em;
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;

View file

@ -1,5 +1,3 @@
"use server";
import type { paths } from "./jellyfin.generated"; // generated by openapi-typescript
import createClient from "openapi-fetch";
import { query } from "@solidjs/router";
@ -20,42 +18,29 @@ type ItemImageType =
| "BoxRear"
| "Profile";
const baseUrl = process.env.JELLYFIN_BASE_URL;
const client = createClient<paths>({
baseUrl,
headers: {
Authorization: `MediaBrowser DeviceId="Streamarr", Token="${process.env.JELLYFIN_API_KEY}"`,
"Content-Type": 'application/json; profile="CamelCase"',
},
});
const getBaseUrl = () => {
"use server";
export const TEST = query(async () => {
const userId = "a9c51af8-4bf5-4578-a99a-b4dd0ebf0763";
const itemId = "919dfa97-e4da-d275-8a92-5d056e590a28";
const seriesId = "5230ddbcd-9400-733d-c07e-5b8cb7a4f49";
return process.env.JELLYFIN_BASE_URL;
};
const { data: seriesData } = await client.GET(
"/UserItems/{itemId}/UserData",
{
params: {
path: { itemId: seriesId },
query: { userId },
},
const getClient = () => {
"use server";
return createClient<paths>({
baseUrl: getBaseUrl(),
headers: {
Authorization: `MediaBrowser DeviceId="Streamarr", Token="${process.env.JELLYFIN_API_KEY}"`,
"Content-Type": 'application/json; profile="CamelCase"',
},
);
const { data: epData } = await client.GET("/UserItems/{itemId}/UserData", {
params: {
path: { itemId },
query: { userId },
},
});
console.log(seriesData, epData);
}, "jellyfin.TEST");
})
};
export const getCurrentUser = query(async () => {
const { data, error, response } = await client.GET("/Users/Public", {
"use server";
const { data, error, response } = await getClient().GET("/Users/Public", {
params: {},
});
@ -63,7 +48,9 @@ export const getCurrentUser = query(async () => {
}, "jellyfin.getCurrentUser");
export const listUsers = query(async () => {
const { data, error } = await client.GET("/Users", {
"use server";
const { data, error } = await getClient().GET("/Users", {
params: {},
});
@ -72,7 +59,9 @@ export const listUsers = query(async () => {
export const listItems = query(
async (userId: string): Promise<Entry[] | undefined> => {
const { data, error } = await client.GET("/Items", {
"use server";
const { data, error } = await getClient().GET("/Items", {
params: {
query: {
userId,
@ -99,7 +88,7 @@ export const listItems = query(
// id: item.Id!,
id: item.ProviderIds!["Tmdb"]!,
title: item.Name!,
thumbnail: new URL(`/Items/${item.Id!}/Images/Primary`, baseUrl), //await getItemImage(data.Id!, 'Primary'),
thumbnail: new URL(`/Items/${item.Id!}/Images/Primary`, getBaseUrl()), //await getItemImage(data.Id!, 'Primary'),
})) ?? []
);
},
@ -107,14 +96,19 @@ export const listItems = query(
);
export const getRandomItem = query(
async (userId: string): Promise<Entry | undefined> =>
getRandomItems(userId, 1).then((items) => items?.at(0)),
async (userId: string): Promise<Entry | undefined> => {
"use server";
return getRandomItems(userId, 1).then((items) => items?.at(0));
},
"jellyfin.listRandomItem",
);
export const getRandomItems = query(
async (userId: string, limit: number = 10): Promise<Entry[]> => {
const { data, error } = await client.GET("/Items", {
async (userId: string, limit: number = 20): Promise<Entry[]> => {
"use server";
const { data, error } = await getClient().GET("/Items", {
params: {
query: {
userId,
@ -140,8 +134,8 @@ export const getRandomItems = query(
// id: item.Id!,
id: item.ProviderIds!["Tmdb"]!,
title: item.Name!,
thumbnail: new URL(`/Items/${item.Id!}/Images/Primary`, baseUrl), //await getItemImage(data.Id!, 'Primary'),
image: new URL(`/Items/${item.Id!}/Images/Backdrop`, baseUrl), //await getItemImage(data.Id!, 'Primary'),
thumbnail: new URL(`/Items/${item.Id!}/Images/Primary`, getBaseUrl()), //await getItemImage(data.Id!, 'Primary'),
image: new URL(`/Items/${item.Id!}/Images/Backdrop`, getBaseUrl()), //await getItemImage(data.Id!, 'Primary'),
})) ?? []
);
},
@ -150,7 +144,9 @@ export const getRandomItems = query(
export const getItem = query(
async (userId: string, itemId: string): Promise<Entry | undefined> => {
const { data, error } = await client.GET("/Items/{itemId}", {
"use server";
const { data, error } = await getClient().GET("/Items/{itemId}", {
params: {
path: {
itemId,
@ -180,8 +176,8 @@ export const getItem = query(
id: data.ProviderIds!["Tmdb"]!,
title: data.Name!,
overview: data.Overview!,
thumbnail: new URL(`/Items/${itemId}/Images/Primary`, baseUrl), //await getItemImage(data.Id!, 'Primary'),
image: new URL(`/Items/${itemId}/Images/Backdrop`, baseUrl),
thumbnail: new URL(`/Items/${itemId}/Images/Primary`, getBaseUrl()), //await getItemImage(data.Id!, 'Primary'),
image: new URL(`/Items/${itemId}/Images/Backdrop`, getBaseUrl()),
// ...data,
};
},
@ -193,7 +189,9 @@ export const getItemImage = query(
itemId: string,
imageType: ItemImageType,
): Promise<any | undefined> => {
const { data, error } = await client.GET(
"use server";
const { data, error } = await getClient().GET(
"/Items/{itemId}/Images/{imageType}",
{
parseAs: "blob",
@ -214,7 +212,9 @@ export const getItemImage = query(
export const getItemPlaybackInfo = query(
async (userId: string, itemId: string): Promise<any | undefined> => {
const { data, error, response } = await client.GET(
"use server";
const { data, error, response } = await getClient().GET(
"/Items/{itemId}/PlaybackInfo",
{
parseAs: "text",
@ -236,7 +236,9 @@ export const getItemPlaybackInfo = query(
);
export const queryItems = query(async () => {
const { data, error } = await client.GET("/Items", {
"use server";
const { data, error } = await getClient().GET("/Items", {
params: {
query: {
mediaTypes: ["Video"],
@ -254,7 +256,9 @@ export const queryItems = query(async () => {
export const getContinueWatching = query(
async (userId: string): Promise<Entry[]> => {
const { data, error } = await client.GET("/UserItems/Resume", {
"use server";
const { data, error } = await getClient().GET("/UserItems/Resume", {
params: {
query: {
userId,

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
export interface paths {
"/4/account/{account_object_id}/movie/recommendations": {
"/account/{account_object_id}/movie/recommendations": {
parameters: {
query?: never;
header?: never;
@ -15,70 +15,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/3/movie/{movie_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["GetMovieById"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/3/series/{series_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["GetSeriesById"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/3/discover/movie": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["GetDiscovery_Movie"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/3/discover/tv": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["GetDiscovery_Serie"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {

View file

@ -1,25 +1,42 @@
"use server";
import createClient from "openapi-fetch";
import { query } from "@solidjs/router";
import { Entry } from "../types";
import { paths } from "./tmdb.not.generated";
import { Entry, SearchResult } from "../types";
import { paths as pathsV3 } from "./tmdb.generated";
import { paths as pathsV4 } from "./tmdb.not.generated";
const baseUrl = process.env.TMDB_BASE_URL;
const client = createClient<paths>({
baseUrl,
headers: {
Authorization: `Bearer ${process.env.TMDB_TOKEN}`,
"Content-Type": "application/json;",
},
});
const getClients = () => {
"use server";
const baseUrl = process.env.TMDB_BASE_URL;
const clientV3 = createClient<pathsV3>({
baseUrl: `${baseUrl}/3`,
headers: {
Authorization: `Bearer ${process.env.TMDB_TOKEN}`,
"Content-Type": "application/json;",
},
});
const clientV4 = createClient<pathsV4>({
baseUrl: `${baseUrl}/4`,
headers: {
Authorization: `Bearer ${process.env.TMDB_TOKEN}`,
"Content-Type": "application/json;",
},
});
return [clientV3, clientV4] as const;
};
export const getEntry = query(
async (id: string): Promise<Entry | undefined> => {
const { data } = await client.GET("/3/movie/{movie_id}", {
"use server";
const [ clientV3 ] = getClients();
const { data } = await clientV3.GET("/movie/{movie_id}", {
params: {
path: {
movie_id: id,
movie_id: Number.parseInt(id),
},
},
});
@ -29,8 +46,8 @@ export const getEntry = query(
}
return {
id: String(data.id),
title: data.title,
id: String(data.id ?? -1),
title: data.title!,
overview: data.overview,
thumbnail: `http://image.tmdb.org/t/p/w342${data.poster_path}`,
image: `http://image.tmdb.org/t/p/original${data.backdrop_path}`,
@ -40,10 +57,14 @@ export const getEntry = query(
);
export const getRecommendations = query(async (): Promise<Entry[]> => {
"use server";
const [ ,clientV4 ] = getClients();
const account_object_id = "6668b76e419b28ec1a1c5aab";
const { data } = await client.GET(
"/4/account/{account_object_id}/movie/recommendations",
const { data } = await clientV4.GET(
"/account/{account_object_id}/movie/recommendations",
{
params: {
path: { account_object_id },
@ -57,8 +78,8 @@ export const getRecommendations = query(async (): Promise<Entry[]> => {
return data?.results.map(
({ id, title, overview, poster_path, backdrop_path }) => ({
id: String(id),
title,
id: String(id ?? -1),
title: title!,
overview,
thumbnail: `http://image.tmdb.org/t/p/w342${poster_path}`,
image: `http://image.tmdb.org/t/p/original${backdrop_path}`,
@ -67,25 +88,71 @@ export const getRecommendations = query(async (): Promise<Entry[]> => {
}, "tmdb.getRecommendations");
export const getDiscovery = query(async (): Promise<Entry[]> => {
"use server";
const [ clientV3 ] = getClients();
const [{ data: movies }, { data: series }] = await Promise.all([
client.GET("/3/discover/movie"),
client.GET("/3/discover/movie"),
clientV3.GET("/discover/movie"),
clientV3.GET("/discover/tv"),
]);
if (movies === undefined || series === undefined) {
return [];
}
// console.log({ movies: movies.results.length, series: series.results.length });
return movies?.results
.slice(0, 9)
.concat(series?.results.slice(0, 9))
const movieEntries = movies?.results?.slice(0, 10)
.map(({ id, title, overview, poster_path, backdrop_path }) => ({
id: String(id),
title,
id: String(id ?? -1),
title: title!,
overview,
thumbnail: `http://image.tmdb.org/t/p/w342${poster_path}`,
image: `http://image.tmdb.org/t/p/original${backdrop_path}`,
}));
})) ?? []
const seriesEntries = series?.results?.slice(0, 10)
.map(({ id, name, overview, poster_path, backdrop_path }) => ({
id: String(id ?? -1),
title: name!,
overview,
thumbnail: `http://image.tmdb.org/t/p/w342${poster_path}`,
image: `http://image.tmdb.org/t/p/original${backdrop_path}`,
})) ?? []
return movieEntries.concat(seriesEntries);
}, "tmdb.getDiscovery");
export const searchMulti = query(async (query: string, page: number = 1): Promise<SearchResult> => {
"use server";
if (query.length === 0) {
return { count: 0, pages: 0, results: [] };
}
const [ clientV3 ] = getClients();
const { data } = await clientV3.GET("/search/multi", {
params: {
query: {
query,
page,
include_adult: false,
language: 'en-US'
}
}
});
if (data === undefined) {
return { count: 0, pages: 0, results: [] };
}
console.log(`loaded page ${page}, found ${data.results?.length} results`);
return { count: data.total_results!, pages: data.total_pages!, results: data.results?.map(({ id, name, title, media_type, overview, backdrop_path, poster_path }) => ({
id: String(id),
title: `${name ?? title ?? ''} (${media_type})`,
overview,
thumbnail: `http://image.tmdb.org/t/p/w342${poster_path}`,
image: `http://image.tmdb.org/t/p/original${backdrop_path}`,
})) ?? [] };
}, "tmdb.search.multi");

File diff suppressed because it is too large Load diff

View file

@ -2,12 +2,12 @@
import type { Category, Entry } from "./types";
import { query } from "@solidjs/router";
import { entries } from "./data";
import { getContinueWatching, getItem, getRandomItems } from "./apis/jellyfin";
import { getContinueWatching, getRandomItems } from "./apis/jellyfin";
import {
getDiscovery,
getRecommendations,
getEntry as getTmdbEntry,
searchMulti,
} from "./apis/tmdb";
const jellyfinUserId = "a9c51af84bf54578a99ab4dd0ebf0763";
@ -19,20 +19,25 @@ export const listCategories = query(async (): Promise<Category[]> => {
return [
// { label: "Continue", entries: await getContinueWatching(jellyfinUserId) },
{
label: "Recommendations (For you?)",
label: "For you",
entries: await getRecommendations(),
},
{ label: "Discover", entries: await getDiscovery() },
{ label: "Random", entries: await getRandomItems(jellyfinUserId) },
];
}, "series.categories.list");
}, "content.categories.list");
export const getEntry = query(
async (id: Entry["id"]): Promise<Entry | undefined> => {
return getTmdbEntry(id);
// return getItem(jellyfinUserId, id);
},
"series.get",
"content.get",
);
export const search = query(async (query: string, page: number = 1) => {
"use server";
return searchMulti(query, page);
}, 'content.search');
export { listUsers, getContinueWatching, listItems } from "./apis/jellyfin";

View file

@ -29,3 +29,9 @@ export namespace Entry {
}
}
}
export interface SearchResult {
count: number;
pages: number;
results: Entry[];
}

View file

@ -1,14 +1,6 @@
.container {
display: grid;
grid-auto-flow: row;
gap: 2em;
gap: var(--size-6);
border-radius: inherit;
& > .hero {
border-radius: inherit;
}
& > .list {
padding-inline: 4em;
}
}

View file

@ -3,10 +3,10 @@ import {
createEventSignal,
} from "@solid-primitives/event-listener";
import { createAsync, json, query } from "@solidjs/router";
import { Component, createEffect, createMemo, createSignal } from "solid-js";
import { Component, createEffect, createMemo, createSignal, on } from "solid-js";
import css from "./player.module.css";
import { Volume } from "./controls/volume";
import { getEntry } from "../content";
import { Entry, getEntry } from "../content";
const metadata = query(async (id: string) => {
"use server";
@ -36,7 +36,7 @@ const metadata = query(async (id: string) => {
}, "player.metadata");
interface PlayerProps {
id: string;
entry: Entry;
}
export const Player: Component<PlayerProps> = (props) => {
@ -44,9 +44,7 @@ export const Player: Component<PlayerProps> = (props) => {
undefined as unknown as HTMLVideoElement,
);
const entry = createAsync(() => getEntry(props.id));
const data = createAsync(() => metadata(props.id), {
const data = createAsync(() => metadata(props.entry.id), {
deferStream: true,
initialValue: {} as any,
});
@ -65,25 +63,12 @@ export const Player: Component<PlayerProps> = (props) => {
: "";
});
createEffect(() => {
const metadata = data();
const el = video();
if (metadata === undefined || el === undefined) {
return;
}
console.log(metadata);
});
createEffect(() => {
thumbnails();
console.log(video()!.textTracks.getTrackById("thumbnails")?.cues);
createEffect(on(thumbnails, (thumbnails) => {
// console.log(thumbnails, video()!.textTracks.getTrackById("thumbnails")?.cues);
// const captions = el.addTextTrack("captions", "English", "en");
// captions.
});
}));
const onDurationChange = createEventSignal(video, "durationchange");
const onTimeUpdate = createEventSignal(video, "timeupdate");
@ -102,53 +87,53 @@ export const Player: Component<PlayerProps> = (props) => {
createEventListenerMap(() => video()!, {
durationchange(e) {
console.log("durationchange", e);
// console.log("durationchange", e);
},
loadeddata(e) {
console.log("loadeddata", e);
// console.log("loadeddata", e);
},
loadedmetadata(e) {
console.log("loadedmetadata", e);
// console.log("loadedmetadata", e);
},
ratechange(e) {
console.log("ratechange", e);
// console.log("ratechange", e);
},
seeked(e) {
console.log("seeked", e);
// console.log("seeked", e);
},
seeking(e) {
console.log("seeking", e);
// console.log("seeking", e);
},
stalled(e) {
console.log(
"stalled (meaning downloading data failed)",
e,
video()!.error,
);
// console.log(
// "stalled (meaning downloading data failed)",
// e,
// video()!.error,
// );
},
play(e) {
console.log("play", e);
// console.log("play", e);
},
canplay(e) {
console.log("canplay", e);
// console.log("canplay", e);
},
playing(e) {
console.log("playing", e);
// console.log("playing", e);
},
pause(e) {
console.log("pause", e);
// console.log("pause", e);
},
suspend(e) {
// console.log("suspend", e);
},
volumechange(e) {
console.log("volumechange", e);
// console.log("volumechange", e);
},
waiting(e) {
console.log("waiting", e);
// console.log("waiting", e);
},
progress(e) {
@ -172,7 +157,7 @@ export const Player: Component<PlayerProps> = (props) => {
return (
<figure class={css.player}>
<h1>{entry()?.title}</h1>
<h1>{props.entry?.title}</h1>
<video
ref={setVideo}

View file

@ -26,15 +26,12 @@
content: "";
position: absolute;
inset-inline-start: 100%;
inset-block: 0;
inline-size: 20vw;
/* background:
radial-gradient(ellipse at left center 100% 100%, #f00, transparent),
linear-gradient(to right, #0003, transparent); */
background-image: linear-gradient(to right, #0003, transparent);
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 20vw 100% at left center,
black,
ellipse 40vw 100% at left center,
black 25%,
transparent
);
backdrop-filter: blur(5px);
@ -53,24 +50,24 @@
transition:
transform 2s var(--ease-spring-5),
opacity 0.3s var(--ease-3);
color: var(--stone-4);
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 1em #000;
text-shadow: 0 0 .5em var(--surface-1);
}
& > svg {
fill: var(--stone-4);
fill: var(--text-2);
inline-size: 2.5rem;
block-size: 2.5rem;
}
&.active {
color: var(--yellow-4);
color: var(--yellow-5);
list-style: disc;
&::before {
@ -80,29 +77,29 @@
}
& > svg {
fill: var(--yellow-4);
fill: var(--yellow-5);
}
}
}
&:has(a:is(:hover, :focus))::before {
opacity: 1;
&: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)) > a {
transform: scale(
max(1, calc(1.5 - (0.2 * abs(var(--target) - var(--sibling-index)))))
);
& > span {
opacity: 1;
}
}
&:has(a:is(:hover, :focus):nth-child(1)) {
--target: 1;
}

View file

@ -67,10 +67,10 @@ const [ThemeContextProvider, useTheme] = createContextProvider<
},
setColorScheme(colorScheme) {
// updateState({ colorScheme, hue: state.latest!.hue });
updateState({ colorScheme, hue: state.latest!.hue });
},
setHue(hue) {
// updateState({ hue, colorScheme: state.latest!.colorScheme });
updateState({ hue, colorScheme: state.latest!.colorScheme });
},
};
},

View file

@ -0,0 +1,59 @@
import {
createAsync,
json,
Params,
query,
redirect,
RouteDefinition,
useParams,
} from "@solidjs/router";
import { Show } from "solid-js";
import { Details } from "~/components/details";
import { createSlug, Entry, getEntry } from "~/features/content";
const healUrl = async (slug: string, entry: Entry) => {
const actualSlug = createSlug(entry);
if (slug !== actualSlug) {
// Not entirely sure a permanent redirect is what we want in this case
throw redirect(`/details/${actualSlug}`, { status: 308 });
}
};
interface ItemParams extends Params {
slug: string;
}
export const route = {
async preload({ params }) {
const slug = params.slug;
if (!slug) {
return;
}
const entry = await getEntry(slug.slice(slug.lastIndexOf("-") + 1));
if (entry === undefined) {
return json(null, { status: 404 });
}
healUrl(slug, entry);
return entry;
},
} satisfies RouteDefinition;
export default function Item() {
const { slug } = useParams<ItemParams>();
const id = slug.slice(slug.lastIndexOf("-") + 1);
const entry = createAsync(() => getEntry(id));
return (
<>
<Show when={entry()} fallback="Some kind of pretty 404 page I guess">{
entry => <Details entry={entry()} />
}</Show>
</>
);
}

View file

@ -0,0 +1,46 @@
.container {
display: block grid;
grid-auto-flow: row;
grid-template-columns: 100%;
padding: var(--size-7);
gap: var(--size-7);
}
.header {
position: sticky;
inset-block-start: 0;
padding: var(--size-7);
padding-block-end: var(--size-2);
margin: calc(-1 * var(--size-7));
margin-block-end: calc(-1 * var(--size-2));
background-color: var(--surface-2);
}
.grid {
inline-size: 100%;
display: block grid;
grid-template-columns: repeat(5, 1fr);
gap: var(--size-6);
list-style: none;
padding: 0;
& > .item {
inline-size: 100%;
display: block grid;
grid: 100% / 100%;
place-items: center;
padding: 0;
background-color: var(--surface-3);
border-radius: var(--size-2);
aspect-ratio: 3 / 5;
}
& > svg {
grid-column: 1 / -1;
}
}

View file

@ -1,9 +1,67 @@
import { createInfiniteScroll } from "@solid-primitives/pagination";
import { Title } from "@solidjs/meta";
import { createEffect, createSignal, For, on, onMount, Show, createComputed, batch, createMemo, untrack } from "solid-js";
import { createSlug, search } from "~/features/content";
import { AiOutlineLoading } from "solid-icons/ai";
import css from './index.module.css';
import { debounce } from "@solid-primitives/scheduled";
const getResults = async (query: string, page: number) => {
const { results } = await search(query, page + 1);
return results;
};
export default function Index() {
const [ query, setQuery ] = createSignal(""); // lord of the rings
const [ ref, setRef ] = createSignal<HTMLInputElement>();
const KAAS = createMemo(() => {
const q = query();
const [pages, setEl, { end }] = createInfiniteScroll((page) => getResults(q, page));
return { pages, setEl, end };
});
// const result = createAsync(() => search(query()), { initialValue: { count: 0, pages: 0, results: [] } });
const title = 'Search';
return <>
createEffect(() => {
KAAS();
untrack(ref)?.focus();
});
return <div class={css.container}>
<Title>{title}</Title>
<h1>{title}</h1>
</>;
<header class={css.header}>
<input ref={setRef} type="search" placeholder={title} value={query()} oninput={debounce(e => setQuery(e.target.value), 300)} />
</header>
<ul class={css.grid}>
<For each={KAAS().pages()}>{
item => <a id={`item:${item.id}`} href={`/details/${createSlug(item)}`}>
<img class={css.item} src={item.thumbnail} title={item.title} />
</a>
}</For>
<Show when={!KAAS().end()}>
<AiOutlineLoading ref={KAAS().setEl} />
</Show>
<Show when={KAAS().pages().length === 0}>
<p>No results</p>
</Show>
</ul>
{/* <output>
<p>{result().count}</p>
<ul>
<For each={result().results}>{
result => <li>{result.title}</li>
}</For>
</ul>
</output> */}
</div>;
}

View file

@ -1,4 +1,5 @@
import {
createAsync,
json,
Params,
query,
@ -47,10 +48,11 @@ export const route = {
export default function Item() {
const { slug } = useParams<ItemParams>();
const id = slug.slice(slug.lastIndexOf("-") + 1);
const entry = createAsync(() => getEntry(id));
return (
<>
<Player id={id} />
<Player entry={entry} />
</>
);
}