diff --git a/CLAUDE.md b/CLAUDE.md index a07a15aa..7380e021 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,9 @@ MemPalace wing: `keepsimple` (protocol lives in `~/.claude/CLAUDE.md`). -Human-readable agent guidelines live in `AGENTS.md` next to this file; this file is the machine-facing version. See `AGENTS.md` for repo conventions, build/test commands, and contribution rules. +Human-readable agent guidelines live in `AGENTS.md` next to this file; this file is the machine-facing version. See `AGENTS.md` for repo conventions, build/test commands, and contribution rules — imported below so it loads automatically. + +@AGENTS.md ## Code search — prefer CodeGraph over Grep diff --git a/src/api/library/tag/getTagsList.ts b/src/api/library/tag/getTagsList.ts index 956a4718..42946ced 100644 --- a/src/api/library/tag/getTagsList.ts +++ b/src/api/library/tag/getTagsList.ts @@ -6,9 +6,21 @@ export interface GetTagsListResponse { data: ITag[]; } -export const getTagsList = async (): Promise => { +// Tags are owner-scoped: each is stamped with `user` on create. The default +// GET /api/tags returns every account's tags, so always filter by the current +// user's id. Without an id there's nothing safe to return — refuse rather than +// fall back to the unscoped list, which would leak other accounts' tags. +export const getTagsList = async ( + userId?: number | string, +): Promise => { + if (userId == null || userId === '') { + return { data: [] }; + } + try { - const { data } = await axiosInstance.get('/api/tags'); + const { data } = await axiosInstance.get('/api/tags', { + params: { 'filters[user][id][$eq]': userId }, + }); return data; } catch (error) { diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index 4d7814a2..31668f65 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -17,6 +17,7 @@ import type { TRouter } from '@local-types/global'; import useGlobals from '@hooks/useGlobals'; import { useIsWidthLessThan } from '@hooks/useScreenSize'; +import { getMyLibrary } from '@api/library/getMyLibrary'; import { userInfoUpdate } from '@api/settings'; import { getMyInfo } from '@api/strapi'; @@ -51,6 +52,24 @@ const Header: FC = () => { const canCreateLibrary = accountData?.featureNames?.includes('can-create-library') ?? false; + // "My Library" is only reachable once a library exists, or could be + // bootstrapped by a flag-holder. With neither, the user has no library page, + // so the dropdown item is disabled. Check via the owner-scoped lookup. + const [hasLibrary, setHasLibrary] = useState(false); + useEffect(() => { + if (!accountData?.id) { + setHasLibrary(false); + return; + } + let cancelled = false; + getMyLibrary(accountData.id).then(lib => { + if (!cancelled) setHasLibrary(lib !== null); + }); + return () => { + cancelled = true; + }; + }, [accountData?.id]); + useEffect(() => { const storedToken = localStorage.getItem('accessToken'); setToken(storedToken); @@ -167,6 +186,7 @@ const Header: FC = () => { userImage={accountData?.picture} handleOpenSettings={handleOpenSettings} canCreateLibrary={canCreateLibrary} + hasLibrary={hasLibrary} hideDropdown={isOpenedSidebar} hideUsername /> @@ -247,6 +267,7 @@ const Header: FC = () => { userImage={accountData?.picture} handleOpenSettings={handleOpenSettings} canCreateLibrary={canCreateLibrary} + hasLibrary={hasLibrary} /> )} diff --git a/src/components/UserProfile/UserProfile.tsx b/src/components/UserProfile/UserProfile.tsx index 73cbaae7..0113b838 100644 --- a/src/components/UserProfile/UserProfile.tsx +++ b/src/components/UserProfile/UserProfile.tsx @@ -22,6 +22,7 @@ type UserProfileProps = { hideDropdown?: boolean; hideUsername?: boolean; canCreateLibrary?: boolean; + hasLibrary?: boolean; setAccountData?: (updater: (prev: boolean) => boolean) => void; setOpenLoginModal?: (openModal: boolean) => void; handleOpenSettings?: () => void; @@ -56,6 +57,7 @@ const UserProfile: FC = ({ hideDropdown, hideUsername, canCreateLibrary, + hasLibrary, setAccountData, setOpenLoginModal, handleOpenSettings, @@ -84,10 +86,15 @@ const UserProfile: FC = ({ handleOpenSettings?.(); }, [handleOpenSettings]); + // With neither an existing library nor create permission, the user has no + // library page to open, so the item is inert. + const myLibraryDisabled = !hasLibrary && !canCreateLibrary; + const handleMyLibrary = useCallback(() => { + if (myLibraryDisabled) return; setIsDropdownOpen(false); router.push(`/library/${username}`); - }, [router, username]); + }, [router, username, myLibraryDisabled]); // A library has no standalone create step — it's bootstrapped on the owner's // own page once they add content (gated server-side by the same feature @@ -174,7 +181,13 @@ const UserProfile: FC = ({ {isDropdownOpen && isAccessTokenExist && (
e.stopPropagation()}> {isLibraryEnabled() && username && ( -
+
{ let cancelled = false; - getTagsList().then(res => { + getTagsList(accountData?.id).then(res => { if (cancelled) return; const opts: TagOption[] = res.data.map(t => ({ id: t.id, @@ -293,7 +293,7 @@ export function AddObjectModal(props: AddObjectModalProps): JSX.Element { return () => { cancelled = true; }; - }, []); + }, [accountData?.id]); // Preset the object's existing tags exactly once, from the object's OWN // populated tag data — not by filtering the fetched options. An unpublished diff --git a/src/components/library/organisms/LibraryCard/LibraryCard.tsx b/src/components/library/organisms/LibraryCard/LibraryCard.tsx index d131d08a..d4338cd3 100644 --- a/src/components/library/organisms/LibraryCard/LibraryCard.tsx +++ b/src/components/library/organisms/LibraryCard/LibraryCard.tsx @@ -24,7 +24,11 @@ export function LibraryCard(props: LibraryCardProps): JSX.Element { const router = useRouter(); const handleViewLibrary = () => { - router.push(`/library/${username ?? id}`); + // Route by numeric id, not username: the route resolver short-circuits a + // numeric param to a findOne-by-id, sidestepping the username→id filter + // lookup that the public API currently 500s on. Falls back to username only + // if an id is somehow absent. + router.push(`/library/${id ?? username}`); }; return ( diff --git a/src/components/library/organisms/LibraryToolbar/LibraryToolbar.module.scss b/src/components/library/organisms/LibraryToolbar/LibraryToolbar.module.scss index 655d412b..c822ecde 100644 --- a/src/components/library/organisms/LibraryToolbar/LibraryToolbar.module.scss +++ b/src/components/library/organisms/LibraryToolbar/LibraryToolbar.module.scss @@ -43,6 +43,12 @@ } } +// Guest view of someone else's library: the divider is dropped, so restore the +// vertical breathing room on the controls row itself. +.controlsGuest { + padding: 24px 10px; +} + // Visitor / guest-preview banner that replaces the owner's shelf controls. .welcome { display: flex; diff --git a/src/components/library/organisms/LibraryToolbar/LibraryToolbar.tsx b/src/components/library/organisms/LibraryToolbar/LibraryToolbar.tsx index 6c44ff3c..4e475029 100644 --- a/src/components/library/organisms/LibraryToolbar/LibraryToolbar.tsx +++ b/src/components/library/organisms/LibraryToolbar/LibraryToolbar.tsx @@ -298,9 +298,7 @@ export function LibraryToolbar(props: LibraryToolbarProps): JSX.Element { if (!isOwner) { return (
-
- -
+
( ))}
diff --git a/src/components/library/organisms/Sidebar/Sidebar.tsx b/src/components/library/organisms/Sidebar/Sidebar.tsx index ec2dae80..35fc9ebc 100644 --- a/src/components/library/organisms/Sidebar/Sidebar.tsx +++ b/src/components/library/organisms/Sidebar/Sidebar.tsx @@ -172,7 +172,7 @@ export function Sidebar() { (isMyLibrary ? accountData?.picture : undefined); const aboutAuthorText = stripHtml(currentOwner?.aboutMe); - // Owner sees their full, editable tag palette; a visitor sees the tags + // Owner sees their full tag palette; a true visitor sees only the tags // actually used on this library's objects — no cross-account tag fetch. const libraryTags = useMemo(() => { const byName = new Map(); @@ -186,7 +186,10 @@ export function Sidebar() { return Array.from(byName.values()); }, [currentShelves]); - const displayedTags = canEdit + // Show the owner's palette whenever it's their own library — including guest + // mode (only an owner can toggle that, so we always have their tags loaded). + // Editing stays gated on `canEdit`, so guest preview shows them read-only. + const displayedTags = isMyLibrary ? tags.map(t => ({ name: t.attributes.name, color: t.attributes.color })) : libraryTags; @@ -228,7 +231,7 @@ export function Sidebar() { }; await createTag(body); - const { data } = await getTagsList(); + const { data } = await getTagsList(accountData?.id); setTags(data); } catch (error) { @@ -251,7 +254,7 @@ export function Sidebar() { }; await updateTag(selectedTag.id, body); - const { data } = await getTagsList(); + const { data } = await getTagsList(accountData?.id); setTags(data); setIsOpenTagModal(null); @@ -267,7 +270,7 @@ export function Sidebar() { try { await deleteTag(selectedTag.id); - const { data } = await getTagsList(); + const { data } = await getTagsList(accountData?.id); setTags(data); setIsOpenTagModal(null); @@ -308,13 +311,13 @@ export function Sidebar() { // fresh page load until the user mutates a tag. useEffect(() => { let cancelled = false; - getTagsList().then(({ data }) => { + getTagsList(accountData?.id).then(({ data }) => { if (!cancelled) setTags(data); }); return () => { cancelled = true; }; - }, [setTags]); + }, [setTags, accountData?.id]); // Hide the right panel entirely when the owner lacks permission to create a // library — the page shows only the centered no-permission message. diff --git a/src/layouts/library/Home/Home.module.scss b/src/layouts/library/Home/Home.module.scss index 0a14f38d..0590437e 100644 --- a/src/layouts/library/Home/Home.module.scss +++ b/src/layouts/library/Home/Home.module.scss @@ -60,6 +60,18 @@ } } + .searchGroup { + display: flex; + align-items: center; + gap: 12px; + + @media (max-width: 590px) { + width: 100%; + flex-direction: column; + align-items: stretch; + } + } + .input { max-width: 348px; border-radius: 6px; @@ -77,6 +89,17 @@ top: 9px; } } + + .createButton { + flex-shrink: 0; + white-space: nowrap; + + // The shared plus icon ships a hardcoded brown fill, which disappears on + // the brown primary button — force it (and the label) white. + svg path { + fill: var(--white); + } + } } .content { diff --git a/src/layouts/library/Home/Home.tsx b/src/layouts/library/Home/Home.tsx index aaa63195..40c8a29d 100644 --- a/src/layouts/library/Home/Home.tsx +++ b/src/layouts/library/Home/Home.tsx @@ -1,10 +1,15 @@ import { mapStrapiLibrariesResponseToCards } from '@utils/library/mapStrapiLibraries'; +import { useRouter } from 'next/router'; import React, { useEffect, useMemo, useState } from 'react'; import type { HomeLibraryCardView } from '@local-types/library/library'; import { getLibrariesPaginated } from '@api/library/getLibrariesPaginated'; +import { getMyLibrary } from '@api/library/getMyLibrary'; +import PlusIcon from '@icons/library/svg/plus.svg'; + +import { useAuth } from '@components/Context/library/AuthContext'; import { Text, TypographyVariant } from '@components/library/atoms/Text'; import { AboutLibraryModal } from '@components/library/molecules/AboutLibraryModal'; import { @@ -26,6 +31,41 @@ const sectionId = 'libraries-section'; const perPage = 6; export function HomeTemplate({ data: dataOverride }: HomeTemplateProps) { + const router = useRouter(); + const { accountData } = useAuth(); + + // Creating a library is gated by the `can-create-library` feature flag from + // GET /api/users/me — the same gate the user dropdown's "Create library" item + // uses. A library has no standalone create step: it's bootstrapped on the + // owner's own page, so the button just routes there when the flag is present. + const canCreateLibrary = + accountData?.featureNames?.includes('can-create-library') ?? false; + + // A user may create at most one library, so the button is also disabled once + // they already own one. Check via the owner-scoped lookup the library page + // uses, not the home grid (which is paginated and may not include theirs). + const [hasLibrary, setHasLibrary] = useState(false); + useEffect(() => { + if (!accountData?.id) { + setHasLibrary(false); + return; + } + let cancelled = false; + getMyLibrary(accountData.id).then(lib => { + if (!cancelled) setHasLibrary(lib !== null); + }); + return () => { + cancelled = true; + }; + }, [accountData?.id]); + + const createDisabled = !canCreateLibrary || hasLibrary; + + const handleCreateLibrary = () => { + if (createDisabled || !accountData?.username) return; + router.push(`/library/${accountData.username}`); + }; + const [value, setValue] = useState(''); const [debouncedQuery, setDebouncedQuery] = useState(''); const [isOpen, setIsOpen] = useState(false); @@ -199,15 +239,26 @@ export function HomeTemplate({ data: dataOverride }: HomeTemplateProps) { > Libraries - setValue('')} - wrapperClassName={styles.input} - /> +
+ setValue('')} + wrapperClassName={styles.input} + /> +
diff --git a/src/layouts/library/Library/Library.module.scss b/src/layouts/library/Library/Library.module.scss index d53f7abe..0613f127 100644 --- a/src/layouts/library/Library/Library.module.scss +++ b/src/layouts/library/Library/Library.module.scss @@ -3,7 +3,6 @@ min-width: 0; @media (max-width: 1024px) { - padding: 16px; min-height: calc(100vh - 168px); }