Implement filters front (#1415)

This commit is contained in:
Zoe Roux 2026-03-31 19:27:31 +02:00 committed by GitHub
commit 3d6a80c69a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 2532 additions and 135 deletions

View File

@ -0,0 +1,3 @@
ALTER TABLE "kyoo"."history" ADD COLUMN "external" boolean;--> statement-breakpoint
UPDATE "kyoo"."history" SET "external" = false;--> statement-breakpoint
ALTER TABLE "kyoo"."history" ALTER COLUMN "external" SET NOT NULL;--> statement-breakpoint

File diff suppressed because it is too large Load Diff

View File

@ -211,6 +211,13 @@
"when": 1774623568394,
"tag": "0029_next_refresh",
"breakpoints": true
},
{
"idx": 30,
"version": "7",
"when": 1774974162419,
"tag": "0030_external_hist",
"breakpoints": true
}
]
}

View File

@ -62,6 +62,7 @@ export const entryProgressQ = db
entryPk: history.entryPk,
playedDate: history.playedDate,
videoId: videos.id,
external: history.external,
})
.from(history)
.leftJoin(videos, eq(history.videoPk, videos.pk))

View File

@ -76,7 +76,7 @@ async function updateHistory(
.select({ videoId: videos.id })
.from(history)
.for("update", { of: sql`history` as any })
.leftJoin(videos, eq(videos.pk, history.videoPk))
.innerJoin(videos, eq(videos.pk, history.videoPk))
.where(
and(
eq(history.profilePk, userPk),
@ -86,12 +86,16 @@ async function updateHistory(
).map((x) => x.videoId);
const toUpdate = traverse(
progress.filter((x) => existing.includes(x.videoId)),
progress.filter((x) => x.videoId && existing.includes(x.videoId)),
);
const newEntries = traverse(
progress
.filter((x) => !existing.includes(x.videoId))
.map((x) => ({ ...x, entryUseid: isUuid(x.entry) })),
.filter((x) => !x.videoId || !existing.includes(x.videoId))
.map((x) => ({
...x,
external: x.external ?? false,
entryUseid: isUuid(x.entry),
})),
);
const updated =
@ -140,6 +144,7 @@ async function updateHistory(
playedDate: coalesce(sql`hist.played_date`, sql`now()`).as(
"playedDate",
),
external: sql`hist.external`.as("external"),
})
.from(sql`unnest(
${sqlarr(newEntries.entry)}::text[],
@ -147,8 +152,9 @@ async function updateHistory(
${sqlarr(newEntries.videoId)}::uuid[],
${sqlarr(newEntries.time)}::integer[],
${sqlarr(newEntries.percent)}::integer[],
${sqlarr(newEntries.playedDate)}::timestamptz[]
) as hist(entry, entry_use_id, video_id, ts, percent, played_date)`)
${sqlarr(newEntries.playedDate)}::timestamptz[],
${sqlarr(newEntries.external)}::boolean[]
) as hist(entry, entry_use_id, video_id, ts, percent, played_date, external)`)
.innerJoin(
entries,
sql`
@ -244,7 +250,7 @@ async function updateWatchlist(
seenCount: sql`
case
when ${entries.kind} = 'movie' then hist.percent
when hist.percent >= 95 then 1
when hist.percent >= 95 then 100
else 0
end
`.as("seen_count"),
@ -315,6 +321,7 @@ const historyProgressQ: typeof entryProgressQ = db
entryPk: history.entryPk,
playedDate: history.playedDate,
videoId: videos.id,
external: history.external,
})
.from(history)
.leftJoin(videos, eq(history.videoPk, videos.pk))
@ -360,6 +367,7 @@ export const historyH = new Elysia({ tags: ["profiles"] })
sort,
filter: and(
isNotNull(entryProgressQ.playedDate),
eq(entryProgressQ.external, false),
ne(entries.kind, "extra"),
filter,
),
@ -406,6 +414,7 @@ export const historyH = new Elysia({ tags: ["profiles"] })
sort,
filter: and(
isNotNull(entryProgressQ.playedDate),
eq(entryProgressQ.external, false),
ne(entries.kind, "extra"),
filter,
),

View File

