Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
6a17bdc
library: import source into components/library
MaryWylde Jun 1, 2026
37cfa17
library: namespace public assets under /library
MaryWylde Jun 1, 2026
28f2291
library: add missing dependencies
MaryWylde Jun 1, 2026
bb427e0
library: fix internal import paths
MaryWylde Jun 1, 2026
ee952eb
library: add route
MaryWylde Jun 1, 2026
51992b3
library: gate feature behind flag and wire navigation entry points
MaryWylde Jun 3, 2026
f11ecf1
library: tint global header on library routes
MaryWylde Jun 3, 2026
d94173f
library: replace in-app Header with sticky LibraryToolbar
MaryWylde Jun 3, 2026
1f3e44a
library: scope global styles under .library and self-host fonts
MaryWylde Jun 3, 2026
918730f
library: preserve SVG viewBox to stop icon clipping
MaryWylde Jun 3, 2026
f493ce3
library: shelf management UX — rename, privacy, append, scroll, glide
MaryWylde Jun 3, 2026
831d396
library: persist object reorder, honor order, and surface failures
MaryWylde Jun 3, 2026
ae6f408
library: live sidebar object counts and tag-list load on mount
MaryWylde Jun 3, 2026
9490d6c
library: stabilize card sizing with an always-on tag column
MaryWylde Jun 3, 2026
17db4fd
library: publish tags on create so they survive a refetch
MaryWylde Jun 3, 2026
a30b02a
docs(library): refresh LIBRARY_AGENT.md
MaryWylde Jun 3, 2026
11d4e18
library: cleanup pass — dedupe api, drop dead code, tighten state
MaryWylde Jun 3, 2026
36fc004
library: gate creation by feature flag and warn on duplicate names
MaryWylde Jun 8, 2026
9015dea
library: fix sidebar nav-by-id, tag slug collisions, and description XSS
MaryWylde Jun 8, 2026
d8caeba
Merge pull request #136 from keepsimpleio/feat/library
MaryWylde Jun 8, 2026
5f8e116
library: gate on NEXT_PUBLIC_ENV instead of a dedicated flag
MaryWylde Jun 8, 2026
2386098
Merge pull request #137 from keepsimpleio/feat/library
MaryWylde Jun 8, 2026
1e6e0c9
library: source right panel by ownership and edit before a library ex…
MaryWylde Jun 9, 2026
374057c
library: add share-link selection and shared-with-you view
MaryWylde Jun 10, 2026
a377927
library: give each object its own URL
MaryWylde Jun 10, 2026
3e98ebe
library: add share-link recipient route
MaryWylde Jun 10, 2026
a7b7b40
library: scope Move-To dropdown to the owner's own shelves
MaryWylde Jun 10, 2026
7cac07c
library: source sidebar library id from route param, drop debug effect
MaryWylde Jun 10, 2026
0bbfc7e
library: bulk shelf select/deselect and purge selection on private
MaryWylde Jun 10, 2026
8ccf440
Merge pull request #141 from keepsimpleio/feat/library
MaryWylde Jun 10, 2026
6c9a9aa
library: fix broken icons + replace book/audio placeholders, seat obj…
MaryWylde Jun 10, 2026
5ae15d8
Merge branch 'dev' of github.com-keepsimpleoss:keepsimpleio/KeepSimpl…
manager Jun 11, 2026
7f2958a
library: UI polish — seat objects on shelves, guest-mode gating, cust…
MaryWylde Jun 11, 2026
481bb67
Merge pull request #143 from keepsimpleio/fix/library-ui
MaryWylde Jun 11, 2026
b886517
library: spinner loader + hide native search clear button
MaryWylde Jun 11, 2026
3ae0a5c
feat: mobile UX Core modal polish, swipe nav, dark widget pill, offse…
manager Jun 12, 2026
5131425
fix: UXCG/UX Core mobile modal polish — fullscreen sheet, theme toggl…
manager Jun 13, 2026
b469ea2
fix: gate Copilot host highlight on open panel
manager Jun 15, 2026
3dd7187
fix: unstick UXCG rating row so it scrolls with content
manager Jun 15, 2026
a07c462
feat: gate OffSec use case to dev preview only
manager Jun 15, 2026
10f5619
library: client-side search, autofill, and responsive/mobile UI polish
MaryWylde Jun 15, 2026
bba1db3
Merge pull request #145 from keepsimpleio/feat/uxcore-cybersecurity
MaryWylde Jun 15, 2026
fa6586d
Merge remote-tracking branch 'origin/dev' into fix/library-ui
MaryWylde Jun 16, 2026
e2af7b2
Merge remote-tracking branch 'origin/main' into fix/library-ui
MaryWylde Jun 16, 2026
d5abf9b
Merge pull request #144 from keepsimpleio/fix/library-ui
MaryWylde Jun 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
14 changes: 14 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,20 @@ STRAPI_URL=https://staging-strapi.keepsimple.io
# UX Cat API endpoint — staging instance for development
NEXT_PUBLIC_UXCAT_API=https://staging-uxcat.keepsimple.io/

