Skip to content

Frank/feat/photo actions menu#3487

Open
karlitschek wants to merge 7 commits intomasterfrom
frank/feat/photo-actions-menu
Open

Frank/feat/photo actions menu#3487
karlitschek wants to merge 7 commits intomasterfrom
frank/feat/photo-actions-menu

Conversation

@karlitschek
Copy link
Copy Markdown
Member

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

karlitschek and others added 7 commits May 3, 2026 18:50
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>
@karlitschek
Copy link
Copy Markdown
Member Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant