diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 21cb993f..b0f3b52d 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -1,44 +1,97 @@ -name: Claude Code Review +name: Claude PR Review on: pull_request: types: [opened, synchronize, ready_for_review, reopened] - # Optional: Only run on specific file changes - # paths: - # - "src/**/*.ts" - # - "src/**/*.tsx" - # - "src/**/*.js" - # - "src/**/*.jsx" + +# Only review the latest push: rapid pushes to the same PR would otherwise +# spawn overlapping runs that race on the tracking comment and burn +# Max-subscription quota. +concurrency: + group: claude-review-${{ github.event.pull_request.number }} + cancel-in-progress: true jobs: claude-review: - # Optional: Filter by PR author - # if: | - # github.event.pull_request.user.login == 'external-contributor' || - # github.event.pull_request.user.login == 'new-developer' || - # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' - + # Skip draft PRs, and skip fork PRs: forked pull_request runs get no + # repo secrets and a read-only GITHUB_TOKEN, so the job would fail + # loudly on auth instead of skipping cleanly. + if: >- + github.event.pull_request.draft == false && + github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: read pull-requests: write - issues: write - id-token: write - + actions: read steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 1 - - name: Run Claude Code Review - id: claude-review + - name: Claude PR Review uses: anthropics/claude-code-action@v1 with: + # Authenticates against Mary's Claude Max subscription (OAuth token, + # not an API key). Secret set at repo level: CLAUDE_CODE_OAUTH_TOKEN. claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' - plugins: 'code-review@claude-code-plugins' - prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://code.claude.com/docs/en/cli-reference for available options + # Use the workflow's own token so we don't need to install the + # third-party Claude GitHub App on the org. Requires the job + # permissions block below (pull-requests: write). + github_token: ${{ secrets.GITHUB_TOKEN }} + + # Live progress checklist comment on the PR while reviewing. + track_progress: true + + prompt: | + REPO: ${{ github.repository }} + PR NUMBER: ${{ github.event.pull_request.number }} + + You are reviewing the KeepSimpleOSS codebase: a Next.js **Pages + Router** app (React 19, TypeScript with strict off), styled with + SCSS Modules. See AGENTS.md for the full conventions. + + Review this PR and focus on: + + 1. Correctness & React best practices + - Hooks rules, effect dependencies, stale closures + - Unnecessary re-renders, missing keys, prop drilling + - SSR/hydration safety: no `window`/`localStorage`/`document` + at module top level (guard in effects or use ssr:false) + 2. TypeScript quality + - Avoid `any`, prefer precise types, exhaustive unions + 3. Project conventions (AGENTS.md) — flag violations: + - App Router patterns (`'use client'`, `next/navigation`, + `src/app/`) + - Tailwind, styled-components, CSS-in-JS, or inline styles + - New state libraries (Redux, Zustand, Jotai, SWR, React Query) + - Global CSS imported anywhere except `_app.tsx` + - `` instead of importing SVGs as components + - Named exports from `index.ts` barrels, or empty barrels + - Import-order / path-alias violations + - Changes to UX Core bias data, slugs, or schema (these need + explicit approval — flag, don't wave through) + 4. Accessibility & UX + - Semantic HTML, aria attributes, keyboard nav + 5. Security + - XSS via dangerouslySetInnerHTML, unsanitized input, + leaked secrets/env, unsafe URL handling + 6. Styling + - SCSS module hygiene; no hardcoded colors/spacing/breakpoints + that bypass the design tokens (keepsimple-style) + + Leave inline comments for specific issues via the inline-comment + tool. Put your overall assessment and any praise in the tracking + comment summary. Be concise and actionable; skip nitpicks that a + linter would catch. + + # Only the PR/commit-scoped inline-comment tool is granted. We + # deliberately do NOT grant raw `gh pr comment/view/diff`: those are + # unscoped, and since the review reads untrusted PR content (diff, + # description) a prompt-injection payload could steer them at other + # PRs/issues. PR context + diff are already injected via track_progress. + claude_args: | + --allowedTools "mcp__github_inline_comment__create_inline_comment" 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/public/fonts/SourceSansPro/SourceSansPro-Regular.woff2 b/public/fonts/SourceSansPro/SourceSansPro-Regular.woff2 new file mode 100644 index 00000000..e49928e8 Binary files /dev/null and b/public/fonts/SourceSansPro/SourceSansPro-Regular.woff2 differ 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 && ( -
+
0,1,1) so tag labels + // use the sans face. + font-family: var(--font-source-sans); } .removeButton { diff --git a/src/components/library/organisms/AddObjectModal/AddObjectModal.tsx b/src/components/library/organisms/AddObjectModal/AddObjectModal.tsx index c8678db1..62f09c07 100644 --- a/src/components/library/organisms/AddObjectModal/AddObjectModal.tsx +++ b/src/components/library/organisms/AddObjectModal/AddObjectModal.tsx @@ -281,7 +281,7 @@ export function AddObjectModal(props: AddObjectModalProps): JSX.Element { useEffect(() => { 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..7fa63695 100644 --- a/src/components/library/organisms/LibraryToolbar/LibraryToolbar.module.scss +++ b/src/components/library/organisms/LibraryToolbar/LibraryToolbar.module.scss @@ -12,24 +12,14 @@ } } -.divider { - width: 100%; - height: 1px; - background: var(--black-100); - margin: 16px 0; - - // Mobile: collapse the top margin so the divider connects to the fixed - // header (paired with the layout wrapper dropping its top padding). - @media (max-width: 768px) { - margin-top: 0; - } -} - .controls { display: flex; align-items: center; justify-content: space-between; gap: 24px; + // Replaces the removed divider's 16px top margin so the controls keep their + // breathing room below the global header. + margin-top: 16px; animation: toolbar-fade 0.3s ease; // On phones everything crammed onto one row collapsed the flex:1 pill @@ -40,9 +30,20 @@ flex-direction: column; align-items: stretch; gap: 12px; + // Collapse the top margin on mobile so the controls connect to the fixed + // header (paired with the layout wrapper dropping its top padding), matching + // the removed divider's mobile behavior. + margin-top: 0; } } +// Guest view of someone else's library: the divider is dropped, so restore the +// vertical breathing room on the controls row itself. +.controlsGuest { + margin-top: 0; + 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..b3be94e9 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 (
-
- -
+
-
-
{isReordering ? 'Drag to reorder →' : 'Jump to →'} diff --git a/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.module.scss b/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.module.scss index 3a84b1b8..101a7d05 100644 --- a/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.module.scss +++ b/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.module.scss @@ -282,11 +282,6 @@ gap: 8px; } -// `cursor: pointer` is scoped here — Tag elsewhere stays presentational. -.tag { - cursor: pointer; -} - .destination { display: flex; flex-direction: row; diff --git a/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.tsx b/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.tsx index 2ba959e3..896d8ced 100644 --- a/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.tsx +++ b/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.tsx @@ -569,10 +569,8 @@ export function ObjectOverviewModal( {tagsList.map(t => ( ))}
diff --git a/src/components/library/organisms/Sidebar/Sidebar.module.scss b/src/components/library/organisms/Sidebar/Sidebar.module.scss index af36f9bb..f661b41d 100644 --- a/src/components/library/organisms/Sidebar/Sidebar.module.scss +++ b/src/components/library/organisms/Sidebar/Sidebar.module.scss @@ -87,7 +87,8 @@ .button { width: auto; - padding: 3px 8px; + height: auto; + padding: 4px 8px; } .text { @@ -132,6 +133,15 @@ display: flex; flex-wrap: wrap; + &.tagsEmpty { + height: 50px; + align-items: center; + } + + .emptyTags { + color: var(--gray-medium); + } + .button { color: var(--brown); height: 26px; diff --git a/src/components/library/organisms/Sidebar/Sidebar.tsx b/src/components/library/organisms/Sidebar/Sidebar.tsx index ec2dae80..4980a73c 100644 --- a/src/components/library/organisms/Sidebar/Sidebar.tsx +++ b/src/components/library/organisms/Sidebar/Sidebar.tsx @@ -171,8 +171,11 @@ export function Sidebar() { resolveStrapiUrl(currentOwner?.avatar) ?? (isMyLibrary ? accountData?.picture : undefined); const aboutAuthorText = stripHtml(currentOwner?.aboutMe); + const aboutLibraryText = stripHtml( + currentLibrary?.attributes.libraryDetails?.aboutLibrary, + ); - // 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 +189,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 +234,7 @@ export function Sidebar() { }; await createTag(body); - const { data } = await getTagsList(); + const { data } = await getTagsList(accountData?.id); setTags(data); } catch (error) { @@ -251,7 +257,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 +273,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 +314,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. @@ -394,7 +400,7 @@ export function Sidebar() { ''}
-
+ {aboutLibraryText &&
}
Total objects: @@ -465,7 +471,14 @@ export function Sidebar() { )}
-
+
+ {displayedTags.length === 0 && ( + No tags yet. + )} {displayedTags.map(tag => ( ))} 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); } diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index df1730b9..f0399a94 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -1,3 +1,4 @@ +import { getSlimBiases } from '@uxcore/api/biases'; import { getOurProjects } from '@uxcore/api/our-projects'; import { GlobalContext as UXCoreGlobalContext } from '@uxcore/components/Context/GlobalContext'; import { NewUpdateModalContainer } from '@uxcore/components/NewUpdateModal'; @@ -42,6 +43,7 @@ function AppContent({ Component, pageProps: { session, ...pageProps } }: TApp) { const [selectedTitle, setSelectedTitle] = useState(''); const [updatedUsername, setUpdatedUsername] = useState(''); const [ourProjectsModalData, setOurProjectsModalData] = useState(null); + const [uxCoreData, setUxCoreData] = useState(null); const isIndexingOn = process.env.NEXT_PUBLIC_INDEXING === 'on'; const isProduction = process.env.NEXT_PUBLIC_ENV === 'prod'; @@ -276,6 +278,24 @@ function AppContent({ Component, pageProps: { session, ...pageProps } }: TApp) { }; }, [isUxcoreRoute, router.locale]); + const isUxcatRoute = router.pathname.startsWith('/uxcat'); + + useEffect(() => { + if (!isUxcatRoute || uxCoreData) return; + let cancelled = false; + (async () => { + try { + const data = await getSlimBiases(); + if (!cancelled) setUxCoreData(data); + } catch (err) { + console.warn('[uxcat-biases] fetch failed:', err); + } + })(); + return () => { + cancelled = true; + }; + }, [isUxcatRoute, uxCoreData]); + const uxcoreContextValue = useMemo( () => ({ accountData, @@ -289,7 +309,7 @@ function AppContent({ Component, pageProps: { session, ...pageProps } }: TApp) { setUpdatedUsername, ourProjectsModalData, setOurProjectsModalData, - uxCoreData: null, + uxCoreData, uxcgLocalizedData: null, uxcgData: null, }), @@ -299,6 +319,7 @@ function AppContent({ Component, pageProps: { session, ...pageProps } }: TApp) { selectedTitle, updatedUsername, ourProjectsModalData, + uxCoreData, ], ); diff --git a/src/styles/globals.scss b/src/styles/globals.scss index 8056fa45..650ac572 100644 --- a/src/styles/globals.scss +++ b/src/styles/globals.scss @@ -267,6 +267,15 @@ body { font-display: block; } +@font-face { + font-family: 'Source Sans Pro'; + src: url('/keepsimple_/fonts/SourceSansPro/SourceSansPro-Regular.woff2') + format('woff2'); + font-weight: 400; + font-style: normal; + font-display: block; +} + // Aldrich is being used in Company Management(new) @font-face { font-family: 'Aldrich'; diff --git a/src/styles/library/variables.scss b/src/styles/library/variables.scss index 2be45c5f..5d0d8f46 100644 --- a/src/styles/library/variables.scss +++ b/src/styles/library/variables.scss @@ -113,5 +113,7 @@ /* Fonts — the upstream library injected these via next/font on ; here we declare them directly with web-safe fallbacks. */ --font-source-serif: 'Source Serif 4', Georgia, 'Times New Roman', serif; + --font-source-sans: + 'Source Sans Pro', system-ui, -apple-system, Arial, sans-serif; --font-lato: 'Lato', system-ui, -apple-system, Arial, sans-serif; } diff --git a/src/uxcore/api/biases.ts b/src/uxcore/api/biases.ts index 20cbbd26..a51ab586 100644 --- a/src/uxcore/api/biases.ts +++ b/src/uxcore/api/biases.ts @@ -1,8 +1,52 @@ let cachedBiases: any = null; +let cachedSlimBiases: any = null; const LOCALES = ['en', 'ru', 'hy']; const PAGE_SIZE = 100; const TOTAL_ITEMS_EXPECTED = 105; +const SLIM_FIELDS = [ + 'number', + 'title', + 'description', + 'slug', + // test-result.tsx JSON.parses this to pick recommended reading — omitting it + // makes that page throw once the bias list is populated. + 'mentionedQuestionsIds', +]; +const SLIM_LOCALES = ['en', 'ru']; + +// UXCAT renders bias titles/descriptions from the shared bias list, but the +// full payload (SEO, OG images, usage sections) is ~30x larger than what those +// screens read. Fetch only the fields UXCAT needs. +export const getSlimBiases = async () => { + if (cachedSlimBiases) return cachedSlimBiases; + + const fieldsQuery = SLIM_FIELDS.map( + (field, index) => `fields[${index}]=${field}`, + ).join('&'); + + const allData = { en: [], ru: [], hy: [] }; + + for (const locale of SLIM_LOCALES) { + let page = 1; + while (true) { + const url = `${process.env.NEXT_PUBLIC_STRAPI}/api/biases?locale=${locale}&sort=number&pagination[pageSize]=${PAGE_SIZE}&pagination[page]=${page}&${fieldsQuery}`; + const res = await fetch(url); + const json = await res.json(); + + if (!json.data || json.data.length === 0) break; + + allData[locale].push(...json.data); + + if (json.data.length < PAGE_SIZE) break; + page++; + } + } + + cachedSlimBiases = allData; + return allData; +}; + export const getStrapiBiases = async () => { if (cachedBiases) return cachedBiases; diff --git a/src/uxcore/components/AddToCalendar/AddToCalendar.tsx b/src/uxcore/components/AddToCalendar/AddToCalendar.tsx index 0def0567..aaae7978 100644 --- a/src/uxcore/components/AddToCalendar/AddToCalendar.tsx +++ b/src/uxcore/components/AddToCalendar/AddToCalendar.tsx @@ -1,17 +1,14 @@ +import CalendarItems from '@uxcore/components/CalendarItems'; +import Modal from '@uxcore/components/Modal'; +import calendar from '@uxcore/data/uxcat/calendar'; +import { useClickOutside } from '@uxcore/hooks/useClickOutside'; +import useMobile from '@uxcore/hooks/useMobile'; +import { getEventWindow, toICalUTC } from '@uxcore/lib/ics'; +import type { TRouter } from '@uxcore/local-types/global'; import cn from 'classnames'; import { useRouter } from 'next/router'; import { FC, useState } from 'react'; -import type { TRouter } from '@uxcore/local-types/global'; - -import { useClickOutside } from '@uxcore/hooks/useClickOutside'; -import useMobile from '@uxcore/hooks/useMobile'; - -import calendar from '@uxcore/data/uxcat/calendar'; - -import CalendarItems from '@uxcore/components/CalendarItems'; -import Modal from '@uxcore/components/Modal'; - import styles from './AddToCalendar.module.scss'; type AddToCalendarProps = { @@ -33,18 +30,23 @@ const AddToCalendar: FC = ({ const currentLocale = locale === 'ru' ? 'ru' : 'en'; const { addToCalendar, title, description } = calendar[currentLocale]; - const calendarDescription = `${description} ${process.env.NEXT_PUBLIC_DOMAIN}/uxcat/start-test`; + const testUrl = `${process.env.NEXT_PUBLIC_DOMAIN}/uxcat/start-test`; + const calendarDescription = `${description} ${testUrl}`; + + const eventWindow = getEventWindow(startTime); + if (!eventWindow) return null; const event = { - title: title, - startTime: startTime?.toString(), + title, + start: eventWindow.start, + end: eventWindow.end, description: calendarDescription, - url: `${process.env.NEXT_PUBLIC_DOMAIN}/uxcat/start-test`, + url: testUrl, }; - const googleCalendarUrl = `https://www.google.com/calendar/render?action=TEMPLATE&text=${encodeURIComponent(event.title)}&dates=${event.startTime}&details=${encodeURIComponent(calendarDescription)}`; + const googleCalendarUrl = `https://www.google.com/calendar/render?action=TEMPLATE&text=${encodeURIComponent(title)}&dates=${toICalUTC(eventWindow.start)}/${toICalUTC(eventWindow.end)}&details=${encodeURIComponent(calendarDescription)}`; - const outlookCalendarUrl = `https://outlook.live.com/calendar/0/deeplink/compose?subject=${encodeURIComponent(event.title)}&body=${encodeURIComponent(calendarDescription)}&startdt=${event.startTime}`; + const outlookCalendarUrl = `https://outlook.live.com/calendar/0/deeplink/compose?subject=${encodeURIComponent(title)}&body=${encodeURIComponent(calendarDescription)}&startdt=${eventWindow.start.toISOString()}&enddt=${eventWindow.end.toISOString()}`; return ( <> diff --git a/src/uxcore/components/CalendarItems/CalendarItems.tsx b/src/uxcore/components/CalendarItems/CalendarItems.tsx index 85dc8af4..d9d5ffd5 100644 --- a/src/uxcore/components/CalendarItems/CalendarItems.tsx +++ b/src/uxcore/components/CalendarItems/CalendarItems.tsx @@ -1,11 +1,11 @@ +import { buildICS, CalendarEvent, downloadICS } from '@uxcore/lib/ics'; import Image from 'next/image'; import { FC } from 'react'; -import ICalendarLink from 'react-icalendar-link'; import styles from './CalendarItems.module.scss'; type CalendarItemsProps = { - event: any; + event: CalendarEvent; googleCalendarUrl: string; outlookCalendarUrl: string; }; @@ -17,8 +17,11 @@ const CalendarItems: FC = ({ return (
- {/*@ts-ignore*/} - +
= ({ ), ); } - }, []); + }, [uxCoreData]); return ( String(value).padStart(2, '0'); + +// nextTestTime arrives as an epoch timestamp, and `new Date('1758...')` parses +// as Invalid Date — numeric strings have to be coerced to a number first. +export const parseEventDate = ( + value?: string | number | Date | null, +): Date | null => { + if (value === null || value === undefined || value === '') return null; + + const raw = value instanceof Date ? value : String(value).trim(); + const normalized = + typeof raw === 'string' && /^\d+$/.test(raw) ? Number(raw) : raw; + + const date = new Date(normalized); + return Number.isNaN(date.getTime()) ? null : date; +}; + +export const getEventWindow = (value?: string | number | Date | null) => { + const start = parseEventDate(value); + if (!start) return null; + + return { start, end: new Date(start.getTime() + DEFAULT_DURATION_MS) }; +}; + +// RFC 5545 UTC date-time: YYYYMMDDTHHMMSSZ +export const toICalUTC = (date: Date) => + [ + date.getUTCFullYear(), + pad(date.getUTCMonth() + 1), + pad(date.getUTCDate()), + 'T', + pad(date.getUTCHours()), + pad(date.getUTCMinutes()), + pad(date.getUTCSeconds()), + 'Z', + ].join(''); + +// RFC 5545 §3.3.11 +const escapeText = (value: string) => + value + .replace(/\\/g, '\\\\') + .replace(/;/g, '\\;') + .replace(/,/g, '\\,') + .replace(/\r?\n/g, '\\n'); + +// RFC 5545 §3.1 — content lines are folded at 75 octets, and Cyrillic copy +// blows past that in half the characters, so fold by byte length not length. +const foldLine = (line: string) => { + const encoder = new TextEncoder(); + if (encoder.encode(line).length <= MAX_LINE_OCTETS) return line; + + const chunks: string[] = []; + let current = ''; + let currentOctets = 0; + + for (const char of line) { + const octets = encoder.encode(char).length; + if (currentOctets + octets > MAX_LINE_OCTETS) { + chunks.push(current); + current = ''; + currentOctets = 0; + } + current += char; + currentOctets += octets; + } + chunks.push(current); + + return chunks.join(`${CRLF} `); +}; + +export const buildICS = ({ + title, + start, + end, + description, + url, +}: CalendarEvent) => { + const startUTC = toICalUTC(start); + const endUTC = toICalUTC( + end ?? new Date(start.getTime() + DEFAULT_DURATION_MS), + ); + + const lines = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + `PRODID:${PRODID}`, + 'CALSCALE:GREGORIAN', + 'METHOD:PUBLISH', + 'BEGIN:VEVENT', + // Deterministic UID so re-downloading updates the event instead of + // stacking duplicates in the user's calendar. + `UID:uxcat-${startUTC}@keepsimple.io`, + `DTSTAMP:${toICalUTC(new Date())}`, + `DTSTART:${startUTC}`, + `DTEND:${endUTC}`, + `SUMMARY:${escapeText(title)}`, + ]; + + if (description) lines.push(`DESCRIPTION:${escapeText(description)}`); + // URL is a URI value, not TEXT — it must not be backslash-escaped. + if (url) lines.push(`URL:${url}`); + + lines.push('END:VEVENT', 'END:VCALENDAR'); + + return `${lines.map(foldLine).join(CRLF)}${CRLF}`; +}; + +export const downloadICS = (content: string, filename: string) => { + const blob = new Blob([content], { type: 'text/calendar;charset=utf-8' }); + const objectUrl = URL.createObjectURL(blob); + + const link = document.createElement('a'); + link.href = objectUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + setTimeout(() => URL.revokeObjectURL(objectUrl), 0); +}; diff --git a/widget/src/AskUxCore.tsx b/widget/src/AskUxCore.tsx index b98d25d1..b5579048 100644 --- a/widget/src/AskUxCore.tsx +++ b/widget/src/AskUxCore.tsx @@ -2184,9 +2184,19 @@ export function AskUxCore({ lang }: { lang: Lang }) { const occluded = vv ? Math.max(0, window.innerHeight - (vv.height + vv.offsetTop)) : 0; + // Host pages can lift the widget off the bottom edge (--ks-aux-lift). + // That clearance eats into the room the panel has, so subtract it or + // the panel overflows the top of short screens. + const widget = document.querySelector('.ks-aux-root'); + const lift = widget + ? parseFloat(getComputedStyle(widget).getPropertyValue('--ks-aux-lift')) + : 0; root.style.setProperty('--ks-aux-vh', `${h}px`); root.style.setProperty('--ks-aux-bottom-offset', `${occluded}px`); - root.style.setProperty('--ks-aux-panel-h', `${Math.max(220, h - 96)}px`); + root.style.setProperty( + '--ks-aux-panel-h', + `${Math.max(220, h - 96 - (lift || 0))}px`, + ); }; setVh(); const vv = window.visualViewport; diff --git a/widget/src/styles.css b/widget/src/styles.css index 8d027253..046f47ed 100644 --- a/widget/src/styles.css +++ b/widget/src/styles.css @@ -2,6 +2,10 @@ position: fixed; right: 24px; bottom: 24px; + /* Extra clearance for host pages that park their own controls in the + bottom-right corner. Margin (not `bottom`) so it composes with the + mobile keyboard offset below without rewriting that calc. */ + margin-bottom: var(--ks-aux-lift, 0px); z-index: 2147483600; font-family: 'Jost-Regular', 'Jost', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; @@ -24,6 +28,13 @@ } } +/* UX Core surfaces (uxcore + bias pages, uxcg + questions, uxcp, uxcat, + uxcore-api) keep their own chrome in the bottom-right corner. `_app.tsx` + sets body.uxcorePage for exactly those routes. */ +body.uxcorePage .ks-aux-root { + --ks-aux-lift: 70px; +} + @keyframes ks-aux-pulse { 0%, 100% { box-shadow: 0 4px 16px rgba(31, 29, 26, 0.16); @@ -1312,7 +1323,10 @@ right: 0; bottom: 60px; /* sits just above the pill */ width: auto; - height: var(--ks-aux-panel-h, calc(100dvh - 96px)); + height: var( + --ks-aux-panel-h, + calc(100dvh - 96px - var(--ks-aux-lift, 0px)) + ); max-height: 640px; border-radius: 14px; }