# ---------- Library autofill (OPTIONAL) ----------
# Google Cloud API key(s) powering book + YouTube autofill in the Library.
# Audio suggestions use the keyless iTunes Search API and need no key.
# Server-only — never prefix with NEXT_PUBLIC_.
# Setup: https://console.cloud.google.com/apis/credentials
#
# GOOGLE_APIS_KEY — needs "Books API" enabled. Powers book title suggestions.
# Without it, book search falls back to the low anonymous quota.
# YOUTUBE_API_KEY — needs "YouTube Data API v3" enabled. Powers video autofill.
# Falls back to GOOGLE_APIS_KEY if unset, so a single key with BOTH APIs
# enabled also works. Without either, video autofill is disabled.
GOOGLE_APIS_KEY=
YOUTUBE_API_KEY=

# ---------- NextAuth ----------
# Generate a random secret with: `openssl rand -base64 32`
NEXTAUTH_SECRET=
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,9 @@ QA_RECON.md
TODO.md
marys-notes.md

# Library feature scratch TODO — local-only, never commit.
LIBRARY_TODO.md

# Playwright MCP cache
.playwright-mcp/

Expand Down
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,8 @@ Key variables:
- `NEXT_PUBLIC_ENV` — `local` / `staging` / `prod`
- `NEXT_PUBLIC_INDEXING` — `on` / `off` (controls GA tracking)
- `NEXT_PUBLIC_DOMAIN` — canonical domain for SEO/OG tags
- `GOOGLE_APIS_KEY` — server-only Google Cloud key with **Books API** enabled; powers Library book suggestions. Optional: book search degrades to the keyless anonymous quota without it.
- `YOUTUBE_API_KEY` — server-only Google Cloud key with **YouTube Data API v3** enabled; powers Library video autofill. Falls back to `GOOGLE_APIS_KEY` if unset, so a single key with both APIs enabled also works. Video autofill is disabled if neither is set. Audio autofill (iTunes Search API) needs no key.

The `next.config.js` loads env from `.env.{APP_ENV}` (e.g., `.env.local`, `.env.staging`, `.env.prod`).

