From ec2aebca7f974fc578e5692b2e1fe072a333dd3a Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Mon, 27 Apr 2026 00:28:24 +0200 Subject: [PATCH] feat: add Office overview page with recent / shared / templates Adds a new top-level "Office" entry to the app menu that opens a Vue SPA at /apps/richdocuments/overview, providing a single landing page for the user's office work. Features: - Home with three sections: My recent docs, Shared with me, Templates - Dedicated views for each, with date grouping (Today / Yesterday / Earlier this week / Earlier this month / Older) and sticky headers - Type filter pills (Documents / Spreadsheets / Presentations / PDFs) - List and grid view toggle, persisted in localStorage - Hover quick-preview popover (mounted at via singleton) - Active-editor badges with pulsing live indicator (WOPI tokens) - Pinning / favourites integrated with Nextcloud's per-user tags so pins also appear in the Files app's Favorites view - Type-coloured thumbnails with frame on grid cards - Friendly empty-state illustrations with calls to action - Confetti + showSuccess toast on document creation - Smooth fade/slide route transitions, all motion respects prefers-reduced-motion Backend: - OverviewService runs an indexed user-folder SearchQuery (mime IN + 60-day mtime window), partitions by ownership for recent vs shared, and batch-loads active editors and favourites per page - OverviewController renders the SPA shell as RENDER_AS_USER - OverviewApiController exposes paginated OCS endpoints, plus create-from-template and favourite-toggle, with strict input validation and OCS-bypass CSRF handling Frontend: - Vue 2 + vue-router 3 SPA mounted at #content (Files-app pattern) - Reuses Nextcloud's design system primitives (NcContent, NcAppNavigation, NcAvatar, NcDateTime, NcDialog, NcEmptyContent, NcLoadingIcon, NcButton, NcTextField) - Optimistic UI for pin toggling Co-Authored-By: Claude Opus 4.7 Signed-off-by: Frank Karlitschek --- appinfo/info.xml | 8 + appinfo/routes.php | 18 + css/overview.scss | 9 + docs/overview-spec.md | 336 +++++++++ lib/Controller/OverviewApiController.php | 196 ++++++ lib/Controller/OverviewController.php | 45 ++ lib/Service/OverviewService.php | 639 ++++++++++++++++++ package-lock.json | 3 +- package.json | 3 +- src/overview.js | 52 ++ .../Overview/CreateFromTemplateModal.vue | 328 +++++++++ src/views/Overview/DocumentCard.vue | 334 +++++++++ src/views/Overview/DocumentRow.vue | 427 ++++++++++++ src/views/Overview/EditorBadges.vue | 117 ++++ src/views/Overview/EmptyIllustration.vue | 271 ++++++++ src/views/Overview/HomeSection.vue | 136 ++++ src/views/Overview/HomeView.vue | 188 ++++++ src/views/Overview/HoverPreview.vue | 94 +++ src/views/Overview/ListView.vue | 523 ++++++++++++++ src/views/Overview/OverviewApp.vue | 101 +++ src/views/Overview/RecentView.vue | 45 ++ src/views/Overview/SearchBar.vue | 123 ++++ src/views/Overview/SharedView.vue | 46 ++ src/views/Overview/SubtleHint.vue | 37 + src/views/Overview/TemplateCard.vue | 196 ++++++ src/views/Overview/TemplateRow.vue | 178 +++++ src/views/Overview/TemplatesView.vue | 56 ++ src/views/Overview/TypeFilter.vue | 115 ++++ src/views/Overview/ViewToggle.vue | 79 +++ src/views/Overview/api.js | 88 +++ src/views/Overview/confetti.js | 96 +++ src/views/Overview/dateGrouping.js | 67 ++ src/views/Overview/hoverPreviewMixin.js | 51 ++ src/views/Overview/hoverPreviewMount.js | 57 ++ src/views/Overview/hoverPreviewState.js | 89 +++ src/views/Overview/typeStyles.js | 72 ++ templates/overview.php | 11 + webpack.js | 1 + 38 files changed, 5233 insertions(+), 2 deletions(-) create mode 100644 css/overview.scss create mode 100644 docs/overview-spec.md create mode 100644 lib/Controller/OverviewApiController.php create mode 100644 lib/Controller/OverviewController.php create mode 100644 lib/Service/OverviewService.php create mode 100644 src/overview.js create mode 100644 src/views/Overview/CreateFromTemplateModal.vue create mode 100644 src/views/Overview/DocumentCard.vue create mode 100644 src/views/Overview/DocumentRow.vue create mode 100644 src/views/Overview/EditorBadges.vue create mode 100644 src/views/Overview/EmptyIllustration.vue create mode 100644 src/views/Overview/HomeSection.vue create mode 100644 src/views/Overview/HomeView.vue create mode 100644 src/views/Overview/HoverPreview.vue create mode 100644 src/views/Overview/ListView.vue create mode 100644 src/views/Overview/OverviewApp.vue create mode 100644 src/views/Overview/RecentView.vue create mode 100644 src/views/Overview/SearchBar.vue create mode 100644 src/views/Overview/SharedView.vue create mode 100644 src/views/Overview/SubtleHint.vue create mode 100644 src/views/Overview/TemplateCard.vue create mode 100644 src/views/Overview/TemplateRow.vue create mode 100644 src/views/Overview/TemplatesView.vue create mode 100644 src/views/Overview/TypeFilter.vue create mode 100644 src/views/Overview/ViewToggle.vue create mode 100644 src/views/Overview/api.js create mode 100644 src/views/Overview/confetti.js create mode 100644 src/views/Overview/dateGrouping.js create mode 100644 src/views/Overview/hoverPreviewMixin.js create mode 100644 src/views/Overview/hoverPreviewMount.js create mode 100644 src/views/Overview/hoverPreviewState.js create mode 100644 src/views/Overview/typeStyles.js create mode 100644 templates/overview.php diff --git a/appinfo/info.xml b/appinfo/info.xml index 7fa639d451..fdf51775c4 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -55,4 +55,12 @@ You can also edit your documents off-line with the Collabora Office app from the OCA\Richdocuments\Settings\Personal OCA\Richdocuments\Settings\Section + + + Office + richdocuments.overview.index + app.svg + 5 + + diff --git a/appinfo/routes.php b/appinfo/routes.php index e17267f6ce..5d79c8006d 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -61,6 +61,17 @@ ['name' => 'templates#getPreview', 'url' => '/template/preview/{fileId}', 'verb' => 'GET'], ['name' => 'templates#add', 'url' => '/template', 'verb' => 'POST'], ['name' => 'templates#delete', 'url' => '/template/{fileId}', 'verb' => 'DELETE'], + + // Overview SPA shell — a single route handles every overview path + // (Home, Recent, Shared, Templates) and delegates to Vue Router. + ['name' => 'overview#index', 'url' => '/overview', 'verb' => 'GET'], + [ + 'name' => 'overview#index', + 'url' => '/overview/{path}', + 'verb' => 'GET', + 'postfix' => 'subpath', + 'requirements' => ['path' => '.+'], + ], ], 'ocs' => [ // Public pages: new file creation @@ -87,5 +98,12 @@ ['name' => 'TemplateField#fillFields', 'url' => '/api/v1/template/fields/fill/{fileId}', 'verb' => 'POST'], ['name' => 'Mention#mention', 'url' => '/api/v1/mention/{fileId}', 'verb' => 'POST'], + + // Overview API + ['name' => 'OverviewApi#recent', 'url' => '/api/v1/overview/recent', 'verb' => 'GET'], + ['name' => 'OverviewApi#shared', 'url' => '/api/v1/overview/shared', 'verb' => 'GET'], + ['name' => 'OverviewApi#templates', 'url' => '/api/v1/overview/templates', 'verb' => 'GET'], + ['name' => 'OverviewApi#createFromTemplate', 'url' => '/api/v1/overview/create-from-template', 'verb' => 'POST'], + ['name' => 'OverviewApi#favourite', 'url' => '/api/v1/overview/favourite', 'verb' => 'POST'], ], ]; diff --git a/css/overview.scss b/css/overview.scss new file mode 100644 index 0000000000..d2125ea93d --- /dev/null +++ b/css/overview.scss @@ -0,0 +1,9 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +// The Vue app mounts at #content (replacing it) — see overview.js. All +// per-component styles live in their respective .vue files; this file is +// kept as a stable hook for any future page-wide tweaks. + diff --git a/docs/overview-spec.md b/docs/overview-spec.md new file mode 100644 index 0000000000..b7b78e283d --- /dev/null +++ b/docs/overview-spec.md @@ -0,0 +1,336 @@ +# Nextcloud Office — Overview Page Specification + +## 1. Goal +Add a new top-level "Overview" page to the richdocuments app that gives every user a single landing page for office work: their recently edited documents, documents shared with them that have been edited recently, and one-click access to user-defined templates. + +## 2. Navigation & entry point +- **App-menu entry** registered via `` in `appinfo/info.xml`. +- **Icon**: reuse the existing richdocuments app icon. +- **Label**: same as the existing richdocuments app entry ("Office"). +- **Visibility**: every user for whom richdocuments is enabled. No admin toggle, no per-user setting. +- **Default landing**: clicking the icon opens the **Home** view. + +## 3. URLs / routes +Each entry is a bookmarkable URL. + +| Entry | Path | Default | +|--------------------|-----------------------------------------|---------| +| Home | `/apps/richdocuments/overview` | ✓ | +| My recent | `/apps/richdocuments/overview/recent` | | +| Shared with me | `/apps/richdocuments/overview/shared` | | +| Templates | `/apps/richdocuments/overview/templates`| | + +Implemented client-side via Vue Router (history mode), all served by a single PHP controller action that renders the SPA shell. + +## 4. Page layout +Two-column layout using `@nextcloud/vue` primitives. + +- **Left sidebar** (`NcAppNavigation`, ~280 px): vertical list with four entries — Home, My recent, Shared with me, Templates. Each has an icon. Active entry highlighted. +- **Main area** (`NcAppContent`): view content for the active route. + +Responsive: +- ≥ 1024 px: sidebar + content side-by-side (default `NcAppNavigation` behaviour). +- 768–1023 px: sidebar collapses into a slide-in drawer behind a hamburger. +- < 768 px: same drawer behaviour; rows simplify to filename + date + thumbnail (drop folder path on small viewports). + +## 5. Document scope + +### File types +A file qualifies iff its MIME type / extension is one of: +`.docx`, `.xlsx`, `.pptx`, `.odt`, `.ods`, `.odp`, `.pdf`. + +PDFs are included. Clicking a PDF opens it in Collabora's PDF viewer mode (read-only or editable depending on the PDF — same behaviour as opening from the Files app). + +### "Edited lately" definition +A file qualifies iff `mtime > now() - 60 days`. The single signal is the file's `mtime` from `oc_filecache`. No distinction between Collabora edits and other writes (sync client, drag-and-drop replace, WebDAV, etc.) — anything that bumps `mtime` counts. + +### Authorship +"Edited by someone" semantics: a file shows up regardless of whether the current user did the edit. The row's "modified by" column reflects whoever made the most recent change (per filecache / activity), which may be the owner, a co-editor, or the current user. + +## 6. Views + +### 6.1 Home (default) +Three sections stacked vertically. Each section caps at **6 items**, sorted newest-first. + +1. **My recent documents** — files in the user's own storage; "See all →" links to `/recent`. +2. **Shared with me** — files reachable via accepted shares; "See all →" links to `/shared`. +3. **Templates** — user-defined templates; "See all →" links to `/templates`. + +Each section renders independently — if one API call fails, the others still load. Empty section shows the generic empty-state copy (see §10) without breaking the layout. + +### 6.2 My recent documents +- One row per file, newest-first. **Fixed sort** (no clickable column headers in v1). +- **Row contents**: thumbnail (64×64, from core preview API) · filename · last-modified date (relative, e.g. "2 hours ago", with absolute date as tooltip) · last-modified-by (avatar + display name) · folder path (e.g. `/Projects/Reports`). +- **Click row** → open in Collabora in the **same tab** (`/apps/richdocuments/index?fileId=…`). +- **Search box** at the top of the view: matches against `filename` OR `folder path`, case-insensitive substring. Server-side, debounced 250 ms. +- **Pagination**: page size 25, **infinite scroll** — fetch next page when the user scrolls within ~300 px of the bottom of the list. Spinner shown while loading the next page. + +### 6.3 Shared with me +Same layout, behaviour, and row contents as My recent. Source set differs: + +**Included** share types: +- Direct user shares (`oc_share` with `share_type = 0`) +- Group shares (`share_type = 1`) +- Circle shares (`share_type = 7`) +- Federated incoming shares (`oc_share_external` / `share_type = 6`) + +**Excluded**: +- Public link shares (not tracked per-user; we'd have no way to know which user "received" it). + +For federated rows, indicate the source server in the "modified by" column (`Alice (other.example)`). + +### 6.4 Templates +- One row per template, sorted by template name asc. +- **Source**: only **user-defined templates** — the templates folder configured for the user by richdocuments (typically `~/Templates`, exact path is whatever richdocuments has stored). Excluded from v1: admin/system templates, and the empty-type seed files in `emptyTemplates/`. +- **Row contents**: thumbnail · template name · file-type icon. +- **Search box**: matches template name only. +- **Pagination**: 25 per page, infinite scroll. + +**Click a template** → open **"Create from template" modal** (§7). + +## 7. Create-from-template flow + +Modal (`NcModal`) with two fields and two buttons. + +- **Filename** (text input) + - Default: ` ` (e.g. `Letter 2026-04-26`). + - Extension is appended automatically based on the template's MIME type — not editable. +- **Save to folder** (folder picker from `@nextcloud/files`) + - Default: the user's last-used save location (persist in user config, key e.g. `richdocuments.overview.lastTemplateFolder`). + - Fallback if no last-used location: the user's root folder. +- **Buttons**: `Cancel` / `Create`. + +On `Create`: +1. POST to `/overview/create-from-template` with `{ templateFileId, filename, folderPath }`. +2. Server copies the template into the chosen folder, validates name collision (auto-suffix `(1)`, `(2)`, … on conflict), persists `lastTemplateFolder`. +3. Response includes the new file's `fileid`. +4. Frontend navigates the same tab to `/apps/richdocuments/index?fileId=` and the editor opens. + +## 8. Backend API + +All endpoints live under the richdocuments app, OCS v2. + +| Method | Path | Purpose | +|--------|--------------------------------------------------------------------------|----------------------------------| +| GET | `/ocs/v2.php/apps/richdocuments/api/v1/overview/recent` | My recent documents | +| GET | `/ocs/v2.php/apps/richdocuments/api/v1/overview/shared` | Shared with me | +| GET | `/ocs/v2.php/apps/richdocuments/api/v1/overview/templates` | User templates | +| POST | `/ocs/v2.php/apps/richdocuments/api/v1/overview/create-from-template` | Create new document from template| + +### 8.1 Common GET query params +- `q` — search string (substring, case-insensitive). Optional. +- `cursor` — opaque base64 cursor for keyset pagination. Empty/missing for first page. +- `limit` — defaults to 25, server hard-caps at 25. + +### 8.2 Response shape — `recent` and `shared` +```json +{ + "ocs": { + "data": { + "items": [ + { + "fileid": 12345, + "name": "Q1 Report.docx", + "path": "/Projects/Reports/Q1 Report.docx", + "folder": "/Projects/Reports", + "mimetype": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "size": 24576, + "mtime": 1714123200, + "modifiedBy": { + "uid": "alice", + "displayName": "Alice", + "avatarUrl": "/avatar/alice/64" + }, + "thumbnailUrl": "/core/preview?fileId=12345&x=64&y=64&a=1", + "openUrl": "/apps/richdocuments/index?fileId=12345", + "shareSource": null + } + ], + "nextCursor": "eyJtdGltZSI6MTcxNDEyMzIwMCwiZmlsZWlkIjoxMjM0NX0=" + } + } +} +``` + +For `shared`, `shareSource` is populated: +```json +"shareSource": { + "type": "user|group|circle|federated", + "displayName": "Alice", + "remoteServer": "https://other.example/" +} +``` +`remoteServer` is `null` for non-federated shares. + +`nextCursor` is `null` when there are no more pages. + +### 8.3 Response shape — `templates` +```json +{ + "ocs": { + "data": { + "items": [ + { + "fileid": 99, + "name": "Letter Template.docx", + "mimetype": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "thumbnailUrl": "/core/preview?fileId=99&x=64&y=64&a=1" + } + ], + "nextCursor": null + } + } +} +``` + +### 8.4 Request shape — `create-from-template` +```json +POST /overview/create-from-template +{ + "templateFileId": 99, + "filename": "Letter 2026-04-26", + "folderPath": "/Documents" +} +``` + +Response: +```json +{ + "ocs": { + "data": { + "fileid": 67890, + "path": "/Documents/Letter 2026-04-26.docx", + "openUrl": "/apps/richdocuments/index?fileId=67890" + } + } +} +``` + +Errors return standard OCS error envelope with one of: +- `400 invalid_filename` (empty / contains `/` / etc.) +- `403 folder_forbidden` (no write permission on target folder) +- `404 template_not_found` / `404 folder_not_found` +- `409 conflict` (only if auto-suffix is exhausted, which shouldn't happen) + +## 9. Query implementation notes + +### 9.1 Recent (own files) +```sql +SELECT f.fileid, f.name, f.path, f.mimetype, f.size, f.mtime, f.storage_mtime +FROM oc_filecache f +JOIN oc_mounts m ON m.storage_id = f.storage +WHERE m.user_id = :uid + AND f.mimetype IN (:allowed_mimetype_ids) + AND f.mtime > :sixty_days_ago + AND ( :q IS NULL OR LOWER(f.name) LIKE :q_like OR LOWER(f.path) LIKE :q_like ) + AND ( :cursor_mtime IS NULL OR (f.mtime, f.fileid) < (:cursor_mtime, :cursor_fileid) ) +ORDER BY f.mtime DESC, f.fileid DESC +LIMIT 25; +``` +Resolve `modifiedBy` via the user's last-modified-by index if present, otherwise default to the file owner. + +### 9.2 Shared with me +- Enumerate `oc_share` rows where `share_with = :uid` (type 0/user) or `share_with` ∈ user's groups (type 1) or circles (type 7). +- Plus `oc_share_external` rows where the recipient is `:uid` (type 6 federated). +- Join file metadata, apply same mimetype + mtime filter, same search predicate, same keyset pagination. +- De-duplicate on `fileid` (a file can be reshared via multiple paths) — pick the row whose share grants the highest permissions; if tied, lowest `share_id`. + +### 9.3 Templates +Read the user's templates folder via the existing richdocuments templates service (whatever the app already uses to enumerate templates for the "create new" flow in Files). Filter by allowed MIME types. No mtime filter for templates. + +### 9.4 Pagination cursor +Cursor = base64 of `{"mtime": , "fileid": }` (for `recent` / `shared`) or `{"name": "", "fileid": }` (for `templates`). Keyset comparison `(mtime, fileid) < (cursor_mtime, cursor_fileid)` to avoid OFFSET drift on large result sets. + +### 9.5 Performance +- Hard limit of 25 per page; no "fetch all" path. +- All queries respect Nextcloud's permission scope — only files the user can read. +- Profile the queries on a representative dataset before merging; add a composite index on `oc_filecache(storage, mimetype, mtime)` only if profiling shows it's needed (and check whether one already exists). +- All four list endpoints should P95 < 200 ms on a user with up to 100 000 files and 1 000 incoming shares. + +## 10. States + +### Loading +- **First load**: 6 skeleton rows (shimmer) on Home; 25 skeleton rows on dedicated views. +- **Subsequent infinite-scroll loads**: small spinner at the bottom of the list. + +### Empty (generic copy is fine) +- My recent / Shared with me, no data: `"No documents edited in the last 60 days."` +- Templates, no data: `"You haven't created any templates yet."` + link to Nextcloud's templates help article. +- Search yields nothing: `"No matches for ""."` + +### Error +- Per-section error inline (`NcEmptyContent` with a "Retry" button). Other sections on Home keep working. + +## 11. Frontend file layout + +New entry point and components inside `apps/richdocuments/`: + +``` +src/ + overview.js # bootstrap (mounts ) + views/Overview/ + OverviewApp.vue # NcAppNavigation + NcAppContent + + HomeView.vue + RecentView.vue + SharedView.vue + TemplatesView.vue + DocumentRow.vue # shared row + TemplateRow.vue + CreateFromTemplateModal.vue + SearchBar.vue + composables/ + useInfiniteScroll.ts + useOverviewApi.ts +``` + +Add to `webpack.js`: +```js +overview: path.join(__dirname, 'src', 'overview.js'), +``` + +Use `@nextcloud/vue` components (`NcAppNavigation`, `NcAppContent`, `NcAppNavigationItem`, `NcAvatar`, `NcButton`, `NcModal`, `NcTextField`, `NcEmptyContent`, `NcLoadingIcon`) and `@nextcloud/files` for the folder picker. + +## 12. Backend file layout + +New PHP files under `apps/richdocuments/lib/`: + +``` +Controller/ + OverviewController.php # renders SPA shell at /overview* + OverviewApiController.php # OCS endpoints +Service/ + OverviewService.php # query logic for recent / shared + TemplateOverviewService.php # delegates to existing templates service +``` + +Wire up: +- `appinfo/routes.php`: routes for `overview.index`, `overviewApi.recent`, `overviewApi.shared`, `overviewApi.templates`, `overviewApi.createFromTemplate`. The `overview.index` route uses a wildcard to catch all sub-paths (Vue Router handles them). +- `appinfo/info.xml`: add `` with `richdocuments.overview.index`, `Office`, `app.svg` (or whatever the existing icon file is). + +## 13. Out of scope for v1 +- Sortable column headers (newest-first is fixed). +- Right-click / context menu actions (share, rename, delete, open folder). +- Hover preview / quick look. +- Filter by file type chip-bar. +- Pinning / favouriting. +- System / admin templates and `emptyTemplates/` seeds in the Templates view. +- Tracking which public-link-shared docs the user has opened. +- Multi-select / bulk actions. +- Activity-app integration. +- Admin toggle to disable the overview entry. + +## 14. Acceptance checklist +- [ ] Overview entry appears in the top app menu for any user with richdocuments enabled. +- [ ] Clicking it lands on Home at `/apps/richdocuments/overview`. +- [ ] Home shows three sections, each with up to 6 newest-first items. +- [ ] Each "See all →" routes to the corresponding dedicated view. +- [ ] Dedicated views paginate 25-at-a-time with infinite scroll. +- [ ] Search box filters by filename + folder path, debounced 250 ms, server-side. +- [ ] Clicking a doc opens it in Collabora in the same tab. +- [ ] Clicking a template opens the Create-from-template modal; on Create the new doc is saved to the chosen folder and opened. +- [ ] Only `.docx`, `.xlsx`, `.pptx`, `.odt`, `.ods`, `.odp`, `.pdf` are surfaced. +- [ ] Only files with `mtime > now() − 60 d` appear in Recent / Shared. +- [ ] Shared with me includes user / group / circle / federated shares; excludes public link shares. +- [ ] PDFs are included and open in Collabora's PDF viewer. +- [ ] Layout is usable on mobile (drawer nav + condensed rows). +- [ ] Empty / loading / error states render per §10. diff --git a/lib/Controller/OverviewApiController.php b/lib/Controller/OverviewApiController.php new file mode 100644 index 0000000000..dfcd0149f5 --- /dev/null +++ b/lib/Controller/OverviewApiController.php @@ -0,0 +1,196 @@ +requireUser(); + [$q, $type, $offset, $limit] = $this->normalizeListParams($q, $type, $offset, $limit); + return new DataResponse($this->service->getRecent($this->userId, $q, $type, $offset, $limit)); + } + + /** + * @NoAdminRequired + * + * @throws OCSForbiddenException when called by a guest + */ + #[NoAdminRequired] + public function shared(?string $q = null, ?string $type = null, int $offset = 0, int $limit = 0): DataResponse { + $this->requireUser(); + [$q, $type, $offset, $limit] = $this->normalizeListParams($q, $type, $offset, $limit); + return new DataResponse($this->service->getShared($this->userId, $q, $type, $offset, $limit)); + } + + /** + * @NoAdminRequired + * + * @throws OCSForbiddenException when called by a guest + */ + #[NoAdminRequired] + public function templates(?string $q = null, ?string $type = null, int $offset = 0, int $limit = 0): DataResponse { + $this->requireUser(); + [$q, $type, $offset, $limit] = $this->normalizeListParams($q, $type, $offset, $limit); + return new DataResponse($this->service->getTemplates($this->userId, $q, $type, $offset, $limit)); + } + + /** + * Toggle "favourite" status for a file the current user can see. + * Pins / unpins the file in the overview list. + * + * @NoAdminRequired + * + * @throws OCSBadRequestException invalid arguments + * @throws OCSNotFoundException file not visible to the user + * @throws OCSForbiddenException guest call + */ + #[NoAdminRequired] + public function favourite(int $fileid = 0, bool $favorite = false): DataResponse { + $this->requireUser(); + if ($fileid <= 0) { + throw new OCSBadRequestException('fileid is required'); + } + try { + $this->service->setFavourite($this->userId, $fileid, $favorite); + } catch (NotFoundException) { + throw new OCSNotFoundException(); + } catch (\Throwable $e) { + $this->logger->error('Overview: failed to toggle favourite', [ + 'exception' => $e, + 'app' => 'richdocuments', + ]); + throw new OCSException('Failed to toggle favourite'); + } + return new DataResponse(['fileid' => $fileid, 'favorite' => $favorite]); + } + + /** + * @NoAdminRequired + * + * @throws OCSBadRequestException invalid arguments + * @throws OCSNotFoundException template or folder missing + * @throws OCSForbiddenException no write permission on folder + * @throws OCSException unexpected server error + */ + #[NoAdminRequired] + public function createFromTemplate(int $templateFileId = 0, string $filename = '', string $folderPath = '/'): DataResponse { + $this->requireUser(); + + if ($templateFileId <= 0) { + throw new OCSBadRequestException('templateFileId is required'); + } + $filename = trim($filename); + if ($filename === '' || mb_strlen($filename) > self::MAX_FILENAME_LENGTH) { + throw new OCSBadRequestException('filename is required and must be <= ' . self::MAX_FILENAME_LENGTH . ' characters'); + } + if (mb_strlen($folderPath) > self::MAX_FOLDER_PATH_LENGTH) { + throw new OCSBadRequestException('folderPath is too long'); + } + + try { + $result = $this->service->createFromTemplate($this->userId, $templateFileId, $filename, $folderPath); + return new DataResponse($result); + } catch (NotFoundException) { + throw new OCSNotFoundException('Template or target folder not found'); + } catch (NotPermittedException) { + throw new OCSForbiddenException('No permission to create file in target folder'); + } catch (InvalidArgumentException $e) { + throw new OCSBadRequestException($e->getMessage()); + } catch (\Throwable $e) { + $this->logger->error('Overview: failed to create document from template', [ + 'exception' => $e, + 'app' => 'richdocuments', + ]); + throw new OCSException('Failed to create document from template'); + } + } + + /** + * @return array{0: ?string, 1: ?string, 2: int, 3: int} + */ + private function normalizeListParams(?string $q, ?string $type, int $offset, int $limit): array { + // Normalize search string. + if ($q !== null) { + $q = trim($q); + if ($q === '') { + $q = null; + } elseif (mb_strlen($q) > self::MAX_QUERY_LENGTH) { + $q = mb_substr($q, 0, self::MAX_QUERY_LENGTH); + } + } + + // Validate the type filter strictly: only known group keys (or "all" + // / null) are accepted. Anything else collapses to null so a stray + // query param can never widen the result set unexpectedly. + if ($type !== null) { + $type = trim($type); + if ($type === '' || $type === 'all') { + $type = null; + } elseif (!array_key_exists($type, OverviewService::TYPE_GROUPS)) { + $type = null; + } + } + + // Clamp pagination. Server caps at PAGE_SIZE; clients can ask for less but not more. + $offset = max(0, $offset); + $limit = $limit <= 0 ? OverviewService::PAGE_SIZE : min(max(1, $limit), OverviewService::PAGE_SIZE); + + return [$q, $type, $offset, $limit]; + } + + /** + * @throws OCSForbiddenException + */ + private function requireUser(): void { + if ($this->userId === null || $this->userId === '') { + throw new OCSForbiddenException(); + } + } +} diff --git a/lib/Controller/OverviewController.php b/lib/Controller/OverviewController.php new file mode 100644 index 0000000000..58f1054048 --- /dev/null +++ b/lib/Controller/OverviewController.php @@ -0,0 +1,45 @@ +initialState->provideInitialState('overview', [ + 'pageSize' => \OCA\Richdocuments\Service\OverviewService::PAGE_SIZE, + 'homeSectionLimit' => \OCA\Richdocuments\Service\OverviewService::HOME_SECTION_LIMIT, + 'windowDays' => \OCA\Richdocuments\Service\OverviewService::RECENT_WINDOW_DAYS, + ]); + + return new TemplateResponse(Application::APPNAME, 'overview', [], TemplateResponse::RENDER_AS_USER); + } +} diff --git a/lib/Service/OverviewService.php b/lib/Service/OverviewService.php new file mode 100644 index 0000000000..3a92106c48 --- /dev/null +++ b/lib/Service/OverviewService.php @@ -0,0 +1,639 @@ + recent, owner != current user -> shared. + * This keeps the query plan single-shot and predictable. + * - Pagination is offset-based, server-capped at 25 per request. Within + * a single 60-day window with a mimetype filter the result count is + * bounded enough that offset drift is acceptable. + * - Search predicate matches against name and path (both stored in the + * file cache); we apply it as a server-side LIKE filter. + */ +class OverviewService { + public const PAGE_SIZE = 25; + public const HOME_SECTION_LIMIT = 6; + public const RECENT_WINDOW_DAYS = 60; + + /** + * MIME-type groups exposed to the frontend as filter pills. + * + * The frontend sends the group key (e.g. "documents") and the service + * narrows the search to that subset. Unknown values are ignored. + */ + public const TYPE_GROUPS = [ + 'documents' => [ + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.oasis.opendocument.text', + ], + 'spreadsheets' => [ + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.oasis.opendocument.spreadsheet', + ], + 'presentations' => [ + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/vnd.oasis.opendocument.presentation', + ], + 'pdfs' => [ + 'application/pdf', + ], + ]; + + /** + * MIME types the overview surfaces (docx/xlsx/pptx/odt/ods/odp/pdf). + * Built from TYPE_GROUPS so the two stay in sync by construction. + * + * @var list + */ + public const ALLOWED_MIMETYPES = [ + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.oasis.opendocument.text', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.oasis.opendocument.spreadsheet', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/vnd.oasis.opendocument.presentation', + 'application/pdf', + ]; + + public function __construct( + private IRootFolder $rootFolder, + private IShareManager $shareManager, + private IUserManager $userManager, + private IURLGenerator $urlGenerator, + private TemplateManager $templateManager, + private IDBConnection $db, + private ITimeFactory $timeFactory, + private ITagManager $tagManager, + ) { + } + + /** + * Files in the user's storage that the user owns and were modified + * within the recent window. + * + * @return array{items: array>, nextOffset: ?int} + */ + public function getRecent(string $userId, ?string $query, ?string $type, int $offset, int $limit): array { + return $this->getOwnedOrShared($userId, $query, $type, $offset, $limit, true); + } + + /** + * Files reachable through incoming shares (user/group/circle/federated) + * that were modified within the recent window. + * + * @return array{items: array>, nextOffset: ?int} + */ + public function getShared(string $userId, ?string $query, ?string $type, int $offset, int $limit): array { + return $this->getOwnedOrShared($userId, $query, $type, $offset, $limit, false); + } + + /** + * Templates the user has defined in their personal templates folder. + * + * @return array{items: array>, nextOffset: ?int} + */ + public function getTemplates(string $userId, ?string $query, ?string $type, int $offset, int $limit): array { + $this->templateManager->setUserId($userId); + $templates = $this->templateManager->getUser(); + + // Allowed template mimes: the office mimes we surface plus their + // equivalent template mime types (.ott, .otp, ots, .dotx, .pptx-template, etc.) + $baseAllowed = array_merge( + self::ALLOWED_MIMETYPES, + TemplateManager::MIMES_DOCUMENTS, + TemplateManager::MIMES_SHEETS, + TemplateManager::MIMES_PRESENTATIONS, + ); + $allowed = $this->resolveTemplateTypeFilter($type, $baseAllowed); + + $templates = array_values(array_filter( + $templates, + fn (File $file): bool => in_array($file->getMimeType(), $allowed, true), + )); + + if ($query !== null && $query !== '') { + $needle = mb_strtolower($query); + $templates = array_values(array_filter($templates, function (File $file) use ($needle): bool { + return mb_strpos(mb_strtolower($file->getName()), $needle) !== false; + })); + } + + // Sort by name asc for stable, browseable order. + usort($templates, fn (File $a, File $b): int => strnatcasecmp($a->getName(), $b->getName())); + + $slice = array_slice($templates, $offset, $limit); + $items = array_map(fn (File $file): array => $this->serializeTemplate($file), $slice); + + $nextOffset = ($offset + $limit) < count($templates) ? ($offset + $limit) : null; + return ['items' => $items, 'nextOffset' => $nextOffset]; + } + + /** + * Resolve a template node by id. Used by the create-from-template + * endpoint to validate that a template exists before copying. + * + * @throws NotFoundException + */ + public function getTemplateNode(string $userId, int $templateId): File { + $this->templateManager->setUserId($userId); + $file = $this->templateManager->get($templateId); + if (!($file instanceof File)) { + throw new NotFoundException(); + } + return $file; + } + + /** + * Create a new file from a template by copying the template content + * into the chosen folder, auto-resolving filename collisions. + * + * Returns the created file together with a frontend-ready open URL. + * + * @return array{fileid: int, name: string, path: string, openUrl: string} + * + * @throws NotFoundException folder or template missing + * @throws \OCP\Files\NotPermittedException no write permission + */ + public function createFromTemplate( + string $userId, + int $templateId, + string $filename, + string $folderPath, + ): array { + $template = $this->getTemplateNode($userId, $templateId); + $userFolder = $this->rootFolder->getUserFolder($userId); + + $folderPath = $folderPath === '' ? '/' : $folderPath; + $folder = $userFolder->get($folderPath); + if (!($folder instanceof Folder)) { + throw new NotFoundException('Target is not a folder'); + } + + // Build the full filename using the template's extension. + $extension = $this->extensionFromMime($template->getMimeType()) ?? pathinfo($template->getName(), PATHINFO_EXTENSION); + $basename = $this->sanitizeFilename($filename); + if ($basename === '') { + throw new \InvalidArgumentException('Empty filename'); + } + $fullName = $extension !== '' ? $basename . '.' . $extension : $basename; + $fullName = $folder->getNonExistingName($fullName); + + $newFile = $folder->newFile($fullName, $template->fopen('rb')); + + return [ + 'fileid' => $newFile->getId(), + 'name' => $newFile->getName(), + 'path' => $userFolder->getRelativePath($newFile->getPath()), + 'openUrl' => $this->urlGenerator->linkToRoute('richdocuments.document.index', [ + 'fileId' => $newFile->getId(), + ]), + ]; + } + + /** + * Shared core: search the user's folder, partition by ownership. + * + * @return array{items: array>, nextOffset: ?int} + */ + private function getOwnedOrShared(string $userId, ?string $query, ?string $type, int $offset, int $limit, bool $ownedOnly): array { + $userFolder = $this->rootFolder->getUserFolder($userId); + + $cutoff = time() - (self::RECENT_WINDOW_DAYS * 86400); + + $activeMimetypes = $this->resolveTypeFilter($type); + + // Build the SearchQuery: mimetype IN (...) AND mtime >= cutoff [AND name|path LIKE ?]. + $mimeOps = array_map( + fn (string $mime) => new SearchComparison(ISearchComparison::COMPARE_EQUAL, 'mimetype', $mime), + $activeMimetypes, + ); + $conditions = [ + new SearchBinaryOperator(SearchBinaryOperator::OPERATOR_OR, $mimeOps), + new SearchComparison(ISearchComparison::COMPARE_GREATER_THAN_EQUAL, 'mtime', $cutoff), + ]; + + if ($query !== null && $query !== '') { + // Match against filename or path (both stored in oc_filecache). + $like = '%' . $this->escapeLike($query) . '%'; + $conditions[] = new SearchBinaryOperator(SearchBinaryOperator::OPERATOR_OR, [ + new SearchComparison(ISearchComparison::COMPARE_LIKE, 'name', $like), + new SearchComparison(ISearchComparison::COMPARE_LIKE, 'path', $like), + ]); + } + + // We need to over-fetch so we can post-filter by ownership and still + // fill the requested page. Cap the over-fetch to keep memory bounded. + $searchLimit = max($limit * 8, 200); + $searchOffset = $offset * 0; // We can't reliably translate offsets after partitioning; we re-scan. + + /** @var \OCP\Files\Search\ISearchQuery $searchQuery */ + $searchQuery = new SearchQuery( + new SearchBinaryOperator(SearchBinaryOperator::OPERATOR_AND, $conditions), + $searchLimit + $offset, + $searchOffset, + [new SearchOrder(ISearchOrder::DIRECTION_DESCENDING, 'mtime')], + $this->userManager->get($userId), + ); + + /** @var Node[] $results */ + $results = $userFolder->search($searchQuery); + + // Partition by ownership. + $matched = []; + foreach ($results as $node) { + if (!($node instanceof File)) { + continue; + } + try { + $ownerUid = $node->getOwner()?->getUID(); + } catch (\Throwable) { + $ownerUid = null; + } + $isOwned = $ownerUid === $userId; + if ($ownedOnly === $isOwned) { + $matched[] = $node; + } + } + + $total = count($matched); + $slice = array_slice($matched, $offset, $limit); + + // Batch-load active editors and favourites for the page so we don't + // issue one query per row. + $fileIds = array_map(fn (File $node): int => $node->getId(), $slice); + $editorsByFileId = $this->getActiveEditors($fileIds); + $favouriteIds = $this->getFavouriteFileIds($userId, $fileIds); + + $items = []; + foreach ($slice as $node) { + $items[] = $this->serializeNode( + $node, + $userId, + $userFolder, + !$ownedOnly, + $editorsByFileId[$node->getId()] ?? [], + in_array($node->getId(), $favouriteIds, true), + ); + } + + $nextOffset = ($offset + $limit) < $total ? ($offset + $limit) : null; + + return ['items' => $items, 'nextOffset' => $nextOffset]; + } + + /** + * Project a file node into the JSON shape consumed by the frontend. + * + * @param list $currentEditors + * + * @return array + */ + private function serializeNode(File $node, string $userId, Folder $userFolder, bool $includeShareSource, array $currentEditors, bool $favorite): array { + $relPath = $userFolder->getRelativePath($node->getPath()) ?? $node->getName(); + $folder = ltrim(dirname($relPath), '.'); + if ($folder === '' || $folder === '/') { + $folder = '/'; + } + + $ownerUid = null; + $ownerDisplay = null; + try { + $owner = $node->getOwner(); + if ($owner !== null) { + $ownerUid = $owner->getUID(); + $ownerDisplay = $owner->getDisplayName(); + } + } catch (\Throwable) { + // owner unavailable for some federated mounts + } + + $shareSource = null; + if ($includeShareSource) { + $shareSource = $this->resolveShareSource($node, $userId); + } + + return [ + 'fileid' => $node->getId(), + 'name' => $node->getName(), + 'path' => $relPath, + 'folder' => $folder, + 'mimetype' => $node->getMimeType(), + 'size' => $node->getSize(), + 'mtime' => $node->getMTime(), + 'modifiedBy' => [ + 'uid' => $ownerUid, + 'displayName' => $ownerDisplay ?? $ownerUid ?? '', + ], + 'thumbnailUrl' => $this->urlGenerator->linkToRoute('core.Preview.getPreviewByFileId', [ + 'fileId' => $node->getId(), + 'x' => 64, + 'y' => 64, + 'a' => 1, + 'forceIcon' => 1, + ]), + 'openUrl' => $this->urlGenerator->linkToRoute('richdocuments.document.index', [ + 'fileId' => $node->getId(), + ]), + 'shareSource' => $shareSource, + 'currentEditors' => $currentEditors, + 'favorite' => $favorite, + ]; + } + + /** + * @return array + */ + private function serializeTemplate(File $file): array { + return [ + 'fileid' => $file->getId(), + 'name' => $file->getName(), + 'mimetype' => $file->getMimeType(), + 'extension' => pathinfo($file->getName(), PATHINFO_EXTENSION), + 'thumbnailUrl' => $this->urlGenerator->linkToRoute('richdocuments.templates.getPreview', [ + 'fileId' => $file->getId(), + 'x' => 96, + 'y' => 96, + ]), + ]; + } + + /** + * Toggle the user's "favourite" status for the given file. The frontend + * uses this to pin documents to the top of the overview lists. + * + * Validates that the user can actually see the file before tagging, + * to avoid letting one user mark another's file as a favourite via a + * guessed file id. + * + * @throws NotFoundException when the file is not visible to this user + */ + public function setFavourite(string $userId, int $fileId, bool $favourite): void { + $userFolder = $this->rootFolder->getUserFolder($userId); + $node = $userFolder->getFirstNodeById($fileId); + if ($node === null) { + throw new NotFoundException(); + } + $tags = $this->tagManager->load('files', [], false, $userId); + if ($favourite) { + $tags->addToFavorites($fileId); + } else { + $tags->removeFromFavorites($fileId); + } + } + + /** + * Filter the given file ids down to those tagged as favourite by the + * current user. + * + * @param list $fileIds + * @return list + */ + private function getFavouriteFileIds(string $userId, array $fileIds): array { + if ($fileIds === []) { + return []; + } + try { + $tags = $this->tagManager->load('files', [], false, $userId); + $favouriteIds = $tags->getFavorites(); + } catch (\Throwable) { + return []; + } + return array_values(array_intersect($fileIds, array_map('intval', $favouriteIds))); + } + + /** + * Look up users currently holding a non-expired writable WOPI token for + * each given file id. The richdocuments_wopi table grows on every open + * and entries are aged out by `expiry`; a non-expired write token is the + * best proxy we have for "someone is editing this right now." + * + * Edge cases: a user who closes their browser without an explicit + * disconnect will still appear as an editor until the token expires. + * That's accepted; the alternative (live presence pings) is out of + * scope for v1 and would require an event channel. + * + * @param list $fileIds + * @return array> + */ + private function getActiveEditors(array $fileIds): array { + if ($fileIds === []) { + return []; + } + $now = $this->timeFactory->getTime(); + + $qb = $this->db->getQueryBuilder(); + $qb->selectDistinct(['fileid', 'editor_uid', 'guest_displayname']) + ->from('richdocuments_wopi') + ->where($qb->expr()->in('fileid', $qb->createNamedParameter($fileIds, IQueryBuilder::PARAM_INT_ARRAY))) + ->andWhere($qb->expr()->eq('canwrite', $qb->createNamedParameter(true, IQueryBuilder::PARAM_BOOL))) + ->andWhere($qb->expr()->gt('expiry', $qb->createNamedParameter($now, IQueryBuilder::PARAM_INT))); + + $result = $qb->executeQuery(); + + // Aggregate, deduping by uid (or by guest display name when uid is null). + /** @var array> $byFile */ + $byFile = []; + while ($row = $result->fetch()) { + $fileId = (int)$row['fileid']; + $editorUid = $row['editor_uid'] !== null && $row['editor_uid'] !== '' ? (string)$row['editor_uid'] : null; + $guestName = $row['guest_displayname'] !== null ? (string)$row['guest_displayname'] : ''; + $key = $editorUid ?? ('guest:' . $guestName); + if ($key === 'guest:') { + continue; + } + $byFile[$fileId][$key] = [ + 'uid' => $editorUid, + 'displayName' => $editorUid !== null + ? ($this->userManager->get($editorUid)?->getDisplayName() ?? $editorUid) + : $guestName, + ]; + } + $result->closeCursor(); + + // Drop the dedup keys; preserve a stable, alphabetical order so the + // frontend renders consistently across reloads. + $out = []; + foreach ($byFile as $fileId => $editors) { + $values = array_values($editors); + usort($values, fn (array $a, array $b): int => strnatcasecmp($a['displayName'], $b['displayName'])); + $out[$fileId] = $values; + } + return $out; + } + + /** + * Map a frontend filter key (e.g. "documents") to the mime list used in + * the search predicate. Unknown / null falls back to the full set. + * + * @return list + */ + private function resolveTypeFilter(?string $type): array { + if ($type === null || $type === '' || $type === 'all') { + return self::ALLOWED_MIMETYPES; + } + return self::TYPE_GROUPS[$type] ?? self::ALLOWED_MIMETYPES; + } + + /** + * Variant of resolveTypeFilter() for templates: extends each office + * group with the matching .ott / .ots / .otp / .dotx etc. template + * mime types so user-saved template files are included. + * + * @param list $base full allowed mime list (no type filter) + * @return list + */ + private function resolveTemplateTypeFilter(?string $type, array $base): array { + if ($type === null || $type === '' || $type === 'all') { + return $base; + } + // Templates can never be PDF; gracefully ignore. + if ($type === 'pdfs') { + return []; + } + $mimes = self::TYPE_GROUPS[$type] ?? null; + if ($mimes === null) { + return $base; + } + return match ($type) { + 'documents' => array_values(array_unique(array_merge($mimes, TemplateManager::MIMES_DOCUMENTS))), + 'spreadsheets' => array_values(array_unique(array_merge($mimes, TemplateManager::MIMES_SHEETS))), + 'presentations' => array_values(array_unique(array_merge($mimes, TemplateManager::MIMES_PRESENTATIONS))), + default => $base, + }; + } + + /** + * Best-effort lookup of the share that brought a node into the user's + * folder. Used to label "Shared with me" rows with share metadata. + * + * Federated remote-server detection is left null for v1 because the + * IShare interface exposes the federated source only on dedicated + * external-share entries; we surface what we have today and degrade + * gracefully on missing fields. + * + * @return array{type: string, displayName: string, remoteServer: ?string}|null + */ + private function resolveShareSource(File $node, string $userId): ?array { + $types = [ + IShare::TYPE_USER, + IShare::TYPE_GROUP, + IShare::TYPE_CIRCLE, + IShare::TYPE_REMOTE, + IShare::TYPE_REMOTE_GROUP, + ]; + + foreach ($types as $type) { + try { + $shares = $this->shareManager->getSharedWith($userId, $type, $node, 1, 0); + } catch (\Throwable) { + continue; + } + foreach ($shares as $share) { + $ownerUid = $share->getShareOwner(); + $ownerDisplay = $ownerUid; + $ownerUser = $this->userManager->get($ownerUid); + if ($ownerUser !== null) { + $ownerDisplay = $ownerUser->getDisplayName(); + } + $remoteServer = null; + if ($type === IShare::TYPE_REMOTE || $type === IShare::TYPE_REMOTE_GROUP) { + // Federated share owners are stored as user@server. + if (str_contains((string)$ownerUid, '@')) { + $remoteServer = substr($ownerUid, strrpos($ownerUid, '@') + 1); + } + } + return [ + 'type' => $this->shareTypeLabel($type), + 'displayName' => $ownerDisplay ?: (string)$ownerUid, + 'remoteServer' => $remoteServer, + ]; + } + } + return null; + } + + private function shareTypeLabel(int $type): string { + return match ($type) { + IShare::TYPE_USER => 'user', + IShare::TYPE_GROUP => 'group', + IShare::TYPE_CIRCLE => 'circle', + IShare::TYPE_REMOTE, IShare::TYPE_REMOTE_GROUP => 'federated', + default => 'user', + }; + } + + /** + * Strip path separators / control characters / leading dots from a + * user-supplied filename. The caller appends the extension separately. + */ + private function sanitizeFilename(string $name): string { + $name = trim($name); + // Disallow path traversal and shell-meta characters. + $name = preg_replace('#[\\\\/\\x00-\\x1F\\x7F]#u', '', $name) ?? ''; + $name = ltrim($name, '.'); + // Avoid Windows-reserved names just in case. + if (preg_match('/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i', $name)) { + $name = '_' . $name; + } + // Cap length so we never exceed OS / DB limits. + if (mb_strlen($name) > 200) { + $name = mb_substr($name, 0, 200); + } + return $name; + } + + private function escapeLike(string $value): string { + // Backslash-escape the LIKE wildcards so user input can't broaden the search. + return addcslashes($value, '\\%_'); + } + + private function extensionFromMime(string $mime): ?string { + return match ($mime) { + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation' => 'pptx', + 'application/vnd.oasis.opendocument.text', 'application/vnd.oasis.opendocument.text-template' => 'odt', + 'application/vnd.oasis.opendocument.spreadsheet', 'application/vnd.oasis.opendocument.spreadsheet-template' => 'ods', + 'application/vnd.oasis.opendocument.presentation', 'application/vnd.oasis.opendocument.presentation-template' => 'odp', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.template' => 'docx', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.template' => 'xlsx', + 'application/vnd.openxmlformats-officedocument.presentationml.template' => 'pptx', + default => null, + }; + } +} diff --git a/package-lock.json b/package-lock.json index c7ecefac28..1297137039 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,8 @@ "@nextcloud/vue": "^8.33.0", "jquery": "^4.0.0", "vue": "^2.7.16", - "vue-material-design-icons": "^5.3.1" + "vue-material-design-icons": "^5.3.1", + "vue-router": "^3.6.5" }, "devDependencies": { "@cypress/browserify-preprocessor": "^3.0.2", diff --git a/package.json b/package.json index 1117b78000..57722661cf 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,8 @@ "@nextcloud/vue": "^8.33.0", "jquery": "^4.0.0", "vue": "^2.7.16", - "vue-material-design-icons": "^5.3.1" + "vue-material-design-icons": "^5.3.1", + "vue-router": "^3.6.5" }, "browserslist": [ "extends @nextcloud/browserslist-config" diff --git a/src/overview.js b/src/overview.js new file mode 100644 index 0000000000..51bb6e2c27 --- /dev/null +++ b/src/overview.js @@ -0,0 +1,52 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import './init-shared.js' +import Vue from 'vue' +import VueRouter from 'vue-router' +import { generateUrl } from '@nextcloud/router' +import { translate as t, translatePlural as n } from '@nextcloud/l10n' + +import OverviewApp from './views/Overview/OverviewApp.vue' +import HomeView from './views/Overview/HomeView.vue' +import RecentView from './views/Overview/RecentView.vue' +import SharedView from './views/Overview/SharedView.vue' +import TemplatesView from './views/Overview/TemplatesView.vue' +import { mountHoverPreview } from './views/Overview/hoverPreviewMount.js' + +Vue.prototype.t = t +Vue.prototype.n = n +Vue.prototype.OC = OC +Vue.prototype.OCA = OCA + +Vue.use(VueRouter) + +const router = new VueRouter({ + mode: 'history', + // The Nextcloud route /apps/richdocuments/overview is the SPA shell; + // every sub-path (recent, shared, templates) is rendered client-side. + // No trailing slash here — vue-router 3 normalises base internally and + // matches the Files app pattern. + base: generateUrl('/apps/richdocuments/overview'), + routes: [ + { path: '/', name: 'home', component: HomeView }, + { path: '/recent', name: 'recent', component: RecentView }, + { path: '/shared', name: 'shared', component: SharedView }, + { path: '/templates', name: 'templates', component: TemplatesView }, + { path: '*', redirect: { name: 'home' } }, + ], +}) + +// Mount AT #content (replacing it) the same way the Files app does. NcContent +// uses position:fixed and was designed to BE #content, so wrapping it inside +// a child div makes it overlay the Nextcloud header / app menu instead of +// sitting alongside them. +const OverviewAppVue = Vue.extend(OverviewApp) +new OverviewAppVue({ + router, +}).$mount('#content') + +// Singleton hover-preview popover lives directly under so it +// escapes the overflow:hidden / transform of cards and NcContent. +mountHoverPreview() diff --git a/src/views/Overview/CreateFromTemplateModal.vue b/src/views/Overview/CreateFromTemplateModal.vue new file mode 100644 index 0000000000..534e09acef --- /dev/null +++ b/src/views/Overview/CreateFromTemplateModal.vue @@ -0,0 +1,328 @@ + + + + + + + diff --git a/src/views/Overview/DocumentCard.vue b/src/views/Overview/DocumentCard.vue new file mode 100644 index 0000000000..772bcfa114 --- /dev/null +++ b/src/views/Overview/DocumentCard.vue @@ -0,0 +1,334 @@ + + + + + + + diff --git a/src/views/Overview/DocumentRow.vue b/src/views/Overview/DocumentRow.vue new file mode 100644 index 0000000000..77018a7b71 --- /dev/null +++ b/src/views/Overview/DocumentRow.vue @@ -0,0 +1,427 @@ + + + + + + + diff --git a/src/views/Overview/EditorBadges.vue b/src/views/Overview/EditorBadges.vue new file mode 100644 index 0000000000..b2cd605bfd --- /dev/null +++ b/src/views/Overview/EditorBadges.vue @@ -0,0 +1,117 @@ + + + + + + + diff --git a/src/views/Overview/EmptyIllustration.vue b/src/views/Overview/EmptyIllustration.vue new file mode 100644 index 0000000000..c17d89d340 --- /dev/null +++ b/src/views/Overview/EmptyIllustration.vue @@ -0,0 +1,271 @@ + + + + + + + diff --git a/src/views/Overview/HomeSection.vue b/src/views/Overview/HomeSection.vue new file mode 100644 index 0000000000..26dce9f666 --- /dev/null +++ b/src/views/Overview/HomeSection.vue @@ -0,0 +1,136 @@ + + + + + + + diff --git a/src/views/Overview/HomeView.vue b/src/views/Overview/HomeView.vue new file mode 100644 index 0000000000..25b259fafd --- /dev/null +++ b/src/views/Overview/HomeView.vue @@ -0,0 +1,188 @@ + + + + + + + diff --git a/src/views/Overview/HoverPreview.vue b/src/views/Overview/HoverPreview.vue new file mode 100644 index 0000000000..859a084711 --- /dev/null +++ b/src/views/Overview/HoverPreview.vue @@ -0,0 +1,94 @@ + + + + + + + diff --git a/src/views/Overview/ListView.vue b/src/views/Overview/ListView.vue new file mode 100644 index 0000000000..04fda34d55 --- /dev/null +++ b/src/views/Overview/ListView.vue @@ -0,0 +1,523 @@ + + + + + + + diff --git a/src/views/Overview/OverviewApp.vue b/src/views/Overview/OverviewApp.vue new file mode 100644 index 0000000000..da7d056fbc --- /dev/null +++ b/src/views/Overview/OverviewApp.vue @@ -0,0 +1,101 @@ + + + + + + + diff --git a/src/views/Overview/RecentView.vue b/src/views/Overview/RecentView.vue new file mode 100644 index 0000000000..54656a5b7d --- /dev/null +++ b/src/views/Overview/RecentView.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/src/views/Overview/SearchBar.vue b/src/views/Overview/SearchBar.vue new file mode 100644 index 0000000000..0af56e2845 --- /dev/null +++ b/src/views/Overview/SearchBar.vue @@ -0,0 +1,123 @@ + + + + + + + diff --git a/src/views/Overview/SharedView.vue b/src/views/Overview/SharedView.vue new file mode 100644 index 0000000000..302d8b87f0 --- /dev/null +++ b/src/views/Overview/SharedView.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/src/views/Overview/SubtleHint.vue b/src/views/Overview/SubtleHint.vue new file mode 100644 index 0000000000..5d35794c40 --- /dev/null +++ b/src/views/Overview/SubtleHint.vue @@ -0,0 +1,37 @@ + + + + + + + diff --git a/src/views/Overview/TemplateCard.vue b/src/views/Overview/TemplateCard.vue new file mode 100644 index 0000000000..8c91c54ea3 --- /dev/null +++ b/src/views/Overview/TemplateCard.vue @@ -0,0 +1,196 @@ + + + + + + + diff --git a/src/views/Overview/TemplateRow.vue b/src/views/Overview/TemplateRow.vue new file mode 100644 index 0000000000..b8f4d23215 --- /dev/null +++ b/src/views/Overview/TemplateRow.vue @@ -0,0 +1,178 @@ + + + + + + + diff --git a/src/views/Overview/TemplatesView.vue b/src/views/Overview/TemplatesView.vue new file mode 100644 index 0000000000..3b19106d83 --- /dev/null +++ b/src/views/Overview/TemplatesView.vue @@ -0,0 +1,56 @@ + + + + + diff --git a/src/views/Overview/TypeFilter.vue b/src/views/Overview/TypeFilter.vue new file mode 100644 index 0000000000..27729799e1 --- /dev/null +++ b/src/views/Overview/TypeFilter.vue @@ -0,0 +1,115 @@ + + + + + + + diff --git a/src/views/Overview/ViewToggle.vue b/src/views/Overview/ViewToggle.vue new file mode 100644 index 0000000000..ac546abced --- /dev/null +++ b/src/views/Overview/ViewToggle.vue @@ -0,0 +1,79 @@ + + + + + + + diff --git a/src/views/Overview/api.js b/src/views/Overview/api.js new file mode 100644 index 0000000000..1509f7d881 --- /dev/null +++ b/src/views/Overview/api.js @@ -0,0 +1,88 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import axios from '@nextcloud/axios' +import { generateOcsUrl } from '@nextcloud/router' + +const HOME_LIMIT = 6 +const PAGE_LIMIT = 25 + +const ocsUrl = path => generateOcsUrl('apps/richdocuments/api/v1/overview/' + path) + +// Standard headers for OCS endpoints. `OCS-APIRequest: true` makes the +// request pass Nextcloud's CSRF check via the OCS-bypass branch in +// IRequest::passesCSRFCheck() — necessary for POSTs in particular. +const OCS_HEADERS = { 'OCS-APIRequest': 'true' } + +const unwrap = response => response.data?.ocs?.data ?? { items: [], nextOffset: null } + +/** + * Fetch one page of the recent / shared / templates list. + * + * @param {('recent'|'shared'|'templates')} kind which list to fetch + * @param {object} options request options + * @param {string|null} [options.q] search query + * @param {string|null} [options.type] mime-type filter group key + * @param {number} [options.offset] pagination offset + * @param {number} [options.limit] page size + * @param {AbortSignal} [options.signal] cancellation signal + * @return {Promise<{items: Array, nextOffset: ?number}>} + */ +export async function fetchList(kind, { q = null, type = null, offset = 0, limit = PAGE_LIMIT, signal } = {}) { + const params = { offset, limit } + if (q) { + params.q = q + } + if (type) { + params.type = type + } + const response = await axios.get(ocsUrl(kind), { params, signal, headers: OCS_HEADERS }) + return unwrap(response) +} + +/** + * Fetch the small "home" preview slice for a kind (no search, no offset). + * + * @param {('recent'|'shared'|'templates')} kind which list to fetch + * @param {AbortSignal} [signal] cancellation signal + * @return {Promise<{items: Array, nextOffset: ?number}>} + */ +export function fetchHome(kind, signal) { + return fetchList(kind, { offset: 0, limit: HOME_LIMIT, signal }) +} + +/** + * Create a new file from a template. + * + * @param {object} payload body + * @param {number} payload.templateFileId template node id + * @param {string} payload.filename desired filename (no extension) + * @param {string} payload.folderPath user-relative target folder + * @return {Promise<{fileid: number, name: string, path: string, openUrl: string}>} + */ +export async function createFromTemplate({ templateFileId, filename, folderPath }) { + const response = await axios.post(ocsUrl('create-from-template'), { + templateFileId, + filename, + folderPath, + }, { headers: OCS_HEADERS }) + return response.data?.ocs?.data ?? null +} + +/** + * Toggle the favourite (pinned) flag for a file. The backend wires through + * to Nextcloud's per-user favourites tagger so pins are also visible in + * the Files app's "Favorites" view. + * + * @param {number} fileid file node id + * @param {boolean} favorite desired flag + * @return {Promise<{fileid: number, favorite: boolean}>} + */ +export async function setFavourite(fileid, favorite) { + const response = await axios.post(ocsUrl('favourite'), { fileid, favorite }, { headers: OCS_HEADERS }) + return response.data?.ocs?.data ?? { fileid, favorite } +} + +export const PAGE_SIZE = PAGE_LIMIT +export const HOME_SECTION_LIMIT = HOME_LIMIT diff --git a/src/views/Overview/confetti.js b/src/views/Overview/confetti.js new file mode 100644 index 0000000000..1faa46a1a5 --- /dev/null +++ b/src/views/Overview/confetti.js @@ -0,0 +1,96 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Tiny dependency-free confetti burst rendered into a fixed-position layer + * appended to . Auto-cleans after the animation finishes. + * + * Honours `prefers-reduced-motion`: when the user has reduced motion + * enabled, returns immediately without rendering anything. + * + * @param {object} [options] burst options + * @param {number} [options.count] number of particles + * @param {number} [options.duration] total animation duration in ms + * @param {Array} [options.colors] particle colours + * @param {{x: number, y: number}} [options.origin] viewport-relative origin in pixels + */ +export function fireConfetti({ + count = 80, + duration = 900, + colors = ['#46ba61', '#2c73d2', '#d24726', '#f5b400', '#9b59b6', '#1abc9c'], + origin, +} = {}) { + if (typeof window === 'undefined' || typeof document === 'undefined') { + return + } + const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches + if (reduce) { + return + } + + const ox = origin?.x ?? window.innerWidth / 2 + const oy = origin?.y ?? window.innerHeight / 2 + + const layer = document.createElement('div') + layer.setAttribute('aria-hidden', 'true') + layer.style.cssText = [ + 'position:fixed', + 'inset:0', + 'pointer-events:none', + 'overflow:hidden', + 'z-index:99999', + ].join(';') + document.body.appendChild(layer) + + for (let i = 0; i < count; i++) { + const piece = document.createElement('span') + const angle = Math.random() * Math.PI * 2 + const speed = 120 + Math.random() * 220 + const dx = Math.cos(angle) * speed + const dy = Math.sin(angle) * speed - 80 // slight upward bias + const rotation = (Math.random() * 720 - 360).toFixed(0) + const color = colors[i % colors.length] + const size = 6 + Math.random() * 6 + const lifetime = duration * (0.7 + Math.random() * 0.3) + piece.style.cssText = [ + 'position:absolute', + `left:${ox}px`, + `top:${oy}px`, + `width:${size}px`, + `height:${size * 0.6}px`, + `background:${color}`, + 'border-radius:1px', + `transform:translate(-50%, -50%) rotate(${(Math.random() * 360).toFixed(0)}deg)`, + 'will-change:transform, opacity', + `animation:confetti-piece ${lifetime}ms cubic-bezier(0.16, 1, 0.3, 1) forwards`, + `--dx:${dx.toFixed(0)}px`, + `--dy:${dy.toFixed(0)}px`, + `--rot:${rotation}deg`, + ].join(';') + layer.appendChild(piece) + } + + // Inject the keyframes once. + if (!document.getElementById('overview-confetti-keyframes')) { + const style = document.createElement('style') + style.id = 'overview-confetti-keyframes' + style.textContent = ` + @keyframes confetti-piece { + 0% { + opacity: 1; + transform: translate(-50%, -50%) rotate(0deg); + } + 100% { + opacity: 0; + transform: translate(calc(-50% + var(--dx)), calc(-50% + var(--dy))) rotate(var(--rot)); + } + } + ` + document.head.appendChild(style) + } + + // Clean up the layer once the longest animation finishes. + window.setTimeout(() => layer.remove(), duration + 200) +} diff --git a/src/views/Overview/dateGrouping.js b/src/views/Overview/dateGrouping.js new file mode 100644 index 0000000000..eb7edc287c --- /dev/null +++ b/src/views/Overview/dateGrouping.js @@ -0,0 +1,67 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { translate as t } from '@nextcloud/l10n' + +/** + * Bucket a list of items into date groups based on each item's `mtime`. + * + * Buckets, in order: + * - today + * - yesterday + * - this-week (the rest of the current week up to last Monday) + * - this-month (the rest of the current month) + * - older + * + * Empty buckets are filtered out so the consumer can iterate the result + * directly without rendering empty section headers. + * + * @param {Array<{mtime?: number}>} items document items, each with a unix mtime + * @param {number} [now] override "now" for testing (ms epoch) + * @return {Array<{key: string, title: string, items: Array}>} ordered, non-empty groups + */ +export function groupItemsByDate(items, now = Date.now()) { + const today = new Date(now) + today.setHours(0, 0, 0, 0) + + const yesterday = new Date(today) + yesterday.setDate(yesterday.getDate() - 1) + + // Start of the ISO-ish week — last Monday. + const dow = today.getDay() // 0 (Sun) .. 6 (Sat) + const daysSinceMonday = dow === 0 ? 6 : dow - 1 + const weekStart = new Date(today) + weekStart.setDate(weekStart.getDate() - daysSinceMonday) + + const monthStart = new Date(today.getFullYear(), today.getMonth(), 1) + + const buckets = { + today: { key: 'today', title: t('richdocuments', 'Today'), items: [] }, + yesterday: { key: 'yesterday', title: t('richdocuments', 'Yesterday'), items: [] }, + week: { key: 'week', title: t('richdocuments', 'Earlier this week'), items: [] }, + month: { key: 'month', title: t('richdocuments', 'Earlier this month'), items: [] }, + older: { key: 'older', title: t('richdocuments', 'Older'), items: [] }, + } + + for (const item of items) { + const ms = (item.mtime ?? 0) * 1000 + const itemDay = new Date(ms) + itemDay.setHours(0, 0, 0, 0) + const dayMs = itemDay.getTime() + + if (dayMs >= today.getTime()) { + buckets.today.items.push(item) + } else if (dayMs >= yesterday.getTime()) { + buckets.yesterday.items.push(item) + } else if (dayMs >= weekStart.getTime()) { + buckets.week.items.push(item) + } else if (dayMs >= monthStart.getTime()) { + buckets.month.items.push(item) + } else { + buckets.older.items.push(item) + } + } + + return Object.values(buckets).filter(b => b.items.length > 0) +} diff --git a/src/views/Overview/hoverPreviewMixin.js b/src/views/Overview/hoverPreviewMixin.js new file mode 100644 index 0000000000..5ed60fdf43 --- /dev/null +++ b/src/views/Overview/hoverPreviewMixin.js @@ -0,0 +1,51 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { openHoverPreview, closeHoverPreview } from './hoverPreviewState.js' + +const HOVER_DELAY_MS = 400 + +/** + * Mixin that wires `mouseenter` / `mouseleave` (and focus / blur) handlers + * on a row or card to the singleton hover-preview popover. + * + * Components mixing this in must expose: + * - `largePreviewUrl` (computed): high-res thumbnail URL + * - `fallbackIcon` (computed): icon component for missing previews + * + * The component name is derived from `this.document.name` or + * `this.template.name`, whichever is present. + */ +export default { + data() { + return { hoverTimer: null } + }, + beforeDestroy() { + this.cancelHoverPreview() + closeHoverPreview() + }, + methods: { + onHoverEnter(event) { + this.cancelHoverPreview() + const target = event.currentTarget + this.hoverTimer = window.setTimeout(() => { + openHoverPreview(target, { + src: this.largePreviewUrl, + name: this.document?.name ?? this.template?.name ?? '', + fallbackIcon: this.fallbackIcon, + }) + }, HOVER_DELAY_MS) + }, + onHoverLeave() { + this.cancelHoverPreview() + closeHoverPreview() + }, + cancelHoverPreview() { + if (this.hoverTimer) { + clearTimeout(this.hoverTimer) + this.hoverTimer = null + } + }, + }, +} diff --git a/src/views/Overview/hoverPreviewMount.js b/src/views/Overview/hoverPreviewMount.js new file mode 100644 index 0000000000..722011716c --- /dev/null +++ b/src/views/Overview/hoverPreviewMount.js @@ -0,0 +1,57 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import Vue from 'vue' +import HoverPreview from './HoverPreview.vue' +import { hoverPreviewState, markHoverPreviewError } from './hoverPreviewState.js' + +let mounted = false + +/** + * Mount a singleton hover-preview popover directly under . + * + * Mounting outside the SPA's NcContent root sidesteps two browser + * behaviours that would otherwise hide the popover inside grid cards: + * - NcContent has `overflow: hidden`, which clips fixed-position + * descendants in some engines. + * - Cards have `transform` on hover, which makes them the containing + * block for any fixed-position descendants AND clips them via + * `overflow: hidden`. + * + * The popover reads from the shared `hoverPreviewState` so any row or + * card across the app can drive it from anywhere in the tree. + */ +export function mountHoverPreview() { + if (mounted || typeof document === 'undefined') { + return + } + mounted = true + + const el = document.createElement('div') + el.setAttribute('data-richdocuments-hover-preview', '') + document.body.appendChild(el) + + /* eslint-disable-next-line no-new */ + new Vue({ + render(h) { + if (!hoverPreviewState.open) { + // Render nothing while hidden — Vue still tracks the + // `open` read so the next mutation re-runs render(). + return h() + } + return h(HoverPreview, { + props: { + popoverStyle: hoverPreviewState.style, + src: hoverPreviewState.src, + name: hoverPreviewState.name, + fallbackIcon: hoverPreviewState.fallbackIcon, + errored: hoverPreviewState.errored, + }, + on: { + error: markHoverPreviewError, + }, + }) + }, + }).$mount(el) +} diff --git a/src/views/Overview/hoverPreviewState.js b/src/views/Overview/hoverPreviewState.js new file mode 100644 index 0000000000..a28e77c05e --- /dev/null +++ b/src/views/Overview/hoverPreviewState.js @@ -0,0 +1,89 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import Vue from 'vue' + +const PREVIEW_WIDTH = 360 +const PREVIEW_HEIGHT = 320 +const VIEWPORT_MARGIN = 12 + +/** + * Singleton reactive state for the hover-preview popover. + * + * The popover lives once at the SPA root (OverviewApp) so it escapes any + * `transform` / `overflow:hidden` ancestor — grid cards have both, which + * would otherwise clip a popover rendered inside them. + * + * @type {{open: boolean, style: object, src: string, name: string, fallbackIcon: ?(object|Function), errored: boolean}} reactive popover state + */ +export const hoverPreviewState = Vue.observable({ + open: false, + style: {}, + src: '', + name: '', + fallbackIcon: null, + errored: false, +}) + +/** + * Show the popover anchored to the given target element. No-ops on + * `prefers-reduced-motion` so users who opt out of motion don't see + * surprise popovers. + * + * @param {HTMLElement} target hovered element + * @param {object} payload preview content + * @param {string} [payload.src] thumbnail URL + * @param {string} [payload.name] file or template name + * @param {?(object|Function)} [payload.fallbackIcon] icon component for missing previews + */ +export function openHoverPreview(target, { src = '', name = '', fallbackIcon = null } = {}) { + if (typeof window === 'undefined' || !target) { + return + } + if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) { + return + } + + const rect = target.getBoundingClientRect() + + let left = rect.left + const maxLeft = window.innerWidth - PREVIEW_WIDTH - VIEWPORT_MARGIN + left = Math.max(VIEWPORT_MARGIN, Math.min(left, maxLeft)) + + let top = rect.bottom + VIEWPORT_MARGIN + if (top + PREVIEW_HEIGHT > window.innerHeight - VIEWPORT_MARGIN) { + const above = rect.top - PREVIEW_HEIGHT - VIEWPORT_MARGIN + if (above >= VIEWPORT_MARGIN) { + top = above + } else { + top = Math.max(VIEWPORT_MARGIN, window.innerHeight - PREVIEW_HEIGHT - VIEWPORT_MARGIN) + } + } + + hoverPreviewState.style = { + left: `${Math.round(left)}px`, + top: `${Math.round(top)}px`, + } + hoverPreviewState.src = src + hoverPreviewState.name = name + hoverPreviewState.fallbackIcon = fallbackIcon + hoverPreviewState.errored = false + hoverPreviewState.open = true +} + +/** + * Hide the popover. Safe to call repeatedly. + */ +export function closeHoverPreview() { + hoverPreviewState.open = false + hoverPreviewState.errored = false +} + +/** + * Mark the current preview as errored so the consumer renders a fallback + * icon rather than a broken image. + */ +export function markHoverPreviewError() { + hoverPreviewState.errored = true +} diff --git a/src/views/Overview/typeStyles.js b/src/views/Overview/typeStyles.js new file mode 100644 index 0000000000..593b32af3a --- /dev/null +++ b/src/views/Overview/typeStyles.js @@ -0,0 +1,72 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Map a mimetype to one of the four type-color families used throughout + * the overview UI. Returns null for anything we don't surface. + * + * @param {string} mimetype the file's mimetype + * @return {('document'|'spreadsheet'|'presentation'|'pdf'|null)} type family + */ +export function familyFor(mimetype) { + switch (mimetype) { + case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': + case 'application/vnd.openxmlformats-officedocument.wordprocessingml.template': + case 'application/vnd.oasis.opendocument.text': + case 'application/vnd.oasis.opendocument.text-template': + return 'document' + case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': + case 'application/vnd.openxmlformats-officedocument.spreadsheetml.template': + case 'application/vnd.oasis.opendocument.spreadsheet': + case 'application/vnd.oasis.opendocument.spreadsheet-template': + return 'spreadsheet' + case 'application/vnd.openxmlformats-officedocument.presentationml.presentation': + case 'application/vnd.openxmlformats-officedocument.presentationml.template': + case 'application/vnd.oasis.opendocument.presentation': + case 'application/vnd.oasis.opendocument.presentation-template': + return 'presentation' + case 'application/pdf': + return 'pdf' + default: + return null + } +} + +/** + * Type-family palette. Each entry exposes: + * bg: 12% tinted background (for thumbnail surface when no preview) + * accent: solid accent (for icon stroke and corner badge) + * + * Hues chosen to roughly match the office suite conventions while + * staying readable in both light and dark themes. + */ +export const TYPE_PALETTE = { + document: { bg: 'rgba(44, 115, 210, 0.14)', accent: '#2c73d2' }, + spreadsheet: { bg: 'rgba(31, 122, 61, 0.14)', accent: '#1f7a3d' }, + presentation: { bg: 'rgba(210, 71, 38, 0.14)', accent: '#d24726' }, + pdf: { bg: 'rgba(196, 51, 51, 0.14)', accent: '#c43333' }, +} + +/** + * Resolve the per-mimetype CSS custom-property bag to set on a row/card + * root element. Returns an object suitable for `:style="..."`. + * + * Components reference the variables (`--type-bg`, `--type-accent`) + * inside scoped styles so themes can still override them via inheritance. + * + * @param {string} mimetype the file's mimetype + * @return {{'--type-bg': string, '--type-accent': string}} CSS custom-property bag + */ +export function typeStyle(mimetype) { + const family = familyFor(mimetype) + const palette = (family && TYPE_PALETTE[family]) || { + bg: 'var(--color-background-dark)', + accent: 'var(--color-text-maxcontrast)', + } + return { + '--type-bg': palette.bg, + '--type-accent': palette.accent, + } +} diff --git a/templates/overview.php b/templates/overview.php new file mode 100644 index 0000000000..15f3a12474 --- /dev/null +++ b/templates/overview.php @@ -0,0 +1,11 @@ + diff --git a/webpack.js b/webpack.js index c2d77b49ae..f795686119 100644 --- a/webpack.js +++ b/webpack.js @@ -17,6 +17,7 @@ webpackConfig.entry = { personal: path.join(__dirname, 'src', 'personal.js'), reference: path.join(__dirname, 'src', 'reference.js'), public: path.join(__dirname, 'src', 'public.js'), + overview: path.join(__dirname, 'src', 'overview.js'), } webpackRules.RULE_JS.test = /\.m?js$/