diff --git a/CLAUDE.md b/CLAUDE.md index 2dc6f03e33..cbde1ebbfd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ Luminary is an offline-first content platform. The repo is a monorepo with no ro - `app/` — Offline-first Vue 3 PWA (Vite, port 4174). See `app/CLAUDE.md`. - `cms/` — Vue 3 CMS SPA (Vite, port 4175). See `cms/CLAUDE.md`. - `playwright-tests/` — Standalone E2E suite targeting **deployed** environments. Not wired into any package build. See `playwright-tests/README.md`. -- `docs/` — ADRs (`docs/adr/`) and architecture diagrams. +- `docs/` — ADRs (`docs/adr/`), guides, architecture, and feature docs. Index: `docs/README.md`. **Always read the relevant subpackage's `CLAUDE.md` before working there.** This file only covers cross-package concerns. diff --git a/README.md b/README.md index e1cb568a1f..6d40ad2dc7 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ lu·​mi·​nary - ˈlü-mə-ˌner-ē - `app`: Web and native frontend app - `cms`: Backend CMS for managing content - `shared`: Shared library used by the CMS and app -- `docs`: Documentation, including ADRs +- `docs`: Documentation — see [docs/README.md](./docs/README.md) (ADRs, guides, architecture, features) ## Architectural Decision Records @@ -49,4 +49,4 @@ See the [Shared readme](./shared/README.md) ### Project automation -See the [Project automation readme](./docs/project-addons/automation/project-automation.md) +See the [Project automation guide](./docs/guides/project-automation.md) diff --git a/api/src/db/schemaUpgrade/README.md b/api/src/db/schemaUpgrade/README.md index 2df5ce247d..dfeedbbd9f 100644 --- a/api/src/db/schemaUpgrade/README.md +++ b/api/src/db/schemaUpgrade/README.md @@ -110,7 +110,7 @@ Schema upgrades can be safely removed when: curl http://admin:password@localhost:5984/database/_schemas ``` 2. **Archive old upgrades** (optional but recommended): - - Move old upgrade files to `docs/historical-upgrades/` for reference + - Move old upgrade files to `docs/archive/historical-upgrades/` for reference - Include git commit hash and date when they were removed 3. **Remove from codebase**: - Delete old upgrade files diff --git a/api/src/db/seedingDocs/lang-eng.json b/api/src/db/seedingDocs/lang-eng.json index 94466560f0..8a6ab1c96d 100644 --- a/api/src/db/seedingDocs/lang-eng.json +++ b/api/src/db/seedingDocs/lang-eng.json @@ -49,7 +49,7 @@ "settings.device_info.title": "Device info", "settings.device_info.description": "Provide these details when contacting support", "home.title": "Home", - "home.continue": "Continue Watching", + "home.continue": "Continue", "home.continueListening": "Continue Listening", "explore.title": "Explore", "explore.other": "Other", @@ -57,6 +57,9 @@ "home.newest": "Newest", "content.related_title": "Related", "content.coming_soon": "Coming soon", + "content.continueReading.prompt": "Continue where you left off?", + "content.continueReading.action": "Continue where you left off", + "content.continueReading.dismiss": "Start from top", "notification.login.title": "You are missing out!", "notification.login.message": "Click here to create an account or log in", "notification.offline.title": "You are offline.", diff --git a/api/src/db/seedingDocs/lang-fra.json b/api/src/db/seedingDocs/lang-fra.json index d4eec95883..c7c818d3be 100644 --- a/api/src/db/seedingDocs/lang-fra.json +++ b/api/src/db/seedingDocs/lang-fra.json @@ -49,7 +49,7 @@ "settings.device_info.title": "Informations sur l'appareil", "settings.device_info.description": "Fournissez ces détails lors de la prise de contact avec le support", "home.title": "Accueil", - "home.continue": "Continuer à regarder", + "home.continue": "Continuer", "home.continueListening": "Continuer à écouter", "explore.title": "Explore", "explore.other": "Autre", @@ -57,6 +57,9 @@ "home.newest": "Nouveaux", "content.related_title": "Contenus similaires", "content.coming_soon": "Bientôt disponible", + "content.continueReading.prompt": "Reprendre où vous vous êtes arrêté ?", + "content.continueReading.action": "Reprendre où vous vous êtes arrêté", + "content.continueReading.dismiss": "Recommencer depuis le début", "notification.login.title": "Vous manquez quelque chose!", "notification.login.message": "Cliquez ici pour créer un compte ou vous connecter.", "notification.offline.title": "Vous êtes hors ligne.", diff --git a/api/src/permissions/permissions.service.ts b/api/src/permissions/permissions.service.ts index 0327e22779..905284ac70 100644 --- a/api/src/permissions/permissions.service.ts +++ b/api/src/permissions/permissions.service.ts @@ -683,7 +683,7 @@ export class PermissionSystem extends EventEmitter { } /** - * Update upstream inherited permissions on the parent group's map for a given ACL entry (referring to (1) in permissionSystem.drawio.svg) + * Update upstream inherited permissions on the parent group's map for a given ACL entry (referring to (1) in docs/architecture/diagrams/permissionSystem.drawio.svg) * This function is an event handler for the AclEntryUpdatedEvent, and should only be called from within a group (PermissionSystem) object (i.e. do not call it on parent or child groups) */ private upsertUpstreamInheritedMap(event: AclEntryUpdatedEvent) { @@ -697,7 +697,7 @@ export class PermissionSystem extends EventEmitter { } /** - * Update downstream inherited group maps for a given DocType in the passed ACL group map (referring to (3) in permissionSystem.drawio.svg) + * Update downstream inherited group maps for a given DocType in the passed ACL group map (referring to (3) in docs/architecture/diagrams/permissionSystem.drawio.svg) */ private upsertDownstreamInheritedMap(aclGroup: AclGroupMap, type: DocType) { const parentGroup = aclGroup.ref; @@ -740,7 +740,7 @@ export class PermissionSystem extends EventEmitter { } /** - * Iteratively forward inherited group maps until the top level parent has been reached (referring to (3) in permissionSystem.drawio.svg) + * Iteratively forward inherited group maps until the top level parent has been reached (referring to (3) in docs/architecture/diagrams/permissionSystem.drawio.svg) */ private forwardInheritedMap( target: Uuid, diff --git a/app/CLAUDE.md b/app/CLAUDE.md index c122f6fbf2..a2b5118443 100644 --- a/app/CLAUDE.md +++ b/app/CLAUDE.md @@ -52,7 +52,9 @@ Uses `@auth0/auth0-vue` with multiple providers selected at runtime from `AuthPr ### i18n -UI strings live in CouchDB Language documents, loaded at runtime (`src/i18n.ts`). English seed is `../api/src/db/seedingDocs/lang-eng.json`. See `../docs/translations.md` for the workflow. +UI strings live in CouchDB Language documents, loaded at runtime (`src/i18n.ts`). English seed is `../api/src/db/seedingDocs/lang-eng.json`. See `../docs/guides/translations.md` for the workflow. + +Reading progress (segments, gates, homepage row): `../docs/features/reading-progress-tracker/README.md`. ### Plugins diff --git a/app/README.md b/app/README.md index 2750b210df..4856142a8a 100644 --- a/app/README.md +++ b/app/README.md @@ -6,7 +6,7 @@ This is the frontend of the Luminary app. It's an offline-first Vue app that run Cross-cutting services follow a **contract + injection key + build-time virtual module** pattern: Vite resolves each `virtual:…` id to an entry under **`src/build-time/plugins//`**, and **`src/build-time/contracts/plugin-registry.ts`** registers those services on the app. Feature code uses **`inject`** with keys from **`token.ts`** under **`src/build-time/contracts/`**, not direct imports of adapter code. -**Example in this repo:** the **demo banner** (`virtual:demo-banner`, `src/build-time/plugins/demo-banner/`). Full pattern and diagrams: **[docs/vue-plugin-architecture/README.md](../docs/vue-plugin-architecture/README.md)**. Step-by-step for a **second** plugin: **[Adding another build-swapped plugin](../docs/vue-plugin-architecture/README.md#adding-another-plugin)**. +**Example in this repo:** the **demo banner** (`virtual:demo-banner`, `src/build-time/plugins/demo-banner/`). Full pattern and diagrams: **[docs/features/vue-plugin-architecture/README.md](../docs/features/vue-plugin-architecture/README.md)**. Step-by-step for a **second** plugin: **[Adding another build-swapped plugin](../docs/features/vue-plugin-architecture/README.md#adding-another-plugin)**. ## Project structure @@ -67,16 +67,22 @@ app/ UI strings are stored in CouchDB language documents and loaded at runtime via `src/i18n.ts` using [vue-i18n](https://vue-i18n.intlify.dev/). The default English strings are seeded from `api/src/db/seedingDocs/lang-eng.json`. -See [docs/translations.md](../docs/translations.md) for details on: +See [docs/guides/translations.md](../docs/guides/translations.md) for details on: - How to add or update translation strings - Strings that contain named interpolation placeholders (`{variable}`) - Strings that are shown only under specific UI conditions - Strings reserved for future use +## Reading progress + +The app tracks how far a user has read through article text and surfaces in-progress posts on the homepage **Continue Reading** row. Segment-based gates (visibility, skim detection, dwell time) ensure progress reflects actual reading, including long paragraphs on small screens. + +Full design, diagrams, and constants: **[docs/features/reading-progress-tracker/README.md](../docs/features/reading-progress-tracker/README.md)**. + ## Local setup -Refer to the [setup guide](../docs/setup-vue-app.md). +Refer to the [setup guide](../docs/guides/setup-vue-app.md). When running `npm run dev` the local reloading server of the app will start at http://localhost:4174. diff --git a/app/src/build-time/plugins/Readme.md b/app/src/build-time/plugins/Readme.md index 459e0b569b..e6aa535ffe 100644 --- a/app/src/build-time/plugins/Readme.md +++ b/app/src/build-time/plugins/Readme.md @@ -19,4 +19,4 @@ This folder holds **implementations** of app services that depend on the build t 2. Implement the service (and optional UI shell) in `.//`. 3. Register `virtual:` in `buildTargetVirtuals.ts` and call `install*` from `plugin-registry.ts`. -Full diagrams and bootstrap details: **[docs/vue-plugin-architecture/README.md](../../../../docs/vue-plugin-architecture/README.md)**. +Full diagrams and bootstrap details: **[docs/features/vue-plugin-architecture/README.md](../../../../docs/features/vue-plugin-architecture/README.md)**. diff --git a/app/src/components/HomePage/ContinueWatching.spec.ts b/app/src/components/HomePage/ContinueProgress.spec.ts similarity index 63% rename from app/src/components/HomePage/ContinueWatching.spec.ts rename to app/src/components/HomePage/ContinueProgress.spec.ts index 3530fc0e9d..56f4918c4f 100644 --- a/app/src/components/HomePage/ContinueWatching.spec.ts +++ b/app/src/components/HomePage/ContinueProgress.spec.ts @@ -12,8 +12,13 @@ import { } from "@/tests/mockdata"; import { db, type ContentDto, DocType, PostType } from "luminary-shared"; import waitForExpect from "wait-for-expect"; -import { appLanguageIdsAsRef } from "@/globalConfig"; -import ContinueWatching from "./ContinueWatching.vue"; +import { + appLanguageIdsAsRef, + setMediaProgress, + setReadingProgress, + syncContentProgressFromStorage, +} from "@/globalConfig"; +import ContinueProgress from "./ContinueProgress.vue"; vi.mock("vue-router"); vi.mock("vue-i18n", () => ({ @@ -22,22 +27,18 @@ vi.mock("vue-i18n", () => ({ }), })); -/** - * Helper: set localStorage "mediaProgress" to a list of watched content IDs. - */ -function setMediaProgress(contentIds: string[]) { - const entries = contentIds.map((contentId) => ({ - mediaId: `media-${contentId}`, - contentId, - })); - localStorage.setItem("mediaProgress", JSON.stringify(entries)); +function setWatchingProgress(contentIds: string[]) { + [...contentIds].reverse().forEach((contentId, index) => { + setMediaProgress(`media-${contentId}`, contentId, 60 + index, 300); + }); } -describe("ContinueWatching", () => { +describe("ContinueProgress", () => { beforeEach(async () => { await db.docs.clear(); await db.localChanges.clear(); localStorage.clear(); + syncContentProgressFromStorage(); await db.docs.bulkPut([mockLanguageDtoEng, mockLanguageDtoFra, mockLanguageDtoSwa]); appLanguageIdsAsRef.value = [mockLanguageDtoEng._id]; @@ -51,24 +52,40 @@ describe("ContinueWatching", () => { localStorage.clear(); }); - it("displays watched content that is published", async () => { + it("displays content with video progress that is published", async () => { await db.docs.bulkPut([mockEnglishContentDto]); - setMediaProgress([mockEnglishContentDto._id]); + setWatchingProgress([mockEnglishContentDto._id]); - const wrapper = mount(ContinueWatching); + const wrapper = mount(ContinueProgress); await waitForExpect(() => { expect(wrapper.text()).toContain(mockEnglishContentDto.title); }); }); - it("does not render when there is no media progress", async () => { + it("displays content with reading progress that is published", async () => { + const textContent: ContentDto = { + ...mockEnglishContentDto, + _id: "content-read-eng", + title: "Reading Article", + video: undefined, + text: "

