diff --git a/api/src/auth.ts b/api/src/auth.ts index ba1d9932..e0cc3e6d 100644 --- a/api/src/auth.ts +++ b/api/src/auth.ts @@ -101,7 +101,7 @@ const User = t.Object({ oidc: t.Record( t.String(), t.Object({ - id: t.String({ format: "uuid" }), + id: t.String(), username: t.String(), profileUrl: t.Nullable(t.String({ format: "url" })), }), diff --git a/front/public/translations/en.json b/front/public/translations/en.json index d9a2ed6d..90399a83 100644 --- a/front/public/translations/en.json +++ b/front/public/translations/en.json @@ -97,6 +97,18 @@ "switchToGrid": "Switch to grid view", "switchToList": "Switch to list view" }, + "profile": { + "history": "History", + "watchlist": "Watchlist", + "statuses": { + "all": "All", + "completed": "Completed", + "watching": "Watching", + "rewatching": "Rewatching", + "dropped": "Dropped", + "planned": "Planned" + } + }, "genres": { "action": "Action", "adventure": "Adventure", @@ -139,6 +151,7 @@ "navbar": { "home": "Home", "browse": "Browse", + "profile": "Profile", "download": "Download", "search": "Search", "login": "Login", diff --git a/front/src/app/(app)/(tabs)/_layout.tsx b/front/src/app/(app)/(tabs)/_layout.tsx index 05977132..eca02da5 100644 --- a/front/src/app/(app)/(tabs)/_layout.tsx +++ b/front/src/app/(app)/(tabs)/_layout.tsx @@ -1,6 +1,7 @@ import Browse from "@material-symbols/svg-400/rounded/browse-fill.svg"; // import Downloading from "@material-symbols/svg-400/rounded/downloading-fill.svg"; import Home from "@material-symbols/svg-400/rounded/home-fill.svg"; +import Person from "@material-symbols/svg-400/rounded/person-fill.svg"; import { Slot, Tabs } from "expo-router"; import { useTranslation } from "react-i18next"; import { Platform } from "react-native"; @@ -48,6 +49,18 @@ export default function TabsLayout() { ), }} /> + ( + + ), + }} + /> ); } diff --git a/front/src/app/(app)/(tabs)/profile.tsx b/front/src/app/(app)/(tabs)/profile.tsx new file mode 100644 index 00000000..e075c691 --- /dev/null +++ b/front/src/app/(app)/(tabs)/profile.tsx @@ -0,0 +1,7 @@ +import { ProfileScreen } from "~/ui/profile"; + +export { ErrorBoundary } from "~/ui/error-boundary"; + +export default function MyProfilePage() { + return ; +} diff --git a/front/src/app/(app)/profiles/[slug].tsx b/front/src/app/(app)/profiles/[slug].tsx new file mode 100644 index 00000000..a3a8ef11 --- /dev/null +++ b/front/src/app/(app)/profiles/[slug].tsx @@ -0,0 +1,5 @@ +import { ProfilePage } from "~/ui/profile"; + +export { ErrorBoundary } from "~/ui/error-boundary"; + +export default ProfilePage; diff --git a/front/src/components/items/item-helpers.tsx b/front/src/components/items/item-helpers.tsx index 2bb566cf..72270840 100644 --- a/front/src/components/items/item-helpers.tsx +++ b/front/src/components/items/item-helpers.tsx @@ -1,7 +1,8 @@ -import Done from "@material-symbols/svg-400/rounded/check-fill.svg"; +import { useTranslation } from "react-i18next"; import { View } from "react-native"; import type { WatchStatusV } from "~/models"; -import { Icon, P } from "~/primitives"; +import { tooltip } from "~/primitives"; +import { cn } from "~/utils"; export const ItemWatchStatus = ({ watchStatus, @@ -13,20 +14,30 @@ export const ItemWatchStatus = ({ availableCount?: number | null; seenCount?: number | null; }) => { - if (watchStatus !== "completed" && !availableCount) return null; + const { t } = useTranslation(); + + if (!watchStatus && !availableCount) return null; return ( - {watchStatus === "completed" ? ( - - ) : ( -

- {seenCount ?? 0}/{availableCount} -

+ className={cn( + "absolute top-0 left-0 m-2 aspect-square w-4 rounded-full p-1", + "bg-gray-800/70", + watchStatus === "completed" && "bg-sky-500", + watchStatus === "watching" && "bg-emerald-500", + watchStatus === "rewatching" && "bg-teal-500", + watchStatus === "dropped" && "bg-rose-500", + watchStatus === "planned" && "bg-amber-500", )} -
+ {...tooltip( + [ + watchStatus && t(`profile.statuses.${watchStatus}`), + availableCount && `${seenCount ?? 0}/${availableCount ?? 0}`, + ] + .filter((x) => x) + .join(" ยท "), + )} + {...props} + /> ); }; diff --git a/front/src/primitives/tabs.tsx b/front/src/primitives/tabs.tsx index 62472b80..e342c6e6 100644 --- a/front/src/primitives/tabs.tsx +++ b/front/src/primitives/tabs.tsx @@ -32,7 +32,7 @@ export const Tabs = ({ return ( +

await mutateAsync("delete")} /> - + ); }; diff --git a/front/src/ui/navbar.tsx b/front/src/ui/navbar.tsx index 0bd97f05..8b479059 100644 --- a/front/src/ui/navbar.tsx +++ b/front/src/ui/navbar.tsx @@ -3,6 +3,7 @@ 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"; @@ -52,7 +53,7 @@ export const NavbarLeft = () => { if (Platform.OS !== "web") return ; return ( - + { > {t("navbar.browse")} + + {t("navbar.profile")} + ); }; @@ -74,7 +81,7 @@ export const NavbarTitle = ({ diff --git a/front/src/ui/profile/index.tsx b/front/src/ui/profile/index.tsx new file mode 100644 index 00000000..efd2e988 --- /dev/null +++ b/front/src/ui/profile/index.tsx @@ -0,0 +1,181 @@ +import Bookmark from "@material-symbols/svg-400/rounded/bookmark-fill.svg"; +import Cancel from "@material-symbols/svg-400/rounded/cancel-fill.svg"; +import CheckCircle from "@material-symbols/svg-400/rounded/check_circle-fill.svg"; +import Replay from "@material-symbols/svg-400/rounded/replay.svg"; +import Clock from "@material-symbols/svg-400/rounded/schedule-fill.svg"; +import { useTranslation } from "react-i18next"; +import { View } from "react-native"; +import { EntryBox, entryDisplayNumber } from "~/components/entries"; +import { ItemGrid, itemMap } from "~/components/items"; +import { Entry, Show, type User, User as UserModel } from "~/models"; +import { Avatar, H1, H3, P, Tabs } from "~/primitives"; +import { Fetch, InfiniteFetch, type QueryIdentifier } from "~/query"; +import { EmptyView } from "~/ui/empty-view"; +import { useQueryState } from "~/utils"; + +const statusTabs = [ + { + value: "all", + icon: Bookmark, + translation: "profile.statuses.all", + }, + { + value: "completed", + icon: CheckCircle, + translation: "profile.statuses.completed", + }, + { + value: "watching", + icon: Clock, + translation: "profile.statuses.watching", + }, + { + value: "rewatching", + icon: Replay, + translation: "profile.statuses.rewatching", + }, + { + value: "dropped", + icon: Cancel, + translation: "profile.statuses.dropped", + }, + { + value: "planned", + icon: Bookmark, + translation: "profile.statuses.planned", + }, +] as const; + +type WatchlistFilter = (typeof statusTabs)[number]["value"]; + +const ProfileHeader = ({ + slug, + status, + setStatus, +}: { + slug: string; + status: WatchlistFilter; + setStatus: (value: WatchlistFilter) => void; +}) => { + const { t } = useTranslation(); + + return ( + + ( + + + +

{user.username}

+
+
+ )} + Loader={() => ( + + + +

{t("misc.loading")}

+
+
+ )} + /> + + +

