diff --git a/packages/react/src/components/layout/InlayContainer.tsx b/packages/react/src/components/layout/InlayContainer.tsx index f72f792c0..221905d5b 100644 --- a/packages/react/src/components/layout/InlayContainer.tsx +++ b/packages/react/src/components/layout/InlayContainer.tsx @@ -54,7 +54,7 @@ export function InlayContainer({ routes }: InlayContainerProps) { {t("component.mainNav.back")} -
+
}> diff --git a/packages/react/src/components/org/OrgPicker.tsx b/packages/react/src/components/org/OrgPicker.tsx index 1180a6f79..9ebde89b1 100644 --- a/packages/react/src/components/org/OrgPicker.tsx +++ b/packages/react/src/components/org/OrgPicker.tsx @@ -13,18 +13,21 @@ import { } from "@/shadcn/ui/command"; import { Popover, PopoverTrigger, PopoverContent } from "@/shadcn/ui/popover"; import { useTranslation } from "react-i18next"; -import { currentOrgAtom } from "@/store/org"; -import { useAtom } from "jotai/react"; +import { getThumbnailForOrg } from "@/lib/thumb"; -export function OrgSelectorCombobox() { +export function OrgSelectorCombobox({ + org, + setOrg, +}: { + org?: Org; + setOrg?: (org: Org) => void; +}) { const { t } = useTranslation(); const [open, setOpen] = React.useState(false); - const [value, setValue] = React.useState(""); - const [currentOrg, setCurrentOrg] = useAtom(currentOrgAtom); - // const [currentOrg, setOrg] = useAtom(orgAtom) + const [value, setValue] = React.useState(org?.name || ""); // Use the useOrgs API service to fetch organizations - const { data: orgs, isError } = useOrgs(); + const { data: orgs, isError } = useOrgs({ enabled: open }); if (isError) { return
Error fetching organizations
; @@ -63,8 +66,8 @@ export function OrgSelectorCombobox() { key={org.name} onSelect={(currentValue) => { setValue(currentValue === value ? "" : currentValue); - setCurrentOrg(org); setOpen(false); + setOrg?.(org); }} >
+ {org.name} ))} diff --git a/packages/react/src/components/playlist/PlaylistEntry.tsx b/packages/react/src/components/playlist/PlaylistEntry.tsx index 98a3ed51e..5eaf06817 100644 --- a/packages/react/src/components/playlist/PlaylistEntry.tsx +++ b/packages/react/src/components/playlist/PlaylistEntry.tsx @@ -1,14 +1,13 @@ -import { makeYtThumbnailUrl } from "@/lib/utils"; -import { TypographyLarge, TypographyP } from "@/shadcn/ui/typography"; +import React from "react"; +import { Link } from "react-router-dom"; import { useAtomValue } from "jotai"; -import { localeAtom } from "@/store/i18n"; import { useTranslation } from "react-i18next"; -import { buttonVariants } from "@/shadcn/ui/button.variants"; -import { Link } from "react-router-dom"; -import { Button } from "@/shadcn/ui/button"; +import { makeYtThumbnailUrl } from "@/lib/utils"; +import { localeAtom } from "@/store/i18n"; import { userAtom } from "@/store/auth"; -import DeletePlaylistDialog from "@/components/playlist/DeletePlaylistDialog"; +import { Button } from "@/shadcn/ui/button"; import { VideoThumbnail } from "../video/VideoThumbnail"; +import DeletePlaylistDialog from "@/components/playlist/DeletePlaylistDialog"; export default function PlaylistEntry({ video_ids, @@ -22,39 +21,77 @@ export default function PlaylistEntry({ const user = useAtomValue(userAtom); return ( -
- -
- {name} - +
+
+ {video_ids && video_ids.length > 0 ? ( + + ) : ( +
+ + + + + + + +
+ )} +
+
+

{name}

+ + {video_ids?.length || 0} {t("views.channel.video")} + + {t("views.playlist.item-last-updated") + " " + dayjs(updated_at).format("LLL")} - -
- - - - - {user?.id === user_id ? ( + + {user?.id === user_id && ( - + } playlistId={id} playlistName={name} /> - ) : null} + )}
diff --git a/packages/react/src/lib/thumb.ts b/packages/react/src/lib/thumb.ts new file mode 100644 index 000000000..02c006e8e --- /dev/null +++ b/packages/react/src/lib/thumb.ts @@ -0,0 +1,43 @@ +import { getChannelPhoto } from "./utils"; + +/** + * Generates a thumbnail URL for a given image URL and resolution definition. + * If the provided URL does not start with "http", the original URL is returned. + * Otherwise, the URL is encoded and formatted according to the specified definition. + * + * @param imgUrl The original image URL. + * @param definition The desired resolution of the thumbnail. Can be "maxres" or "default". + * @returns A string representing the formatted thumbnail URL or the original URL. + */ +export function getThumbnailUrl( + imgUrl: string, + definition: "maxres" | "default", +) { + // Check if imgUrl starts with "http", indicating it's an external URL + if (imgUrl.startsWith("http") === false) { + return imgUrl; // Return the original URL if it's not an external URL + } + // Encode the URL and replace characters to make it URL-safe + const encodedUrl = btoa(imgUrl) + .replace("+", "-") + .replace("/", "_") + .replace(/=+$/, ""); // Remove any trailing '=' characters + + return `/statics/thumbnail/${definition}/${encodedUrl}.jpg`; +} + +type MaybeUndefined = T | undefined; +type NullIfUndefined = T extends undefined ? null : T; + +export function getThumbnailForOrg( + orgIcon: MaybeUndefined, +): NullIfUndefined { + if (!orgIcon) return null as NullIfUndefined; + + if (orgIcon.startsWith("UC")) { + // it's a channel icon: + return getChannelPhoto(orgIcon, 20) as NullIfUndefined; + } + + return getThumbnailUrl(orgIcon, "default") as NullIfUndefined; +} diff --git a/packages/react/src/routes/playlists.tsx b/packages/react/src/routes/playlists.tsx index f3050b759..a5043d9d0 100644 --- a/packages/react/src/routes/playlists.tsx +++ b/packages/react/src/routes/playlists.tsx @@ -1,5 +1,8 @@ import React, { useState } from "react"; -import { usePlaylists } from "@/services/playlist.service"; +import { + usePlaylistSaveMutation, + usePlaylists, +} from "@/services/playlist.service"; import PlaylistEntry from "@/components/playlist/PlaylistEntry"; import { TypographyH3, TypographyP } from "@/shadcn/ui/typography"; import { useTranslation } from "react-i18next"; @@ -17,21 +20,27 @@ import { } from "@/shadcn/ui/dialog"; import { Input } from "@/shadcn/ui/input"; import { useNavigate } from "react-router-dom"; +import { useClient } from "@/hooks/useClient"; export function Playlists() { const { data: playlists, refetch } = usePlaylists(); + const { mutateAsync: savePlaylist } = usePlaylistSaveMutation(); const { t } = useTranslation(); const user = useAtomValue(userAtom); + const fetchClient = useClient(); const [isNewPlaylistDialogOpen, setIsNewPlaylistDialogOpen] = useState(false); const [newPlaylistName, setNewPlaylistName] = useState(""); const navigate = useNavigate(); const handleCreateNewPlaylist = async () => { - // TODO: Implement the API call to create a new playlist console.log("Creating new playlist:", newPlaylistName); setIsNewPlaylistDialogOpen(false); setNewPlaylistName(""); - await refetch(); + const newId = await savePlaylist({ + name: newPlaylistName, + video_ids: [], + user_id: user?.id, + }); }; return ( @@ -49,7 +58,7 @@ export function Playlists() { } >
- +
{t("views.playlist.new-playlist-btn-label")} @@ -72,7 +81,7 @@ export function Playlists() { open={isNewPlaylistDialogOpen} onOpenChange={setIsNewPlaylistDialogOpen} > - + {t("views.playlist.new-playlist-btn-label")} diff --git a/packages/react/src/routes/settings/lang.tsx b/packages/react/src/routes/settings/lang.tsx index 886f8c4d4..4ee3555e8 100644 --- a/packages/react/src/routes/settings/lang.tsx +++ b/packages/react/src/routes/settings/lang.tsx @@ -106,7 +106,7 @@ export function SettingsLang() {
{TL_LANGS.map(({ text, value }) => ( -
+
({ queryKey: ["orgs"], queryFn: async () => - fetch(`${window.location.origin}/statics/orgs.json`).then((r) => { + fetch(`${window.location.origin}/statics/orgsV2.json`).then((r) => { if (!r.ok) { throw new Error(`HTTP error! Status: ${r.status}`); } diff --git a/packages/react/src/types/org.d.ts b/packages/react/src/types/org.d.ts index 31d375556..fd7d156fb 100644 --- a/packages/react/src/types/org.d.ts +++ b/packages/react/src/types/org.d.ts @@ -2,4 +2,5 @@ interface Org { name: string; short?: string; name_jp?: string; + icon?: string; }