Hello world

", + }; + await db.docs.bulkPut([textContent]); + setReadingProgress(textContent._id, 42); + + const wrapper = mount(ContinueProgress); + + await waitForExpect(() => { + expect(wrapper.text()).toContain("Reading Article"); + }); + }); + + it("does not render when there is no content progress", async () => { await db.docs.bulkPut([mockEnglishContentDto]); - // No media progress set in localStorage - const wrapper = mount(ContinueWatching); + const wrapper = mount(ContinueProgress); - // The component should not render the collection (v-if="watchedContent.length > 0") await waitForExpect(() => { expect(wrapper.text()).not.toContain(mockEnglishContentDto.title); }); @@ -82,9 +99,9 @@ describe("ContinueWatching", () => { title: "Page Content", }; await db.docs.bulkPut([mockEnglishContentDto, pageContent]); - setMediaProgress([mockEnglishContentDto._id, pageContent._id]); + setWatchingProgress([mockEnglishContentDto._id, pageContent._id]); - const wrapper = mount(ContinueWatching); + const wrapper = mount(ContinueProgress); await waitForExpect(() => { expect(wrapper.text()).toContain(mockEnglishContentDto.title); @@ -98,9 +115,9 @@ describe("ContinueWatching", () => { title: "Category Content", }; await db.docs.bulkPut([mockEnglishContentDto, categoryContent]); - setMediaProgress([mockEnglishContentDto._id, categoryContent._id]); + setWatchingProgress([mockEnglishContentDto._id, categoryContent._id]); - const wrapper = mount(ContinueWatching); + const wrapper = mount(ContinueProgress); await waitForExpect(() => { expect(wrapper.text()).toContain(mockEnglishContentDto.title); @@ -116,9 +133,9 @@ describe("ContinueWatching", () => { title: "Future Content", }; await db.docs.bulkPut([mockEnglishContentDto, futureContent]); - setMediaProgress([mockEnglishContentDto._id, futureContent._id]); + setWatchingProgress([mockEnglishContentDto._id, futureContent._id]); - const wrapper = mount(ContinueWatching); + const wrapper = mount(ContinueProgress); await waitForExpect(() => { expect(wrapper.text()).toContain(mockEnglishContentDto.title); @@ -130,13 +147,13 @@ describe("ContinueWatching", () => { const expiredContent: ContentDto = { ...mockEnglishContentDto, _id: "content-expired-eng", - expiryDate: 1000, // far in the past + expiryDate: 1000, title: "Expired Content", }; await db.docs.bulkPut([mockEnglishContentDto, expiredContent]); - setMediaProgress([mockEnglishContentDto._id, expiredContent._id]); + setWatchingProgress([mockEnglishContentDto._id, expiredContent._id]); - const wrapper = mount(ContinueWatching); + const wrapper = mount(ContinueProgress); await waitForExpect(() => { expect(wrapper.text()).toContain(mockEnglishContentDto.title); @@ -152,9 +169,9 @@ describe("ContinueWatching", () => { title: "Not a Content Doc", }; await db.docs.bulkPut([mockEnglishContentDto, nonContent as any]); - setMediaProgress([mockEnglishContentDto._id, nonContent._id]); + setWatchingProgress([mockEnglishContentDto._id, nonContent._id]); - const wrapper = mount(ContinueWatching); + const wrapper = mount(ContinueProgress); await waitForExpect(() => { expect(wrapper.text()).toContain(mockEnglishContentDto.title); @@ -164,9 +181,9 @@ describe("ContinueWatching", () => { it("handles missing documents gracefully (IDs not in database)", async () => { await db.docs.bulkPut([mockEnglishContentDto]); - setMediaProgress([mockEnglishContentDto._id, "nonexistent-id"]); + setWatchingProgress([mockEnglishContentDto._id, "nonexistent-id"]); - const wrapper = mount(ContinueWatching); + const wrapper = mount(ContinueProgress); await waitForExpect(() => { expect(wrapper.text()).toContain(mockEnglishContentDto.title); @@ -174,67 +191,60 @@ describe("ContinueWatching", () => { }); it("handles invalid JSON in localStorage gracefully", async () => { - localStorage.setItem("mediaProgress", "not-valid-json"); + localStorage.setItem("contentProgress", "not-valid-json"); + syncContentProgressFromStorage(); - const wrapper = mount(ContinueWatching); + const wrapper = mount(ContinueProgress); - // Should not crash, and should not render content await waitForExpect(() => { expect(wrapper.html()).toBeDefined(); }); }); - it("cleans up event listeners and intervals on unmount", () => { + it("cleans up storage listeners on unmount", () => { const removeEventSpy = vi.spyOn(window, "removeEventListener"); - const clearIntervalSpy = vi.spyOn(window, "clearInterval"); - const wrapper = mount(ContinueWatching); + const wrapper = mount(ContinueProgress); wrapper.unmount(); expect(removeEventSpy).toHaveBeenCalledWith("storage", expect.any(Function)); - expect(clearIntervalSpy).toHaveBeenCalled(); }); - it("preserves the watched order from localStorage regardless of database order", async () => { - // Create three content documents with IDs that sort alphabetically as A < B < C + it("preserves progress order regardless of database order", async () => { const contentA: ContentDto = { ...mockEnglishContentDto, _id: "content-aaa", - title: "Alpha Video", - slug: "alpha-video", + title: "Alpha Content", + slug: "alpha-content", }; const contentB: ContentDto = { ...mockEnglishContentDto, _id: "content-bbb", - title: "Bravo Video", - slug: "bravo-video", + title: "Bravo Content", + slug: "bravo-content", }; const contentC: ContentDto = { ...mockEnglishContentDto, _id: "content-ccc", - title: "Charlie Video", - slug: "charlie-video", + title: "Charlie Content", + slug: "charlie-content", }; - // Insert into database (Dexie will store by primary key order: A, B, C) await db.docs.bulkPut([contentA, contentB, contentC]); + setWatchingProgress([contentC._id, contentB._id, contentA._id]); - // Set localStorage in reverse order: C, B, A (last watched first) - setMediaProgress([contentC._id, contentB._id, contentA._id]); - - const wrapper = mount(ContinueWatching); + const wrapper = mount(ContinueProgress); await waitForExpect(() => { - expect(wrapper.text()).toContain("Alpha Video"); - expect(wrapper.text()).toContain("Bravo Video"); - expect(wrapper.text()).toContain("Charlie Video"); + expect(wrapper.text()).toContain("Alpha Content"); + expect(wrapper.text()).toContain("Bravo Content"); + expect(wrapper.text()).toContain("Charlie Content"); }); - // Verify the DOM order matches the localStorage order (C, B, A), not database order const html = wrapper.html(); - const posC = html.indexOf("Charlie Video"); - const posB = html.indexOf("Bravo Video"); - const posA = html.indexOf("Alpha Video"); + const posC = html.indexOf("Charlie Content"); + const posB = html.indexOf("Bravo Content"); + const posA = html.indexOf("Alpha Content"); expect(posC).toBeLessThan(posB); expect(posB).toBeLessThan(posA); diff --git a/app/src/components/HomePage/ContinueProgress.vue b/app/src/components/HomePage/ContinueProgress.vue new file mode 100644 index 0000000000..7d2dae128e --- /dev/null +++ b/app/src/components/HomePage/ContinueProgress.vue @@ -0,0 +1,80 @@ + + + diff --git a/app/src/components/HomePage/ContinueWatching.vue b/app/src/components/HomePage/ContinueWatching.vue deleted file mode 100644 index 1a9eece07c..0000000000 --- a/app/src/components/HomePage/ContinueWatching.vue +++ /dev/null @@ -1,98 +0,0 @@ - - - diff --git a/app/src/components/content/ContentTile.spec.ts b/app/src/components/content/ContentTile.spec.ts index d57ebb4745..1ab328b6d3 100644 --- a/app/src/components/content/ContentTile.spec.ts +++ b/app/src/components/content/ContentTile.spec.ts @@ -6,7 +6,7 @@ import ContentTile from "./ContentTile.vue"; import { mockEnglishContentDto, mockLanguageDtoEng } from "@/tests/mockdata"; import { PlayIcon, PlayIcon as PlayIconOutline } from "@heroicons/vue/24/solid"; import type { ContentDto } from "luminary-shared"; -import { setMediaProgress } from "@/globalConfig"; +import { setMediaProgress, setReadingProgress } from "@/globalConfig"; import { computed } from "vue"; vi.mock("@/composables/useBucketInfo", () => ({ @@ -224,7 +224,7 @@ describe("ContentTile", () => { expect(playIconOutline.exists()).toBe(false); }); - it("shows the progress and duration if the content has a video", () => { + it("shows the progress bar if the content has a video", () => { const content = { _id: "sample-content-id", title: "Sample Content", @@ -257,9 +257,147 @@ describe("ContentTile", () => { }, }); - // Duration 300s = 5:00 - expect(wrapper.html()).toContain("5:00"); expect(wrapper.html()).toContain('style="width: 40%'); + expect(wrapper.html()).not.toContain("5:00"); + }); + + it("does not show media progress when showProgress is false", () => { + const content = { + _id: "sample-content-id-hidden", + title: "Sample Content", + slug: "sample-content-hidden", + parentImageData: {}, + publishDate: 1, + parentPublishDateVisible: false, + video: "sample-media-id-hidden", + parentId: "post-blog1", + } as unknown as ContentDto; + + setMediaProgress("sample-media-id-hidden", content._id, 120, 300); + + const wrapper = mount(ContentTile, { + props: { + content, + showProgress: false, + titlePosition: "center", + }, + global: { + stubs: { + LImage: { + template: "
", + }, + PlayIcon, + PlayIconOutline, + }, + }, + }); + + expect(wrapper.html()).not.toContain("5:00"); + expect(wrapper.html()).not.toContain('style="width: 40%'); + }); + + it("shows a progress bar for reading-only content when showProgress is true", () => { + const content = { + _id: "sample-reading-id", + title: "Reading Article", + slug: "reading-article", + parentImageData: {}, + publishDate: 1, + parentPublishDateVisible: false, + text: "