@ -5,9 +5,11 @@ import {
entries,
entryVideoJoin,
profiles,
roles,
showStudioJoin,
shows,
showTranslations,
staff,
studios,
studioTranslations,
videos,
@ -92,6 +94,24 @@ export const showFilters: FilterDef = {
type: "int",
},
nextRefresh: { column: shows.nextRefresh, type: "date" },
studios: {
column: db
.select({ slug: studios.slug })
.from(showStudioJoin)
.innerJoin(studios, eq(studios.pk, showStudioJoin.studioPk))
.where(eq(showStudioJoin.showPk, shows.pk)),
type: "string",
isArray: true,
},
staff: {
column: db
.select({ slug: staff.slug })
.from(roles)
.innerJoin(staff, eq(staff.pk, roles.staffPk))
.where(eq(roles.showPk, shows.pk)),
type: "string",
isArray: true,
},
};
export const showSort = Sort(
{

View File

@ -1,5 +1,5 @@
import { sql } from "drizzle-orm";
import { check, index, integer, timestamp } from "drizzle-orm/pg-core";
import { boolean, check, index, integer, timestamp } from "drizzle-orm/pg-core";
import { entries } from "./entries";
import { profiles } from "./profiles";
import { schema } from "./utils";
@ -23,6 +23,8 @@ export const history = schema.table(
playedDate: timestamp({ withTimezone: true, precision: 3 })
.notNull()
.defaultNow(),
// true if the user only marked the entry has seen and has not seen it on kyoo
external: boolean().notNull(),
},
(t) => [
index("history_play_date").on(t.playedDate.desc()),

View File

@ -33,6 +33,18 @@ export const SeedHistory = t.Intersect([
entry: t.String({
description: "Id or slug of the entry/movie you watched",
}),
external: t.Optional(
t.Boolean({
description: comment`
Set this to true if the user marked the entry as watched
without actually watching it on kyoo.
If true, it will not add it to the history but still mark it as
seen.
`,
default: false,
}),
),
}),
]);
export type SeedHistory = typeof SeedHistory.static;

View File

@ -95,7 +95,8 @@
"desc": "decs"
},
"switchToGrid": "Switch to grid view",
"switchToList": "Switch to list view"
"switchToList": "Switch to list view",
"not": "Not"
},
"profile": {
"history": "History",

View File

@ -29,8 +29,25 @@ export const EntryContext = ({
} & Partial<ComponentProps<typeof Menu>> &
Partial<ComponentProps<typeof IconButton>>) => {
// const downloader = useDownloader();
const account = useAccount();
const { t } = useTranslation();
const markAsSeenMutation = useMutation({
method: "POST",
path: ["api", "profiles", "me", "history"],
body: [
{
percent: 100,
entry: slug,
videoId: null,
time: 0,
playedDate: null,
external: true,
},
],
invalidate: null,
});
return (
<Menu
Trigger={IconButton}
@ -39,6 +56,13 @@ export const EntryContext = ({
{...tooltip(t("misc.more"))}
{...(props as any)}
>
{account && (
<Menu.Item
label={t("show.watchlistMark.completed")}
icon={watchListIcon("completed")}
onSelect={() => markAsSeenMutation.mutate()}
/>
)}
{serieSlug && (
<Menu.Item
label={t("home.episodeMore.goToShow")}

View File

@ -123,7 +123,7 @@ export const ItemDetails = ({
<Chip
key={x ?? i}
label={t(`genres.${x}`)}
href={`/genres/${x}`}
href={`/browse?filter=genres has ${x}`}
size="small"
className="mx-1"
/>

View File

@ -10,7 +10,7 @@ import { cn } from "~/utils";
import type { ComboBoxProps } from "./combobox";
import { Icon } from "./icons";
import { PressableFeedback } from "./links";
import { InternalTriger } from "./menu.web";
import { InternalTrigger } from "./menu.web";
import { Skeleton } from "./skeleton";
import { P } from "./text";
@ -59,9 +59,9 @@ export const ComboBox = <Data,>({
>
<Popover.Trigger aria-label={label} asChild>
{Trigger ? (
<InternalTriger Component={Trigger} />
<InternalTrigger Component={Trigger} />
) : (
<InternalTriger
<InternalTrigger
Component={Platform.OS === "web" ? "div" : PressableFeedback}
className={cn(
"group flex-row items-center justify-center overflow-hidden rounded-4xl",
@ -83,7 +83,7 @@ export const ComboBox = <Data,>({
className="group-focus-within:fill-slate-200 group-hover:fill-slate-200"
/>
</View>
</InternalTriger>
</InternalTrigger>
)}
</Popover.Trigger>
<Popover.Portal>

View File

@ -28,7 +28,7 @@ export const Input = ({
ref={ref}
textAlignVertical="center"
className={cn(
"h-6 flex-1 font-sans text-base text-slate-600 outline-0 dark:text-slate-400",
"min-h-6 flex-1 font-sans text-base text-slate-600 outline-0 dark:text-slate-400",
className,
)}
{...props}

View File

@ -107,12 +107,14 @@ const MenuItem = ({
href,
icon,
disabled,
closeOnSelect = true,
...props
}: {
label: string;
selected?: boolean;
left?: ReactElement;
disabled?: boolean;
closeOnSelect?: boolean;
icon?: ComponentType<SvgProps>;
} & (
| { onSelect: () => void; href?: undefined }
@ -131,7 +133,7 @@ const MenuItem = ({
return (
<PressableFeedback
onPress={() => {
setOpen?.call(null, false);
if (closeOnSelect) setOpen?.call(null, false);
onSelect?.call(null);
if (href) router.push(href);
}}

View File

@ -13,7 +13,7 @@ import { Icon } from "./icons";
import { useLinkTo } from "./links";
import { P } from "./text";
export const InternalTriger = forwardRef<unknown, any>(function _Triger(
export const InternalTrigger = forwardRef<unknown, any>(function _Triger(
{ Component, ComponentProps, ...props },
ref,
) {
@ -23,6 +23,7 @@ export const InternalTriger = forwardRef<unknown, any>(function _Triger(
{...ComponentProps}
{...props}
onClickCapture={props.onPointerDown}
onPress={props.onPress ?? props.onClick}
/>
);
});
@ -54,12 +55,12 @@ const Menu = <AsProps extends { onPress: PressableProps["onPress"] }>({
}}
>
<DropdownMenu.Trigger asChild>
<InternalTriger Component={Trigger} {...props} />
<InternalTrigger Component={Trigger} {...props} />
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
onFocusOutside={(e) => e.stopImmediatePropagation()}
className="z-10 min-w-2xs overflow-hidden rounded bg-popover shadow-xl"
className="z-10 min-w-2xs overflow-y-auto rounded bg-popover shadow-xl"
style={{
maxHeight:
"calc(var(--radix-dropdown-menu-content-available-height) * 0.8)",
@ -81,6 +82,7 @@ const MenuItem = forwardRef<
left?: ReactElement;
disabled?: boolean;
selected?: boolean;
closeOnSelect?: boolean;
className?: string;
} & (
| { onSelect: () => void; href?: undefined }
@ -95,6 +97,7 @@ const MenuItem = forwardRef<
onSelect,
href,
disabled,
closeOnSelect = true,
className,
...props
},
@ -117,7 +120,8 @@ const MenuItem = forwardRef<
<DropdownMenu.Item
ref={ref}
{...linkProps}
onSelect={() => {
onSelect={(e) => {
if (!closeOnSelect) e.preventDefault();
onSelect?.();
onPress?.(undefined!);
}}

View File

@ -7,7 +7,7 @@ import { Platform, View } from "react-native";
import { cn } from "~/utils";
import { Icon } from "./icons";
import { PressableFeedback } from "./links";
import { InternalTriger } from "./menu.web";
import { InternalTrigger } from "./menu.web";
import { P } from "./text";
export const Select = ({
@ -26,7 +26,7 @@ export const Select = ({
return (
<RSelect.Root value={value} onValueChange={onValueChange}>
<RSelect.Trigger aria-label={label} asChild>
<InternalTriger
<InternalTrigger
Component={Platform.OS === "web" ? "div" : PressableFeedback}
className={cn(
"group flex-row items-center justify-center overflow-hidden rounded-4xl",
@ -44,7 +44,7 @@ export const Select = ({
/>
</RSelect.Icon>
</View>
</InternalTriger>
</InternalTrigger>
</RSelect.Trigger>
<RSelect.Portal>
<RSelect.Content
@ -86,7 +86,7 @@ const Item = forwardRef<HTMLDivElement, { label: string; value: string }>(
>
<RSelect.ItemText className={cn()}>{label}</RSelect.ItemText>
<RSelect.ItemIndicator asChild>
<InternalTriger
<InternalTrigger
Component={Icon}
icon={Check}
className={cn(

View File

@ -106,7 +106,6 @@ export const AddVideoFooter = ({
icon={LibraryAdd}
text={t("videos-map.add")}
className="m-6 mt-10"
onPress={props.onPress ?? (props as any).onClick}
{...props}
/>
)}

View File

@ -1,16 +1,24 @@
import ArrowDownward from "@material-symbols/svg-400/rounded/arrow_downward.svg";
import ArrowUpward from "@material-symbols/svg-400/rounded/arrow_upward.svg";
import Check from "@material-symbols/svg-400/rounded/check.svg";
import CloseIcon from "@material-symbols/svg-400/rounded/close.svg";
import Collection from "@material-symbols/svg-400/rounded/collections_bookmark.svg";
import FilterList from "@material-symbols/svg-400/rounded/filter_list.svg";
import GridView from "@material-symbols/svg-400/rounded/grid_view.svg";
import Movie from "@material-symbols/svg-400/rounded/movie.svg";
import Person from "@material-symbols/svg-400/rounded/person.svg";
import Sort from "@material-symbols/svg-400/rounded/sort.svg";
import TheaterComedy from "@material-symbols/svg-400/rounded/theater_comedy.svg";
import TV from "@material-symbols/svg-400/rounded/tv.svg";
import All from "@material-symbols/svg-400/rounded/view_headline.svg";
import ViewList from "@material-symbols/svg-400/rounded/view_list.svg";
import type { ComponentType } from "react";
import { useTranslation } from "react-i18next";
import { type PressableProps, View } from "react-native";
import type { SvgProps } from "react-native-svg";
import { Genre, Staff, Studio } from "~/models";
import {
ComboBox,
HR,
Icon,
IconButton,
@ -73,6 +81,58 @@ const MediaTypeTrigger = ({
);
};
const FilterTrigger = ({
label,
count,
icon,
className,
...props
}: {
label: string;
count: number;
icon?: ComponentType<SvgProps>;
} & PressableProps) => {
return (
<PressableFeedback
className={cn("flex-row items-center", className)}
{...props}
>
<Icon icon={icon ?? FilterList} className="mx-1" />
<P>
{label}
{count > 0 ? ` (${count})` : ""}
</P>
</PressableFeedback>
);
};
const FilterChip = ({
label,
onRemove,
}: {
label: string;
onRemove: () => void;
}) => {
return (
<PressableFeedback
onPress={onRemove}
className={cn(
"flex-row items-center gap-1 rounded-4xl border border-accent px-2.5 py-1",
"bg-accent",
)}
>
<P className="text-slate-200 text-sm dark:text-slate-300">{label}</P>
<Icon
icon={CloseIcon}
className="h-4 w-4 fill-slate-200 dark:fill-slate-300"
/>
</PressableFeedback>
);
};
const parseFilterValues = (filter: string, pattern: RegExp) =>
Array.from(filter.matchAll(pattern)).map((x) => x[1]);
export const BrowseSettings = ({
sortBy,
sortOrd,
@ -92,61 +152,271 @@ export const BrowseSettings = ({
}) => {
const { t } = useTranslation();
// TODO: have a proper filter frontend
const mediaType = /kind eq (\w+)/.exec(filter)?.[1] ?? "all";
const setMediaType = (kind: string) =>
setFilter(kind !== "all" ? `kind eq ${kind}` : "");
const includedGenres = parseFilterValues(
filter,
/(?<!not )genres (?:has|eq) ([a-z-]+)/g,
);
const excludedGenres = parseFilterValues(
filter,
/not genres (?:has|eq) ([a-z-]+)/g,
);
const studioSlugs = parseFilterValues(
filter,
/studios (?:has|eq) ([a-z0-9-]+)/g,
);
const staffSlugs = parseFilterValues(
filter,
/staff (?:has|eq) ([a-z0-9-]+)/g,
);
const applyFilters = ({
kind,
nextIncludedGenres,
nextExcludedGenres,
nextStudios,
nextStaff,
}: {
kind?: string;
nextIncludedGenres?: string[];
nextExcludedGenres?: string[];
nextStudios?: string[];
nextStaff?: string[];
}) => {
const clauses: string[] = [];
kind ??= mediaType;
nextIncludedGenres ??= includedGenres;
nextExcludedGenres ??= excludedGenres;
nextStudios ??= studioSlugs;
nextStaff ??= staffSlugs;
if (kind !== "all") clauses.push(`kind eq ${kind}`);
for (const studio of nextStudios) clauses.push(`studios has ${studio}`);
for (const person of nextStaff) clauses.push(`staff has ${person}`);
for (const genre of nextIncludedGenres) clauses.push(`genres has ${genre}`);
for (const genre of nextExcludedGenres)
clauses.push(`not genres has ${genre}`);
setFilter(clauses.join(" and "));
};
return (
<View className="mx-8 my-2 flex-row items-center justify-between">
<Menu
Trigger={MediaTypeTrigger}
mediaType={mediaType as keyof typeof MediaTypeIcons}
>
{Object.keys(MediaTypeIcons).map((x) => (
<Menu.Item
key={x}
label={t(`browse.mediatypekey.${x as keyof typeof MediaTypeIcons}`)}
selected={mediaType === x}
icon={MediaTypeIcons[x as keyof typeof MediaTypeIcons]}
onSelect={() => setMediaType(x)}
<View>
<View className="mx-8 my-2 flex-1 flex-row flex-wrap items-center justify-between">
<View className="flex-row gap-3">
<Menu
Trigger={MediaTypeTrigger}
mediaType={mediaType as keyof typeof MediaTypeIcons}
>
{Object.keys(MediaTypeIcons).map((x) => (
<Menu.Item
key={x}
label={t(
`browse.mediatypekey.${x as keyof typeof MediaTypeIcons}`,
)}
selected={mediaType === x}
icon={MediaTypeIcons[x as keyof typeof MediaTypeIcons]}
onSelect={() => applyFilters({ kind: x })}
/>
))}
</Menu>
<Menu
Trigger={(props: PressableProps) => (
<FilterTrigger
label={t("show.genre")}
count={includedGenres.length + excludedGenres.length}
icon={TheaterComedy}
{...props}
/>
)}
>
{Genre.options.map((genre) => {
const isIncluded = includedGenres.includes(genre);
const isExcluded = excludedGenres.includes(genre);
return (
<Menu.Item
key={genre}
label={t(`genres.${genre}`)}
left={
<View className="h-6 w-6 items-center justify-center">
{(isIncluded || isExcluded) && (
<Icon icon={isExcluded ? CloseIcon : Check} />
)}
</View>
}
closeOnSelect={false}
onSelect={() => {
let nextIncluded = includedGenres;
let nextExcluded = excludedGenres;
if (isIncluded) {
// include -> exclude
nextIncluded = nextIncluded.filter((g) => g !== genre);
nextExcluded = [...nextExcluded, genre];
} else if (isExcluded) {
// exclude -> neutral
nextExcluded = nextExcluded.filter((g) => g !== genre);
} else {
// neutral -> include
nextIncluded = [...nextIncluded, genre];
}
applyFilters({
nextIncludedGenres: nextIncluded,
nextExcludedGenres: nextExcluded,
});
}}
/>
);
})}
</Menu>
<ComboBox
multiple
label={t("show.studios")}
searchPlaceholder={t("navbar.search")}
Trigger={(props) => (
<FilterTrigger
label={t("show.studios")}
count={studioSlugs.length}
icon={TV}
{...props}
/>
)}
query={(search) => ({
path: ["api", "studios"],
parser: Studio,
infinite: true,
params: {
query: search,
},
})}
values={studioSlugs.map((x) => ({ slug: x, name: x }))}
getKey={(studio) => studio.slug}
getLabel={(studio) => studio.name}
onValueChange={(items) =>
applyFilters({ nextStudios: items.map((item) => item.slug) })
}
/>
))}
</Menu>
<View className="flex-row">
<Menu Trigger={SortTrigger} sortBy={sortBy}>
{availableSorts.map((x) => (
<Menu.Item
key={x}
label={t(`browse.sortkey.${x}`)}
selected={sortBy === x}
icon={sortOrd === "asc" ? ArrowUpward : ArrowDownward}
onSelect={() =>
setSort(x, sortBy === x && sortOrd === "asc" ? "desc" : "asc")
<ComboBox
multiple
label={t("show.staff")}
searchPlaceholder={t("navbar.search")}
Trigger={(props) => (
<FilterTrigger
label={t("show.staff")}
count={staffSlugs.length}
icon={Person}
{...props}
/>
)}
query={(search) => ({
path: ["api", "staff"],
parser: Staff,
infinite: true,
params: {
query: search,
},
})}
values={staffSlugs.map((x) => ({ slug: x, name: x }))}
getKey={(member) => member.slug}
getLabel={(member) => member.name}
onValueChange={(items) =>
applyFilters({ nextStaff: items.map((item) => item.slug) })
}
/>
</View>
<View className="flex-row">
<Menu Trigger={SortTrigger} sortBy={sortBy}>
{availableSorts.map((x) => (
<Menu.Item
key={x}
label={t(`browse.sortkey.${x}`)}
selected={sortBy === x}
icon={sortOrd === "asc" ? ArrowUpward : ArrowDownward}
onSelect={() =>
setSort(x, sortBy === x && sortOrd === "asc" ? "desc" : "asc")
}
/>
))}
</Menu>
<HR orientation="vertical" />
<IconButton
icon={GridView}
onPress={() => setLayout("grid")}
className="m-1"
iconClassName={cn(
layout === "grid" && "fill-accent dark:fill-accent",
)}
{...tooltip(t("browse.switchToGrid"))}
/>
<IconButton
icon={ViewList}
onPress={() => setLayout("list")}
className="m-1"
iconClassName={cn(
layout === "list" && "fill-accent dark:fill-accent",
)}
{...tooltip(t("browse.switchToList"))}
/>
</View>
</View>
{(mediaType !== "all" ||
includedGenres.length > 0 ||
excludedGenres.length > 0 ||
studioSlugs.length > 0 ||
staffSlugs.length > 0) && (
<View className="mx-8 mb-2 flex-row flex-wrap gap-2">
{mediaType !== "all" && (
<FilterChip
label={t(
`browse.mediatypekey.${mediaType as keyof typeof MediaTypeIcons}`,
)}
onRemove={() => applyFilters({ kind: "all" })}
/>
)}
{includedGenres.map((genre) => (
<FilterChip
key={`genre-inc-${genre}`}
label={t(`genres.${genre as Genre}`)}
onRemove={() =>
applyFilters({
nextIncludedGenres: includedGenres.filter((g) => g !== genre),
})
}
/>
))}
</Menu>
<HR orientation="vertical" />
<IconButton
icon={GridView}
onPress={() => setLayout("grid")}
className="m-1"
iconClassName={cn(
layout === "grid" && "fill-accent dark:fill-accent",
)}
{...tooltip(t("browse.switchToGrid"))}
/>
<IconButton
icon={ViewList}
onPress={() => setLayout("list")}
className="m-1"
iconClassName={cn(
layout === "list" && "fill-accent dark:fill-accent",
)}
{...tooltip(t("browse.switchToList"))}
/>
</View>
{excludedGenres.map((genre) => (
<FilterChip
key={`genre-exc-${genre}`}
label={`${t("browse.not")} ${t(`genres.${genre as Genre}`)}`}
onRemove={() =>
applyFilters({
nextExcludedGenres: excludedGenres.filter((g) => g !== genre),
})
}
/>
))}
{studioSlugs.map((studio) => (
<FilterChip
key={`studio-${studio}`}
label={studio}
onRemove={() =>
applyFilters({
nextStudios: studioSlugs.filter((s) => s !== studio),
})
}
/>
))}
{staffSlugs.map((staff) => (
<FilterChip
key={`staff-${staff}`}
label={staff}
onRemove={() =>
applyFilters({
nextStaff: staffSlugs.filter((s) => s !== staff),
})
}
/>
))}
</View>
)}
</View>
);
};

View File

@ -401,7 +401,9 @@ const Description = ({
<UL className="flex-1 flex-wrap max-sm:flex-row max-sm:items-center max-sm:text-center">
{genres.map((genre) => (
<LI key={genre}>
<A href={`/genres/${genre.toLowerCase()}`}>
<A
href={`/browse?filter=genres has ${genre.toLowerCase()}`}
>
{t(`genres.${genre}`)}
</A>
</LI>
@ -435,7 +437,7 @@ const Description = ({
{studios.map((x, i) => (
<Fragment key={x.id}>
{i !== 0 && ","}
<A href={x.slug} className="ml-2">
<A href={`/browse?filter=studios has ${x.slug}`} className="ml-2">
{x.name}
</A>
</Fragment>

View File

@ -95,6 +95,7 @@ export const EntryList = ({
slug,
season,
onSelectVideos,
search,
...props
}: {
slug: string;
@ -104,6 +105,7 @@ export const EntryList = ({
name: string | null;
videos: Entry["videos"];
}) => void;
search?: string;
} & Partial<ComponentProps<typeof InfiniteFetch<EntryOrSeason>>>) => {
const { t } = useTranslation();
const { items: seasons, error } = useInfiniteFetch(SeasonHeader.query(slug));
@ -112,7 +114,7 @@ export const EntryList = ({
return (
<InfiniteFetch
query={EntryList.query(slug, season)}
query={EntryList.query(slug, season, search)}
layout={EntryLine.layout}
Empty={<EmptyView message={t("show.episode-none")} />}
Divider={() => (
@ -176,10 +178,12 @@ type EntryOrSeason = z.infer<typeof EntryOrSeason>;
EntryList.query = (
slug: string,
season: string | number,
query: string | undefined,
): QueryIdentifier<EntryOrSeason> => ({
parser: EntryOrSeason,
path: ["api", "series", slug, "entries"],
params: {
query,
// TODO: use a better filter, it removes specials and movies
filter: season ? `seasonNumber ge ${season}` : undefined,
includeSeasons: true,

View File

@ -8,6 +8,7 @@ import { EntrySelect } from "~/components/entries/select";
import type { Entry, Serie } from "~/models";
import { Container, H2, Svg, usePopup } from "~/primitives";
import { Fetch } from "~/query";
import { SearchBar } from "~/ui/navbar";
import { useQueryState } from "~/utils";
import { HeaderBackground, useScrollNavbar } from "../navbar";
import { Header } from "./header";
@ -88,6 +89,8 @@ const SerieHeader = ({
videos: Entry["videos"];
}) => void;
}) => {
const [_, setSearch] = useQueryState("search", "");
return (
<View className="bg-background">
<Header kind="serie" slug={slug} onImageLayout={onImageLayout} />
@ -104,6 +107,15 @@ const SerieHeader = ({
/>
<Staff kind="serie" slug={slug} />
<SvgWave className="flex-1 shrink-0 fill-card" />
<View className="bg-card pb-4 pl-[10%]">
<View className="-mt-4 lg:-mt-12 xl:-mt-24">
<SearchBar
onChangeText={(q) => setSearch(q)}
forceExpand
containerClassName="w-2/5 max-w-90"
/>
</View>
</View>
</View>
);
};
@ -111,6 +123,7 @@ const SerieHeader = ({
export const SerieDetails = () => {
const [slug] = useQueryState("slug", undefined!);
const [season] = useQueryState("season", undefined!);
const [search] = useQueryState("search", "");
const insets = useSafeAreaInsets();
const [imageHeight, setHeight] = useState(300);
const { scrollHandler, headerProps, headerHeight } = useScrollNavbar({
@ -142,6 +155,7 @@ export const SerieDetails = () => {
<EntryList
slug={slug}
season={season}
search={search}
onSelectVideos={openEntrySelect}
Header={() => (
<SerieHeader

View File

@ -22,7 +22,10 @@ export const RegisterPage = () => {
const router = useRouter();
const { t } = useTranslation();
const { data: info } = useFetch(OidcLogin.query(apiUrl));
const { data: info } = useFetch({
...OidcLogin.query(apiUrl),
options: { returnError: true },
});
if (Platform.OS !== "web" && !apiUrl) return <ServerUrlPage />;
if (info?.allowRegister === false) {

View File

@ -3,24 +3,18 @@ import Register from "@material-symbols/svg-400/rounded/app_registration.svg";
import Close from "@material-symbols/svg-400/rounded/close.svg";
import Login from "@material-symbols/svg-400/rounded/login.svg";
import Logout from "@material-symbols/svg-400/rounded/logout.svg";
import Person from "@material-symbols/svg-400/rounded/person-fill.svg";
import Search from "@material-symbols/svg-400/rounded/search-fill.svg";
import Settings from "@material-symbols/svg-400/rounded/settings.svg";
import { useIsFocused } from "@react-navigation/native";
import { useNavigation, usePathname, useRouter } from "expo-router";
import KyooLongLogo from "public/icon-long.svg";
import {
type ComponentProps,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { type ComponentProps, useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Platform,
type PressableProps,
TextInput,
type TextInputProps,
View,
type ViewProps,
} from "react-native";
@ -38,14 +32,13 @@ import {
HR,
HRP,
IconButton,
Link,
Menu,
PressableFeedback,
tooltip,
} from "~/primitives";
import { useAccount, useAccounts } from "~/providers/account-context";
import { logout } from "~/ui/login/logic";
import { cn } from "~/utils";
import { cn, useQueryState } from "~/utils";
export const NavbarLeft = () => {
const { t } = useTranslation();
@ -91,54 +84,56 @@ export const NavbarTitle = ({
};
export const NavbarRight = () => {
const { t } = useTranslation();
const isAdmin = false; //useHasPermission(AdminPage.requiredPermissions);
const router = useRouter();
const path = usePathname();
const [q, setQuery] = useQueryState<string | undefined>("q", undefined);
return (
<View className="shrink flex-row items-center">
<SearchBar />
{isAdmin && (
<IconButton
icon={Admin}
as={Link}
href={"/admin"}
iconClassName="fill-slate-200 dark:fill-slate-200"
{...tooltip(t("navbar.admin"))}
/>
)}
<SearchBar
key={path}
value={path === "/browse" ? q : undefined}
onChangeText={(query) => {
console.log(query);
if (path === "/browse") {
setQuery(query ?? undefined);
}
}}
onSubmitEditing={(e) => {
const query = e.nativeEvent.text;
if (query && path !== "/browse") {
router.push(`/browse?q=${query}`);
}
}}
/>
<NavbarProfile />
</View>
);
};
const SearchBar = () => {
export const SearchBar = ({
value,
onChangeText,
onSubmitEditing,
onBlur,
onFocus,
className,
containerClassName,
forceExpand,
...props
}: TextInputProps & { forceExpand?: boolean; containerClassName?: string }) => {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const [_expanded, setExpanded] = useState(!!value);
const inputRef = useRef<TextInput>(null);
const router = useRouter();
const [query, setQuery] = useState("");
const path = usePathname();
const shouldExpand = useRef(false);
useEffect(() => {
if (path === "/browse" && shouldExpand.current) {
shouldExpand.current = false;
// Small delay to allow animation to start before focusing
setTimeout(() => {
setExpanded(true);
inputRef.current?.focus();
}, 300);
} else if (path === "/") {
inputRef.current?.blur();
}
}, [path]);
const expanded = _expanded || forceExpand;
return (
<Animated.View
className={cn(
"mr-2 flex-row items-center overflow-hidden p-0 pl-4",
"mr-2 flex-1 flex-row items-center overflow-hidden p-0 pl-4",
"rounded-full bg-slate-100 dark:bg-slate-800",
containerClassName,
)}
style={[
expanded ? { flex: 1 } : { backgroundColor: "transparent" },
@ -150,15 +145,16 @@ const SearchBar = () => {
>
<TextInput
ref={inputRef}
value={query}
onChangeText={(q) => {
setQuery(q);
router.setParams({ q });
value={value}
onChangeText={(q) => onChangeText?.(q)}
onSubmitEditing={(e) => onSubmitEditing?.(e)}
onFocus={(e) => {
onFocus?.(e);
setExpanded(true);
}}
onFocus={() => router.push(query ? `/browse?q=${query}` : "/browse")}
onBlur={() => {
if (query !== "") return;
setExpanded(false);
onBlur={(e) => {
onBlur?.(e);
if (!value) setExpanded(false);
}}
placeholder={t("navbar.search")}
textAlignVertical="center"
@ -166,8 +162,10 @@ const SearchBar = () => {
"h-full flex-1 font-sans text-base outline-0",
"align-middle text-slate-600 dark:text-slate-200",
!expanded && "w-0 grow-0",
className,
)}
placeholderTextColorClassName="accent-slate-400 dark:text-slate-600"
{...props}
/>
<IconButton
@ -176,13 +174,13 @@ const SearchBar = () => {
// https://github.com/react-navigation/react-navigation/issues/12274
// https://github.com/react-navigation/react-navigation/issues/12667
onPressIn={() => {
console.log(expanded);
if (expanded) {
inputRef.current?.blur();
inputRef.current?.clear();
onChangeText?.("");
setExpanded(false);
setQuery("");
router.setParams({ q: undefined });
} else {
shouldExpand.current = true;
setExpanded(true);
// Small delay to allow animation to start before focusing
setTimeout(() => inputRef.current?.focus(), 100);

View File

@ -15,13 +15,14 @@ export const useLanguagePreference = (player: VideoPlayer, slug: string) => {
lang: account?.claims.settings.audioLanguage ?? null,
});
useEvent(player, "onAudioTrackChange", () => {
if (!audios?.length) return;
const selected =
audios?.[player.getAvailableTextTracks().findIndex((x) => x.selected)];
if (!selected) return;
aud.current = { idx: selected.index, lang: selected.language };
});
useEffect(() => {
if (!audios) return;
if (!audios?.length) return;
let audRet = audios.findIndex(
aud.current.lang === "default"
? (x) => x.isDefault
@ -47,7 +48,7 @@ export const useLanguagePreference = (player: VideoPlayer, slug: string) => {
sub.current = { idx: null, lang: null, forced: false };
return;
}
if (!subtitles) return;
if (!subtitles?.length) return;
const idx = player.getAvailableTextTracks().findIndex((x) => x.selected);
sub.current = {
idx: idx,
@ -56,7 +57,7 @@ export const useLanguagePreference = (player: VideoPlayer, slug: string) => {
};
});
useEffect(() => {
if (!subtitles || sub.current.idx === null) return;
if (!subtitles?.length || sub.current.idx === null) return;
let subRet = subtitles.findIndex(
sub.current.lang === "default"
? (x) => x.isDefault

View File

@ -1,5 +1,9 @@
import { type ClassValue, clsx } from "clsx";
import { useLocalSearchParams, useRouter } from "expo-router";
import {
useGlobalSearchParams,
useLocalSearchParams,
useRouter,
} from "expo-router";
import { useCallback, useReducer } from "react";
import { twMerge } from "tailwind-merge";
@ -18,9 +22,10 @@ export function getServerData(key: string): any {
export const useQueryState = <S>(key: string, initial: S) => {
const params = useLocalSearchParams();
const global = useGlobalSearchParams();
const router = useRouter();
const state = (params[key] as S) ?? initial;
const state = (params[key] as S) ?? (global[key] as S) ?? initial;
const update = useCallback(
(val: S | ((old: S) => S)) => {
router.setParams({ [key]: val } as any);