{t("profile.history")}

+ } + Render={({ item }) => ( + {}} + /> + )} + Loader={EntryBox.Loader} + /> +
+ + +

{t("profile.watchlist")}

+ ({ + label: t(tab.translation), + value: tab.value, + icon: tab.icon, + }))} + value={status} + setValue={setStatus} + className="shrink self-start" + /> +
+ + ); +}; + +export const ProfilePage = () => { + const [slug] = useQueryState("slug", undefined!); + + return ; +}; + +export const ProfileScreen = ({ slug }: { slug: string }) => { + const { t } = useTranslation(); + const [status, setStatus] = useQueryState("status", "all"); + + return ( + + } + Render={({ item }) => } + Loader={() => } + Empty={} + /> + ); +}; + +ProfilePage.watchlistQuery = ( + slug: string, + status: WatchlistFilter, +): QueryIdentifier => ({ + parser: Show, + infinite: true, + path: ["api", "profiles", slug, "watchlist"], + params: { + ...(status !== "all" ? { filter: `watchStatus eq ${status}` } : {}), + }, +}); + +ProfilePage.historyQuery = (slug: string): QueryIdentifier => ({ + parser: Entry, + infinite: true, + path: ["api", "profiles", slug, "history"], + params: { + with: ["show"], + }, +}); + +ProfilePage.userQuery = (slug: string): QueryIdentifier => ({ + parser: UserModel, + path: ["auth", "users", slug], +});