diff --git a/.gitignore b/.gitignore index db4fec52..addfb8f1 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/next.config.js b/next.config.js index 6a451e48..e4ca3913 100644 --- a/next.config.js +++ b/next.config.js @@ -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, }, @@ -108,7 +125,25 @@ 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 } }, + }, + ], + }, + }, + }, + ], }); return config; }, diff --git a/package.json b/package.json index f8a7e07c..84794264 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", @@ -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", @@ -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}": [ @@ -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", diff --git a/public/library/images/icons/all.svg b/public/library/images/icons/all.svg new file mode 100644 index 00000000..cb1ce8a0 --- /dev/null +++ b/public/library/images/icons/all.svg @@ -0,0 +1,91 @@ + diff --git a/public/library/images/readmeImages/cover.png b/public/library/images/readmeImages/cover.png new file mode 100644 index 00000000..145cbc60 Binary files /dev/null and b/public/library/images/readmeImages/cover.png differ diff --git a/public/library/images/readmeImages/user.png b/public/library/images/readmeImages/user.png new file mode 100644 index 00000000..94821984 Binary files /dev/null and b/public/library/images/readmeImages/user.png differ diff --git a/public/library/images/readmeImages/warning.png b/public/library/images/readmeImages/warning.png new file mode 100644 index 00000000..4b92da2f Binary files /dev/null and b/public/library/images/readmeImages/warning.png differ diff --git a/src/api/library/createLibrary.ts b/src/api/library/createLibrary.ts new file mode 100644 index 00000000..37c3b847 --- /dev/null +++ b/src/api/library/createLibrary.ts @@ -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 => { + 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; + } +}; diff --git a/src/api/library/getLibrariesList.ts b/src/api/library/getLibrariesList.ts new file mode 100644 index 00000000..4a0af659 --- /dev/null +++ b/src/api/library/getLibrariesList.ts @@ -0,0 +1,17 @@ +import axiosInstance from '@lib/library/axios'; + +import { LIBRARY_CARD_POPULATE } from '@api/library/libraryCardPopulate'; + +export const getLibrariesList = async (): Promise => { + try { + const { data } = await axiosInstance.get('/api/libraries', { + params: LIBRARY_CARD_POPULATE, + }); + + return data ?? null; + } catch (e) { + console.error(e); + + return null; + } +}; diff --git a/src/api/library/getLibrariesPaginated.ts b/src/api/library/getLibrariesPaginated.ts new file mode 100644 index 00000000..58491a9e --- /dev/null +++ b/src/api/library/getLibrariesPaginated.ts @@ -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 => { + try { + const { data } = await axiosInstance.get( + '/api/libraries', + { + params: { + ...LIBRARY_CARD_POPULATE, + 'pagination[page]': page, + 'pagination[pageSize]': pageSize, + }, + }, + ); + + return data ?? null; + } catch (e) { + console.error(e); + + return null; + } +}; diff --git a/src/api/library/getLibraryIdByUsername.ts b/src/api/library/getLibraryIdByUsername.ts new file mode 100644 index 00000000..cfec0e95 --- /dev/null +++ b/src/api/library/getLibraryIdByUsername.ts @@ -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 => { + try { + const { data } = await axiosInstance.get( + '/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; + } +}; diff --git a/src/api/library/getMyLibrary.ts b/src/api/library/getMyLibrary.ts new file mode 100644 index 00000000..e3e61fcd --- /dev/null +++ b/src/api/library/getMyLibrary.ts @@ -0,0 +1,26 @@ +import type { ILibrary } from '@local-types/library/library'; +import type { IStrapiListResponse } from '@local-types/library/strapi'; + +import axiosInstance from '@lib/library/axios'; + +export const getMyLibrary = async ( + userId: number | string, +): Promise => { + try { + const { data } = await axiosInstance.get>( + '/api/libraries', + { + params: { + 'filters[user][id][$eq]': userId, + 'pagination[pageSize]': 1, + 'populate[avatar]': true, + 'populate[libraryDetails]': true, + }, + }, + ); + return data.data[0] ?? null; + } catch (error) { + console.error('getMyLibrary failed:', error); + return null; + } +}; diff --git a/src/api/library/getSingleLibrary.ts b/src/api/library/getSingleLibrary.ts new file mode 100644 index 00000000..deda6a0f --- /dev/null +++ b/src/api/library/getSingleLibrary.ts @@ -0,0 +1,34 @@ +import type { StrapiSingleLibraryResponse } from '@local-types/library/library'; + +import axiosInstance from '@lib/library/axios'; + +export const getSingleLibrary = async ( + id: number | string, +): Promise => { + try { + const { data } = await axiosInstance.get( + `/api/libraries/${id}`, + { + params: { + 'populate[avatar]': true, + 'populate[user]': true, + 'populate[libraryDetails]': true, + 'populate[singleShelves][populate][objects][populate][coverImage]': true, + 'populate[singleShelves][populate][objects][populate][tags]': true, + // The schema's `config.list.defaultSortBy` only sorts the admin + // content-manager — the public REST API defaults to id order. Sort + // the populated relations explicitly so persisted `order` is honored + // (the client also sorts as a fallback for older Strapi populate). + 'populate[singleShelves][sort][0]': 'order:asc', + 'populate[singleShelves][populate][objects][sort][0]': 'order:asc', + }, + }, + ); + + return data ?? null; + } catch (e) { + console.error(e); + + return null; + } +}; diff --git a/src/api/library/libraryCardPopulate.ts b/src/api/library/libraryCardPopulate.ts new file mode 100644 index 00000000..ad8a9118 --- /dev/null +++ b/src/api/library/libraryCardPopulate.ts @@ -0,0 +1,8 @@ +// Populate the relations the home/sidebar library cards need: avatar (image), +// user (for the `/library/[username]` URL), and shelves + their objects (so the +// per-type counts reflect object totals, not shelf totals). +export const LIBRARY_CARD_POPULATE = { + 'populate[avatar]': true, + 'populate[user]': true, + 'populate[singleShelves][populate][objects]': true, +} as const; diff --git a/src/api/library/object/createObject.ts b/src/api/library/object/createObject.ts new file mode 100644 index 00000000..60757c7f --- /dev/null +++ b/src/api/library/object/createObject.ts @@ -0,0 +1,25 @@ +import type { + ICreateObjectPayload, + IObjectSingleResponse, +} from '@local-types/library/object'; + +import axiosInstance from '@lib/library/axios'; + +export const createObject = async ( + payload: ICreateObjectPayload, +): Promise => { + // No `populate` query params here: Strapi users-permissions runs a + // relation-level permission check on each populated field, and the + // Authenticated role typically lacks find/findOne on shelf/tag/upload, + // which surfaces as a 403 on the whole POST. AddObjectModal backfills + // coverImage + tags from local data; the next library refetch returns + // the canonical populated shape. + const { data } = await axiosInstance.post( + '/api/objects', + { + data: payload, + }, + ); + + return data; +}; diff --git a/src/api/library/object/deleteObject.ts b/src/api/library/object/deleteObject.ts new file mode 100644 index 00000000..99cb8a68 --- /dev/null +++ b/src/api/library/object/deleteObject.ts @@ -0,0 +1,13 @@ +import type { IObjectSingleResponse } from '@local-types/library/object'; + +import axiosInstance from '@lib/library/axios'; + +export const deleteObject = async ( + id: number, +): Promise => { + const { data } = await axiosInstance.delete( + `/api/objects/${id}`, + ); + + return data; +}; diff --git a/src/api/library/object/reorderObjects.ts b/src/api/library/object/reorderObjects.ts new file mode 100644 index 00000000..10079c1f --- /dev/null +++ b/src/api/library/object/reorderObjects.ts @@ -0,0 +1,12 @@ +import type { IReorderObjectsPayload } from '@local-types/library/object'; + +import axiosInstance from '@lib/library/axios'; + +// Unlike create/update, the reorder endpoint expects a RAW body (no `{ data }` +// wrapper): `{ shelfId, objects: [{ id, order }] }`. Backend default-sorts +// objects by `order` ASC on subsequent reads. +export const reorderObjects = async ( + payload: IReorderObjectsPayload, +): Promise => { + await axiosInstance.post('/api/objects/reorder', payload); +}; diff --git a/src/api/library/object/updateObject.ts b/src/api/library/object/updateObject.ts new file mode 100644 index 00000000..1e394bde --- /dev/null +++ b/src/api/library/object/updateObject.ts @@ -0,0 +1,22 @@ +import type { + IObjectSingleResponse, + IUpdateObjectPayload, +} from '@local-types/library/object'; + +import axiosInstance from '@lib/library/axios'; + +export const updateObject = async ( + id: number, + payload: IUpdateObjectPayload, +): Promise => { + // See createObject.ts — populate params on write endpoints trigger a + // relation-permission 403 for the Authenticated role. Skip them here too. + const { data } = await axiosInstance.put( + `/api/objects/${id}`, + { + data: payload, + }, + ); + + return data; +}; diff --git a/src/api/library/shelf/createShelf.ts b/src/api/library/shelf/createShelf.ts new file mode 100644 index 00000000..e25f27c4 --- /dev/null +++ b/src/api/library/shelf/createShelf.ts @@ -0,0 +1,27 @@ +import type { + ICreateShelfPayload, + IShelfSingleResponse, +} from '@local-types/library/shelf'; + +import axiosInstance from '@lib/library/axios'; + +export const createShelf = async ( + payload: ICreateShelfPayload, +): Promise => { + const { data } = await axiosInstance.post( + '/api/single-shelves', + { + data: { + visibility: 'public', + order: 0, + objects: [], + // single-shelf has draftAndPublish: true. Direct queries filter by + // publication state, so publish explicitly to keep the new shelf visible. + publishedAt: new Date().toISOString(), + ...payload, + }, + }, + ); + + return data; +}; diff --git a/src/api/library/shelf/deleteShelf.ts b/src/api/library/shelf/deleteShelf.ts new file mode 100644 index 00000000..13b2f924 --- /dev/null +++ b/src/api/library/shelf/deleteShelf.ts @@ -0,0 +1,6 @@ +import axiosInstance from '@lib/library/axios'; + +// Backend cascades — deletes every object on the shelf too. See docs/shelf-api.md. +export const deleteShelf = async (id: number): Promise => { + await axiosInstance.delete(`/api/single-shelves/${id}`); +}; diff --git a/src/api/library/shelf/getShelvesList.ts b/src/api/library/shelf/getShelvesList.ts new file mode 100644 index 00000000..7f293dd4 --- /dev/null +++ b/src/api/library/shelf/getShelvesList.ts @@ -0,0 +1,27 @@ +import type { ObjectType } from '@local-types/library/object'; +import type { IShelf } from '@local-types/library/shelf'; +import type { IStrapiListResponse } from '@local-types/library/strapi'; + +import axiosInstance from '@lib/library/axios'; + +export type IShelvesListResponse = IStrapiListResponse; + +export const getShelvesList = async ( + filterType?: ObjectType, +): Promise => { + try { + const params = filterType + ? { 'filters[type][$eq]': filterType, sort: 'order:asc' } + : { sort: 'order:asc' }; + const { data } = await axiosInstance.get( + '/api/single-shelves', + { + params, + }, + ); + return data; + } catch (error) { + console.error(error); + return null; + } +}; diff --git a/src/api/library/shelf/reorderShelves.ts b/src/api/library/shelf/reorderShelves.ts new file mode 100644 index 00000000..17ad03af --- /dev/null +++ b/src/api/library/shelf/reorderShelves.ts @@ -0,0 +1,12 @@ +import type { IReorderShelvesPayload } from '@local-types/library/shelf'; + +import axiosInstance from '@lib/library/axios'; + +// Unlike create/update, the reorder endpoint expects a RAW body that is a bare +// array (no `{ data }` wrapper): `[{ id, order }]`. Backend default-sorts +// shelves by `order` ASC on subsequent reads. +export const reorderShelves = async ( + payload: IReorderShelvesPayload, +): Promise => { + await axiosInstance.post('/api/single-shelves/reorder', payload); +}; diff --git a/src/api/library/shelf/updateShelf.ts b/src/api/library/shelf/updateShelf.ts new file mode 100644 index 00000000..066494de --- /dev/null +++ b/src/api/library/shelf/updateShelf.ts @@ -0,0 +1,20 @@ +import type { + IShelfSingleResponse, + IUpdateShelfPayload, +} from '@local-types/library/shelf'; + +import axiosInstance from '@lib/library/axios'; + +export const updateShelf = async ( + id: number, + payload: IUpdateShelfPayload, +): Promise => { + const { data } = await axiosInstance.put( + `/api/single-shelves/${id}`, + { + data: payload, + }, + ); + + return data; +}; diff --git a/src/api/library/tag/createTag.ts b/src/api/library/tag/createTag.ts new file mode 100644 index 00000000..dbb5dd9e --- /dev/null +++ b/src/api/library/tag/createTag.ts @@ -0,0 +1,30 @@ +import { ITag } from '@local-types/library/tag'; + +import axiosInstance from '@lib/library/axios'; + +export interface CreateTagRequest { + name: string; + slug: string; + user: string; + color: string; + description?: string; +} + +export interface CreateTagResponse { + data: ITag; + meta: Record; +} + +export const createTag = async ( + tagData: CreateTagRequest, +): Promise => { + // The tag content-type has draftAndPublish enabled, so a plain POST creates + // an unpublished draft: it comes back in this response (and shows in the UI) + // but the default GET /api/tags only returns published entries, so it + // vanishes on refresh. Stamp publishedAt to publish it immediately. + const { data } = await axiosInstance.post('/api/tags', { + data: { ...tagData, publishedAt: new Date().toISOString() }, + }); + + return data; +}; diff --git a/src/api/library/tag/deleteTag.ts b/src/api/library/tag/deleteTag.ts new file mode 100644 index 00000000..8a18fbb9 --- /dev/null +++ b/src/api/library/tag/deleteTag.ts @@ -0,0 +1,7 @@ +import axiosInstance from '@lib/library/axios'; + +export const deleteTag = async (tagId: number | string) => { + const { data } = await axiosInstance.delete(`/api/tags/${tagId}`); + + return data; +}; diff --git a/src/api/library/tag/getTagsList.ts b/src/api/library/tag/getTagsList.ts new file mode 100644 index 00000000..956a4718 --- /dev/null +++ b/src/api/library/tag/getTagsList.ts @@ -0,0 +1,19 @@ +import { ITag } from '@local-types/library/tag'; + +import axiosInstance from '@lib/library/axios'; + +export interface GetTagsListResponse { + data: ITag[]; +} + +export const getTagsList = async (): Promise => { + try { + const { data } = await axiosInstance.get('/api/tags'); + + return data; + } catch (error) { + console.error(error); + + return { data: [] }; + } +}; diff --git a/src/api/library/tag/updateTag.ts b/src/api/library/tag/updateTag.ts new file mode 100644 index 00000000..e6762f98 --- /dev/null +++ b/src/api/library/tag/updateTag.ts @@ -0,0 +1,20 @@ +import axiosInstance from '@lib/library/axios'; + +export interface UpdateTagRequest { + name: string; + slug: string; + user: string; + color: string; + description?: string; +} + +export const updateTag = async ( + tagId: number | string, + tagData: UpdateTagRequest, +) => { + const { data } = await axiosInstance.put(`/api/tags/${tagId}`, { + data: tagData, + }); + + return data; +}; diff --git a/src/api/library/updateLibrary.ts b/src/api/library/updateLibrary.ts new file mode 100644 index 00000000..e71283bf --- /dev/null +++ b/src/api/library/updateLibrary.ts @@ -0,0 +1,20 @@ +import type { + ILibrarySingleResponse, + IUpdateLibraryPayload, +} from '@local-types/library/library'; + +import axiosInstance from '@lib/library/axios'; + +export const updateLibrary = async ( + id: number, + payload: IUpdateLibraryPayload, +): Promise => { + const { data } = await axiosInstance.put( + `/api/libraries/${id}`, + { + data: payload, + }, + ); + + return data; +}; diff --git a/src/api/library/upload/uploadFile.ts b/src/api/library/upload/uploadFile.ts new file mode 100644 index 00000000..b664f69b --- /dev/null +++ b/src/api/library/upload/uploadFile.ts @@ -0,0 +1,18 @@ +import type { IUploadedFile } from '@local-types/library/media'; + +import axiosInstance from '@lib/library/axios'; + +export const uploadFile = async (file: File): Promise => { + const formData = new FormData(); + formData.append('files', file); + + const { data } = await axiosInstance.post( + '/api/upload', + formData, + { + headers: { 'Content-Type': 'multipart/form-data' }, + }, + ); + + return data[0]; +}; diff --git a/src/api/library/user/getUserInfo.ts b/src/api/library/user/getUserInfo.ts new file mode 100644 index 00000000..5710a0b6 --- /dev/null +++ b/src/api/library/user/getUserInfo.ts @@ -0,0 +1,15 @@ +import type { IUser } from '@local-types/library/user'; + +import axiosInstance from '@lib/library/axios'; + +export const getUserInfo = async (): Promise => { + try { + const { data } = await axiosInstance.get('/api/users/me'); + + return data ?? null; + } catch (e) { + console.error(e); + + return null; + } +}; diff --git a/src/api/library/user/updateMe.ts b/src/api/library/user/updateMe.ts new file mode 100644 index 00000000..4f20cb8c --- /dev/null +++ b/src/api/library/user/updateMe.ts @@ -0,0 +1,17 @@ +import type { + IUpdateMePayload, + IUpdateMeResponse, +} from '@local-types/library/user'; + +import axiosInstance from '@lib/library/axios'; + +export const updateMe = async ( + payload: IUpdateMePayload, +): Promise => { + // Note the singular `user` — see docs/user-api.md §1. + const { data } = await axiosInstance.put( + '/api/user/me', + payload, + ); + return data; +}; diff --git a/src/assets/icons/library/images/avatar.png b/src/assets/icons/library/images/avatar.png new file mode 100644 index 00000000..a61c9e4a Binary files /dev/null and b/src/assets/icons/library/images/avatar.png differ diff --git a/src/assets/icons/library/images/shelfBackground.png b/src/assets/icons/library/images/shelfBackground.png new file mode 100644 index 00000000..b2ff17c7 Binary files /dev/null and b/src/assets/icons/library/images/shelfBackground.png differ diff --git a/src/assets/icons/library/svg/arrow.svg b/src/assets/icons/library/svg/arrow.svg new file mode 100644 index 00000000..630e8eca --- /dev/null +++ b/src/assets/icons/library/svg/arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/library/svg/articles.svg b/src/assets/icons/library/svg/articles.svg new file mode 100644 index 00000000..c56e1ec4 --- /dev/null +++ b/src/assets/icons/library/svg/articles.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/library/svg/audio.svg b/src/assets/icons/library/svg/audio.svg new file mode 100644 index 00000000..1eff0059 --- /dev/null +++ b/src/assets/icons/library/svg/audio.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/library/svg/avatar.svg b/src/assets/icons/library/svg/avatar.svg new file mode 100644 index 00000000..e9a093e1 --- /dev/null +++ b/src/assets/icons/library/svg/avatar.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/icons/library/svg/book-shadow.svg b/src/assets/icons/library/svg/book-shadow.svg new file mode 100644 index 00000000..9ea658cd --- /dev/null +++ b/src/assets/icons/library/svg/book-shadow.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/library/svg/book.svg b/src/assets/icons/library/svg/book.svg new file mode 100644 index 00000000..15ec7c45 --- /dev/null +++ b/src/assets/icons/library/svg/book.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/assets/icons/library/svg/calendar.svg b/src/assets/icons/library/svg/calendar.svg new file mode 100644 index 00000000..e1801180 --- /dev/null +++ b/src/assets/icons/library/svg/calendar.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/library/svg/check.svg b/src/assets/icons/library/svg/check.svg new file mode 100644 index 00000000..ec525091 --- /dev/null +++ b/src/assets/icons/library/svg/check.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/library/svg/close.svg b/src/assets/icons/library/svg/close.svg new file mode 100644 index 00000000..d84a9437 --- /dev/null +++ b/src/assets/icons/library/svg/close.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/library/svg/company.svg b/src/assets/icons/library/svg/company.svg new file mode 100644 index 00000000..0da5a43a --- /dev/null +++ b/src/assets/icons/library/svg/company.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/library/svg/copy.svg b/src/assets/icons/library/svg/copy.svg new file mode 100644 index 00000000..4d255117 --- /dev/null +++ b/src/assets/icons/library/svg/copy.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/library/svg/delete.svg b/src/assets/icons/library/svg/delete.svg new file mode 100644 index 00000000..cb19a480 --- /dev/null +++ b/src/assets/icons/library/svg/delete.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/assets/icons/library/svg/discord.svg b/src/assets/icons/library/svg/discord.svg new file mode 100644 index 00000000..c03e8e12 --- /dev/null +++ b/src/assets/icons/library/svg/discord.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/src/assets/icons/library/svg/dots-vertical.svg b/src/assets/icons/library/svg/dots-vertical.svg new file mode 100644 index 00000000..c2ac57ad --- /dev/null +++ b/src/assets/icons/library/svg/dots-vertical.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/icons/library/svg/edit.svg b/src/assets/icons/library/svg/edit.svg new file mode 100644 index 00000000..fb795e43 --- /dev/null +++ b/src/assets/icons/library/svg/edit.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/assets/icons/library/svg/error.svg b/src/assets/icons/library/svg/error.svg new file mode 100644 index 00000000..5f901985 --- /dev/null +++ b/src/assets/icons/library/svg/error.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/icons/library/svg/google.svg b/src/assets/icons/library/svg/google.svg new file mode 100644 index 00000000..bc22fa4d --- /dev/null +++ b/src/assets/icons/library/svg/google.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/icons/library/svg/hamburger.svg b/src/assets/icons/library/svg/hamburger.svg new file mode 100644 index 00000000..18addab6 --- /dev/null +++ b/src/assets/icons/library/svg/hamburger.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/library/svg/index.ts b/src/assets/icons/library/svg/index.ts new file mode 100644 index 00000000..02a419c7 --- /dev/null +++ b/src/assets/icons/library/svg/index.ts @@ -0,0 +1,57 @@ +import ArrowIcon from './arrow.svg'; +import ArticlesIcon from './articles.svg'; +import AudioIcon from './audio.svg'; +import AvatarIcon from './avatar.svg'; +import BookIcon from './book.svg'; +import BookShadowIcon from './book-shadow.svg'; +import CalendarIcon from './calendar.svg'; +import CheckIcon from './check.svg'; +import CloseIcon from './close.svg'; +import CompanyIcon from './company.svg'; +import CopyIcon from './copy.svg'; +import DeleteIcon from './delete.svg'; +import DotsVerticalIcon from './dots-vertical.svg'; +import EditIcon from './edit.svg'; +import ErrorIcon from './error.svg'; +import HamburgerIcon from './hamburger.svg'; +import InfoIcon from './info.svg'; +import LibraryIcon from './library.svg'; +import LogoIcon from './logo.svg'; +import PlusIcon from './plus.svg'; +import SearchIcon from './search.svg'; +import SettingsIcon from './settings.svg'; +import ShareIcon from './share.svg'; +import ToolsIcon from './tools.svg'; +import UxcoreIcon from './uxcore.svg'; +import VideoIcon from './video.svg'; +import VideoShadowIcon from './video-shadow.svg'; + +export { + ArrowIcon, + ArticlesIcon, + AudioIcon, + AvatarIcon, + BookIcon, + BookShadowIcon, + CalendarIcon, + CheckIcon, + CloseIcon, + CompanyIcon, + CopyIcon, + DeleteIcon, + DotsVerticalIcon, + EditIcon, + ErrorIcon, + HamburgerIcon, + InfoIcon, + LibraryIcon, + LogoIcon, + PlusIcon, + SearchIcon, + SettingsIcon, + ShareIcon, + ToolsIcon, + UxcoreIcon, + VideoIcon, + VideoShadowIcon, +}; diff --git a/src/assets/icons/library/svg/info.svg b/src/assets/icons/library/svg/info.svg new file mode 100644 index 00000000..a8c3805d --- /dev/null +++ b/src/assets/icons/library/svg/info.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/library/svg/library.svg b/src/assets/icons/library/svg/library.svg new file mode 100644 index 00000000..f8e5036e --- /dev/null +++ b/src/assets/icons/library/svg/library.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/library/svg/logo.svg b/src/assets/icons/library/svg/logo.svg new file mode 100644 index 00000000..d2f5b521 --- /dev/null +++ b/src/assets/icons/library/svg/logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/library/svg/plus.svg b/src/assets/icons/library/svg/plus.svg new file mode 100644 index 00000000..a641bd40 --- /dev/null +++ b/src/assets/icons/library/svg/plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/library/svg/search.svg b/src/assets/icons/library/svg/search.svg new file mode 100644 index 00000000..3aa56f74 --- /dev/null +++ b/src/assets/icons/library/svg/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/library/svg/settings.svg b/src/assets/icons/library/svg/settings.svg new file mode 100644 index 00000000..d02f7179 --- /dev/null +++ b/src/assets/icons/library/svg/settings.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/library/svg/share.svg b/src/assets/icons/library/svg/share.svg new file mode 100644 index 00000000..ff332303 --- /dev/null +++ b/src/assets/icons/library/svg/share.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/library/svg/tools.svg b/src/assets/icons/library/svg/tools.svg new file mode 100644 index 00000000..23023eb7 --- /dev/null +++ b/src/assets/icons/library/svg/tools.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/icons/library/svg/ux-core.svg b/src/assets/icons/library/svg/ux-core.svg new file mode 100644 index 00000000..d6d54f34 --- /dev/null +++ b/src/assets/icons/library/svg/ux-core.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/assets/icons/library/svg/uxcore.svg b/src/assets/icons/library/svg/uxcore.svg new file mode 100644 index 00000000..d6d54f34 --- /dev/null +++ b/src/assets/icons/library/svg/uxcore.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/assets/icons/library/svg/video-shadow.svg b/src/assets/icons/library/svg/video-shadow.svg new file mode 100644 index 00000000..a1720235 --- /dev/null +++ b/src/assets/icons/library/svg/video-shadow.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/library/svg/video.svg b/src/assets/icons/library/svg/video.svg new file mode 100644 index 00000000..332d5b46 --- /dev/null +++ b/src/assets/icons/library/svg/video.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/components/Context/library/AuthContext.tsx b/src/components/Context/library/AuthContext.tsx new file mode 100644 index 00000000..9899d08b --- /dev/null +++ b/src/components/Context/library/AuthContext.tsx @@ -0,0 +1,112 @@ +import { useRouter } from 'next/router'; +import { signOut, useSession } from 'next-auth/react'; +import React, { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useState, +} from 'react'; + +import { IUser } from '@local-types/library/user'; + +import { getCookie, removeCookie } from '@lib/library/cookie'; + +import { logout } from '@api/auth'; + +type AuthContextValue = { + accountData: IUser | null; + setAccountData: (value: IUser | null) => void; + token: string | null; + setToken: (value: string | null) => void; + handleProviderSignIn: (provider: string) => void; + handleLogout: () => void; +}; + +const defaultValues: AuthContextValue = { + accountData: null, + setAccountData: () => {}, + token: null, + setToken: () => {}, + handleProviderSignIn: () => {}, + handleLogout: () => {}, +}; + +export const AuthContext = createContext(defaultValues); + +type AuthProviderProps = { + children: ReactNode; +}; + +export const AuthProvider = ({ children }: AuthProviderProps) => { + const router = useRouter(); + const { data: session } = useSession(); + + const [token, setToken] = useState(null); + const [accountData, setAccountData] = useState(null); + + const handleProviderSignIn = async (provider: string) => { + // Store current page as return URL before login + if (typeof window !== 'undefined') { + const currentPath = window.location.pathname + window.location.search; + // Don't store auth or dashboard pages as return URLs + if ( + !currentPath.includes('/auth') && + !currentPath.includes('/dashboard') + ) { + localStorage.setItem('returnUrl', currentPath); + } + } + + if (session && accountData === null) { + await signOut({ redirect: false }); + + sessionStorage.clear(); + removeCookie('next-auth.session-token'); + + setTimeout(() => { + router.replace(`/auth?provider=${provider}`); + }, 100); + } else { + router.push(`/auth?provider=${provider}`); + } + }; + + const handleLogout = useCallback(() => { + logout(); + removeCookie('accessToken'); + setToken(null); + setAccountData(null); + }, []); + + useEffect(() => { + const accessToken = getCookie('accessToken') as string | undefined; + setToken(accessToken || null); + }, [session]); + + return ( + + {children} + + ); +}; + +export function useAuth(): AuthContextValue { + const context = useContext(AuthContext); + + if (!context) { + throw new Error('Auth component must be used within AuthProvider'); + } + + return context; +} diff --git a/src/components/Context/library/DashboardContext.tsx b/src/components/Context/library/DashboardContext.tsx new file mode 100644 index 00000000..b1ce9945 --- /dev/null +++ b/src/components/Context/library/DashboardContext.tsx @@ -0,0 +1,54 @@ +import { + createContext, + type ReactNode, + useContext, + useMemo, + useState, +} from 'react'; + +import { ITag } from '@local-types/library/tag'; + +interface DashboardContextValue { + tags: ITag[]; + setTags: (tags: ITag[]) => void; +} + +const DashboardContext = createContext( + undefined, +); + +interface DashboardProviderProps { + children: ReactNode; + initialTags?: ITag[]; +} + +export function DashboardProvider({ + children, + initialTags = [], +}: DashboardProviderProps) { + const [tags, setTags] = useState(initialTags); + + const value = useMemo( + () => ({ + tags, + setTags, + }), + [tags], + ); + + return ( + + {children} + + ); +} + +export function useDashboard(): DashboardContextValue { + const context = useContext(DashboardContext); + + if (!context) { + throw new Error('useDashboard must be used within a DashboardProvider'); + } + + return context; +} diff --git a/src/components/Context/library/GlobalStateContext.tsx b/src/components/Context/library/GlobalStateContext.tsx new file mode 100644 index 00000000..1fc4c7ae --- /dev/null +++ b/src/components/Context/library/GlobalStateContext.tsx @@ -0,0 +1,157 @@ +import { useSession } from 'next-auth/react'; +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; + +import type { + StrapiLibrariesResponse, + StrapiSingleShelfEntry, +} from '@local-types/library/library'; +import type { IUser } from '@local-types/library/user'; + +import { getCookie } from '@lib/library/cookie'; + +import { getLibrariesList } from '@api/library/getLibrariesList'; +import { getUserInfo } from '@api/library/user/getUserInfo'; + +import { useAuth } from '@components/Context/library/AuthContext'; + +interface GlobalStateContextValue { + isGuestMode: boolean; + isSidebarOpen: boolean; + toggleGuestMode: () => void; + toggleSidebar: () => void; + user: IUser | null; + isUserLoading: boolean; + refetchUser: () => Promise; + libraries: StrapiLibrariesResponse | null; + isLibrariesLoading: boolean; + refetchLibraries: () => Promise; + /** + * Shelves of the library currently being viewed — populated by + * `LibraryTemplate` so the Header can render the Jump-to nav without + * having to fetch its own copy. + */ + currentShelves: StrapiSingleShelfEntry[]; + setCurrentShelves: (shelves: StrapiSingleShelfEntry[]) => void; +} + +const GlobalStateContext = createContext( + undefined, +); + +export function GlobalStateProvider({ children }: { children: ReactNode }) { + const { data: session } = useSession(); + const { accountData, setAccountData, token } = useAuth(); + + const [isGuestMode, setIsGuestMode] = useState(false); + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + const [isUserLoading, setIsUserLoading] = useState(false); + const [libraries, setLibraries] = useState( + null, + ); + const [isLibrariesLoading, setIsLibrariesLoading] = useState(false); + const [currentShelves, setCurrentShelves] = useState< + StrapiSingleShelfEntry[] + >([]); + const didAttemptUserLoad = useRef(false); + const didAttemptLibrariesLoad = useRef(false); + + const refetchUser = useCallback(async () => { + setIsUserLoading(true); + try { + const data = await getUserInfo(); + setAccountData(data); + } finally { + setIsUserLoading(false); + } + }, [setAccountData]); + + const refetchLibraries = useCallback(async () => { + setIsLibrariesLoading(true); + try { + const data = await getLibrariesList(); + setLibraries(data); + } finally { + setIsLibrariesLoading(false); + } + }, []); + + useEffect(() => { + const hasToken = Boolean(getCookie('accessToken')); + if (!hasToken) { + didAttemptUserLoad.current = false; + return; + } + if (accountData || didAttemptUserLoad.current) { + return; + } + didAttemptUserLoad.current = true; + void refetchUser(); + }, [accountData, session, refetchUser]); + + useEffect(() => { + if (!token) { + didAttemptLibrariesLoad.current = false; + setLibraries(null); + return; + } + if (didAttemptLibrariesLoad.current) { + return; + } + didAttemptLibrariesLoad.current = true; + void refetchLibraries(); + }, [token, session, refetchLibraries]); + + const value = useMemo( + () => ({ + isGuestMode, + isSidebarOpen, + toggleGuestMode: () => setIsGuestMode(prev => !prev), + toggleSidebar: () => setIsSidebarOpen(prev => !prev), + user: accountData, + isUserLoading, + refetchUser, + libraries, + isLibrariesLoading, + refetchLibraries, + currentShelves, + setCurrentShelves, + }), + [ + isGuestMode, + isSidebarOpen, + accountData, + isUserLoading, + refetchUser, + libraries, + isLibrariesLoading, + refetchLibraries, + currentShelves, + setCurrentShelves, + ], + ); + + return ( + + {children} + + ); +} + +export function useGlobalState(): GlobalStateContextValue { + const context = useContext(GlobalStateContext); + + if (!context) { + throw new Error('useGlobalState must be used within a GlobalStateProvider'); + } + + return context; +} diff --git a/src/components/Header/Header.module.scss b/src/components/Header/Header.module.scss index f54ab9f4..42568ddf 100644 --- a/src/components/Header/Header.module.scss +++ b/src/components/Header/Header.module.scss @@ -338,6 +338,32 @@ } } +// Library has no dark theme: force the warm light header on every library +// page, overriding the dark-theme background and text colors. +.header.library, +.header.library.darkTheme { + background-color: #f8f1e5 !important; + + & .actions { + background: #f8f1e5; + + .toggleLanguage .languageTitle { + color: #252626; + } + } + + & .burgerMenu div { + background-color: #5d6063; + } + + & .closeButton { + &:after, + &:before { + background-color: #5d6063; + } + } +} + @media (max-width: 960px) { .header.darkTheme { & .actions { diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index e2cef50c..c9c48145 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -117,6 +117,7 @@ const Header: FC = () => { className={cn(styles.header, { [styles.darkTheme]: isDarkTheme, [styles.openedSidebar]: isOpenedSidebar, + [styles.library]: router.pathname.startsWith('/library'), })} >
diff --git a/src/components/Navbar/Navbar.tsx b/src/components/Navbar/Navbar.tsx index 9d1ff044..2b7180df 100644 --- a/src/components/Navbar/Navbar.tsx +++ b/src/components/Navbar/Navbar.tsx @@ -2,6 +2,8 @@ import cn from 'classnames'; import { useRouter } from 'next/router'; import React, { FC, useContext } from 'react'; +import { isLibraryEnabled } from '@constants/library/common'; + import type { TRouter } from '@local-types/global'; import useGlobals from '@hooks/useGlobals'; @@ -11,6 +13,7 @@ import navbar from '@data/navbar'; import ArticlesDarkIcon from '@icons/ArticlesDarkIcon'; import ArticlesIcon from '@icons/ArticlesIcon'; +import LibraryIcon from '@icons/library/svg/library.svg'; import AiAtlasIcon from '@icons/navbar/ai-atlas.svg'; import AiAtlasDarkIcon from '@icons/navbar/ai-atlas-dark.svg'; import LongevityIcon from '@icons/navbar/longevity.svg'; @@ -36,7 +39,7 @@ const Navbar: FC = ({ handleToggleSidebar, handleClick }) => { const { isDarkTheme, isOpenedSidebar } = useGlobals()[1]; const { accountData } = useContext(GlobalContext); - const { articles, contributorsTxt, tools, longevity, aiAtlas } = + const { articles, contributorsTxt, tools, library, longevity, aiAtlas } = navbar[locale]; const normalizePath = (p: string) => { @@ -72,6 +75,14 @@ const Navbar: FC = ({ handleToggleSidebar, handleClick }) => { activeMatch: '/tools', exact: true, }, + { + name: library, + path: '/library', + logo: , + target: '', + id: 'library', + activeMatch: '/library', + }, { name: aiAtlas, path: '/ai-atlas', @@ -100,43 +111,45 @@ const Navbar: FC = ({ handleToggleSidebar, handleClick }) => { [styles.authorized]: !!accountData, })} > - {routes.map( - ({ name, path, target, logo, id, activeMatch, exact }, index) => { - const match = activeMatch ?? path; - const currentPath = normalizePath(router.asPath); - const matchPath = normalizePath(match); - - const isActive = - matchPath === '/' - ? currentPath === '/' - : exact - ? currentPath === matchPath - : currentPath.startsWith(matchPath); - - return ( - { - if (target === '_blank') return; - e.preventDefault(); - if (isSmallScreen) handleToggleSidebar(); - handleClick(e, path); - }} - className={cn(styles.url, { - [styles.active]: isActive, - [styles.uxcoreIcon]: id === 'uxcore', - [styles.companyManagementIcon]: id === 'companyManagement', - [styles.articlesIcon]: id === 'articles', - [styles.ruUrl]: locale === 'ru', - })} - > - {logo} {name} - - ); - }, - )} + {routes + .filter(route => route.id !== 'library' || isLibraryEnabled()) + .map( + ({ name, path, target, logo, id, activeMatch, exact }, index) => { + const match = activeMatch ?? path; + const currentPath = normalizePath(router.asPath); + const matchPath = normalizePath(match); + + const isActive = + matchPath === '/' + ? currentPath === '/' + : exact + ? currentPath === matchPath + : currentPath.startsWith(matchPath); + + return ( + { + if (target === '_blank') return; + e.preventDefault(); + if (isSmallScreen) handleToggleSidebar(); + handleClick(e, path); + }} + className={cn(styles.url, { + [styles.active]: isActive, + [styles.uxcoreIcon]: id === 'uxcore', + [styles.companyManagementIcon]: id === 'companyManagement', + [styles.articlesIcon]: id === 'articles', + [styles.ruUrl]: locale === 'ru', + })} + > + {logo} {name} + + ); + }, + )} = ({ @@ -62,6 +68,11 @@ const UserProfile: FC = ({ handleOpenSettings?.(); }, [handleOpenSettings]); + const handleMyLibrary = useCallback(() => { + setIsDropdownOpen(false); + router.push(`/library/${username}`); + }, [router, username]); + useEffect(() => { if (hideDropdown) setIsDropdownOpen(false); }, [hideDropdown]); @@ -136,6 +147,18 @@ const UserProfile: FC = ({ )} {isDropdownOpen && isAccessTokenExist && (
e.stopPropagation()}> + {username && ( +
+ + {t.myLibrary} +
+ )}
**This feature was ported from a standalone app.** "library" (a.k.a. "keepSimple Library") began life as its own Next.js **App Router** project. It now lives _inside_ KeepSimpleOSS, which is **Pages Router**. The original App Router / `@/*` / Storybook conventions **no longer apply** — this file documents the feature as it actually exists in this repo today. When this file conflicts with the root `AGENTS.md` / `CLAUDE.md`, the **host repo rules win**. + +## Feature overview + +Browse user libraries of books / videos / music. Auth is NextAuth (Google + Discord) bridged to a Strapi backend. UI follows **atomic design** (atoms → molecules → organisms) with **SCSS Modules**. The feature is gated behind the `isLibraryEnabled` flag (`@constants/library/common`). + +## Where the library lives (namespacing map) + +The port kept the library's structure but namespaced every folder under the host repo's existing aliases with a `library/` segment. There is **no `@/*` alias** — use the host aliases below. + +| Concern | Original (App Router) | Now (in KeepSimpleOSS) | +| ----------------------- | -------------------------- | ---------------------------------------------------------------------------------------- | +| Routes | `src/app/library/...` | `src/pages/library/index.tsx`, `src/pages/library/[username].tsx` | +| Components | `src/components/{atoms,…}` | `@components/library/{atoms,molecules,organisms}/*` | +| Page-level layouts | `templates/` | `@layouts/library/*` (`Home` → `HomeTemplate`, `Library`) | +| Context (state) | `src/context/` | `@components/Context/library/*` | +| HTTP wrappers | `src/api/` | `@api/library/*` (`strapi.ts` + `library/object/shelf/tag/upload/user/`) | +| Axios / cookie adapters | `src/libraries/` | `@lib/library/*` (`axios`, `cookie`) | +| Utilities | `src/utils/` | `@utils/library/*` (`resolveStrapiUrl`, `color`, `mapStrapiLibraries`, `schema/`, `seo`) | +| Shared types | `src/types/` | `@local-types/library/*` | +| Constants | `src/constants/` | `@constants/library/*` (`common`, `seo.config`, `tags`) | +| SVGs / images | `src/assets/svg/` | `@icons/library/svg`, `@icons/library/images` | + +Public assets are namespaced under `/library` (see commit `namespace public assets under /library`). + +## Commands (host repo — yarn only) + +| Command | What it does | +| ------------------- | ----------------------------------------------- | +| `yarn dev` | Next dev server at http://localhost:**3005** | +| `yarn build` | Production Next build (`APP_ENV=prod`) | +| `yarn tsc --noEmit` | Typecheck (there is no `typecheck` script) | +| `yarn test:e2e` | Playwright (Chromium) E2E — the only test layer | + +There is **no Storybook** and **no `yarn new:*` generator** in this repo — those were dropped in the port. Hand-create component folders following the shape below, or copy an existing sibling. Husky + lint-staged run ESLint `--fix` + Prettier on pre-commit. + +## Conventions (rules) + +- **Routing is Pages Router.** All library routes live in `src/pages/library/`. **Never** use App Router patterns (`src/app/`, `'use client'`, `next/navigation`, server components). Fetch with `getServerSideProps` / `getStaticProps`; for client-only code use `useEffect` or `dynamic(() => import(...), { ssr: false })`. +- **Component file shape.** Each component is its own folder: `Name.tsx`, `Name.types.ts`, `Name.module.scss`, `index.tsx`. No `.stories.tsx`. +- **Exports.** **Named** exports for components: `export function Button(...)`. The barrel `index.tsx` re-exports both the component and its types: + ```ts + export * from './Button'; + export * from './Button.types'; + ``` +- **Props typing.** Define a `NameProps` interface in `Name.types.ts`. Use TS `enum`s for closed variant sets (`ButtonType`, `ButtonSize`, …). +- **Naming.** PascalCase for component dirs/files/types. camelCase for hooks (`useThing.ts`) and utilities. +- **Styling.** SCSS Modules only, composed with `classnames`. The App Router auto-prepend of `styles.scss` is **gone** — global SCSS is imported only in `src/pages/_app.tsx` (host rule). New styles follow the `keepsimple-style` skill; don't invent colors/spacing. +- **State.** React Context only — `AuthContext`, `GlobalStateContext`, `DashboardContext` under `@components/Context/library/`. No Redux/Zustand/TanStack Query. Prefer extending `GlobalStateContext` over adding a new provider. +- **HTTP.** All Strapi calls go through `axiosInstance` (`@lib/library/axios`) — it attaches the `accessToken` cookie as a Bearer. Wrap calls in `@api/library/*`; never `fetch` Strapi directly (exception: the OAuth callback). +- **SVGs.** Import as React components from the `@icons/library/svg` barrel (SVGR is configured). Don't use `next/image` for SVGs. +- **SEO & semantic HTML.** First-class at every level of the atomic hierarchy. Raster images go through `next/image` (allowlist hosts in `next.config.js` `images.remotePatterns`), never raw ``. Use semantic elements, ordered headings, meaningful `alt`, accurate ARIA. The page also wraps content in the host `SeoGenerator` component. +- **Imports.** Use the namespaced host aliases above for anything cross-folder; relative imports only within the same component folder (`./Button.module.scss`). ESLint `simple-import-sort` enforces ordering — run `eslint --fix`. +- **Commits.** Conventional Commits (`feat:`, `fix:`, `chore:`, `docs:`, `style:`, `refactor:`). This feature's commits are prefixed `library:`. + +## Patterns to follow + +- **Molecule:** `@components/library/molecules/BookCard` — `export function`, `classnames`, `next/image`, types in `BookCard.types.ts`, barrel re-exports both. +- **Organism:** `@components/library/organisms/Shelf`, `Sidebar`, `AddObjectModal`. +- **Page-level layout:** `@layouts/library/Home` (`HomeTemplate`), `@layouts/library/Library`. +- **Page:** `src/pages/library/index.tsx` — `GetServerSideProps`, gated by `isLibraryEnabled`, wraps `AuthProvider` + `GlobalStateProvider` + `SeoGenerator` + a `*Template`. +- **API call:** `@api/library/strapi.ts` and `@api/library/{object,shelf,tag,upload,user}/*` — async, `axiosInstance`, returns `data`. +- **Context provider:** `@components/Context/library/GlobalStateContext` — `useMemo`'d value, hook throws if used outside provider. + +## Things to avoid + +- App Router patterns: `src/app/`, `'use client'`, `next/navigation`, server components, the `@/*` alias. +- Bypassing `axiosInstance` for Strapi — header injection lives there. +- Adding a new Context provider when a flag on `GlobalStateContext` would do. +- New global CSS files — extend `src/styles/` (imported only in `_app.tsx`). +- Widening with `any` or scattering `@ts-expect-error`. +- New state managers (Redux/Zustand/TanStack Query) or styling systems (Tailwind, CSS-in-JS). +- Inventing colors/spacing/fonts — read the `keepsimple-style` skill first. + +## Definition of done + +1. `yarn tsc --noEmit` passes. +2. ESLint clean / `eslint --fix` applied (import order included). +3. Prettier clean. +4. `yarn build` succeeds. +5. No new `any` / `@ts-ignore` / `@ts-expect-error`. +6. No unused imports or dead code. +7. Commit uses Conventional Commits with the `library:` prefix. diff --git a/src/components/library/atoms/Avatar/Avatar.module.scss b/src/components/library/atoms/Avatar/Avatar.module.scss new file mode 100644 index 00000000..8f5fb435 --- /dev/null +++ b/src/components/library/atoms/Avatar/Avatar.module.scss @@ -0,0 +1,17 @@ +.avatar { + width: 100%; + height: 100%; + overflow: hidden; + border-radius: 8px; + + svg, + img { + width: 100%; + height: 100%; + } +} + +.story { + width: 48px; + height: 48px; +} diff --git a/src/components/library/atoms/Avatar/Avatar.tsx b/src/components/library/atoms/Avatar/Avatar.tsx new file mode 100644 index 00000000..7887c24e --- /dev/null +++ b/src/components/library/atoms/Avatar/Avatar.tsx @@ -0,0 +1,23 @@ +import classNames from 'classnames'; +import Image from 'next/image'; +import React, { JSX } from 'react'; + +import { AvatarIcon } from '@icons/library/svg'; + +import type { AvatarProps } from './Avatar.types'; + +import styles from './Avatar.module.scss'; + +export function Avatar(props: AvatarProps): JSX.Element { + const { className, url } = props; + + return ( +
+ {url ? ( + Picture of the author + ) : ( + + )} +
+ ); +} diff --git a/src/components/library/atoms/Avatar/Avatar.types.ts b/src/components/library/atoms/Avatar/Avatar.types.ts new file mode 100644 index 00000000..4a997991 --- /dev/null +++ b/src/components/library/atoms/Avatar/Avatar.types.ts @@ -0,0 +1,6 @@ +import { StaticImageData } from 'next/image'; + +export interface AvatarProps { + className?: string; + url?: string | StaticImageData; +} diff --git a/src/components/library/atoms/Avatar/index.tsx b/src/components/library/atoms/Avatar/index.tsx new file mode 100644 index 00000000..b3460a60 --- /dev/null +++ b/src/components/library/atoms/Avatar/index.tsx @@ -0,0 +1,2 @@ +export * from './Avatar'; +export * from './Avatar.types'; diff --git a/src/components/library/atoms/Icon/Icon.module.scss b/src/components/library/atoms/Icon/Icon.module.scss new file mode 100644 index 00000000..2ababc65 --- /dev/null +++ b/src/components/library/atoms/Icon/Icon.module.scss @@ -0,0 +1,3 @@ +.icon { + vertical-align: middle; +} diff --git a/src/components/library/atoms/Icon/Icon.tsx b/src/components/library/atoms/Icon/Icon.tsx new file mode 100644 index 00000000..c77d10d2 --- /dev/null +++ b/src/components/library/atoms/Icon/Icon.tsx @@ -0,0 +1,23 @@ +import type { JSX } from 'react'; +import classNames from 'classnames'; + +import { IconProps } from './Icon.types'; + +import styles from './Icon.module.scss'; + +export function Icon(props: IconProps): JSX.Element { + const { width = 40, height = 40, color = 'currentColor', className, name } = props; + + return ( + + + + ); +} diff --git a/src/components/library/atoms/Icon/Icon.types.ts b/src/components/library/atoms/Icon/Icon.types.ts new file mode 100644 index 00000000..ca36c3f1 --- /dev/null +++ b/src/components/library/atoms/Icon/Icon.types.ts @@ -0,0 +1,27 @@ +export enum IconName { + Edit = 'edit', + Telegram = 'telegram', + Close = 'close', + Info = 'info', + Settings = 'settings', + Book = 'book', + Audio = 'audio', + Video = 'video', + Logo = 'logo', + VerticalLine = 'vertical-line', + TextLogo = 'header', + Arrow = 'arrow', + Search = 'search', + Avatar = 'avatar', + Hamburger = 'hamburger', + Plus = 'plus', + Copy = 'copy', +} + +export interface IconProps { + name: IconName; + color?: string; + width?: number; + height?: number; + className?: string; +} diff --git a/src/components/library/atoms/Icon/index.tsx b/src/components/library/atoms/Icon/index.tsx new file mode 100644 index 00000000..3f8266e5 --- /dev/null +++ b/src/components/library/atoms/Icon/index.tsx @@ -0,0 +1,2 @@ +export * from './Icon'; +export * from './Icon.types'; diff --git a/src/components/library/atoms/Loader/Loader.module.scss b/src/components/library/atoms/Loader/Loader.module.scss new file mode 100644 index 00000000..1d129732 --- /dev/null +++ b/src/components/library/atoms/Loader/Loader.module.scss @@ -0,0 +1,46 @@ +.wrapper { + position: absolute; + top: 0; + left: 0; + z-index: 100; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: var(--white-transparent-400); +} + +.loader { + width: 48px; + height: 48px; + border: 3px solid var(--brown); + border-radius: 50%; + display: inline-block; + position: relative; + box-sizing: border-box; + animation: rotation 1s linear infinite; + + &::after { + content: ''; + box-sizing: border-box; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: 56px; + height: 56px; + border-radius: 50%; + border: 3px solid; + border-color: var(--black) transparent; + } +} + +@keyframes rotation { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} diff --git a/src/components/library/atoms/Loader/Loader.tsx b/src/components/library/atoms/Loader/Loader.tsx new file mode 100644 index 00000000..4ac7bb85 --- /dev/null +++ b/src/components/library/atoms/Loader/Loader.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +import styles from './Loader.module.scss'; + +export function Loader() { + return ( +
+ +
+ ); +} diff --git a/src/components/library/atoms/Loader/Loader.types.ts b/src/components/library/atoms/Loader/Loader.types.ts new file mode 100644 index 00000000..e4c2e4c9 --- /dev/null +++ b/src/components/library/atoms/Loader/Loader.types.ts @@ -0,0 +1,4 @@ +export interface LoaderProps { + className?: string; + title?: string; +} diff --git a/src/components/library/atoms/Loader/index.tsx b/src/components/library/atoms/Loader/index.tsx new file mode 100644 index 00000000..4ae22966 --- /dev/null +++ b/src/components/library/atoms/Loader/index.tsx @@ -0,0 +1,2 @@ +export * from './Loader'; +export * from './Loader.types'; diff --git a/src/components/library/atoms/Text/Text.module.scss b/src/components/library/atoms/Text/Text.module.scss new file mode 100644 index 00000000..26940336 --- /dev/null +++ b/src/components/library/atoms/Text/Text.module.scss @@ -0,0 +1,51 @@ +.title-primary { + @extend %title-primary; +} + +.title-secondary-bold { + @extend %title-secondary-bold; +} + +.subtitle-secondary-semi { + @extend %subtitle-secondary-semi; +} + +.subtitle-secondary-bold { + @extend %subtitle-secondary-bold; +} + +.subtitle-secondary-alt { + @extend %subtitle-secondary-alt; +} + +.text-base-bold { + @extend %text-base-bold; +} + +.text-base { + @extend %text-base; +} + +.text-regular { + @extend %text-regular; +} + +.text-base-semibold { + @extend %text-base-semibold; +} + +.text-quaternary { + @extend %text-quaternary; +} + +.text-small { + @extend %text-small; +} + +.text-tin { + @extend %text-tiny; +} + +.base { + color: var(--green-100); +} diff --git a/src/components/library/atoms/Text/Text.tsx b/src/components/library/atoms/Text/Text.tsx new file mode 100644 index 00000000..8485e58f --- /dev/null +++ b/src/components/library/atoms/Text/Text.tsx @@ -0,0 +1,23 @@ +import classNames from 'classnames'; +import React, { JSX } from 'react'; + +import { TagType, TextProps, TypographyVariant } from './Text.types'; + +import styles from './Text.module.scss'; + +export function Text(props: TextProps): JSX.Element { + const { + children, + tag = TagType.P, + className, + variant = TypographyVariant.TextBase, + id, + } = props; + const Tag = tag as keyof JSX.IntrinsicElements; + + return ( + + {children} + + ); +} diff --git a/src/components/library/atoms/Text/Text.types.ts b/src/components/library/atoms/Text/Text.types.ts new file mode 100644 index 00000000..bbdf8d6d --- /dev/null +++ b/src/components/library/atoms/Text/Text.types.ts @@ -0,0 +1,35 @@ +import { ReactNode } from 'react'; + +export enum TagType { + Span = 'span', + P = 'p', + H1 = 'h1', + H2 = 'h2', + H3 = 'h3', + H4 = 'h4', + H5 = 'h5', + H6 = 'h6', +} + +export enum TypographyVariant { + TitlePrimary = 'title-primary', + TitleSecondaryBold = 'title-secondary-bold', + SubtitleSecondarySemi = 'subtitle-secondary-semi', + SubtitleSecondaryBold = 'subtitle-secondary-bold', + SubtitleSecondaryAlt = 'subtitle-secondary-alt', + TextBaseBold = 'text-base-bold', + TextBase = 'text-base', + TextBaseSemibold = 'text-base-semibold', + TextRegular = 'text-regular', + TextQuaternary = 'text-quaternary', + TextSmall = 'text-small', + TextTiny = 'text-tiny', +} + +export interface TextProps { + children: ReactNode; + variant?: TypographyVariant; + tag?: TagType; + className?: string; + id?: string; +} diff --git a/src/components/library/atoms/Text/index.tsx b/src/components/library/atoms/Text/index.tsx new file mode 100644 index 00000000..f45c2c8c --- /dev/null +++ b/src/components/library/atoms/Text/index.tsx @@ -0,0 +1,2 @@ +export * from './Text'; +export * from './Text.types'; diff --git a/src/components/library/atoms/Toggle/Toggle.module.scss b/src/components/library/atoms/Toggle/Toggle.module.scss new file mode 100644 index 00000000..244be181 --- /dev/null +++ b/src/components/library/atoms/Toggle/Toggle.module.scss @@ -0,0 +1,48 @@ +.wrapper { + display: flex; + align-items: center; + justify-content: center; +} + +.input { + width: 0; + height: 0; + visibility: hidden; +} + +.label { + width: 44px; + height: 24px; + display: block; + background: var(--gray); + border-radius: 60px; + position: relative; + cursor: pointer; + transition: 0.5s; + + &::after { + content: ''; + width: 20px; + height: 20px; + background: var(--white); + position: absolute; + border-radius: 70px; + top: 2px; + left: 2px; + transition: 0.3s; + } + + &.disabled { + cursor: default; + opacity: 0.6; + } +} + +.input:checked + .label { + background: var(--brown); + + &::after { + left: calc(100% - 2px); + transform: translateX(-100%); + } +} diff --git a/src/components/library/atoms/Toggle/Toggle.tsx b/src/components/library/atoms/Toggle/Toggle.tsx new file mode 100644 index 00000000..27174102 --- /dev/null +++ b/src/components/library/atoms/Toggle/Toggle.tsx @@ -0,0 +1,33 @@ +import React, { JSX, useId } from 'react'; +import classNames from 'classnames'; + +import type { ToggleProps } from './Toggle.types'; + +import styles from './Toggle.module.scss'; + +export function Toggle(props: ToggleProps): JSX.Element { + const { className, checked, ariaLabel, disabled, onChange } = props; + const uniqueId = `toggle-${useId()}`; + + return ( +
+ + +
+ ); +} diff --git a/src/components/library/atoms/Toggle/Toggle.types.ts b/src/components/library/atoms/Toggle/Toggle.types.ts new file mode 100644 index 00000000..2fe3363c --- /dev/null +++ b/src/components/library/atoms/Toggle/Toggle.types.ts @@ -0,0 +1,7 @@ +export interface ToggleProps { + checked: boolean; + ariaLabel: string; + disabled?: boolean; + className?: string; + onChange: () => void; +} diff --git a/src/components/library/atoms/Toggle/index.tsx b/src/components/library/atoms/Toggle/index.tsx new file mode 100644 index 00000000..befd8b8c --- /dev/null +++ b/src/components/library/atoms/Toggle/index.tsx @@ -0,0 +1,2 @@ +export * from './Toggle'; +export * from './Toggle.types'; diff --git a/src/components/library/atoms/Tooltip/Tooltip.module.scss b/src/components/library/atoms/Tooltip/Tooltip.module.scss new file mode 100644 index 00000000..4d618113 --- /dev/null +++ b/src/components/library/atoms/Tooltip/Tooltip.module.scss @@ -0,0 +1,15 @@ +.wrapper { + color: var(--gray-darker) !important; + background: var(--white) !important; + box-shadow: var(--tooltip-shadow); + max-width: 300px; + text-align: center; + padding: 12px 14px !important; + font-size: 16px !important; + border-radius: 8px !important; + @extend %text-base; +} + +.story { + color: var(--green-100); +} diff --git a/src/components/library/atoms/Tooltip/Tooltip.tsx b/src/components/library/atoms/Tooltip/Tooltip.tsx new file mode 100644 index 00000000..afcaf575 --- /dev/null +++ b/src/components/library/atoms/Tooltip/Tooltip.tsx @@ -0,0 +1,34 @@ +import React, { JSX } from 'react'; +import { Tooltip as ReactTooltip } from 'react-tooltip'; +import classNames from 'classnames'; + +import 'react-tooltip/dist/react-tooltip.css'; + +import type { TooltipProps } from './Tooltip.types'; + +import styles from './Tooltip.module.scss'; + +export function Tooltip(props: TooltipProps): JSX.Element { + const { + place = 'bottom', + children, + className, + tooltipContent, + arrowClassName, + wrapperClassName, + } = props; + const generatedId = `tooltip-${React.useId().replace(/:/g, '-')}`; + + return ( +
+
{children}
+ +
+ ); +} diff --git a/src/components/library/atoms/Tooltip/Tooltip.types.ts b/src/components/library/atoms/Tooltip/Tooltip.types.ts new file mode 100644 index 00000000..93df5358 --- /dev/null +++ b/src/components/library/atoms/Tooltip/Tooltip.types.ts @@ -0,0 +1,11 @@ +import { ReactNode } from 'react'; +import { PlacesType } from 'react-tooltip'; + +export interface TooltipProps { + className?: string; + children: ReactNode; + place?: PlacesType; + arrowClassName?: string; + wrapperClassName?: string; + tooltipContent: string; +} diff --git a/src/components/library/atoms/Tooltip/index.tsx b/src/components/library/atoms/Tooltip/index.tsx new file mode 100644 index 00000000..90ca8d95 --- /dev/null +++ b/src/components/library/atoms/Tooltip/index.tsx @@ -0,0 +1,2 @@ +export * from './Tooltip'; +export * from './Tooltip.types'; diff --git a/src/components/library/molecules/AboutLibraryModal/AboutLibraryModal.module.scss b/src/components/library/molecules/AboutLibraryModal/AboutLibraryModal.module.scss new file mode 100644 index 00000000..1844656c --- /dev/null +++ b/src/components/library/molecules/AboutLibraryModal/AboutLibraryModal.module.scss @@ -0,0 +1,35 @@ +.modal { + max-width: 518px !important; + + .wrapper { + color: var(--gray-darkest); + padding: 32px; + border-color: var(--brown-border); + border-style: solid; + border-width: 1px 0 1px 0; + + .text { + margin-bottom: 14px; + + a { + text-decoration: none; + color: var(--gray-darkest); + + &:hover { + text-decoration: underline; + } + } + } + } + + .footer { + padding: 24px 32px; + display: flex; + align-items: center; + justify-content: flex-end; + + .close { + max-width: 70px; + } + } +} diff --git a/src/components/library/molecules/AboutLibraryModal/AboutLibraryModal.tsx b/src/components/library/molecules/AboutLibraryModal/AboutLibraryModal.tsx new file mode 100644 index 00000000..6754999c --- /dev/null +++ b/src/components/library/molecules/AboutLibraryModal/AboutLibraryModal.tsx @@ -0,0 +1,51 @@ +import React, { JSX } from 'react'; + +import { Text, TypographyVariant } from '@components/library/atoms/Text'; + +import { Button, ButtonSize,ButtonType } from '../Button'; +import { Modal, useModalClose } from '../Modal'; +import type { AboutLibraryModalProps } from './AboutLibraryModal.types'; + +import styles from './AboutLibraryModal.module.scss'; + +export function AboutLibraryModal(props: AboutLibraryModalProps): JSX.Element { + const { onClose } = props; + const { closeRef, close } = useModalClose(onClose); + + return ( + +
+
+
+ + ); +} diff --git a/src/components/library/molecules/AboutLibraryModal/AboutLibraryModal.types.ts b/src/components/library/molecules/AboutLibraryModal/AboutLibraryModal.types.ts new file mode 100644 index 00000000..bbebd7dc --- /dev/null +++ b/src/components/library/molecules/AboutLibraryModal/AboutLibraryModal.types.ts @@ -0,0 +1,3 @@ +export interface AboutLibraryModalProps { + onClose: () => void; +} diff --git a/src/components/library/molecules/AboutLibraryModal/index.tsx b/src/components/library/molecules/AboutLibraryModal/index.tsx new file mode 100644 index 00000000..34987f12 --- /dev/null +++ b/src/components/library/molecules/AboutLibraryModal/index.tsx @@ -0,0 +1,2 @@ +export * from './AboutLibraryModal'; +export * from './AboutLibraryModal.types'; diff --git a/src/components/library/molecules/AddShelfModal/AddShelfModal.module.scss b/src/components/library/molecules/AddShelfModal/AddShelfModal.module.scss new file mode 100644 index 00000000..a2046795 --- /dev/null +++ b/src/components/library/molecules/AddShelfModal/AddShelfModal.module.scss @@ -0,0 +1,69 @@ +.modal { + width: 100% !important; + max-width: 456px !important; + + .wrapper { + padding: 32px; + border-top: 1px solid var(--brown-border); + position: relative; + } + + .field { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 24px; + } + + .label { + color: var(--gray-darkest); + } + + .content { + gap: 16px; + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 4px; + + .item { + background: var(--off-white); + flex: 1; + padding: 12px 4px; + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + min-height: 82px; + flex-direction: column; + border-radius: 4px; + cursor: pointer; + border: 1px solid transparent; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease, + background-color 0.2s ease; + + &:hover { + border-color: var(--brown-border); + } + + &.active { + border-color: var(--brown); + background: var(--white); + } + } + } + + .footer { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + margin-top: 40px; + + button { + width: auto; + } + } +} diff --git a/src/components/library/molecules/AddShelfModal/AddShelfModal.tsx b/src/components/library/molecules/AddShelfModal/AddShelfModal.tsx new file mode 100644 index 00000000..12cc7364 --- /dev/null +++ b/src/components/library/molecules/AddShelfModal/AddShelfModal.tsx @@ -0,0 +1,103 @@ +import classNames from 'classnames'; +import React, { JSX, useState } from 'react'; + +import { shelfCardData } from '@constants/library/common'; + +import { Loader } from '@components/library/atoms/Loader'; +import { Text, TypographyVariant } from '@components/library/atoms/Text'; + +import { Button, ButtonSize, ButtonType } from '../Button'; +import { Input } from '../Input'; +import { Modal, useModalClose } from '../Modal'; +import type { AddShelfModalProps, ShelfType } from './AddShelfModal.types'; + +import styles from './AddShelfModal.module.scss'; + +// Matches the single-shelf `name` constraint (`maxLength: 50`) in the backend schema. +const SHELF_NAME_MAX_LENGTH = 50; + +export function AddShelfModal(props: AddShelfModalProps): JSX.Element { + const { onClose, onAddShelf } = props; + const { closeRef, close } = useModalClose(onClose); + const [activeItem, setActiveItem] = useState('books'); + const [name, setName] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + const trimmedName = name.trim(); + const canSubmit = trimmedName.length > 0 && !isSubmitting; + + const handleAddShelf = async () => { + if (!canSubmit) return; + setIsSubmitting(true); + try { + await onAddShelf(activeItem, trimmedName); + } finally { + setIsSubmitting(false); + } + }; + + return ( + +
+ {isSubmitting && } +
+ + Shelf name + + setName(e.target.value)} + placeholder="My shelf" + placeholderColor="#9E9E9E" + ariaLabel="Shelf name" + maxLength={SHELF_NAME_MAX_LENGTH} + /> +
+ +
+ {shelfCardData.map(item => { + return ( +
setActiveItem(item.key)} + > + + {item.label} +
+ ); + })} +
+ +
+
+
+
+ ); +} diff --git a/src/components/library/molecules/AddShelfModal/AddShelfModal.types.ts b/src/components/library/molecules/AddShelfModal/AddShelfModal.types.ts new file mode 100644 index 00000000..66f07433 --- /dev/null +++ b/src/components/library/molecules/AddShelfModal/AddShelfModal.types.ts @@ -0,0 +1,6 @@ +export type ShelfType = 'books' | 'videos' | 'audios'; + +export interface AddShelfModalProps { + onClose: () => void; + onAddShelf: (type: ShelfType, name: string) => void | Promise; +} diff --git a/src/components/library/molecules/AddShelfModal/index.tsx b/src/components/library/molecules/AddShelfModal/index.tsx new file mode 100644 index 00000000..d5a10cb5 --- /dev/null +++ b/src/components/library/molecules/AddShelfModal/index.tsx @@ -0,0 +1,2 @@ +export * from './AddShelfModal'; +export * from './AddShelfModal.types'; diff --git a/src/components/library/molecules/AudioCard/AudioCard.module.scss b/src/components/library/molecules/AudioCard/AudioCard.module.scss new file mode 100644 index 00000000..5988b283 --- /dev/null +++ b/src/components/library/molecules/AudioCard/AudioCard.module.scss @@ -0,0 +1,63 @@ +.row { + display: inline-flex; + flex-direction: row; + align-items: stretch; + gap: 10px; + // Fixed container so every audio object occupies the same cell whether or not + // it has tags (the tag column is always reserved below). + width: 256px; + height: 194px; +} + +.card { + position: relative; + // Fill the cell minus the reserved tag column + gap so the cover size is + // identical with or without tags. + flex: 1; + min-width: 0; + height: 100%; + cursor: pointer; + background: transparent; + border: none; + padding: 0; + outline: none; + + &:focus-visible { + box-shadow: 0 0 0 2px var(--focus-ring); + border-radius: 2px; + } +} + +.cover { + position: absolute; + inset: 0; + overflow: hidden; +} + +.coverImage { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.coverPlaceholder { + width: 100%; + height: 100%; + background: var(--white); +} + +.tags { + display: flex; + flex-direction: column; + gap: 5px; + width: 12px; + padding-top: 4px; +} + +.tagDot { + width: 12px; + height: 12px; + border-radius: 2px; + display: block; +} diff --git a/src/components/library/molecules/AudioCard/AudioCard.tsx b/src/components/library/molecules/AudioCard/AudioCard.tsx new file mode 100644 index 00000000..e63adb84 --- /dev/null +++ b/src/components/library/molecules/AudioCard/AudioCard.tsx @@ -0,0 +1,70 @@ +import { resolveStrapiUrl } from '@utils/library/resolveStrapiUrl'; +import classNames from 'classnames'; +import Image from 'next/image'; +import React, { JSX } from 'react'; + +import type { AudioCardProps } from './AudioCard.types'; + +import styles from './AudioCard.module.scss'; + +export function AudioCard({ + object, + onClick, + className, +}: AudioCardProps): JSX.Element { + const { attributes } = object; + const coverUrl = resolveStrapiUrl( + attributes.coverImage?.data?.attributes.url, + ); + const tags = attributes.tags?.data ?? []; + const title = attributes.title; + + const handleActivate = () => onClick?.(object); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleActivate(); + } + }; + + return ( +
+
+
+ {coverUrl ? ( + {title} + ) : ( +
+ )} +
+
+ + {/* Always render the tag column (even when empty) so the card keeps a + consistent width whether or not the object has tags. */} +
+ {tags.map(tag => ( + + ))} +
+
+ ); +} diff --git a/src/components/library/molecules/AudioCard/AudioCard.types.ts b/src/components/library/molecules/AudioCard/AudioCard.types.ts new file mode 100644 index 00000000..bd764e94 --- /dev/null +++ b/src/components/library/molecules/AudioCard/AudioCard.types.ts @@ -0,0 +1,7 @@ +import type { IObject } from '@local-types/library/object'; + +export interface AudioCardProps { + object: IObject; + onClick?: (object: IObject) => void; + className?: string; +} diff --git a/src/components/library/molecules/AudioCard/index.tsx b/src/components/library/molecules/AudioCard/index.tsx new file mode 100644 index 00000000..dede84f8 --- /dev/null +++ b/src/components/library/molecules/AudioCard/index.tsx @@ -0,0 +1,2 @@ +export * from './AudioCard'; +export * from './AudioCard.types'; diff --git a/src/components/library/molecules/BookCard/BookCard.module.scss b/src/components/library/molecules/BookCard/BookCard.module.scss new file mode 100644 index 00000000..9ddaf57e --- /dev/null +++ b/src/components/library/molecules/BookCard/BookCard.module.scss @@ -0,0 +1,86 @@ +.row { + display: inline-flex; + flex-direction: row; + align-items: stretch; + gap: 10px; + // Fixed container so every book occupies the same cell whether or not it has + // tags (the tag column is always reserved below). + width: 206px; + height: 208px; +} + +.card { + position: relative; + width: 180px; + height: 208px; + flex-shrink: 0; + cursor: pointer; + background: transparent; + border: none; + padding: 0; + outline: none; + + &:focus-visible { + box-shadow: 0 0 0 2px var(--focus-ring); + border-radius: 2px; + } +} + +.cover { + position: absolute; + top: 0; + left: 33px; + width: 146px; + height: 206px; + border-radius: 1.44px; + overflow: hidden; + // Paint the gradient straight onto the cover box so the card shows a filled + // spine immediately — both for cover-less objects and while next/image is + // still fetching/optimizing a freshly uploaded cover (which otherwise left a + // blank gap on add). + background: var(--gradient-book-placeholder); +} + +.coverImage { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.coverPlaceholder { + width: 100%; + height: 100%; + background: var(--gradient-book-placeholder); +} + +.alpha { + position: absolute; + inset: 0; + background: var(--surface-overlay); + pointer-events: none; +} + +.shadow { + position: absolute; + top: 0; + left: 0; + width: 180px; + height: 208px; + pointer-events: none; +} + +.tags { + display: flex; + flex-direction: column; + gap: 5px; + width: 12px; + padding-top: 4px; +} + +.tagDot { + width: 12px; + height: 12px; + border-radius: 2px; + display: block; +} diff --git a/src/components/library/molecules/BookCard/BookCard.tsx b/src/components/library/molecules/BookCard/BookCard.tsx new file mode 100644 index 00000000..9e0933bb --- /dev/null +++ b/src/components/library/molecules/BookCard/BookCard.tsx @@ -0,0 +1,74 @@ +import { resolveStrapiUrl } from '@utils/library/resolveStrapiUrl'; +import classNames from 'classnames'; +import Image from 'next/image'; +import React, { JSX } from 'react'; + +import { BookShadowIcon } from '@icons/library/svg'; + +import type { BookCardProps } from './BookCard.types'; + +import styles from './BookCard.module.scss'; + +export function BookCard({ + object, + onClick, + className, +}: BookCardProps): JSX.Element { + const { attributes } = object; + const coverUrl = resolveStrapiUrl( + attributes.coverImage?.data?.attributes.url, + ); + const tags = attributes.tags?.data ?? []; + const title = attributes.title; + + const handleActivate = () => onClick?.(object); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleActivate(); + } + }; + + return ( +
+
+
+ {coverUrl ? ( + {attributes.title} + ) : ( +
+ )} +
+
+ +
+ + {/* Always render the tag column (even when empty) so the card keeps a + consistent width whether or not the object has tags. */} +
+ {tags.map(tag => ( + + ))} +
+
+ ); +} diff --git a/src/components/library/molecules/BookCard/BookCard.types.ts b/src/components/library/molecules/BookCard/BookCard.types.ts new file mode 100644 index 00000000..772db75e --- /dev/null +++ b/src/components/library/molecules/BookCard/BookCard.types.ts @@ -0,0 +1,7 @@ +import type { IObject } from '@local-types/library/object'; + +export interface BookCardProps { + object: IObject; + onClick?: (object: IObject) => void; + className?: string; +} diff --git a/src/components/library/molecules/BookCard/index.tsx b/src/components/library/molecules/BookCard/index.tsx new file mode 100644 index 00000000..0fac764b --- /dev/null +++ b/src/components/library/molecules/BookCard/index.tsx @@ -0,0 +1,2 @@ +export * from './BookCard'; +export * from './BookCard.types'; diff --git a/src/components/library/molecules/Button/Button.module.scss b/src/components/library/molecules/Button/Button.module.scss new file mode 100644 index 00000000..15c06118 --- /dev/null +++ b/src/components/library/molecules/Button/Button.module.scss @@ -0,0 +1,73 @@ +.button { + gap: 8px; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + border-radius: 10px; + transition: all 0.3s ease-in; + @extend %text-base-semibold; + + &:disabled { + cursor: default; + opacity: 0.5; + } + + &.default { + padding: 2px 8px; + } + + &.wide { + width: 100%; + text-align: center; + padding: 2px 16px; + } + + &.primary { + color: var(--white); + background: var(--brown); + box-shadow: 0px 4px 6px 0px var(--black-transparent-100); + + &:hover:not(:disabled) { + background: var(--brown-100); + } + } + + &.secondary { + color: var(--brown); + background: var(--white); + border: 1px solid var(--beige); + box-shadow: 0px 4px 4px 0px var(--black-transparent-200); + + &:hover:not(:disabled) { + background: var(--white-100); + border: 1px solid var(--beige); + color: var(--brown); + } + } + + &.warning { + background: var(--red-500); + color: var(--white); + box-shadow: 0px 4px 6px 0px var(--black-transparent-100); + + &:hover:not(:disabled) { + background: var(--red-700); + } + } + + &.outlined { + background: var(--beige); + color: var(--brown); + } + + &.text { + background: transparent; + color: var(--white); + + &:hover:not(:disabled) { + box-shadow: 3px 4px 10px 0px var(--black-transparent-200); + } + } +} diff --git a/src/components/library/molecules/Button/Button.tsx b/src/components/library/molecules/Button/Button.tsx new file mode 100644 index 00000000..b6180264 --- /dev/null +++ b/src/components/library/molecules/Button/Button.tsx @@ -0,0 +1,63 @@ +import classNames from 'classnames'; +import React from 'react'; + +import { + TagType, + Text, + TypographyVariant, +} from '@components/library/atoms/Text'; + +import { + ButtonProps, + ButtonSize, + ButtonType, + IconPosition, +} from './Button.types'; + +import styles from './Button.module.scss'; + +export const Button: React.FC = props => { + const { + size = ButtonSize.Default, + type = ButtonType.Primary, + Icon, + label, + disabled, + ariaLabel, + className, + labelClassName, + buttonType = 'button', + iconPosition = IconPosition.Left, + onClick, + } = props; + + return ( + + ); +}; + +export default Button; diff --git a/src/components/library/molecules/Button/Button.types.ts b/src/components/library/molecules/Button/Button.types.ts new file mode 100644 index 00000000..9d18a3a2 --- /dev/null +++ b/src/components/library/molecules/Button/Button.types.ts @@ -0,0 +1,30 @@ +export enum ButtonType { + Primary = 'primary', + Secondary = 'secondary', + Warning = 'warning', + Outlined = 'outlined', + Text = 'text', +} + +export enum ButtonSize { + Default = 'default', + Wide = 'wide', +} + +export enum IconPosition { + Left = 'left', + Right = 'right', +} +export interface ButtonProps { + size?: ButtonSize; + type?: ButtonType; + label?: string; + Icon?: React.ReactNode; + disabled?: boolean; + ariaLabel: string; + className?: string; + buttonType?: 'button' | 'submit' | 'reset'; + iconPosition?: IconPosition; + labelClassName?: string; + onClick?: (event: React.MouseEvent) => void; +} diff --git a/src/components/library/molecules/Button/index.ts b/src/components/library/molecules/Button/index.ts new file mode 100644 index 00000000..8b371468 --- /dev/null +++ b/src/components/library/molecules/Button/index.ts @@ -0,0 +1,2 @@ +export * from './Button'; +export * from './Button.types'; diff --git a/src/components/library/molecules/ConfirmationModal/ConfirmationModal.module.scss b/src/components/library/molecules/ConfirmationModal/ConfirmationModal.module.scss new file mode 100644 index 00000000..b90a8655 --- /dev/null +++ b/src/components/library/molecules/ConfirmationModal/ConfirmationModal.module.scss @@ -0,0 +1,44 @@ +.modal { + width: 100% !important; + max-width: 391px !important; + background-color: var(--white) !important; + + .wrapper { + padding: 32px; + border-top: 1px solid var(--brown-border); + + .content { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 16px; + + .icon { + color: var(--gray-darkest); + } + + .title { + color: var(--gray-darkest); + } + + .text { + color: var(--black-transparent-300); + max-width: 100%; + } + } + + .footer { + gap: 16px; + display: flex; + align-items: center; + justify-content: center; + padding: 24px 32px 0; + + .cancelButton, + .actionButton { + width: auto !important; + } + } + } +} diff --git a/src/components/library/molecules/ConfirmationModal/ConfirmationModal.tsx b/src/components/library/molecules/ConfirmationModal/ConfirmationModal.tsx new file mode 100644 index 00000000..f038a56c --- /dev/null +++ b/src/components/library/molecules/ConfirmationModal/ConfirmationModal.tsx @@ -0,0 +1,75 @@ +import React from 'react'; + +import { CheckIcon, ErrorIcon } from '@icons/library/svg'; + +import { Text, TypographyVariant } from '@components/library/atoms/Text'; +import { + Button, + ButtonSize, + ButtonType, +} from '@components/library/molecules/Button'; +import { Modal, useModalClose } from '@components/library/molecules/Modal'; + +import type { ConfirmationModalProps } from './ConfirmationModal.types'; + +import styles from './ConfirmationModal.module.scss'; + +export function ConfirmationModal(props: ConfirmationModalProps) { + const { + variant = 'delete', + text, + title, + isLoading = false, + actionButtonType = ButtonType.Primary, + actionButtonLabel = 'Delete', + onClose, + onConfirm, + } = props; + + const isSuccess = variant === 'success'; + const { closeRef, close } = useModalClose(onClose); + + return ( + +
+
+
+ {isSuccess ? : } +
+ + {title} + + + {text} + +
+ +
+ {!isSuccess && ( +
+
+
+ ); +} diff --git a/src/components/library/molecules/ConfirmationModal/ConfirmationModal.types.ts b/src/components/library/molecules/ConfirmationModal/ConfirmationModal.types.ts new file mode 100644 index 00000000..93d682a1 --- /dev/null +++ b/src/components/library/molecules/ConfirmationModal/ConfirmationModal.types.ts @@ -0,0 +1,19 @@ +import { ReactNode } from 'react'; + +import { IconName } from '@components/library/atoms/Icon'; + +import { ButtonType } from '../Button'; + +export type ConfirmationModalVariant = 'success' | 'delete'; + +export interface ConfirmationModalProps { + variant?: ConfirmationModalVariant; + icon?: IconName | ReactNode; + title: string; + text: string; + actionButtonLabel?: string; + actionButtonType?: ButtonType; + onClose: () => void; + onConfirm: () => void; + isLoading?: boolean; +} diff --git a/src/components/library/molecules/ConfirmationModal/index.tsx b/src/components/library/molecules/ConfirmationModal/index.tsx new file mode 100644 index 00000000..c07b3bbd --- /dev/null +++ b/src/components/library/molecules/ConfirmationModal/index.tsx @@ -0,0 +1,2 @@ +export { ConfirmationModal } from './ConfirmationModal'; +export type { ConfirmationModalProps, ConfirmationModalVariant } from './ConfirmationModal.types'; diff --git a/src/components/library/molecules/CreateTagModal/CreateTagModal.module.scss b/src/components/library/molecules/CreateTagModal/CreateTagModal.module.scss new file mode 100644 index 00000000..ab3c8621 --- /dev/null +++ b/src/components/library/molecules/CreateTagModal/CreateTagModal.module.scss @@ -0,0 +1,175 @@ +.modal { + width: 100% !important; + max-width: 518px !important; + background: var(--white) !important; + overflow: hidden; + transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1); + will-change: height; + + form { + display: block; + transition: height 0.4s cubic-bezier(0.4, 0, 0.2, 1); + overflow: hidden; + } +} + +.wrapper { + padding: 32px; + border-top: 1px solid var(--brown-border); + border-bottom: 1px solid var(--brown-border); + max-height: calc(100vh - 250px); + overflow-y: auto; + overflow-x: hidden; + transition: max-height 0.4s cubic-bezier(0.4, 0, 0.2, 1); + + &::-webkit-scrollbar { + display: none; + } + + .labelWrapper { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + + .label { + margin-bottom: 0; + } + } + + .label { + margin-bottom: 12px; + color: var(--black-transparent-300); + } + + .field { + margin-bottom: 32px; + transition: + opacity 0.4s ease-in-out, + transform 0.3s ease-in-out; + animation: fadeInUp 0.4s ease-in-out; + + .error { + color: var(--red-600); + font-size: 12px; + margin-top: 4px; + margin-bottom: 0; + } + + .color { + gap: 22px 12px; + display: flex; + flex-wrap: wrap; + + .blok { + display: flex; + + div { + width: 26px; + height: 26px; + margin-left: -1px; + cursor: pointer; + transition: all 0.2s ease-in; + + &.active { + outline: 3px solid #616469; + z-index: 1; + position: relative; + } + } + } + } + + .preview { + display: flex; + justify-content: center; + background: var(--white-warm); + border-radius: 4px; + padding: 12px 16px; + + .tag { + min-height: 22px; + min-width: 114px; + display: inline-flex; + align-items: center; + justify-content: center; + text-align: center; + } + } + + &.delete { + display: flex; + align-items: center; + justify-content: space-between; + border: 1px solid var(--gray-100); + border-radius: 4px; + padding: 8px 12px; + + .label { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 0; + color: var(--brown); + } + + .arrow { + width: 14px; + height: 14px; + + path { + fill: var(--gray-darkest); + } + } + } + } + + .noTagFound { + display: block; + animation: fadeInUp 0.4s ease-in-out; + transition: + opacity 0.4s ease-in-out, + transform 0.4s ease-in-out; + } + + .tagsList { + transition: opacity 0.4s ease-in-out; + + .label { + margin-bottom: 12px; + } + } + + .tags { + background: var(--white-warm); + padding: 16px; + border-radius: 4px; + display: flex; + flex-wrap: wrap; + gap: 12px; + transition: opacity 0.4s ease-in-out; + } +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.footer { + gap: 16px; + display: flex; + align-items: center; + justify-content: flex-end; + padding: 24px 32px; + + button { + width: auto !important; + } +} diff --git a/src/components/library/molecules/CreateTagModal/CreateTagModal.tsx b/src/components/library/molecules/CreateTagModal/CreateTagModal.tsx new file mode 100644 index 00000000..613c3305 --- /dev/null +++ b/src/components/library/molecules/CreateTagModal/CreateTagModal.tsx @@ -0,0 +1,353 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { createTagSchema } from '@utils/library/schema/createTagSchema'; +import classNames from 'classnames'; +import React, { useEffect,useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { tagColors } from '@constants/library/tags'; + +import { ArrowIcon, DeleteIcon, InfoIcon } from '@icons/library/svg'; + +import { IconName } from '@components/library/atoms/Icon'; +import { Text, TypographyVariant } from '@components/library/atoms/Text'; +import { + Button, + ButtonSize, + ButtonType, +} from '@components/library/molecules/Button'; +import { ConfirmationModal } from '@components/library/molecules/ConfirmationModal'; +import { Input } from '@components/library/molecules/Input'; +import { Modal, useModalClose } from '@components/library/molecules/Modal'; +import { Tag } from '@components/library/molecules/Tag'; +import { Textarea } from '@components/library/molecules/Textarea'; + +import type { + CreateTagFormData, + CreateTagModalProps, +} from './CreateTagModal.types'; + +import styles from './CreateTagModal.module.scss'; + +export function CreateTagModal(props: CreateTagModalProps) { + const { + onClose, + onSubmit, + isEdit = false, + activeTag, + tags = [], + onDelete, + onTagSelect, + } = props; + const { closeRef, close } = useModalClose(onClose); + const defaultColor = tagColors[0][0]; + const isSelectTag = isEdit && !activeTag; + + const [isDeleting, setIsDeleting] = useState(false); + const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false); + const [showCreateSuccessConfirmation, setShowCreateSuccessConfirmation] = + useState(false); + + const { + register, + handleSubmit, + watch, + setValue, + formState: { errors, isSubmitting }, + } = useForm>({ + resolver: zodResolver(createTagSchema), + mode: 'onChange', + defaultValues: { + name: '', + description: '', + color: defaultColor, + }, + }); + + const tagName = watch('name'); + const activeColor = watch('color'); + + const handleColorSelect = (color: string) => { + setValue('color', color, { shouldValidate: true }); + }; + + const onSubmitForm = async (data: CreateTagFormData) => { + if (onSubmit) { + await onSubmit(data); + if (!isEdit) { + setShowCreateSuccessConfirmation(true); + } + } + }; + + const handleDeleteClick = () => { + setShowDeleteConfirmation(true); + }; + + const handleDeleteConfirm = async () => { + if (!onDelete) return; + + setIsDeleting(true); + try { + await onDelete(); + + setShowDeleteConfirmation(false); + onClose(); + } catch (error) { + console.error('Failed to delete tag:', error); + setIsDeleting(false); + } + }; + + useEffect(() => { + if (activeTag) { + setValue('name', activeTag.name || ''); + setValue('description', activeTag.description || ''); + setValue('color', activeTag.color || defaultColor); + } + }, [activeTag, defaultColor, setValue]); + + return ( + <> + {!showCreateSuccessConfirmation && ( + {} : onClose} + closeRef={closeRef} + > +
+
+ {isSelectTag ? ( +
+ {tags.length > 0 && ( +
+ + Select tag : + +
+ {tags.map(({ attributes, id }) => ( + { + if (onTagSelect) { + onTagSelect({ ...attributes, id }); + } + }} + /> + ))} +
+
+ )} +
+ ) : ( + <> +
+ + Tag name + + + {errors.name && ( +

{errors.name.message}

+ )} +
+ +
+ + Description + +