Expand Down
46 changes: 45 additions & 1 deletion next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,23 @@ module.exports = withBundleAnalyzer({
compiler: {
removeConsole: process.env.NODE_ENV === 'prod',
},
sassOptions: {
includePaths: [path.join(__dirname, 'src/styles')],
// Library SCSS modules rely on placeholder selectors (e.g. %text-base)
// that the original app injected globally. Scope that injection to the
// migrated library files only so keepsimple's own SCSS stays untouched.
additionalData: (content, loaderContext) => {
const resourcePath = (loaderContext && loaderContext.resourcePath) || '';
const isLibraryModule =
/[\\/]src[\\/](components|layouts|pages)[\\/]library[\\/]/.test(
resourcePath,
);
if (isLibraryModule) {
return `@use "library/styles.scss" as *;\n${content}`;
}
return content;
},
},
eslint: {
ignoreDuringBuilds: true,
},
Expand All @@ -108,7 +125,34 @@ module.exports = withBundleAnalyzer({
config.module.rules.push({
test: /\.svg$/i,
issuer: /\.[jt]sx?$/,
use: ['@svgr/webpack'],
use: [
{
loader: '@svgr/webpack',
options: {
// SVGO's preset-default strips viewBox, which breaks icons rendered
// at a smaller width/height than their intrinsic size (e.g. a 44x44
// icon shown at 14px clips to its top-left corner instead of
// scaling). Keep the viewBox so downscaled icons render fully.
svgoConfig: {
plugins: [
{
name: 'preset-default',
params: { overrides: { removeViewBox: false } },
},
// preset-default's cleanupIds minifies internal ids to short
// strings (a, b, c…) per file. SVGR inlines every icon into the
// same DOM, so icons that reference their own clipPath/filter/
// gradient via url(#id) (book, video, their shadows, delete,
// edit) end up with colliding ids — url(#a) resolves to whichever
// #a renders first, pointing at the wrong def and rendering blank.
// prefixIds namespaces each file's ids by filename so they stay
// unique across icons.
'prefixIds',
],
},
},
},
],
});
return config;
},
Expand Down
13 changes: 12 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,13 @@
"prepare": "husky install"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2",
"@next/bundle-analyzer": "^16.2.3",
"@svgr/webpack": "^8.1.0",
"axios": "^1.13.4",
"classnames": "2.3.1",
"cookie": "0.6.0",
"cross-env": "7.0.3",
Expand All @@ -43,6 +48,7 @@
"geoip-lite": "1.4.2",
"html2canvas": "^1.4.1",
"isomorphic-dompurify": "^3.12.0",
"js-cookie": "^3.0.5",
"lodash.debounce": "4.0.8",
"lodash.unescape": "4.0.1",
"mixpanel-browser": "^2.65.0",
Expand All @@ -54,8 +60,11 @@
"react-beautiful-dnd": "13.1.1",
"react-confetti": "6.1.0",
"react-confetti-explosion": "2.1.2",
"react-day-picker": "^10.0.0",
"react-dom": "19.0.3",
"react-dropzone": "^15.0.0",
"react-ga4": "1.4.1",
"react-hook-form": "^7.71.1",
"react-icalendar-link": "3.0.2",
"react-intersection-observer": "^9.16.0",
"react-loading-skeleton": "3.4.0",
Expand All @@ -74,7 +83,8 @@
"slick-carousel": "1.8.1",
"topojson-client": "^3.1.0",
"uuid": "8.3.2",
"victory": "36.3.0"
"victory": "36.3.0",
"zod": "^4.3.6"
},
"lint-staged": {
"**/*.{ts,tsx}": [
Expand All @@ -94,6 +104,7 @@
"@types/classnames": "2.2.11",
"@types/d3-geo": "^3.1.0",
"@types/geojson": "^7946.0.16",
"@types/js-cookie": "^3.0.6",
"@types/lodash.debounce": "4.0.7",
"@types/lodash.unescape": "4.0.7",
"@types/node": "^24.5.2",
Expand Down
Binary file added public/assets/library/audio-cover.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/assets/library/book-cover.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
91 changes: 91 additions & 0 deletions public/library/images/icons/all.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/library/images/readmeImages/cover.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/library/images/readmeImages/user.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/library/images/readmeImages/warning.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
37 changes: 37 additions & 0 deletions src/api/library/autofill/fetchCoverFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
const EXT_BY_MIME: Record<string, string> = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
};

const MAX_BYTES = 5 * 1024 * 1024; // matches the cover upload limit

/**
* Pull a provider cover through the local proxy and wrap it as a File so it
* rides the existing ImageDropzone → uploadFile flow. Best-effort: any
* failure returns null and the rest of the autofill still applies.
*/
export const fetchCoverFile = async (
coverUrl: string,
baseName: string,
): Promise<File | null> => {
try {
const res = await fetch(
`/api/library/autofill/cover?url=${encodeURIComponent(coverUrl)}`,
);
if (!res.ok) return null;
const blob = await res.blob();
const ext = EXT_BY_MIME[blob.type];
if (!ext || blob.size === 0 || blob.size > MAX_BYTES) return null;

const safeName =
baseName
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 60) || 'cover';
return new File([blob], `${safeName}.${ext}`, { type: blob.type });
} catch {
return null;
}
};
24 changes: 24 additions & 0 deletions src/api/library/autofill/lookupVideoByUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { IAutofillSuggestion } from '@local-types/library/autofill';

export type VideoLookupResult =
| { status: 'ok'; suggestion: IAutofillSuggestion }
| { status: 'unsupported' }
| { status: 'error' };

export const lookupVideoByUrl = async (
url: string,
): Promise<VideoLookupResult> => {
try {
const res = await fetch(
`/api/library/autofill/video?url=${encodeURIComponent(url)}`,
);
if (res.status === 422) return { status: 'unsupported' };
if (!res.ok) return { status: 'error' };
const body = (await res.json()) as { suggestion?: IAutofillSuggestion };
return body.suggestion
? { status: 'ok', suggestion: body.suggestion }
: { status: 'error' };
} catch {
return { status: 'error' };
}
};
14 changes: 14 additions & 0 deletions src/api/library/autofill/searchAudioSuggestions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { IAutofillSuggestion } from '@local-types/library/autofill';

export const searchAudioSuggestions = async (
query: string,
): Promise<IAutofillSuggestion[]> => {
const res = await fetch(
`/api/library/autofill/audio?q=${encodeURIComponent(query)}`,
);
// Throw on failure so the caller can show "search unavailable" instead of a
// misleading "no matches" when the upstream provider is down.
if (!res.ok) throw new Error('audio_search_failed');
const body = (await res.json()) as { suggestions?: IAutofillSuggestion[] };
return body.suggestions ?? [];
};
14 changes: 14 additions & 0 deletions src/api/library/autofill/searchBookSuggestions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { IAutofillSuggestion } from '@local-types/library/autofill';

export const searchBookSuggestions = async (
query: string,
): Promise<IAutofillSuggestion[]> => {
const res = await fetch(
`/api/library/autofill/book?q=${encodeURIComponent(query)}`,
);
// Throw on failure (e.g. 502 when Google's quota is exhausted) so the caller
// can show "search unavailable" instead of a misleading "no matches".
if (!res.ok) throw new Error('book_search_failed');
const body = (await res.json()) as { suggestions?: IAutofillSuggestion[] };
return body.suggestions ?? [];
};
23 changes: 23 additions & 0 deletions src/api/library/createLibrary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import axiosInstance from '@lib/library/axios';

// Bootstrap a published library for the given user. Returns the new library id,
// or null on failure.
export const createLibrary = async (
userId: number | string,
): Promise<number | null> => {
try {
const { data } = await axiosInstance.post<{ data: { id: number } }>(
'/api/libraries',
{
data: {
user: userId,
publishedAt: new Date().toISOString(),
},
},
);
return data?.data?.id ?? null;
} catch (error) {
console.error('createLibrary failed:', error);
return null;
}
};
38 changes: 38 additions & 0 deletions src/api/library/createShareLink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type {
ICreateShareLinkPayload,
IShareLinkResult,
} from '@local-types/library/shareLink';

import axiosInstance from '@lib/library/axios';

// Pull the token out of whatever shape the backend returns. Strapi custom
// controllers vary between a bare body, a `{ data }` wrapper, and the full
// `{ data: { attributes } }` entity form — so probe each in turn.
// TODO(backend): pin the exact success shape and drop the extra fallbacks.
function readToken(body: unknown): string | null {
if (!body || typeof body !== 'object') return null;
const b = body as Record<string, unknown>;
const data = b.data as Record<string, unknown> | undefined;
const attributes = data?.attributes as Record<string, unknown> | undefined;
const token = b.token ?? data?.token ?? attributes?.token;
return typeof token === 'string' && token.length > 0 ? token : null;
}

// Mint a share link for the ordered object ids. Returns the token, or null on
// failure (the caller surfaces a retry message — the backend 400s on an empty
// selection, a non-public object, or more than 21 objects after expansion).
export const createShareLink = async (
objectIds: number[],
): Promise<IShareLinkResult | null> => {
try {
const payload: ICreateShareLinkPayload = { objectIds };
const { data } = await axiosInstance.post('/api/share-links', {
data: payload,
});
const token = readToken(data);
return token ? { token } : null;
} catch (error) {
console.error('createShareLink failed:', error);
return null;
}
};
17 changes: 17 additions & 0 deletions src/api/library/getLibrariesList.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import axiosInstance from '@lib/library/axios';

import { LIBRARY_CARD_POPULATE } from '@api/library/libraryCardPopulate';

export const getLibrariesList = async <T = unknown>(): Promise<T | null> => {
try {
const { data } = await axiosInstance.get<T>('/api/libraries', {
params: LIBRARY_CARD_POPULATE,
});

return data ?? null;
} catch (e) {
console.error(e);

return null;
}
};
29 changes: 29 additions & 0 deletions src/api/library/getLibrariesPaginated.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { StrapiLibrariesResponse } from '@local-types/library/library';

import axiosInstance from '@lib/library/axios';

import { LIBRARY_CARD_POPULATE } from '@api/library/libraryCardPopulate';

export const getLibrariesPaginated = async (
page = 1,
pageSize = 8,
): Promise<StrapiLibrariesResponse | null> => {
try {
const { data } = await axiosInstance.get<StrapiLibrariesResponse>(
'/api/libraries',
{
params: {
...LIBRARY_CARD_POPULATE,
'pagination[page]': page,
'pagination[pageSize]': pageSize,
},
},
);

return data ?? null;
} catch (e) {
console.error(e);

return null;
}
};
25 changes: 25 additions & 0 deletions src/api/library/getLibraryIdByUsername.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { StrapiLibrariesResponse } from '@local-types/library/library';

import axiosInstance from '@lib/library/axios';

// Resolve a `/library/[username]` slug to a numeric library id. Returns null
// when the username has no library (or the lookup fails).
export const getLibraryIdByUsername = async (
username: string,
): Promise<number | null> => {
try {
const { data } = await axiosInstance.get<StrapiLibrariesResponse>(
'/api/libraries',
{
params: {
'filters[user][username][$eqi]': username,
'pagination[pageSize]': 1,
},
},
);
return data.data?.[0]?.id ?? null;
} catch (error) {
console.error('getLibraryIdByUsername failed:', error);
return null;
}
};
Loading
Loading