Hello

", + parentId: "post-blog1", + } as unknown as ContentDto; + + setReadingProgress(content._id, 45); + + const wrapper = mount(ContentTile, { + props: { + content, + showProgress: true, + titlePosition: "center", + }, + global: { + stubs: { + LImage: { + template: "
", + }, + }, + }, + }); + + expect(wrapper.html()).toContain('style="width: 45%'); + }); + + it("shows reading progress when it is higher than video progress", () => { + const content = { + _id: "sample-mixed-id", + title: "Mixed Content", + slug: "mixed-content", + parentImageData: {}, + publishDate: 1, + parentPublishDateVisible: false, + video: "sample-mixed-media-id", + text: "

Hello

", + parentId: "post-blog1", + } as unknown as ContentDto; + + setMediaProgress("sample-mixed-media-id", content._id, 120, 300); // 40% + setReadingProgress(content._id, 60); + + const wrapper = mount(ContentTile, { + props: { + content, + showProgress: true, + titlePosition: "center", + }, + global: { + stubs: { + LImage: { + template: "
", + }, + PlayIcon, + PlayIconOutline, + }, + }, + }); + + expect(wrapper.html()).toContain('style="width: 60%'); + }); + + it("shows video progress when it is higher than reading progress", () => { + const content = { + _id: "sample-mixed-id-2", + title: "Mixed Content 2", + slug: "mixed-content-2", + parentImageData: {}, + publishDate: 1, + parentPublishDateVisible: false, + video: "sample-mixed-media-id-2", + text: "

