mirror of
https://github.com/zoriya/Kyoo.git
synced 2026-04-07 01:31:56 -04:00
Implement filters front (#1415)
This commit is contained in:
commit
3d6a80c69a
3
api/drizzle/0030_external_hist.sql
Normal file
3
api/drizzle/0030_external_hist.sql
Normal 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
|
||||
2016
api/drizzle/meta/0030_snapshot.json
Normal file
2016
api/drizzle/meta/0030_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -211,6 +211,13 @@
|
||||
"when": 1774623568394,
|
||||
"tag": "0029_next_refresh",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 30,
|
||||
"version": "7",
|
||||
"when": 1774974162419,
|
||||
"tag": "0030_external_hist",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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,
|
||||
),
|
||||
|
||||
@ -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(
|
||||
{
|
||||
|
||||
@ -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()),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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")}
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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);
|
||||
}}
|
||||
|
||||
@ -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!);
|
||||
}}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user