Frank/feat/photo actions menu#3487
Open
karlitschek wants to merge 7 commits intomasterfrom
Open
Conversation
Move the photos app off Vue 2.7 onto the Vue 3.5 toolchain and
update the surrounding Nextcloud library line to its Vue 3
counterparts.
Toolchain
- vue 2.7 → 3.5; vue-router 3 → 4; vuex 3 → 4; pinia 2 → 3
- @nextcloud/vue 8 → 9; @nextcloud/dialogs 6 → 7;
@nextcloud/upload 1 → 2.0.0-rc.0
- @nextcloud/vite-config 1 → 2.5; @vue/tsconfig 0.5 → 0.8;
@vue/test-utils 1 → 2; eslint 10
- vuex-router-sync 5 → 6.0.0-rc.1
- Drop vue-template-compiler, vue2-leaflet, vue-virtual-grid
Entry points and core wiring
- main.ts / public.ts / sidebar.ts / dashboard.ts now use
createApp + app.use(...) + app.mount() (sidebar uses createApp /
app.unmount() per tab mount/destroy)
- router/index.ts: createRouter + createWebHistory; /maps redirects
via beforeEnter return value
- store/index.ts: createStore; vuex-router-sync v6
- services/GridConfig.ts switched from a Vue 2 instance event-bus
to a reactive() singleton; mixin reads it via computed
- All `import Vue from 'vue'`, Vue.set/Vue.delete/Vue.nextTick,
Vue.use(...), and Vue.prototype assignments removed
- this.$set / this.$delete replaced with direct assignment / delete
- beforeDestroy / destroyed renamed to beforeUnmount / unmounted
Template syntax
- Vue 2 slot= / slot-scope= → Vue 3 v-slot / #name (74 conversions
across 16 components)
- Vue 2 `filters: {}` option + `{{ x | y }}` pipes → methods
- .sync modifiers → v-model:propName
- :deep with space → :deep(...)
- .native modifier removed
- vue-router 4 dropped the `exact` prop
- inheritAttrs: false removed where parents now pass class/style
(which Vue 3 routes through $attrs and would otherwise drop)
- TiledRows.vue: `<template functional>` rewritten as a regular
defineComponent
Reactivity / private-field trap
- Vue 3's reactive Proxy is incompatible with classes that use ES
private fields (#field). markRaw the AbortController,
SemaphoreWithPriority, ResizeObserver, IntersectionObserver,
Uploader and webdav client instances stored in data(); use
shallowRef in useAbortController.
Library replacements
- LocationMap.vue: vue2-leaflet → @vue-leaflet/vue-leaflet
- FoldersView.vue: vue-virtual-grid replaced with new GridLayout
components (GridLayout.vue / GridLayout.ts / GridRow.vue) +
VirtualScrolling, adopted from the upstream artonge/migrate_to_vue3
branch
- @nextcloud/upload@2 dropped <UploadPicker>; replaced by a small
LocalUploadPicker.vue that wraps getUploader() / batchUpload()
- isMobile mixin → useIsMobile() composable
- @nextcloud/dialogs v7: DialogSeverity enum → string-literal API
- @nextcloud/event-bus v3 typed channels declared in event-bus.d.ts
Type system
- tsconfig: vueCompilerOptions.target 2.7 → 3.5
- New global.d.ts with OC / OCA ambient declarations
- New assets-modules.d.ts for *.svg / *.svg?raw / *.png / *.jpg
- vuex.d.ts: corrected store path (./store.ts → ./store/index.ts)
so $store augmentation flows through component types
- vue-router 4: Route → RouteLocationNamedRaw
- @vue/tsconfig 0.8: ComponentPublicInstanceConstructor (Vue 2.7
internal) → Component
- webdav 5: GetDirectoryContentsOptions → ...WithDetails
Lint and code-quality cleanup
- ESLint config switched to recommendedVue2 → recommended (Vue 3)
- Auto-fixed attribute hyphenation and event hyphenation
- Custom event / slot naming kept as kebab-case (project
convention) via local override
- Added explicit `emits: []` declarations across 10 components
- Removed 5 unused template refs
- Boolean prop defaults audited (2 inverted to false; 2 kept on
with eslint-disable + rationale)
- 2 cypress JSDoc inline-tag escapes
- preserve-caught-error fixes (added cause)
Build state
- npm run build: ✓ 9.94s, full bundle produced
- eslint .: 0 errors, 0 warnings
- vitest run: 1/1 passing
Carried-over caveats (documented in source with TODO markers):
- OCA.Files.Sidebar tab registration is deprecated in NC 33; we
cannot move to the new `@nextcloud/files@4` Sidebar API yet
because @nextcloud/upload@2.0.0-rc.0 hard-pins
@nextcloud/files@^3.10.2.
Signed-off-by: Frank Karlitschek <frank@nextcloud.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
OCP\Files\DavUtil::getDavPermissions was changed in Nextcloud 34 to require a parent FileInfo as a second argument (used to determine renamability). Our PropFindPlugin still called it with a single argument, throwing an ArgumentCountError on every PROPFIND of a photos node and surfacing in the UI as "Failed to fetch collections list." Pass the node's parent through, mirroring the pattern used in server's apps/dav and apps/files. Signed-off-by: Frank Karlitschek <frank@nextcloud.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…sfade
Three UI improvements that build on the Vue 3 migration.
1. Consolidate on the justified-row grid algorithm
FoldersView was the last surface still using the fixed-square
GridLayout component (introduced during Phase 3 to replace
vue-virtual-grid). Switch it to TiledLayout, which is the same
row-justified algorithm the timeline and album content already
use, and delete the now-unused GridLayout/ directory.
Folders and files are mapped through a small wrapper that adds
the `id` and `ratio` fields TiledLayout needs (ratio=1 for both
since the folder-listing endpoint doesn't return per-photo
dimensions); the inner FileLegacy / FolderComponent rely on
`object-fit: cover` to fill whatever tile shape is assigned.
FileLegacy and FolderComponent also lose the `item.injected.X`
indirection inherited from vue-virtual-grid's wrapper shape and
read the item directly, matching the upstream Vue 3 migration
branch.
2. Blurhash → small → large crossfade in FileComponent
The previous code toggled the canvas blurhash and the two img
layers via v-if chains driven by `loadedSmall`/`loadedLarge`,
producing a hard pop as each layer arrived. Keep all three in
the DOM and stack them: blurhash z-index 1, small thumbnail z-2
(200ms fade-in), large preview z-3 (250ms fade-in). Each upper
layer covers the one beneath as it becomes opaque, so the
visual is a smooth blur → pixelated → sharp progression.
Bonus fix: beforeUnmount referenced this.$refs.srcLarge (typo)
instead of imgLarge, leaving in-flight large-preview loads
uncancelled when a tile scrolled out of view.
3. Tile density toggle (small / default / large)
New view-density toggle in the timeline header — NcActions menu
with three NcActionRadio options. The selection drives a
tileBaseHeight computed (mobile 80/120/200, desktop 120/200/320)
that's forwarded to FilesListViewer.baseHeight; TiledLayout's
row-justification already adapts to any base height.
Persisted via the existing apps/photos/api/v1/config/{key}
endpoint:
- frontend: new `gridDensity: 'small' | 'medium' | 'large'`
field on the userConfig store (default 'medium').
- backend: UserConfigService.DEFAULT_CONFIGS gets `gridDensity`
so it's auto-hydrated into the page initial state alongside
the other config keys; ApiController::setUserConfig validates
the value is one of small | medium | large.
Build / lint / tests all green after these changes.
Signed-off-by: Frank Karlitschek <frank@nextcloud.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five user-visible additions on top of the Vue 3 migration. Each is
intentionally minimum-viable so they ship together; each has clear
"deferred to a follow-up" boundaries called out below.
1. Free-text search by filename
- New PhotosFilter `nameFilter` that emits a DAV `<d:like>` query
against `<d:displayname/>`. Special characters in the user's
query are escaped (XML + SQL LIKE wildcards) before being
embedded in the request body.
- NcTextField in PhotosApp's `#search` slot, debounced 300ms.
Typing pushes a single value into `selectedFilters.name`; the
existing filtersQuery / extraFilters pipeline does the rest, so
search composes cleanly with the existing date-range and place
filters.
- Deferred: content search ("beach", "sunset") — that requires
Recognize-app integration and is out of scope here. The
filename + EXIF-anchored search should already feel
transformative.
2. Trip memories (/memories)
- New `services/memories.ts` clusters loaded photos by capture-
date gaps (>2 days starts a new trip; clusters of <8 photos
are dropped to keep noise out). Cover photo is picked from
near the trip's midpoint to avoid arrival/departure shots.
- New MemoriesView with tile cards (cover + date range + count).
Click opens the NC Viewer with the trip's photos as the
gallery list.
- Routed at /memories with a navigation entry next to "On this
day". On first visit the view triggers a 500-photo fetch so
the page isn't empty before the timeline has been scrolled.
- Deferred: server-side trip detection (would scale to libraries
of millions of photos), and place-anchored / "year ago today"
memory cards.
3. Photos map (/map)
- New MapView using @vue-leaflet's LMap + LMarker, plotting every
loaded photo that has GPS metadata. Click a marker → opens NC
Viewer with all geotagged photos as the gallery list.
- `nc:metadata-photos-gps` is now registered in main.ts so the
timeline endpoint returns the field; previously it was only
registered in sidebar.ts.
- Centred on the centroid of the first 200 photos so users land
somewhere relevant rather than on null island.
- Replaces the previous /maps route that redirected out to the
external Maps app. The inline view works for everyone whether
or not Maps is installed.
- Deferred: leaflet.markercluster integration (real libraries
above ~5000 geotagged photos will need it).
4. Photo slideshow
- New Slideshow component, full-page Teleport, plays through a
supplied list of photos with play/pause + prev/next + close
and ESC/arrow/space keyboard shortcuts. Auto-advance is 4s.
- Triggered from a "Slideshow" button in the TimelineView header
(visible when no selection is active and at least one photo
has been fetched). Plays through the timeline's currently
loaded photos in capture order.
- Deliberately self-contained — the NC Viewer app is in a
separate codebase, so adding a slideshow mode there is
out-of-scope. This stays a Photos-internal feature.
5. Long-press multi-select
- FileComponent now starts a 500ms long-press timer on
pointerdown; if the press is held that long, the selection
toggles and the subsequent click is swallowed. Mouse and
touch both go through pointer events, so the same code paths
handle desktop right-click-equivalent and mobile tap-and-hold.
- The existing checkbox-on-hover affordance still works on
desktop; long-press is the addition for mobile users for whom
the checkbox was a finicky tap target.
Build / lint / tests all green after these changes.
Signed-off-by: Frank Karlitschek <frank@nextcloud.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rations, animated counters, album hero, year recap, EXIF overlay
Eight micro / meso polish items, each at minimum-viable scope. Calls
out the deferred bits inline so reviewers know what's deliberately not
in scope.
Animated favourite star
- Replace `v-once` on FavoriteIcon (which silently masked any
toggle from the bulk-action menu) with a Vue `<Transition>`. The
enter animation is a 320ms scale-bounce keyframe with a cubic
overshoot timing function; leave fades out in 180ms.
- Skipped: the "particle burst on first favourite of session" bit;
that needs session-state plumbing and a one-shot sentinel.
Selection ripple
- The previous `.selected` ring was a hard `outline` glued to the
tile edge. Replace with a soft 0.97-scale lift + a 3px primary-
colour glow + a 6px-blur drop shadow, all transitioned in 160ms.
Keeps the focus ring (different state) for keyboard interaction
via `&:focus-within / &:has(:focus)` so focus and selection are
visually distinct.
Skeleton shimmer
- Layer a translucent diagonal gradient sweep on top of the
blurhash (or the empty primary-element-light background when no
blurhash exists) until the small or large preview lands. Pure
CSS — translating gradient — so it costs nothing JS-side.
Honours `prefers-reduced-motion: reduce`.
Illustrated empty states
- New EmptyIllustration.vue with four hand-drawn-ish SVG
compositions (memories / map / faces / timeline). Each uses the
Photos accent colour via `var(--color-primary-element)` so it
follows the user's theme, including dark mode.
- Wired into MemoriesView and MapView; FacesView / empty-timeline
TBD when those surfaces get a polish pass.
Animated counters
- Tiny AnimatedNumber.vue that tween-displays a `value` prop using
requestAnimationFrame with an ease-out cubic curve, slot-exposing
the live `displayValue` so callers can wrap it in their own
translation (e.g. `n('photos', '%n photo', '%n photos', value)`).
Honours `prefers-reduced-motion: reduce` by snapping straight to
the final value.
- Used in Memories trip cards + the year-recap feature card; can be
dropped in elsewhere later without changes.
Album page magazine spread
- New AlbumHero.vue: the album cover photo as a full-bleed 280px-
tall hero with the album title overlaid on a darkening gradient.
Subtitle combines location (if any) with the photo count via
`translatePlural`. Hooks a passive scroll listener to translate
the cover background up to 60px slower than the page for a soft
parallax effect.
- Skipped (called out in commit so they're easy to find): album
cover picker UI and accent-colour extraction from the cover
image. Both worthwhile follow-ups; both larger than this round.
Year-in-review recap
- New `buildYearRecap` in services/memories.ts: groups loaded
photos by year, picks the most-recent year with at least 30
photos, then curates a target-of-60 set by including all
favourites first then sampling evenly across the calendar so
December doesn't dominate. Cover is the curated-set midpoint
(avoiding the "first or last photo of the year" trap).
- Surface as a hero feature card on MemoriesView (above the trip
grid) with eyebrow / title / count metadata. Click opens the
curated set in the existing in-app Slideshow component, so the
flow is "click → autoplay slideshow → close to return" with no
cross-app coordination needed.
- Skipped: text-card sections between groups, music, and "X years
ago today" cards. They're separate algorithms.
EXIF overlay in the slideshow
- Press `i` in the slideshow to toggle a frosted-glass aside that
surfaces camera (Make + Model from IFD0), aperture, focal
length, exposure, and ISO. Renders nothing when no EXIF data is
available so we don't show an empty panel.
- Required registering `nc:metadata-photos-exif` and
`nc:metadata-photos-ifd0` in main.ts — previously these were
only fetched by the file sidebar.
Build / lint / tests all green after these changes.
Signed-off-by: Frank Karlitschek <frank@nextcloud.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a 3-dot overflow menu on every photo tile so the actions you already have buried in selection-mode or the slideshow are reachable from the tile itself, without first having to enter selection. PhotoActionsMenu.vue (new) - NcActions popup with 4 entries: View metadata, Add to album, Share, Delete (separated). Action button is positioned top-start on the tile with a translucent black backdrop so it stays legible on any photo. - "View metadata" is owned by the menu — it opens an inline NcDialog with the same camera / exposure / focal-length / ISO / aperture lines the slideshow overlay shows. Empty-state copy when the photo carries no EXIF. - The EXIF field-selection logic is duplicated (rather than extracted into a shared util) on purpose; it's small, and a third caller doesn't exist yet. Comment in the file flags this for refactor. - Delete prompts confirmation through a second NcDialog before emitting; the action menu itself never deletes silently. - Add-to-album and Share emit upward; the parent owns the heavy flows so we don't double-implement album-pick / sidebar-open. FileComponent.vue - Mounts PhotoActionsMenu next to the existing favourite + selection affordances. New `showActionsMenu` prop (default true) so picker contexts can opt out — the menu would only confuse when the user is choosing photos to add somewhere. - The menu is opacity-0 by default and revealed on `:hover` / `:focus-within`, matching the existing checkbox affordance. - Forwards request-add-to-album / request-share / request-delete upward without interpreting them. TimelineView.vue - Wires the three new request-* events. Add-to-album re-uses the existing AlbumPicker flow but stores the single file in `singleFileForAlbumPicker`; addSelectionToAlbum then targets that file alone instead of the bulk selection. - Share opens NC's Files sidebar on the path (its sharing tab already does the heavy lifting; no need to re-implement). - Delete dispatches `deleteFiles` after optimistically dropping the id from `fetchedFileIds` so the tile vanishes immediately; the store re-adds on failure. PhotosPicker.vue - Passes `:showActionsMenu="false"` so the picker context stays about picking, not managing. Build / lint clean (1 → 0 warnings; the showActionsMenu prop is default-true on purpose, eslint-disable scoped to that line). Signed-off-by: Frank Karlitschek <frank@nextcloud.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The earlier wiring called `OCA.Files.Sidebar.open(path)`, but that legacy API was removed in NC34 (the modern API lives behind `getSidebar()` from `@nextcloud/files`). So the Share menu item was silently a no-op. The replacement `getSidebar().open(node, 'sharing')` would also fail in the photos context: the Files Pinia store guards on `activeView` + `activeFolder` being set, and photos uses its own router so neither ever gets populated. Instead, navigate the user to the Files app on the photo with `opendetails=true`. That lands them in a fully-loaded sharing-capable context (link shares, share-with-user, email, expiry, password, etc.), which is what "share dialog" actually means in NC. Signed-off-by: Frank Karlitschek <frank@nextcloud.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Member
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a 3-dot overflow menu on every photo tile so EXIF / Add to album / Share / Delete are reachable without first entering selection mode. Stacked on top of #3486.
PhotoActionsMenu.vue (new) — NcActions popup with 4 entries; "View metadata" opens an inline NcDialog reusing the same field-selection logic as the slideshow overlay; Delete prompts confirmation before emitting.
FileComponent.vue mounts the menu next to the existing favourite/select affordances; opacity-0 by default, revealed on :hover / :focus-within. showActionsMenu prop (default true) lets picker contexts opt out.
TimelineView.vue wires the three request-* events: Add to album re-uses AlbumPicker via singleFileForAlbumPicker; Share navigates to /apps/files/files/{fileid}?opendetails=true (NC34 removed the legacy OCA.Files.Sidebar.open(path) and getSidebar() needs Files-app context Photos doesn't have); Delete dispatches deleteFiles after optimistically dropping the id.
PhotosPicker.vue passes :showActionsMenu="false" so the picker stays focused on picking.
Test plan
Hover a tile → 3-dot button fades in
View metadata dialog shows camera + aperture + focal length + exposure + ISO + filename
View metadata empty-state renders for photos without EXIF
Add to album: AlbumPicker adds only that photo, not the bulk selection
Share: navigates to Files app on the photo with sidebar open
Delete: confirm dialog → tile vanishes; cancel keeps it
PhotosPicker tiles have no 3-dot menu
Clicks on the menu don't open the slideshow viewer