Hello

", + parentId: "post-blog1", + } as unknown as ContentDto; + + setMediaProgress("sample-mixed-media-id-2", content._id, 210, 300); // 70% + setReadingProgress(content._id, 30); + + const wrapper = mount(ContentTile, { + props: { + content, + showProgress: true, + titlePosition: "center", + }, + global: { + stubs: { + LImage: { + template: "
", + }, + PlayIcon, + PlayIconOutline, + }, + }, + }); + + expect(wrapper.html()).toContain('style="width: 70%'); }); it("renders title on the image in overlay mode without text below", () => { diff --git a/app/src/components/content/ContentTile.vue b/app/src/components/content/ContentTile.vue index 5dc4b026ff..1667ee668e 100644 --- a/app/src/components/content/ContentTile.vue +++ b/app/src/components/content/ContentTile.vue @@ -4,8 +4,8 @@ import { DateTime } from "luxon"; import LImage from "../images/LImage.vue"; import { type AspectRatio, type ImageSize } from "../images/LImageProvider.vue"; import { PlayIcon, SpeakerWaveIcon } from "@heroicons/vue/24/solid"; -import { getMediaDuration, getMediaProgress } from "@/globalConfig"; -import { computed, ref } from "vue"; +import { getMediaDuration, getMediaProgress, getReadingProgress } from "@/globalConfig"; +import { computed } from "vue"; import { useI18n } from "vue-i18n"; const { t } = useI18n(); @@ -50,11 +50,6 @@ const mediaIconClass = computed(() => : "relative z-20 h-8 w-8 text-white lg:h-12 lg:w-12", ); -const media = ref<{ progress: number; duration: number }>({ - progress: 0, - duration: 0, -}); - const isComingSoon = computed(() => { const publishDate = props.content.publishDate; // "Coming soon" = published doc with a future publishDate AND the opt-in flag set. @@ -65,24 +60,10 @@ const isComingSoon = computed(() => { ); }); -function formatDuration(seconds: number): string { - const totalSeconds = Math.floor(seconds); - const hrs = Math.floor(totalSeconds / 3600); - const mins = Math.floor((totalSeconds % 3600) / 60); - const secs = totalSeconds % 60; - - if (hrs > 0) { - return `${hrs}:${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`; - } else { - return `${mins.toString().padStart(1, "0")}:${secs.toString().padStart(2, "0")}`; - } -} - -const durationText = ref(""); -const hasProgress = ref(false); -const allMedia = localStorage.getItem("mediaProgress"); +const displayProgress = computed(() => { + if (!props.showProgress) return 0; -if (allMedia) { + let mediaProgressPercent = 0; const mediaIds = props.content.video ? [props.content.video] : (props.content.parentMedia?.fileCollections ?? []).map((f) => f.fileUrl); @@ -92,14 +73,16 @@ if (allMedia) { const mediaDuration = getMediaDuration(mediaId, props.content._id); if (mediaProgress > 0 && mediaDuration > 0) { - hasProgress.value = true; - media.value.progress = Math.min(100, (mediaProgress / mediaDuration) * 100); - media.value.duration = mediaDuration; - durationText.value = formatDuration(mediaDuration); + mediaProgressPercent = Math.min(100, (mediaProgress / mediaDuration) * 100); break; } } -} + + const readingProgressPercent = getReadingProgress(props.content._id); + return Math.max(mediaProgressPercent, readingProgressPercent); +}); + +const hasProgress = computed(() => displayProgress.value > 0); diff --git a/app/src/components/content/ContinueReadingPrompt.spec.ts b/app/src/components/content/ContinueReadingPrompt.spec.ts new file mode 100644 index 0000000000..e684f48b1b --- /dev/null +++ b/app/src/components/content/ContinueReadingPrompt.spec.ts @@ -0,0 +1,73 @@ +import { mount } from "@vue/test-utils"; +import { describe, expect, it } from "vitest"; +import ContinueReadingPrompt from "./ContinueReadingPrompt.vue"; + +describe("ContinueReadingPrompt", () => { + it("renders when visible and emits continue on action click", async () => { + const wrapper = mount(ContinueReadingPrompt, { + props: { + visible: true, + progressPercent: 42, + }, + global: { + mocks: { + t: (key: string) => key, + }, + }, + }); + + expect(wrapper.text()).toContain("content.continueReading.action"); + expect(wrapper.text()).not.toContain("42%"); + expect(wrapper.find('[role="progressbar"]').attributes("aria-valuenow")).toBe("42"); + expect(wrapper.find('[style*="width: 42%"]').exists()).toBe(true); + + const continueButton = wrapper.findAll("button")[0]; + await continueButton.trigger("click"); + expect(wrapper.emitted("continue")).toHaveLength(1); + }); + + it("emits dismiss via the X button without emitting continue", async () => { + const wrapper = mount(ContinueReadingPrompt, { + props: { + visible: true, + progressPercent: 42, + }, + global: { + mocks: { + t: (key: string) => key, + }, + }, + }); + + const dismissButton = wrapper.findAll("button")[1]; + expect(dismissButton.attributes("aria-label")).toBe("content.continueReading.dismiss"); + + await dismissButton.trigger("click"); + + expect(wrapper.emitted("dismiss")).toHaveLength(1); + expect(wrapper.emitted("continue")).toBeUndefined(); + }); + + it("grows for long translation strings without clipping the action label", () => { + const longLabel = + "Continue where you left off in this very long article title that keeps going"; + + const wrapper = mount(ContinueReadingPrompt, { + props: { + visible: true, + progressPercent: 75, + }, + global: { + mocks: { + t: (key: string) => + key === "content.continueReading.action" ? longLabel : key, + }, + }, + }); + + expect(wrapper.text()).toContain(longLabel); + expect(wrapper.find(".max-w-\\[min\\(24rem\\,calc\\(100vw-2rem\\)\\)\\]").exists()).toBe( + true, + ); + }); +}); diff --git a/app/src/components/content/ContinueReadingPrompt.vue b/app/src/components/content/ContinueReadingPrompt.vue new file mode 100644 index 0000000000..723b4ca44a --- /dev/null +++ b/app/src/components/content/ContinueReadingPrompt.vue @@ -0,0 +1,68 @@ + + + diff --git a/app/src/components/content/HorizontalContentTileCollection.vue b/app/src/components/content/HorizontalContentTileCollection.vue index 83b91d3380..6453c5464e 100644 --- a/app/src/components/content/HorizontalContentTileCollection.vue +++ b/app/src/components/content/HorizontalContentTileCollection.vue @@ -113,7 +113,7 @@ useInfiniteScroll(
-import { computed, ref } from "vue"; +import { computed, ref, onMounted, onUnmounted } from "vue"; import { useI18n } from "vue-i18n"; import { getNavigationItems } from "./navigationItems"; import { useSearchOverlay } from "@/composables/useSearchOverlay"; @@ -107,6 +107,32 @@ const confirmLogout = async () => { await logout({ logoutParams: { returnTo: window.location.origin } }); }; +// Publish rendered width as --desktop-sidebar-w so fixed overlays (e.g. ContinueReadingPrompt) +// can center in the content column instead of the full viewport. +const rootRef = ref(null); +let resizeObserver: ResizeObserver | null = null; + +const publishWidth = (width: number) => { + document.documentElement.style.setProperty("--desktop-sidebar-w", `${width}px`); +}; + +onMounted(() => { + if (!rootRef.value) return; + const measure = () => { + if (rootRef.value) publishWidth(rootRef.value.getBoundingClientRect().width); + }; + measure(); + if (typeof ResizeObserver !== "undefined") { + resizeObserver = new ResizeObserver(measure); + resizeObserver.observe(rootRef.value); + } +}); + +onUnmounted(() => { + resizeObserver?.disconnect(); + document.documentElement.style.removeProperty("--desktop-sidebar-w"); +}); + const handleLogin = () => { if (isConnected.value) { loginWithRedirect(); @@ -124,6 +150,7 @@ const handleLogin = () => {