Compare commits
34 Commits
a7b8a60473
...
claude/str
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e164c48d0 | |||
| 259adb6a41 | |||
| ccf2c6b7c7 | |||
| a13e171295 | |||
| 73c36b4817 | |||
| 82f2a40269 | |||
| f6c0f7a507 | |||
| 1df16a6142 | |||
| 5da1022ed1 | |||
|
|
da63ad769a | ||
|
|
86e38e152d | ||
|
|
3757eb0170 | ||
|
|
cfd0c6aa81 | ||
| 243e5d3831 | |||
| 14a1b4e54e | |||
| 7df1c04c0f | |||
| 8f97590d9f | |||
| 4c08eba27a | |||
| 6c96c22b33 | |||
| 70dc1b6bdf | |||
| e3d4f6d92e | |||
| 9fc650fb12 | |||
| 29f7ad7073 | |||
| c134afe023 | |||
| a54d90a2d9 | |||
| 97f51a05c4 | |||
| d1ddc48f81 | |||
| 55c870c155 | |||
| 981328faff | |||
| e1707c314d | |||
| 64c0da794d | |||
| 0f4e2e0b8f | |||
| ea1803ec2f | |||
| fc5f30fad1 |
13
.env.example
13
.env.example
@@ -65,6 +65,19 @@ PP_GID=1000
|
||||
# OIDC_ROLE=user
|
||||
|
||||
|
||||
# ── USER LIBRARY ISOLATION ───────────────────────────────────────────────────
|
||||
# Maps PhotoPrism usernames to originals-relative subdirectories so each
|
||||
# user only sees their own photos. Format: comma-separated user:path pairs.
|
||||
# The sidecar reconciler applies this to auth_users.base_path on boot and
|
||||
# every 60s. Leave empty for single-user deployments.
|
||||
#
|
||||
# USER_BASEPATHS="alice:alice, bob:bob"
|
||||
|
||||
# Sidecar DB password — provisioned by mariadb/init/01-sidecar.sql on first
|
||||
# boot. Rotate before any non-local deployment.
|
||||
# SIDECAR_DB_PASSWORD=replace-at-m4-bringup
|
||||
|
||||
|
||||
# ── LOGGING ──────────────────────────────────────────────────────────────────
|
||||
|
||||
PP_LOG_LEVEL=info
|
||||
|
||||
247
.hermes/plans/2026-06-06_120000-photos_users-label-isolation.md
Normal file
247
.hermes/plans/2026-06-06_120000-photos_users-label-isolation.md
Normal file
@@ -0,0 +1,247 @@
|
||||
# Plan: Populate `photos_users` to fix label isolation in PhotoPrism
|
||||
|
||||
**Date:** 2026-06-06
|
||||
**Author:** Hermes Agent
|
||||
**Status:** Draft
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Fix the label isolation leak where a user with `base_path` set (e.g. `muli`) sees photos from other users' directories (e.g. `dtoro`) in PhotoPrism's labels view.
|
||||
|
||||
## 2. Current Context
|
||||
|
||||
### The problem
|
||||
- PhotoPrism's `base_path` feature correctly scopes the main search (`/api/v1/photos`).
|
||||
- Label views (`/api/v1/labels`) do **not** respect `base_path` — labels show photo counts and thumbnails from the entire library.
|
||||
- User reports: "all photos on the main labels page are a mix of both" muli and dtoro.
|
||||
|
||||
### What we know
|
||||
- **53 files changed** in the sidecar (Go + GORM, gorm.io/gorm v1.31.1).
|
||||
- Sidecar has a working PhotoPrism DB connection via `PpDSN` (user: `photoprism`, schema: `photoprism.*`).
|
||||
- The `photos_users` table exists in PhotoPrism's MariaDB schema but is **empty** (0 rows).
|
||||
- Schema of `photos_users`:
|
||||
|
||||
```
|
||||
photos_users:
|
||||
uid varbinary(42) NOT NULL PRI (composite PK or single?)
|
||||
user_uid varbinary(42) NOT NULL PRI
|
||||
team_uid varbinary(42) YES MUL
|
||||
perm int(10) unsigned YES
|
||||
```
|
||||
|
||||
- Known user UIDs: `dtoro=utfetfdk0so2z9zl`, `muli=utg7jjbd8iwaghn6`
|
||||
- Known base paths: `dtoro→dtoro`, `muli→muli`
|
||||
- The `photo_path` column in `photos` stores paths like `muli/files/Photo Archive...` or `dtoro/Memories/...`
|
||||
- Currently: ~88K photos, ~52K files indexed.
|
||||
|
||||
### The sidecar's current reconciler (`users.go`)
|
||||
- Runs every 60s.
|
||||
- Only calls `UPDATE auth_users SET base_path = ? WHERE user_name = ?`.
|
||||
- Does **not** touch `photos_users`.
|
||||
|
||||
### Unknowns
|
||||
1. **Does PhotoPrism use `photos_users` for general label filtering?** The table appears designed for explicit sharing (e.g. share a specific photo with another user), not for base_path ACL. PhotoPrism may ignore `photos_users` in label queries.
|
||||
2. **Performance impact**: 88K photos × 2 users = up to 176K rows. Could slow label queries.
|
||||
3. **Side effects**: If `photos_users` controls sharing, adding auto-entries might break explicit share workflow.
|
||||
4. **`perm` values**: Unclear what `perm` value grants "view" access. Likely a bitmap (bit 0 = view).
|
||||
|
||||
## 3. Proposed Approach
|
||||
|
||||
### Phase 1: Investigate (prove the approach works before building)
|
||||
|
||||
**Step 1.1: Insert test rows into `photos_users` manually**
|
||||
|
||||
On the production DB, insert a few `photos_users` entries for muli mapping to some of muli's own photos, plus one entry mapping to a dtoro photo. Use a guessed `perm` value (e.g. `1` = view).
|
||||
|
||||
Then check:
|
||||
- Does muli see fewer photos now? (If `photos_users` works as an exclusive ACL, yes.)
|
||||
- Does the dtoro photo with a `photos_users` entry for muli show up for muli?
|
||||
- Does the label view change?
|
||||
|
||||
**Step 1.2: Test with `perm` variations**
|
||||
|
||||
If `perm=1` does nothing, try `perm=2`, `perm=7`, or `perm=15` (common Unix-ish bitmap patterns).
|
||||
|
||||
**Step 1.3: Examine PhotoPrism source**
|
||||
|
||||
Check PhotoPrism's search/label code to confirm whether `photos_users` is joined in label queries. This tells us definitively whether the approach is viable.
|
||||
|
||||
### Phase 2: Build (if Phase 1 confirms the approach works)
|
||||
|
||||
**Step 2.1: Add `photos_users` GORM model**
|
||||
|
||||
New struct in `db.go` or a new file `perms.go`:
|
||||
|
||||
```go
|
||||
type PhotoUser struct {
|
||||
PhotoUID string `gorm:"primaryKey;size:42;column:uid"`
|
||||
UserUID string `gorm:"primaryKey;size:42;column:user_uid"`
|
||||
TeamUID string `gorm:"size:42;column:team_uid"`
|
||||
Perm int `gorm:"column:perm"`
|
||||
}
|
||||
|
||||
func (PhotoUser) TableName() string { return "photos_users" }
|
||||
```
|
||||
|
||||
Note: GORM `AutoMigrate` is called on `mule_sidecar` schema, not `photoprism.*`. The `photos_users` table already exists in the `photoprism` schema — we only query/insert, never migrate.
|
||||
|
||||
**Step 2.2: Add `reconcilePhotoUsers` function**
|
||||
|
||||
New function in a new file `perms.go` alongside `users.go`. Signature:
|
||||
|
||||
```go
|
||||
func reconcilePhotoUsers(ppDSN, originalsRoot string, mapping map[string]string) error
|
||||
```
|
||||
|
||||
Logic:
|
||||
1. For each `username:path` pair in `mapping`, look up the user's `user_uid` in `auth_users`.
|
||||
2. Query `photos` for all `photo_uid` where `photo_path LIKE 'path/%'`.
|
||||
3. Batch-insert entries into `photos_users` with a default `perm` value (to be determined in Phase 1).
|
||||
4. Use `INSERT IGNORE` or `ON DUPLICATE KEY UPDATE` for idempotency.
|
||||
5. Handle deletions: if a photo's path is changed (via rename), the old `photos_users` entry should be cleaned up.
|
||||
|
||||
**Step 2.3: Wire into reconciler loop**
|
||||
|
||||
Extend the existing `startUserBasepathReconciler` to call `reconcilePhotoUsers` after `reconcileUserBasepaths`.
|
||||
|
||||
```go
|
||||
func apply() {
|
||||
reconcileUserBasepaths(...)
|
||||
reconcilePhotoUsers(...)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2.4: Handle re-index edge cases**
|
||||
|
||||
- When new photos are indexed, they won't have `photos_users` entries until the next 60s tick.
|
||||
- Could add a webhook or a one-shot trigger after PhotoPrism's index completes.
|
||||
- Alternative: accept the 60s lag as a design trade-off (current base_path reconciler already has this lag).
|
||||
|
||||
### Phase 3: Validate (if Phase 1 confirms)
|
||||
|
||||
1. Build the binary: `cd sidecar && CGO_ENABLED=0 go build -o mule-sidecar .`
|
||||
2. Rebuild the Docker image and restart the sidecar.
|
||||
3. Check `photos_users` has expected rows.
|
||||
4. Log in as `muli` via Authentik SSO, browse labels — verify dtoro photos are gone.
|
||||
5. Log in as `dtoro` — verify still sees own photos.
|
||||
6. Verify no regression: search, album, folder views still work for both users.
|
||||
|
||||
## 4. Files Likely to Change
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `sidecar/perms.go` | **New file** — `PhotoUser` model, `reconcilePhotoUsers` function |
|
||||
| `sidecar/db.go` | Add `photos_users`-related constants/helpers (optional) |
|
||||
| `sidecar/users.go` | Extend `reconcileUserBasepaths` or add a phase to the existing reconciler |
|
||||
| `sidecar/main.go` | Wire the new reconciler phase (minor — call from existing ticker) |
|
||||
| `sidecar/Dockerfile` | Unchanged (Go build picks up new `.go` files automatically) |
|
||||
|
||||
## 5. Tests & Validation
|
||||
|
||||
1. **Build check**: `go build ./...` from `sidecar/`
|
||||
2. **Manual DB test** (Phase 1): Insert test `photos_users` rows via `docker exec pp-mariadb mysql ...`
|
||||
3. **Integration test**: After deploy, check `photos_users` row count matches expected photo count per user.
|
||||
4. **Label isolation check**: Browse labels as each user — confirm no cross-user leaks.
|
||||
|
||||
## 6. Source Code Analysis (Completed)
|
||||
|
||||
### How base_path scoping works in PhotoPrism
|
||||
|
||||
Found the critical function `ScopePhotosForSession` in `internal/entity/search/photos_scope.go`:
|
||||
|
||||
```go
|
||||
func ScopePhotosForSession(stmt *gorm.DB, sess *entity.Session) *gorm.DB {
|
||||
// Admin/library role → no scoping needed
|
||||
if sess == nil || acl.Rules.AllowAny(acl.ResourcePhotos, sess.GetUserRole(), acl.Permissions{acl.AccessAll, acl.AccessLibrary}) {
|
||||
return stmt
|
||||
}
|
||||
user := sess.GetUser()
|
||||
if basePath := user.GetBasePath(); basePath == "" {
|
||||
return stmt.Where(sharedAlbums + "photos.created_by = ? OR ...", ...)
|
||||
} else {
|
||||
return stmt.Where(sharedAlbums + "... OR photos.photo_path = ? OR photos.photo_path LIKE ?",
|
||||
..., basePath, basePath + "/%")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Key: base_path filtering is done by adding `WHERE photos.photo_path LIKE 'muli/%'` to the SQL query. It is **NOT** done via `photos_users`.
|
||||
|
||||
### How endpoints use base_path
|
||||
|
||||
| Endpoint | Function | Applies base_path? |
|
||||
|----------|----------|-------------------|
|
||||
| `GET /api/v1/photos` | `SearchPhotos` → `UserPhotos` → `searchPhotos` → **`ScopePhotosForSession`** | ✅ Yes |
|
||||
| `GET /api/v1/labels` | `SearchLabels` → `search.Labels(frm)` — no session passed | ❌ **No** |
|
||||
| Review tab | Uses `GET /api/v1/photos?quality=3` → goes through `ScopePhotosForSession` | ✅ Should scope |
|
||||
| Archive tab | Uses `GET /api/v1/photos?archived=true` → goes through `ScopePhotosForSession` | ✅ Should scope |
|
||||
| Albums | TBD — depends on whether they use `ScopePhotosForSession` | ⚠️ Unknown |
|
||||
|
||||
### The `photos_users` table
|
||||
|
||||
Found in `internal/entity/photo_user.go`:
|
||||
|
||||
```go
|
||||
type PhotoUser struct {
|
||||
UID string // photo_uid
|
||||
UserUID string // user_uid
|
||||
TeamUID string // team_uid
|
||||
Perm uint // permission bitmap
|
||||
}
|
||||
```
|
||||
|
||||
This table is **not referenced** in `ScopePhotosForSession`, `searchPhotos`, or any label/album search function. It is only used for **explicit sharing** (via `FirstOrCreatePhotoUser` called when sharing a specific photo with another user).
|
||||
|
||||
**Conclusion: Populating `photos_users` will NOT fix the label, review, or archive tab isolation.** PhotoPrism does not consult this table for any of these queries.
|
||||
|
||||
### Why review/archive might show cross-user photos
|
||||
|
||||
Since review and archive use `GET /api/v1/photos` which goes through `ScopePhotosForSession`, they **should** be scoped. The issue might be:
|
||||
1. **Pre-computed counts** in the sidebar tabs show total numbers across all users
|
||||
2. **Label thumbnails** and category summaries are computed from the `labels` table which is global
|
||||
3. The actual photo list in review/archive should be correctly scoped — the user may be seeing dtoro photos only in the summary/counts
|
||||
|
||||
### DB experiment results
|
||||
|
||||
Confirmed `photos_users` is empty (0 rows). Inserted 100 muli-photo entries + 1 dtoro-photo entry for muli with `perm=1`. Label API response unchanged — `photo_count` values remained the same (Dog: 733, Cat: 57), confirming labels ignore `photos_users`.
|
||||
|
||||
## 7. Updated Recommendation
|
||||
|
||||
**Abandon the `photos_users` approach.** It won't fix the problem because PhotoPrism never consults this table for labels, review, or archive queries.
|
||||
|
||||
### Real fix options
|
||||
|
||||
1. **Sidecar label filter** (recommended) — The sidecar already validates sessions via `resolveSession()` which returns the user's `BasePath`. Extend the sidecar to expose a **proxied `/api/v1/labels`** endpoint that:
|
||||
- Accepts the caller's `X-Auth-Token` (already validated by `requireSession`)
|
||||
- Forwards the request to PhotoPrism's `/api/v1/labels`
|
||||
- **Filters the response** to remove labels whose `Thumb` belongs to a photo outside the user's `base_path`
|
||||
- Recalculates `PhotoCount` for the user's scope (count photos under `base_path/%` for that label)
|
||||
- Also filter `Count` values in the sidebar summary response
|
||||
|
||||
**Why this works:** The sidecar already has DB access to PhotoPrism's schema (`PpDSN`) and validates sessions. It can query `photos` to count label intersections per base_path.
|
||||
|
||||
2. **Same approach for review/archive sidebar counts** — Intercept the relevant metadata/summary endpoints to scope counts by base_path.
|
||||
|
||||
3. **Accept the limitation** — Labels show cross-user thumbnails/counts but the actual photo list is scoped.
|
||||
|
||||
### Implementation sketch for option 1
|
||||
|
||||
```
|
||||
sidecar/
|
||||
├── proxy.go # New file
|
||||
│ ├── handleLabels(c) → GET /api/sidecar/labels → proxies to PP, filters by base_path
|
||||
│ ├── handleReviewCount(c) → GET /api/sidecar/review → returns scoped count
|
||||
│ └── handleArchiveCount(c) → GET /api/sidecar/archive → returns scoped count
|
||||
```
|
||||
|
||||
The SvelteKit frontend would call `/api/sidecar/labels` instead of `/api/v1/labels`.
|
||||
|
||||
### Clean up: remove test rows from photos_users
|
||||
|
||||
Since the approach won't work, remove the test rows inserted during Phase 1:
|
||||
|
||||
```sql
|
||||
DELETE FROM photoprism.photos_users WHERE user_uid = 'utg7jjbd8iwaghn6';
|
||||
```
|
||||
@@ -0,0 +1,301 @@
|
||||
# Plan: Fix user isolation in PhotoPrism — labels, review, and archive views
|
||||
|
||||
**Date:** 2026-06-06
|
||||
**Author:** Hermes Agent
|
||||
**Status:** Draft
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Fix the three views where a user with `base_path` set (e.g. `muli`) sees photos from other users (e.g. `dtoro`):
|
||||
|
||||
1. **Labels** — labels list + label drill-down show all library photos
|
||||
2. **Review** — photos needing review tab shows cross-user photos
|
||||
3. **Archive** — archived photos tab shows cross-user photos
|
||||
|
||||
## 2. Current Context & Source Analysis
|
||||
|
||||
### 2.1 How base_path scoping works
|
||||
|
||||
PhotoPrism's `ScopePhotosForSession` (in `internal/entity/search/photos_scope.go`) is the only function that enforces user isolation. It adds `WHERE photos.photo_path = '<basePath>' OR photos.photo_path LIKE '<basePath>/%'` to the SQL query.
|
||||
|
||||
This is called by `searchPhotos()` — used by the **`GET /api/v1/photos`** endpoint (search, timeline, folders).
|
||||
|
||||
### 2.2 How endpoints use base_path
|
||||
|
||||
| View | Endpoint | Function chain | Applies base_path? |
|
||||
|------|----------|----------------|-------------------|
|
||||
| Main timeline | `GET /api/v1/photos` | `SearchPhotos` → `searchPhotos` → `ScopePhotosForSession` | ✅ Yes |
|
||||
| Folders | `GET /api/v1/photos` with path filter | Same chain | ✅ Yes |
|
||||
| **Labels** | `GET /api/v1/labels` | `SearchLabels` → `search.Labels(frm)` — **no session** | ❌ **No** — queries `labels` table directly |
|
||||
| Label drill-down | `GET /api/v1/photos?label=X` | Uses `searchPhotos` → `ScopePhotosForSession` | ✅ Should scope (if label= param doesn't bypass) |
|
||||
| **Review tab** | `GET /api/v1/photos?q=review:true` | `searchPhotos` → `ScopePhotosForSession` | ✅ Should scope, BUT... |
|
||||
| **Archive tab** | `GET /api/v1/photos?q=archived:true` | Same | ✅ Should scope, BUT... |
|
||||
|
||||
### 2.3 The review/archive problem: ACL overrides
|
||||
|
||||
In `searchPhotosForm()` (internal/api/photos_search.go):
|
||||
|
||||
```go
|
||||
if acl.Rules.Deny(acl.ResourcePhotos, s.GetUserRole(), acl.ActionManage) {
|
||||
frm.Quality = 3
|
||||
}
|
||||
```
|
||||
|
||||
For role=user, `Deny(ActionManage)` → true → sets `frm.Quality = 3` (minimum quality).
|
||||
|
||||
Then in `searchPhotos()` (internal/entity/search/photos.go):
|
||||
|
||||
```go
|
||||
if acl.Rules.Deny(acl.ResourcePhotos, aclRole, acl.ActionDelete) {
|
||||
frm.Archived = false
|
||||
frm.Review = false
|
||||
}
|
||||
```
|
||||
|
||||
For role=user, `Deny(ActionDelete)` → true → **overrides `review:true` and `archived:true` to false**.
|
||||
|
||||
So the review and archive filters are **completely ignored** for the `user` role. The frontend sends `review:true` but the server discards it. The result: the review/archive tabs show ALL photos scoped by base_path (no quality/review/archive filter), which means basically the same as the main timeline.
|
||||
|
||||
### 2.4 Label problem: no session scoping at all
|
||||
|
||||
`search.Labels()` queries the `labels` table directly with a `WHERE photo_count > 0` clause. There is no session parameter, no `ScopePhotosForSession`, and no base_path or user filtering whatsoever. Labels are **library-wide** in PhotoPrism.
|
||||
|
||||
The label drill-down (click into a label) uses `GET /api/v1/photos?label=X` which DOES go through `ScopePhotosForSession`, so the photo list itself should be scoped — but the label thumbnails, counts, and covers are global.
|
||||
|
||||
### 2.5 ACL GrantDefaults — missing RoleUser entry
|
||||
|
||||
```go
|
||||
var GrantDefaults = Roles{
|
||||
RoleAdmin: GrantFullAccess, // FullAccess = AccessLibrary + everything
|
||||
RoleGuest: GrantReactShared, // Only shared content
|
||||
RoleVisitor: GrantViewShared, // Only shared content
|
||||
RolePortal: GrantFullAccess,
|
||||
RoleClient: GrantFullAccess,
|
||||
// RoleUser and RoleViewer are NOT listed → fallback to RoleDefault (also missing) → denied
|
||||
}
|
||||
```
|
||||
|
||||
Because `RoleUser` is absent from `GrantDefaults`, the `Allow()` function falls back to `RoleDefault` which is also absent → returns `false` for all permissions. This means:
|
||||
- `ScopePhotosForSession` correctly enters the `base_path` branch (good — user is isolated)
|
||||
- BUT `ActionDelete` is denied → review/archive filters are forced off (bad — can't browse review/archive)
|
||||
|
||||
### 2.6 The `photos_users` table — ruled out
|
||||
|
||||
`internal/entity/photo_user.go` defines `PhotoUser` but it is **not referenced** in `ScopePhotosForSession`, `searchPhotos`, or any label/album search function. Populating it won't fix any of these issues.
|
||||
|
||||
## 3. Proposed Approach
|
||||
|
||||
### Phase 1: Sidecar proxy for labels (direct fix)
|
||||
|
||||
Extend the sidecar to expose a **scoped labels endpoint**:
|
||||
|
||||
```
|
||||
GET /api/sidecar/labels → proxies to GET /api/v1/labels → filters by base_path
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. Sidecar receives the caller's `X-Auth-Token`
|
||||
2. `requireSession` middleware resolves the token → returns user's `BasePath`
|
||||
3. Sidecar makes the same `/api/v1/labels` request to PhotoPrism (using the caller's token)
|
||||
4. **Filter step**: for each label in the response, query the DB to count photos with that label AND `photo_path LIKE '<base_path>/%'`
|
||||
5. Return filtered labels with corrected `PhotoCount` and `Thumb`
|
||||
|
||||
**Implementation:**
|
||||
|
||||
New file `sidecar/handlers_labels.go`:
|
||||
|
||||
```go
|
||||
// handleLabels proxies to PP's /api/v1/labels, then post-filters
|
||||
// counts and thumbnails by the caller's base_path.
|
||||
func handleLabels(pp *ppClient, ppDSN string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := ctxToken(c)
|
||||
user := ctxUser(c) // resolved from session, includes BasePath
|
||||
|
||||
// 1. Get raw labels from PhotoPrism
|
||||
resp, _ := pp.call(c, "GET", "/api/v1/labels?"+c.Request.URL.RawQuery, token, nil)
|
||||
|
||||
// 2. Decode labels
|
||||
var labels []PpLabel
|
||||
json.Unmarshal(resp.Body, &labels)
|
||||
|
||||
// 3. For each label, recalculate count for this user's base_path
|
||||
for i, l := range labels {
|
||||
// Count photos with this label AND where photo_path matches base_path
|
||||
var count int
|
||||
db.Raw(`SELECT COUNT(*) FROM photos_labels pl
|
||||
JOIN photos p ON pl.photo_uid = p.photo_uid
|
||||
WHERE pl.label_uid = ? AND p.photo_path LIKE ?`,
|
||||
l.UID, user.BasePath+"/%").Scan(&count)
|
||||
labels[i].PhotoCount = count
|
||||
|
||||
// If count is 0, the thumb from the global label doesn't apply
|
||||
// Could also update thumb to a user-scoped one
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, labels)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Frontend change:** Update the label query in `web/src/routes/tags/[category]/[[value]]/+page.svelte` to call `/api/sidecar/labels` instead of `/api/v1/labels`.
|
||||
|
||||
### Phase 2: Fix review/archive — skip the ACL override
|
||||
|
||||
Two options:
|
||||
|
||||
**Option A (Recommended): Sidecar proxy for photos search**
|
||||
|
||||
Extend the sidecar with:
|
||||
```
|
||||
GET /api/sidecar/photos → proxies to GET /api/v1/photos → adds path filter
|
||||
```
|
||||
|
||||
The sidecar intercepts the photos request and adds the `path:<basePath>` query parameter to PhotoPrism's API call. This forces PhotoPrism to add `WHERE photo_path LIKE '<base_path>/%'`.
|
||||
|
||||
For review/archive, the sidecar also adds `review:true` or `archived:true` BEFORE the ACL override happens (since the sidecar doesn't hit the ACL code).
|
||||
|
||||
**Option B: Custom frontend query**
|
||||
|
||||
The frontend explicitly adds `path:muli` to the query string for review/archive tabs:
|
||||
```
|
||||
GET /api/v1/photos?q=review:true path:muli&count=50
|
||||
```
|
||||
|
||||
The `path` filter is a standard PhotoPrism search operator that adds `WHERE photos.photo_path = '<path>'`. But this only matches the exact path, not `path/%` (subdirectories). The `path:` operator does `photo_path = ?` (exact match) per the code at line 668.
|
||||
|
||||
**Option A is better** because:
|
||||
- Works for all users without frontend changes
|
||||
- Can add the proper `LIKE` prefix match
|
||||
- Centralized logic in the sidecar
|
||||
|
||||
### Phase 3: Sidecar proxy for sidebar counts
|
||||
|
||||
The session response (or `GET /api/v1/config`) includes library-wide counts:
|
||||
```json
|
||||
"count": {
|
||||
"review": 248,
|
||||
"archived": 94,
|
||||
"all": 88203,
|
||||
"photos": 88000
|
||||
}
|
||||
```
|
||||
|
||||
These show the TOTAL across all users. The sidecar can proxy this and recalculate counts per base_path.
|
||||
|
||||
## 4. Step-by-step Plan
|
||||
|
||||
### Step 1: Sidecar — labels proxy
|
||||
|
||||
Files: `sidecar/handlers_labels.go` (new), `sidecar/main.go` (route wiring)
|
||||
|
||||
1. New types: `PpLabel` (mirrors PhotoPrism's label response shape)
|
||||
2. Handler function `handleLabels()` that:
|
||||
- Validates token via `requireSession`
|
||||
- Gets `BasePath` from session
|
||||
- Calls PhotoPrism's `/api/v1/labels`
|
||||
- For each label, queries photos_labels + photos to count user-scoped photos
|
||||
- Returns filtered labels
|
||||
3. Wire route: `auth.GET("/labels", handleLabels(...))` in `main.go`
|
||||
4. Frontend: change label fetch URL from `/api/v1/labels` to `/api/sidecar/labels`
|
||||
|
||||
### Step 2: Sidecar — photos proxy (review/archive fix)
|
||||
|
||||
Files: `sidecar/handlers_photos.go` (new), `sidecar/main.go` (route wiring)
|
||||
|
||||
1. Handler function `handlePhotos()` that:
|
||||
- Validates token
|
||||
- Gets `BasePath` from session
|
||||
- Parses the query string to detect `review:true` or `archived:true`
|
||||
- Forwards to PhotoPrism's `/api/v1/photos` with `path:<basePath>` added to query
|
||||
- For review/archive, also ensures `review/archived` filter is NOT stripped
|
||||
- Returns PhotoPrism's response
|
||||
|
||||
2. Two implementation variants:
|
||||
|
||||
**Variant A** (simpler): add `path:<basePath>` to the forwarded query. This only matches exact path, not subdirs (PhotoPrism's `path:` operator does exact match). Might miss photos in subdirectories.
|
||||
|
||||
**Variant B** (correct): Forward the query without path, then post-filter the response to remove photos whose `photo_path` doesn't match `basePath/%`. This is more robust.
|
||||
|
||||
### Step 3: Validation
|
||||
|
||||
1. Build sidecar: `cd sidecar && CGO_ENABLED=0 go build -o mule-sidecar .`
|
||||
2. Rebuild Docker image: `docker compose build sidecar`
|
||||
3. Restart sidecar: `docker compose up -d sidecar`
|
||||
4. Test labels as muli — verify only muli's labels appear
|
||||
5. Test review tab as muli — verify only muli's photos needing review appear
|
||||
6. Test archive tab as muli — verify only muli's archived photos appear
|
||||
7. Test same views as admin — verify dtoro still sees all
|
||||
|
||||
## 5. Files Likely to Change
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `sidecar/handlers_labels.go` | **New** — label proxy handler |
|
||||
| `sidecar/handlers_photos.go` | **New** — photos proxy handler (or merged into one proxy.go) |
|
||||
| `sidecar/handlers_folder.go` | Reference for existing handler patterns |
|
||||
| `sidecar/main.go` | Wire new routes under `auth` group |
|
||||
| `sidecar/pp.go` | May need new helper methods for label/photo API calls |
|
||||
| `sidecar/users.go` | No change |
|
||||
| `sidecar/db.go` | May add types for PpLabel, PpPhoto |
|
||||
| `web/src/routes/tags/[category]/[[value]]/+page.svelte` | Change label fetch URL |
|
||||
| `web/src/lib/stores/filters.svelte.ts` | Possibly change how review/archive queries are built |
|
||||
|
||||
## 6. Tests & Validation
|
||||
|
||||
**Build**: `cd sidecar && go build ./... && go vet ./...`
|
||||
|
||||
**Manual validation on LXC 120:**
|
||||
|
||||
```bash
|
||||
# Test labels endpoint
|
||||
curl -s "http://localhost:8000/api/sidecar/labels?count=5" \
|
||||
-H "X-Auth-Token: <muli-token>" | python3 -c "import sys,json;d=json.load(sys.stdin);[print(l.get('Name','?'),l.get('PhotoCount')) for l in d[:5]]"
|
||||
|
||||
# Test photos endpoint with review
|
||||
curl -s "http://localhost:8000/api/sidecar/photos?q=review:true&count=5" \
|
||||
-H "X-Auth-Token: <muli-token>" | python3 -c "import sys,json;d=json.load(sys.stdin);print(f'{len(d)} photos')"
|
||||
|
||||
# Verify vs. admin token — counts should differ
|
||||
```
|
||||
|
||||
**Cross-user check:** Log in as `muli` and `dtoro` in separate browser sessions. Verify:
|
||||
- Labels show different counts per user
|
||||
- Review photos are scoped per user
|
||||
- Archive photos are scoped per user
|
||||
|
||||
## 7. Risks, Tradeoffs & Open Questions
|
||||
|
||||
### Risks
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
|------|--------|------------|
|
||||
| Sidecar proxying adds latency | Slower page loads | Labels are small payloads; single DB query per label is fast |
|
||||
| Frontend needs URL changes | Breaks if not updated | Do frontend change alongside sidecar deploy |
|
||||
| Photo count queries on every label request | DB load | Cache results for 30s in the sidecar |
|
||||
| PhotoPrism's label `PhotoCount` is stale | Mismatch with actual count | Acceptable — PhotoPrism's count is already cached |
|
||||
| Review/archive fix depends on how PhotoPrism handles `path:` operator | Photos in subdirs missed | Use Variant B (post-filter by path prefix) |
|
||||
|
||||
### Open Questions
|
||||
|
||||
- **Q1**: For review/archive — is the user seeing dtoro's photos in the *grid* or only the *sidebar counts*? Need to verify actual API response vs what the frontend renders.
|
||||
- **Q2**: What's the performance impact of running `SELECT COUNT(*) FROM photos_labels ... JOIN photos ...` for every label in the response? (Labels list is typically short, < 100)
|
||||
- **Q3**: Does the frontend cache the label response aggressively? Need to invalidate cache on user switch.
|
||||
- **Q4**: For the `path:` operator — does it do exact match or LIKE? From source: `WHERE photos.photo_path = ?` — exact match only.
|
||||
|
||||
### Tradeoffs
|
||||
|
||||
- **Sidecar proxy vs. frontend-only**: Proxy centralizes logic but adds network hop. Frontend-only is faster but more complex (every route needs path filtering).
|
||||
- **Label count accuracy**: Recalculated per-user counts will differ from the library-wide counts. This is intentional — labels are scoped now.
|
||||
- **Sidecar vs. patching PhotoPrism**: Sidecar approach is non-invasive (no fork/build of PP). PhotoPrism patch would be cleaner but requires maintaining a fork.
|
||||
|
||||
## 8. Recommendation
|
||||
|
||||
1. **Build the labels proxy** (Phase 1) — it directly solves the label isolation problem and can be done with existing sidecar infrastructure
|
||||
2. **Investigate review/archive leak** first — run the actual API query as muli to confirm whether the photos search is actually scoped. The code analysis says it should be, but the user reports otherwise. If confirmed as a real leak, implement the sidecar photos proxy (Phase 2)
|
||||
3. **Sidebar counts** (Phase 3) — lower priority, can be done after labels and review/archive are fixed
|
||||
|
||||
Before building, confirm with the user whether they see cross-user photos in the actual grid or only in the sidebar counts for review/archive.
|
||||
227
.hermes/plans/2026-06-06_210000-remaining-fixes.md
Normal file
227
.hermes/plans/2026-06-06_210000-remaining-fixes.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# Plan: Fix remaining user isolation issues — 404 errors and folder tree
|
||||
|
||||
**Date:** 2026-06-06
|
||||
**Author:** Hermes Agent
|
||||
**Status:** Draft
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Fix the remaining issues after deploying the sidecar scoping proxy:
|
||||
|
||||
1. **404 on photo grid** — "Request failed with status code 404" in private window
|
||||
2. **Folder tree shows other users** — on first load, the library tree lists other users' folders; a refresh fixes it
|
||||
|
||||
## 2. Current Context
|
||||
|
||||
### What's deployed
|
||||
|
||||
| Component | Status |
|
||||
|-----------|--------|
|
||||
| Sidecar labels proxy (`/api/sidecar/labels`) | ✅ Working |
|
||||
| Sidecar counts proxy (`/api/sidecar/counts`) | ✅ Working |
|
||||
| Sidecar timeline proxy (`/api/sidecar/timeline`) | ✅ Working through Caddy |
|
||||
| Caddy fallback for `/api/v1/api/sidecar/*` | ✅ Working |
|
||||
| Frontend rebuild with `sidecar` axios instance | ✅ Built and deployed |
|
||||
|
||||
### Verified working via Caddy
|
||||
|
||||
```bash
|
||||
# Through public URL with valid admin token
|
||||
curl https://photos.hubris.network/api/sidecar/timeline?count=1 → HTTP 200
|
||||
curl https://photos.hubris.network/api/v1/photos?count=1 → HTTP 200
|
||||
```
|
||||
|
||||
Both endpoints return 200 when tested directly through Caddy with a valid token.
|
||||
|
||||
### Reported issues
|
||||
|
||||
1. **404 on photo grid** — even in private window (no cache interference)
|
||||
2. **Folder tree shows other users' folders** on first load, fixed by refresh
|
||||
|
||||
## 3. Root Cause Analysis
|
||||
|
||||
### Issue 1: 404 on photo grid
|
||||
|
||||
The `sidecar` axios instance (`baseURL: ''`) is missing the **response interceptor** that:
|
||||
- Handles 401 → clears session → redirects to login
|
||||
- Re-throws with meaningful error message
|
||||
|
||||
The `http` instance (for `/api/v1` endpoints) has this interceptor. Without it on `sidecar`:
|
||||
- If the sidecar returns a non-2xx (401, 502 from upstream PP failure, etc.), axios throws a raw error
|
||||
- The TanStack Query error boundary catches it and shows "Request failed with status code <status>"
|
||||
- Very likely the sidecar is returning 401 on some calls (token expired / session not yet established) and the error message might show 404 because Caddy's catch-all returns 404 when a matcher doesn't find a route
|
||||
|
||||
**Hypothesis:** During OIDC login flow, the frontend may make some sidecar calls BEFORE the session is fully established (token loaded into `session.accessToken`). The `sidecar` interceptor checks `session.accessToken` but it might be null. Then the request to `/api/sidecar/timeline` has no auth header → sidecar returns 401 → no response interceptor → raw error.
|
||||
|
||||
**Fix:** Add the same 401 → login redirect interceptor to the `sidecar` instance.
|
||||
|
||||
### Issue 2: Folder tree shows other users
|
||||
|
||||
`listFolders()` calls `http.get('/folders/originals')` which hits PhotoPrism directly. PhotoPrism returns **all folders across the library** regardless of user. The frontend then filters by `userBasePath()` on the result:
|
||||
|
||||
```typescript
|
||||
const bp = userBasePath();
|
||||
if (bp === '') return folders; // On first load, bp might be empty!
|
||||
return folders.filter((f) => f.Path === bp || f.Path.startsWith(bp + '/'))
|
||||
```
|
||||
|
||||
On first load, `userBasePath()` returns `""` because:
|
||||
1. The session data is loaded asynchronously
|
||||
2. `session.user.BasePath` might not yet be populated when `listFolders` fires
|
||||
3. The TanStack Query cache from a previous session might still have old data
|
||||
|
||||
After a refresh, the session is fully loaded, and `userBasePath()` returns the correct value.
|
||||
|
||||
A secondary issue: the `http` interceptor's 401 handler clears the session on 401. If the session expires during the app's lifetime, all subsequent requests fail with 401.
|
||||
|
||||
## 4. Proposed Approach
|
||||
|
||||
### Phase 1: Fix 404 — add response interceptor to sidecar
|
||||
|
||||
**File:** `web/src/lib/services/photoprism.ts`
|
||||
|
||||
Add the same 401 → login redirect interceptor to `sidecar` as already exists on `http`:
|
||||
|
||||
```typescript
|
||||
sidecar.interceptors.response.use(
|
||||
(r) => r,
|
||||
(err: AxiosError) => {
|
||||
if (err.response?.status === 401 && browser) {
|
||||
clearSession();
|
||||
const url = err.config?.url ?? '';
|
||||
if (!url.endsWith('/session')) {
|
||||
void goto('/login', { replaceState: true });
|
||||
}
|
||||
}
|
||||
return Promise.reject(err);
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### Phase 2: Fix folder tree — sidecar folder proxy
|
||||
|
||||
**File:** `sidecar/handlers_folders.go` (new)
|
||||
|
||||
Add a sidecar endpoint that proxies `/folders/originals` and post-filters by BasePath:
|
||||
|
||||
```
|
||||
GET /api/sidecar/folders → proxies to GET /api/v1/folders/originals
|
||||
→ removes folders not under user's base_path
|
||||
→ returns filtered list
|
||||
```
|
||||
|
||||
This avoids the timing issue entirely by filtering on the server side.
|
||||
|
||||
**Alternative (simpler):** Fix the frontend timing issue by ensuring `listFolders` doesn't fire until the session is ready.
|
||||
|
||||
### Phase 3: Change folder tree in frontend
|
||||
|
||||
**File:** `web/src/lib/services/photoprism.ts`
|
||||
|
||||
Change `listFolders()` to use `sidecar` instance and call `/api/sidecar/folders`:
|
||||
|
||||
```typescript
|
||||
export async function listFolders(): Promise<PpFolder[]> {
|
||||
const { data } = await sidecar.get<{ folders?: PpFolder[] }>(
|
||||
'/api/sidecar/folders',
|
||||
{ params: { recursive: true, uncached: true, files: false } }
|
||||
);
|
||||
const bp = userBasePath();
|
||||
const folders = data.folders ?? [];
|
||||
if (bp === '') return folders;
|
||||
return folders
|
||||
.filter((f) => f.Path === bp || f.Path.startsWith(bp + '/'))
|
||||
.map((f) => ({ ...f, Path: toUserPath(f.Path) }));
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Step-by-step Plan
|
||||
|
||||
### Step 1: Add sidecar response interceptor
|
||||
|
||||
1. Edit `web/src/lib/services/photoprism.ts`
|
||||
2. Add the 401-handling response interceptor to the `sidecar` instance
|
||||
3. The interceptor mirrors the existing `http` response interceptor exactly
|
||||
|
||||
### Step 2: Rebuild frontend
|
||||
|
||||
```bash
|
||||
cd /opt/mule-image/web && npm run build
|
||||
```
|
||||
|
||||
### Step 3: (Optional) Add sidecar folder proxy
|
||||
|
||||
1. New file `sidecar/handlers_folders_proxy.go`
|
||||
2. Handler similar to `handlePhotos` — proxies to `/api/v1/folders/originals`, post-filters by `Path` prefix
|
||||
3. Wire route in `main.go`: `auth.GET("/folders", handleFoldersProxy(pp))`
|
||||
4. Build Docker image, restart sidecar
|
||||
|
||||
### Step 4: Update listFolders to use sidecar
|
||||
|
||||
1. Change `listFolders()` to use `sidecar` instance
|
||||
2. Call `/api/sidecar/folders` instead of `/folders/originals`
|
||||
|
||||
### Step 5: Rebuild + validate
|
||||
|
||||
```bash
|
||||
# Rebuild frontend
|
||||
cd /opt/mule-image/web && npm run build
|
||||
|
||||
# Test through Caddy
|
||||
curl -s "https://photos.hubris.network/api/sidecar/timeline?count=1" \
|
||||
-H "X-Auth-Token: <token>" | head -c 200
|
||||
|
||||
# Verify folders
|
||||
curl -s "https://photos.hubris.network/api/sidecar/folders" \
|
||||
-H "X-Auth-Token: <token>" | python3 -c "import sys,json;d=json.load(sys.stdin);print(json.dumps(d[:3],indent=2))"
|
||||
```
|
||||
|
||||
### Step 6: Commit
|
||||
|
||||
```bash
|
||||
git add -A && git commit -m "fix: add sidecar response interceptor + folder proxy" && git push
|
||||
```
|
||||
|
||||
## 6. Files Likely to Change
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `web/src/lib/services/photoprism.ts` | Add response interceptor to sidecar instance; change listFolders URL |
|
||||
| `sidecar/handlers_folders_proxy.go` | **New** — folder proxy handler |
|
||||
| `sidecar/main.go` | Wire folder proxy route |
|
||||
|
||||
## 7. Tests & Validation
|
||||
|
||||
**Manual:**
|
||||
1. Open private window → navigate to photos.hubris.network
|
||||
2. Log in as muli via Authentik OIDC
|
||||
3. Verify photo grid loads without 404
|
||||
4. Verify folder tree shows only muli's folders
|
||||
5. Switch to dtoro account → verify folders/timeline scoped to dtoro
|
||||
|
||||
**API tests:**
|
||||
```bash
|
||||
# Sidecar timeline (no token → 401 redirect)
|
||||
curl -s "https://photos.hubris.network/api/sidecar/timeline?count=1"
|
||||
|
||||
# Sidecar folders
|
||||
curl -s "https://photos.hubris.network/api/sidecar/folders"
|
||||
```
|
||||
|
||||
## 8. Risks & Open Questions
|
||||
|
||||
### Risks
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
|------|--------|------------|
|
||||
| Sidecar returns 401 during OIDC login flow before session is ready | 404 showing instead of graceful redirect | Add response interceptor in Phase 1 |
|
||||
| Folder proxy adds latency | Slower folder tree loading | Minimal — single proxy call, same as PP direct |
|
||||
| `userBasePath()` timing issue in listFolders persists even with sidecar | Folder tree still shows wrong folders on first load | Sidecar filter is server-side → no timing dependency |
|
||||
|
||||
### Open Questions
|
||||
|
||||
- **Q1**: Are there other API calls that bypass the `sidecar` instance and might also be unscoped? (e.g., `listSubjects`, `listGeo`, etc.)
|
||||
- **Q2**: Does the sidecar need a folder proxy, or is the timing fix sufficient? The timing fix (delaying `listFolders` until session is ready) is simpler but fragile.
|
||||
- **Q3**: Could the 404 be from Caddy's catch-all returning 404 when the sidecar isn't reachable? The Caddy fallback timeout for the sidecar might need tuning.
|
||||
15
README.md
15
README.md
@@ -115,6 +115,7 @@ Full instructions in [`sidecar/README.md`](sidecar/README.md#dev-iteration-loop-
|
||||
.
|
||||
├── docker-compose.yml base stack: mariadb + photoprism + sidecar
|
||||
├── docker-compose.podman.yml rootless-podman overlay (keep-id mapping)
|
||||
├── docker-compose.gpu.yml opt-in VA-API GPU passthrough overlay
|
||||
├── .env.example required env vars (copy to .env)
|
||||
├── mariadb/init/ first-boot SQL: creates mule_sidecar DB + user
|
||||
├── pp/ PhotoPrism bind-mounted state (storage, import)
|
||||
@@ -122,4 +123,18 @@ Full instructions in [`sidecar/README.md`](sidecar/README.md#dev-iteration-loop-
|
||||
└── web/ SvelteKit frontend
|
||||
```
|
||||
|
||||
## GPU video acceleration (optional)
|
||||
|
||||
Hosts with a VA-API-capable GPU (Intel iGPU, AMD APU, etc.) can layer
|
||||
[`docker-compose.gpu.yml`](docker-compose.gpu.yml) to hand `/dev/dri/*`
|
||||
to PhotoPrism and switch ffmpeg to hardware encode/decode — a large
|
||||
perf win for video thumbnails and HEVC→H.264 transcodes:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d
|
||||
```
|
||||
|
||||
Set `PP_FFMPEG_ENCODER=vaapi` in `.env` (default for the overlay). Verify
|
||||
with `docker exec pp-app photoprism show config | grep -i ffmpeg`.
|
||||
|
||||
[pp]: https://photoprism.app/
|
||||
|
||||
24
docker-compose.gpu.yml
Normal file
24
docker-compose.gpu.yml
Normal file
@@ -0,0 +1,24 @@
|
||||
# Overlay for hosts with a VA-API-capable GPU passed through (Intel
|
||||
# QSV, AMD VCN/VCE, any VA-API driver). PhotoPrism's :latest image
|
||||
# ships VA-API-enabled ffmpeg; this file just wires the device + group
|
||||
# membership + encoder selection. Layered in by the deploy script on
|
||||
# hosts where /dev/dri/renderD128 exists.
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d
|
||||
|
||||
services:
|
||||
photoprism:
|
||||
devices:
|
||||
- /dev/dri/renderD128:/dev/dri/renderD128
|
||||
- /dev/dri/card0:/dev/dri/card0
|
||||
# Match host GIDs (render=992, video=44 on Debian). PhotoPrism's
|
||||
# container user (PP_UID:PP_GID, typically 33:10000) is not in
|
||||
# these groups by default; group_add grants access to the device
|
||||
# nodes without changing the primary user.
|
||||
group_add:
|
||||
- "992"
|
||||
- "44"
|
||||
environment:
|
||||
PHOTOPRISM_FFMPEG_ENCODER: ${PP_FFMPEG_ENCODER:-vaapi}
|
||||
PHOTOPRISM_FFMPEG_BITRATE: ${PP_FFMPEG_BITRATE:-32}
|
||||
@@ -42,7 +42,7 @@ services:
|
||||
# silently no-op on Debian/Ubuntu and macOS Docker Desktop.
|
||||
- ./mariadb/init:/docker-entrypoint-initdb.d:ro,Z
|
||||
healthcheck:
|
||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||
test: ["CMD", "/usr/bin/mariadb-admin", "ping", "-h", "127.0.0.1", "--silent"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
// is the only authority, and we probe PhotoPrism with it before doing any
|
||||
// destructive work. The handler reads the validated token off the context
|
||||
// via ctxToken so it can keep forwarding it to PhotoPrism for the actual
|
||||
// operation.
|
||||
// operation. The resolved username is available via ctxUserName.
|
||||
func requireSession(pp *ppClient) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := c.GetHeader("X-Auth-Token")
|
||||
@@ -19,11 +19,15 @@ func requireSession(pp *ppClient) gin.HandlerFunc {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "no token"})
|
||||
return
|
||||
}
|
||||
if !pp.validateSession(c.Request.Context(), token) {
|
||||
user := pp.resolveSession(c.Request.Context(), token)
|
||||
if user == nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid session"})
|
||||
return
|
||||
}
|
||||
c.Set("token", token)
|
||||
c.Set("userName", user.UserName)
|
||||
c.Set("userUID", user.UserUID)
|
||||
c.Set("basePath", user.BasePath)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -42,3 +46,42 @@ func ctxToken(c *gin.Context) string {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ctxUserName returns the PhotoPrism username resolved by requireSession.
|
||||
func ctxUserName(c *gin.Context) string {
|
||||
v, ok := c.Get("userName")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ctxUserUID returns the PhotoPrism user UID resolved by requireSession.
|
||||
func ctxUserUID(c *gin.Context) string {
|
||||
v, ok := c.Get("userUID")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ctxBasePath returns the PhotoPrism user BasePath resolved by requireSession.
|
||||
func ctxBasePath(c *gin.Context) string {
|
||||
v, ok := c.Get("basePath")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -9,13 +9,13 @@ import (
|
||||
)
|
||||
|
||||
// Mark mirrors the per-photo extras the web client stores via the marks
|
||||
// endpoints — rating + four-colour label. PhotoUID is the row key; both
|
||||
// payload fields are nullable so the sparse "no rating / no colour" state
|
||||
// round-trips cleanly. The Node prototype kept this in a JSON file; we
|
||||
// migrate to MariaDB here so the M4 sharing work has a real table to
|
||||
// extend.
|
||||
// endpoints — rating + four-colour label. Composite primary key
|
||||
// (photo_uid, user_name) so each user has independent marks. Both payload
|
||||
// fields are nullable so the sparse "no rating / no colour" state
|
||||
// round-trips cleanly.
|
||||
type Mark struct {
|
||||
PhotoUID string `gorm:"primaryKey;size:64;column:photo_uid" json:"-"`
|
||||
UserName string `gorm:"primaryKey;size:128;column:user_name" json:"-"`
|
||||
Rating *int `gorm:"column:rating" json:"rating,omitempty"`
|
||||
Color *string `gorm:"size:16;column:color" json:"color,omitempty"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
|
||||
|
||||
70
sidecar/handlers_folders_proxy.go
Normal file
70
sidecar/handlers_folders_proxy.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// handleFoldersProxy proxies PhotoPrism's /api/v1/folders/originals and
|
||||
// post-filters by the caller's BasePath so the folder tree only shows
|
||||
// folders under the user's library root.
|
||||
//
|
||||
// Route: GET /api/sidecar/folders (behind requireSession)
|
||||
func handleFoldersProxy(pp *ppClient) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := ctxToken(c)
|
||||
basePath := ctxBasePath(c)
|
||||
|
||||
// Forward query params to PhotoPrism.
|
||||
query := c.Request.URL.RawQuery
|
||||
if query == "" {
|
||||
query = "recursive=true&uncached=true&files=false"
|
||||
}
|
||||
|
||||
resp, err := pp.call(c.Request.Context(), http.MethodGet, "/api/v1/folders/originals?"+query, token, nil)
|
||||
if err != nil || !resp.OK {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream folders request failed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Decode the response — PhotoPrism returns { folders: [...] }.
|
||||
var payload struct {
|
||||
Folders []map[string]any `json:"folders"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.Body, &payload); err != nil {
|
||||
c.Data(resp.Status, "application/json", resp.Body)
|
||||
return
|
||||
}
|
||||
|
||||
// If the user has no BasePath (admin/empty), return as-is.
|
||||
if basePath == "" {
|
||||
c.JSON(http.StatusOK, payload)
|
||||
return
|
||||
}
|
||||
|
||||
prefix := basePath + "/"
|
||||
|
||||
// Post-filter folders by Path field only — frontend handles BasePath
|
||||
// prefix stripping via toUserPath().
|
||||
filtered := make([]map[string]any, 0, len(payload.Folders))
|
||||
for _, f := range payload.Folders {
|
||||
rawPath, ok := f["Path"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
pathStr, ok := rawPath.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Keep only folders under the user's base path.
|
||||
if pathStr == basePath || strings.HasPrefix(pathStr, prefix) {
|
||||
filtered = append(filtered, f)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"folders": filtered})
|
||||
}
|
||||
}
|
||||
171
sidecar/handlers_labels.go
Normal file
171
sidecar/handlers_labels.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PpLabel mirrors the shape PhotoPrism's /api/v1/labels endpoint returns.
|
||||
// We decode enough to filter + recalculate PhotoCount; fields the client
|
||||
// doesn't render are skipped for token efficiency.
|
||||
type PpLabel struct {
|
||||
UID string `json:"UID"`
|
||||
Name string `json:"Name"`
|
||||
Slug string `json:"Slug"`
|
||||
CustomSlug string `json:"CustomSlug"`
|
||||
Priority int `json:"Priority"`
|
||||
Favorite bool `json:"Favorite"`
|
||||
PhotoCount int `json:"PhotoCount"`
|
||||
Thumb string `json:"Thumb"`
|
||||
CreatedAt string `json:"CreatedAt"`
|
||||
UpdatedAt string `json:"UpdatedAt"`
|
||||
}
|
||||
|
||||
// handleLabels proxies PhotoPrism's /api/v1/labels and then post-filters
|
||||
// each label's PhotoCount (and removes labels with zero count) so they
|
||||
// reflect only photos under the caller's BasePath.
|
||||
//
|
||||
// Route: GET /api/sidecar/labels (behind requireSession)
|
||||
func handleLabels(pp *ppClient, ppDb *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := ctxToken(c)
|
||||
basePath := ctxBasePath(c)
|
||||
|
||||
// Forward the query string (count, offset, q, all, …) to PhotoPrism.
|
||||
query := c.Request.URL.RawQuery
|
||||
|
||||
// Call PhotoPrism's labels endpoint using the caller's token.
|
||||
resp, err := pp.call(c.Request.Context(), http.MethodGet, "/api/v1/labels?"+query, token, nil)
|
||||
if err != nil || !resp.OK {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream labels request failed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Decode labels.
|
||||
var labels []PpLabel
|
||||
if err := json.Unmarshal(resp.Body, &labels); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse labels"})
|
||||
return
|
||||
}
|
||||
|
||||
// If the user has no BasePath (admin/empty), return labels as-is.
|
||||
if basePath == "" || ppDb == nil {
|
||||
c.JSON(http.StatusOK, labels)
|
||||
return
|
||||
}
|
||||
|
||||
// One query: count + a representative scoped thumb for every label
|
||||
// the user can see. Replaces N per-label queries with a single JOIN.
|
||||
prefix := basePath + "/%"
|
||||
|
||||
type labelStat struct {
|
||||
LabelUID string `gorm:"column:label_uid"`
|
||||
Cnt int64 `gorm:"column:cnt"`
|
||||
ThumbHash string `gorm:"column:thumb_hash"`
|
||||
}
|
||||
var stats []labelStat
|
||||
if err := ppDb.Raw(`
|
||||
SELECT lb.label_uid AS label_uid,
|
||||
COUNT(DISTINCT p.id) AS cnt,
|
||||
COALESCE(MIN(f.file_hash), '') AS thumb_hash
|
||||
FROM photos_labels pl
|
||||
JOIN photos p ON pl.photo_id = p.id
|
||||
JOIN labels lb ON pl.label_id = lb.id
|
||||
LEFT JOIN files f ON f.photo_uid = p.photo_uid
|
||||
AND f.file_primary = 1
|
||||
AND f.file_missing = 0
|
||||
WHERE (p.photo_path = ? OR p.photo_path LIKE ?)
|
||||
AND p.deleted_at IS NULL
|
||||
GROUP BY lb.label_uid
|
||||
HAVING cnt > 0
|
||||
`, basePath, prefix).Scan(&stats).Error; err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "label stats query failed"})
|
||||
return
|
||||
}
|
||||
|
||||
cntMap := make(map[string]int64, len(stats))
|
||||
thumbMap := make(map[string]string, len(stats))
|
||||
for _, s := range stats {
|
||||
cntMap[s.LabelUID] = s.Cnt
|
||||
thumbMap[s.LabelUID] = s.ThumbHash
|
||||
}
|
||||
|
||||
filtered := make([]PpLabel, 0, len(stats))
|
||||
for _, l := range labels {
|
||||
cnt, ok := cntMap[l.UID]
|
||||
if !ok || cnt == 0 {
|
||||
continue
|
||||
}
|
||||
l.PhotoCount = int(cnt)
|
||||
if th := thumbMap[l.UID]; th != "" {
|
||||
l.Thumb = th
|
||||
}
|
||||
filtered = append(filtered, l)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, filtered)
|
||||
}
|
||||
}
|
||||
|
||||
// Now also handle the session/config count scoping.
|
||||
|
||||
// PpCounts mirrors PhotoPrism's session config.count block that drives
|
||||
// the sidebar badges (review, archive, all, etc.).
|
||||
type PpCounts struct {
|
||||
All int `json:"all"`
|
||||
Photos int `json:"photos"`
|
||||
Media int `json:"media"`
|
||||
Videos int `json:"videos"`
|
||||
Review int `json:"review"`
|
||||
Archived int `json:"archived"`
|
||||
Hidden int `json:"hidden"`
|
||||
Private int `json:"private"`
|
||||
Favorites int `json:"favorites"`
|
||||
}
|
||||
|
||||
// handleScopedCounts returns user-scoped counts for review/archive/all
|
||||
// so the sidebar badges match what the user actually sees.
|
||||
//
|
||||
// Route: GET /api/sidecar/counts (behind requireSession)
|
||||
func handleScopedCounts(ppDb *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
basePath := ctxBasePath(c)
|
||||
if basePath == "" || ppDb == nil {
|
||||
// Admin or no DB — can't scope, return empty.
|
||||
c.JSON(http.StatusOK, PpCounts{})
|
||||
return
|
||||
}
|
||||
|
||||
prefix := basePath + "/%"
|
||||
pathCond := "(p.photo_path = ? OR p.photo_path LIKE ?)"
|
||||
args := []any{basePath, prefix}
|
||||
|
||||
var counts PpCounts
|
||||
|
||||
// All non-deleted photos in this user's scope.
|
||||
ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NULL AND `+pathCond, args...).Scan(&counts.All)
|
||||
|
||||
// Photos needing review (quality < 3).
|
||||
ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NULL AND p.photo_quality < 3 AND `+pathCond, args...).Scan(&counts.Review)
|
||||
|
||||
// Archived (soft-deleted) photos.
|
||||
ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NOT NULL AND `+pathCond, args...).Scan(&counts.Archived)
|
||||
|
||||
// Private photos.
|
||||
ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NULL AND p.photo_private = 1 AND `+pathCond, args...).Scan(&counts.Private)
|
||||
|
||||
// Photos (type image).
|
||||
ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NULL AND p.photo_type IN ('image','raw','live','animated') AND `+pathCond, args...).Scan(&counts.Photos)
|
||||
|
||||
// Videos.
|
||||
ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NULL AND p.photo_type IN ('video','hdr','burst','live') AND `+pathCond, args...).Scan(&counts.Videos)
|
||||
|
||||
// Favorites.
|
||||
ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NULL AND p.photo_favorite = 1 AND `+pathCond, args...).Scan(&counts.Favorites)
|
||||
|
||||
c.JSON(http.StatusOK, counts)
|
||||
}
|
||||
}
|
||||
@@ -10,13 +10,18 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// validColors is the four-color palette mule-image always shipped. The
|
||||
// empty string is the explicit "clear color" sentinel.
|
||||
// validColors is the color palette the web client offers (COLOR_SWATCHES in
|
||||
// web/src/lib/utils/tagGroups.ts) — keep the two in sync. The empty string is
|
||||
// the explicit "clear color" sentinel.
|
||||
var validColors = map[string]struct{}{
|
||||
"red": {},
|
||||
"orange": {},
|
||||
"yellow": {},
|
||||
"green": {},
|
||||
"teal": {},
|
||||
"blue": {},
|
||||
"purple": {},
|
||||
"pink": {},
|
||||
}
|
||||
|
||||
// markPatch is the request body for all three mutating mark endpoints.
|
||||
@@ -70,12 +75,12 @@ func (p *markPatch) apply(m *Mark) bool {
|
||||
return m.Rating != nil || (m.Color != nil && *m.Color != "")
|
||||
}
|
||||
|
||||
// allMarksJSON renders the entire `marks` table as the wire shape
|
||||
// allMarksJSON renders the current user's marks as the wire shape
|
||||
// `{"<uid>": {"rating": …, "color": …, "updatedAt": …}, …}`. Used by
|
||||
// GET /photos/marks which the web client calls once on session start.
|
||||
func allMarksJSON(db *gorm.DB) (map[string]map[string]any, error) {
|
||||
func allMarksJSON(db *gorm.DB, userName string) (map[string]map[string]any, error) {
|
||||
var rows []Mark
|
||||
if err := db.Find(&rows).Error; err != nil {
|
||||
if err := db.Where("user_name = ?", userName).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]map[string]any, len(rows))
|
||||
@@ -87,7 +92,7 @@ func allMarksJSON(db *gorm.DB) (map[string]map[string]any, error) {
|
||||
|
||||
func handleMarksAll(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
marks, err := allMarksJSON(db)
|
||||
marks, err := allMarksJSON(db, ctxUserName(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -100,7 +105,7 @@ func handleMarkGet(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := c.Param("uid")
|
||||
var m Mark
|
||||
err := db.Where("photo_uid = ?", uid).First(&m).Error
|
||||
err := db.Where("photo_uid = ? AND user_name = ?", uid, ctxUserName(c)).First(&m).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusOK, gin.H{})
|
||||
return
|
||||
@@ -115,18 +120,18 @@ func handleMarkGet(db *gorm.DB) gin.HandlerFunc {
|
||||
|
||||
// upsert applies the patch and writes back. Returns the resulting JSON
|
||||
// shape (empty map if the row was deleted).
|
||||
func upsert(db *gorm.DB, uid string, patch *markPatch) (map[string]any, error) {
|
||||
func upsert(db *gorm.DB, uid, userName string, patch *markPatch) (map[string]any, error) {
|
||||
var m Mark
|
||||
err := db.Where("photo_uid = ?", uid).First(&m).Error
|
||||
err := db.Where("photo_uid = ? AND user_name = ?", uid, userName).First(&m).Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
m.PhotoUID = uid
|
||||
m.UserName = userName
|
||||
keep := patch.apply(&m)
|
||||
m.UpdatedAt = time.Now().UTC()
|
||||
if !keep {
|
||||
// Drop the row entirely so a re-fetch returns {}.
|
||||
if err := db.Where("photo_uid = ?", uid).Delete(&Mark{}).Error; err != nil {
|
||||
if err := db.Where("photo_uid = ? AND user_name = ?", uid, userName).Delete(&Mark{}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{}, nil
|
||||
@@ -149,7 +154,7 @@ func handleMarkPut(db *gorm.DB) gin.HandlerFunc {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
out, err := upsert(db, uid, &patch)
|
||||
out, err := upsert(db, uid, ctxUserName(c), &patch)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -178,6 +183,7 @@ func handleMarkBulk(db *gorm.DB) gin.HandlerFunc {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
userName := ctxUserName(c)
|
||||
applied := make(map[string]map[string]any, len(body.IDs))
|
||||
// Single transaction so a partial failure rolls back. The client
|
||||
// expects atomic semantics for a bulk star/colour stamp.
|
||||
@@ -186,7 +192,7 @@ func handleMarkBulk(db *gorm.DB) gin.HandlerFunc {
|
||||
if uid == "" {
|
||||
continue
|
||||
}
|
||||
out, err := upsert(tx, uid, &body.Patch)
|
||||
out, err := upsert(tx, uid, userName, &body.Patch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
141
sidecar/handlers_photos.go
Normal file
141
sidecar/handlers_photos.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// handlePhotos proxies PhotoPrism's /api/v1/photos and then post-filters
|
||||
// the response so only photos under the caller's BasePath are returned.
|
||||
// This fixes the review/archive tab cross-user leak.
|
||||
//
|
||||
// Route: GET /api/sidecar/timeline (behind requireSession)
|
||||
func handlePhotos(pp *ppClient) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := ctxToken(c)
|
||||
basePath := ctxBasePath(c)
|
||||
|
||||
// Forward the raw query string to PhotoPrism.
|
||||
query := c.Request.URL.RawQuery
|
||||
|
||||
resp, err := pp.call(c.Request.Context(), http.MethodGet, "/api/v1/photos?"+query, token, nil)
|
||||
if err != nil || !resp.OK {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream photos request failed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Decode as a generic array so we can inspect Path without
|
||||
// committing to a rigid struct (PhotoPrism's photo response
|
||||
// varies between list/detail/search endpoints).
|
||||
var photos []map[string]any
|
||||
if err := json.Unmarshal(resp.Body, &photos); err != nil {
|
||||
// If it's not an array (e.g. error, single object), pass through.
|
||||
c.Data(resp.Status, "application/json", resp.Body)
|
||||
return
|
||||
}
|
||||
|
||||
// If the user has no BasePath (admin/empty), return as-is.
|
||||
if basePath == "" {
|
||||
// Forward PhotoPrism's X-Count header for countPhotos().
|
||||
if count := resp.Header.Get("X-Count"); count != "" {
|
||||
c.Header("X-Count", count)
|
||||
}
|
||||
c.JSON(http.StatusOK, photos)
|
||||
return
|
||||
}
|
||||
|
||||
prefix := basePath + "/"
|
||||
|
||||
// Post-filter by FileName field (originals-relative path).
|
||||
filtered := make([]map[string]any, 0, len(photos))
|
||||
for _, ph := range photos {
|
||||
rawPath, ok := ph["FileName"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
pathStr, ok := rawPath.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Match exact basePath or basePath/...
|
||||
if pathStr == basePath || strings.HasPrefix(pathStr, prefix) {
|
||||
filtered = append(filtered, ph)
|
||||
}
|
||||
}
|
||||
|
||||
// Forward X-Count header adjusted to the filtered count.
|
||||
c.Header("X-Count", itoa(len(filtered)))
|
||||
c.JSON(http.StatusOK, filtered)
|
||||
}
|
||||
}
|
||||
|
||||
// handleNotes pages PhotoPrism's photo list to completion and returns only
|
||||
// photos carrying a non-empty Caption (mule-image's "Note"), scoped to the
|
||||
// caller's BasePath. Paging server-side is what makes this correct: the
|
||||
// client can't tell when the *BasePath-filtered* list is exhausted (a full
|
||||
// upstream page can filter down to a short — or empty — slice), but here we
|
||||
// can key the loop off the raw upstream page length.
|
||||
//
|
||||
// Route: GET /api/sidecar/notes (behind requireSession)
|
||||
func handleNotes(pp *ppClient) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := ctxToken(c)
|
||||
basePath := ctxBasePath(c)
|
||||
prefix := basePath + "/"
|
||||
|
||||
const pageSize = 1000
|
||||
out := make([]map[string]any, 0, 64)
|
||||
seen := make(map[string]struct{})
|
||||
|
||||
for offset := 0; ; offset += pageSize {
|
||||
path := fmt.Sprintf("/api/v1/photos?count=%d&offset=%d&merged=true&order=newest", pageSize, offset)
|
||||
resp, err := pp.call(c.Request.Context(), http.MethodGet, path, token, nil)
|
||||
if err != nil || !resp.OK {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream photos request failed"})
|
||||
return
|
||||
}
|
||||
|
||||
var photos []map[string]any
|
||||
if err := json.Unmarshal(resp.Body, &photos); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "unexpected photos response"})
|
||||
return
|
||||
}
|
||||
rawLen := len(photos)
|
||||
|
||||
for _, ph := range photos {
|
||||
// BasePath scope — same rule as handlePhotos.
|
||||
if basePath != "" {
|
||||
pathStr, _ := ph["FileName"].(string)
|
||||
if pathStr != basePath && !strings.HasPrefix(pathStr, prefix) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Non-empty caption only.
|
||||
caption, _ := ph["Caption"].(string)
|
||||
if strings.TrimSpace(caption) == "" {
|
||||
continue
|
||||
}
|
||||
// Dedupe by UID — `merged` can still repeat a photo at a page seam.
|
||||
uid, _ := ph["UID"].(string)
|
||||
if uid != "" {
|
||||
if _, ok := seen[uid]; ok {
|
||||
continue
|
||||
}
|
||||
seen[uid] = struct{}{}
|
||||
}
|
||||
out = append(out, ph)
|
||||
}
|
||||
|
||||
// A short upstream page means PhotoPrism has no more rows.
|
||||
if rawLen < pageSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -46,6 +47,18 @@ func main() {
|
||||
// BasePath wired without an admin restart.
|
||||
startUserBasepathReconciler(cfg)
|
||||
|
||||
// Open a second DB handle pointed at PhotoPrism's own schema for
|
||||
// handlers that need to query auth_users, photos, labels, etc.
|
||||
// May be nil if PpDSN is empty (no PP_DB_PASSWORD set).
|
||||
var ppDb *gorm.DB
|
||||
if cfg.PpDSN != "" {
|
||||
if d, err := openDB(cfg.PpDSN); err == nil {
|
||||
ppDb = d
|
||||
} else {
|
||||
slog.Warn("pp db open failed — scoped labels/counts unavailable", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.New()
|
||||
// Keep `%2F` literal in path params so callers can pass URL-encoded
|
||||
@@ -66,25 +79,43 @@ func main() {
|
||||
|
||||
// Every other endpoint runs behind the session gate. Mounting them
|
||||
// under one group keeps the middleware wiring obvious.
|
||||
auth := r.Group("/api/sidecar", requireSession(pp))
|
||||
{
|
||||
auth.GET("/photos/marks", handleMarksAll(db))
|
||||
auth.GET("/photos/:uid/marks", handleMarkGet(db))
|
||||
auth.PUT("/photos/:uid/marks", handleMarkPut(db))
|
||||
auth.POST("/photos/marks/bulk", handleMarkBulk(db))
|
||||
auth := r.Group("/api/sidecar", requireSession(pp))
|
||||
{
|
||||
auth.GET("/photos/marks", handleMarksAll(db))
|
||||
auth.GET("/photos/:uid/marks", handleMarkGet(db))
|
||||
auth.PUT("/photos/:uid/marks", handleMarkPut(db))
|
||||
auth.POST("/photos/marks/bulk", handleMarkBulk(db))
|
||||
|
||||
auth.POST("/files/:uid/rename", handleRename(cfg, pp))
|
||||
auth.POST("/files/:uid/rename", handleRename(cfg, pp))
|
||||
|
||||
auth.POST("/folders", handleFolderCreate(cfg, pp))
|
||||
auth.POST("/folders/counts", handleFolderCounts(pp))
|
||||
auth.POST("/folders/:rel/rename", handleFolderRename(cfg, pp))
|
||||
auth.DELETE("/folders/:rel", handleFolderDelete(cfg, pp))
|
||||
auth.POST("/folders", handleFolderCreate(cfg, pp))
|
||||
auth.POST("/folders/counts", handleFolderCounts(pp))
|
||||
auth.POST("/folders/:rel/rename", handleFolderRename(cfg, pp))
|
||||
auth.DELETE("/folders/:rel", handleFolderDelete(cfg, pp))
|
||||
|
||||
auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp))
|
||||
auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp))
|
||||
|
||||
auth.GET("/duplicates/scan", handleDupScan(cfg, pp))
|
||||
auth.POST("/duplicates/archive", handleDupArchive(cfg, pp))
|
||||
}
|
||||
auth.GET("/duplicates/scan", handleDupScan(cfg, pp))
|
||||
auth.POST("/duplicates/archive", handleDupArchive(cfg, pp))
|
||||
|
||||
// User-scoped proxies — require PpDSN connection.
|
||||
if ppDb != nil {
|
||||
auth.GET("/labels", handleLabels(pp, ppDb))
|
||||
auth.GET("/counts", handleScopedCounts(ppDb))
|
||||
}
|
||||
|
||||
// User-scoped photos — post-filters by BasePath so review/archive
|
||||
// tabs only show photos the user owns.
|
||||
auth.GET("/timeline", handlePhotos(pp))
|
||||
|
||||
// Photos carrying a Note (Caption) — pages PhotoPrism fully so
|
||||
// the /notes view isn't capped to the newest slice.
|
||||
auth.GET("/notes", handleNotes(pp))
|
||||
|
||||
// User-scoped folders — post-filters the folder tree by BasePath
|
||||
// so the sidebar shows only folders under the user's library root.
|
||||
auth.GET("/folders", handleFoldersProxy(pp))
|
||||
}
|
||||
|
||||
addr := cfg.ListenAddr + ":" + itoa(cfg.Port)
|
||||
srv := &http.Server{
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
@@ -85,6 +86,44 @@ func (c *ppClient) call(ctx context.Context, method, urlPath, token string, body
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ppSessionUser is the subset of PhotoPrism's session response we need.
|
||||
type ppSessionUser struct {
|
||||
UserUID string `json:"UID"`
|
||||
UserName string `json:"Name"`
|
||||
BasePath string `json:"BasePath"`
|
||||
}
|
||||
|
||||
type ppSessionResponse struct {
|
||||
User ppSessionUser `json:"user"`
|
||||
}
|
||||
|
||||
// resolveSession validates the token AND returns the authenticated user.
|
||||
// Returns nil when the token is invalid or the response can't be parsed.
|
||||
func (c *ppClient) resolveSession(ctx context.Context, token string) *ppSessionUser {
|
||||
if token == "" {
|
||||
return nil
|
||||
}
|
||||
r, err := c.call(ctx, http.MethodGet, "/api/v1/session", token, nil)
|
||||
if err != nil {
|
||||
slog.Warn("resolveSession: call failed", "err", err)
|
||||
return nil
|
||||
}
|
||||
if !r.OK {
|
||||
slog.Warn("resolveSession: not OK", "status", r.Status, "body", string(r.Body[:min(len(r.Body), 200)]))
|
||||
return nil
|
||||
}
|
||||
var resp ppSessionResponse
|
||||
if err := json.Unmarshal(r.Body, &resp); err != nil {
|
||||
slog.Warn("resolveSession: unmarshal failed", "err", err, "body", string(r.Body[:min(len(r.Body), 200)]))
|
||||
return nil
|
||||
}
|
||||
if resp.User.UserName == "" {
|
||||
slog.Warn("resolveSession: empty username", "body", string(r.Body[:min(len(r.Body), 200)]))
|
||||
return nil
|
||||
}
|
||||
return &resp.User
|
||||
}
|
||||
|
||||
// validateSession is the cheapest probe that the supplied token is live:
|
||||
// list one photo. 401 → bad/expired token. We never read the payload.
|
||||
func (c *ppClient) validateSession(ctx context.Context, token string) bool {
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="text-scale" content="scale" />
|
||||
<!-- SVG favicon adapts to light/dark via `prefers-color-scheme`
|
||||
inside the file itself; PNG remains as a fallback for browsers
|
||||
that don't support SVG icons. Apple touch icon stays PNG
|
||||
since iOS home-screen icons can't be SVG. -->
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<link rel="apple-touch-icon" href="/favicon.png" />
|
||||
%sveltekit.head%
|
||||
|
||||
@@ -10,6 +10,9 @@ import {
|
||||
removeFromHeap,
|
||||
type PpAlbum
|
||||
} from '$lib/services/photoprism';
|
||||
import { acceptDateAndKeep, cachedPhoto } from '$lib/services/photoActions';
|
||||
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
|
||||
import { photoNameAndDir } from '$lib/types/photoprism';
|
||||
import { queryClient } from '$lib/queryClient';
|
||||
import { filters } from '$lib/stores/filters.svelte';
|
||||
import {
|
||||
@@ -24,8 +27,8 @@ import {
|
||||
toggle
|
||||
} from '$lib/stores/selection.svelte';
|
||||
import { popAndRun, push as pushUndo } from '$lib/stores/undo.svelte';
|
||||
import { startBulk, doneBulk, failBulk, setDetail } from '$lib/stores/bulkAction.svelte';
|
||||
import { openPreview, toggleLeftSidebar, toggleRightSidebar, view } from '$lib/stores/view.svelte';
|
||||
import type { PpPhoto } from '$lib/types/photoprism';
|
||||
|
||||
/**
|
||||
* Optional parameters the host passes via `use:gridKeyNav={...}`.
|
||||
@@ -160,35 +163,6 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Look up a photo's current cached state without forcing a refetch.
|
||||
* Walks every `['photos', …]` cache entry first, then the per-photo
|
||||
* cache. Lets `x` decide "archive vs restore" based on the actual current
|
||||
* state instead of always sending Archived=true.
|
||||
*
|
||||
* The `['photos', …]` namespace holds two shapes: a flat `PpPhoto[]`
|
||||
* (e.g. ratings/colors pools) and TanStack's `InfiniteData` envelope
|
||||
* (`{pages: PpPhoto[][], pageParams}`) used by the timeline's infinite
|
||||
* scroll. Walk both — assuming a flat array on the timeline cache used
|
||||
* to throw `list.find is not a function` and abort the F/X handlers. */
|
||||
function cachedPhoto(uid: string): PpPhoto | undefined {
|
||||
const lists = queryClient.getQueriesData({ queryKey: ['photos'] });
|
||||
for (const [, data] of lists) {
|
||||
if (!data) continue;
|
||||
if (Array.isArray(data)) {
|
||||
const hit = (data as PpPhoto[]).find((p) => p.UID === uid);
|
||||
if (hit) return hit;
|
||||
continue;
|
||||
}
|
||||
const pages = (data as { pages?: PpPhoto[][] }).pages;
|
||||
if (!Array.isArray(pages)) continue;
|
||||
for (const page of pages) {
|
||||
const hit = page?.find?.((p) => p.UID === uid);
|
||||
if (hit) return hit;
|
||||
}
|
||||
}
|
||||
return queryClient.getQueryData<PpPhoto>(['photo', uid]);
|
||||
}
|
||||
|
||||
async function toggleArchive(direction: 'archive' | 'restore' | 'toggle') {
|
||||
const ids = cullTargets();
|
||||
if (ids.length === 0) {
|
||||
@@ -207,34 +181,25 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
target = !(first?.Archived ?? false);
|
||||
}
|
||||
|
||||
// PhotoPrism's photo PUT silently drops the Archived field — the
|
||||
// only working path is /api/v1/batch/photos/{archive,restore}. The
|
||||
// previous patchTargets call PUT'd `{Archived: true}` and got a 200
|
||||
// back, so the toast fired but nothing moved.
|
||||
const opLabel = target ? 'Archiving' : 'Restoring';
|
||||
const doneLabel = target ? `Archived ${ids.length}` : `Restored ${ids.length}`;
|
||||
const tid = toast.loading(`${opLabel} ${ids.length}…`);
|
||||
startBulk(`${opLabel}…`, ids);
|
||||
try {
|
||||
if (target) await batchArchive(ids);
|
||||
else await batchRestore(ids);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Archive failed');
|
||||
failBulk(ids);
|
||||
toast.error(err instanceof Error ? err.message : 'Archive/restore failed', { id: tid });
|
||||
return;
|
||||
}
|
||||
// Move focus forward before the photos query refetches, so the
|
||||
// user can keep X-ing through the timeline without their cursor
|
||||
// snapping back to photo[0]. Walks past every uid we just
|
||||
// archived/restored — relevant when the cull targets came from a
|
||||
// multi-selection rather than the single focused tile.
|
||||
doneBulk(doneLabel, ids);
|
||||
focusAfter(ids);
|
||||
// Drop the now-stale selection set. The archived UIDs are about
|
||||
// to leave the timeline on refetch, but the SvelteSet membership
|
||||
// keeps the selection ring on them until then — confusing for
|
||||
// the user and a footgun if they Ctrl-click to add more and end
|
||||
// up re-archiving the same photos. The BulkActionBar button path
|
||||
// clears for the same reason; mirror it here.
|
||||
clearSelection();
|
||||
invalidatePhotos(ids);
|
||||
const label = target ? `Archived ${ids.length}` : `Restored ${ids.length}`;
|
||||
toast.success(label);
|
||||
pushUndo(label, async () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['marks'] });
|
||||
toast.success(doneLabel, { id: tid });
|
||||
pushUndo(doneLabel, async () => {
|
||||
if (target) await batchRestore(ids);
|
||||
else await batchArchive(ids);
|
||||
invalidatePhotos(ids);
|
||||
@@ -259,16 +224,21 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
? 'Permanently delete this photo? This cannot be undone.'
|
||||
: `Permanently delete ${ids.length} photos? This cannot be undone.`;
|
||||
if (!confirm(msg)) return;
|
||||
const tid = toast.loading(`Deleting ${ids.length}…`);
|
||||
startBulk('Deleting…', ids);
|
||||
try {
|
||||
await batchDelete(ids);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Delete failed');
|
||||
failBulk(ids);
|
||||
toast.error(err instanceof Error ? err.message : 'Delete failed', { id: tid });
|
||||
return;
|
||||
}
|
||||
doneBulk(`Deleted ${ids.length}`, ids);
|
||||
focusAfter(ids);
|
||||
clearSelection();
|
||||
invalidatePhotos(ids);
|
||||
toast.success(`Deleted ${ids.length}`);
|
||||
void queryClient.invalidateQueries({ queryKey: ['marks'] });
|
||||
toast.success(`Deleted ${ids.length}`, { id: tid });
|
||||
}
|
||||
|
||||
/** Approve cull targets — clears them out of the review pile by
|
||||
@@ -284,21 +254,27 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { updated, errors } = await batchEdit(ids, (id) => approvePhoto(id));
|
||||
// Approve moves photos out of the review pile, so the same
|
||||
// stale-selection trap as archive/delete applies — advance focus
|
||||
// past the approved set and drop the now-irrelevant selection
|
||||
// before invalidate refetches the (smaller) view.
|
||||
const tid = toast.loading(`Keeping ${ids.length}…`);
|
||||
startBulk('Keeping…', ids);
|
||||
const { updated, errors } = await batchEdit(ids, (id) => approvePhoto(id), {
|
||||
onProgress: (_done, _total, completedId) => {
|
||||
const p = cachedPhoto(completedId);
|
||||
if (p) setDetail(p.FileName ?? completedId);
|
||||
}
|
||||
});
|
||||
if (errors.length) {
|
||||
failBulk(ids);
|
||||
toast.error(`Kept ${updated.length}; ${errors.length} failed`, {
|
||||
id: tid,
|
||||
description: errors[0].message
|
||||
});
|
||||
} else {
|
||||
doneBulk(`Kept ${ids.length}`, ids);
|
||||
toast.success(`Kept ${ids.length}`, { id: tid });
|
||||
}
|
||||
focusAfter(ids);
|
||||
clearSelection();
|
||||
invalidatePhotos(ids);
|
||||
if (errors.length) {
|
||||
toast.error(`Kept ${updated.length}; ${errors.length} failed`, {
|
||||
description: errors[0].message
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast.success(`Kept ${ids.length}`);
|
||||
}
|
||||
|
||||
// ── S chord (add-to-heap) ────────────────────────────────────────────
|
||||
@@ -324,25 +300,28 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
});
|
||||
return;
|
||||
}
|
||||
const tid = toast.loading(`Adding ${ids.length} → ${heap.Title}…`);
|
||||
startBulk(`Adding to ${heap.Title}…`, ids);
|
||||
try {
|
||||
const { added } = await addToHeap(heap.UID, ids);
|
||||
void queryClient.invalidateQueries({ queryKey: ['heaps'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
||||
// PhotoPrism returns 200 even when nothing was added — distinguish
|
||||
// "really added N" from "skipped all N" so the toast tells the
|
||||
// truth.
|
||||
if (added.length === 0) {
|
||||
failBulk(ids);
|
||||
toast.error(`Nothing added to ${heap.Title}`, {
|
||||
id: tid,
|
||||
description: `The server rejected all ${ids.length} UIDs (already in heap, or not indexed).`
|
||||
});
|
||||
return;
|
||||
}
|
||||
doneBulk(`Added ${added.length} → ${heap.Title}`, ids);
|
||||
if (added.length < ids.length) {
|
||||
toast.success(`Added ${added.length}/${ids.length} → ${heap.Title}`, {
|
||||
id: tid,
|
||||
description: 'The rest were already in this heap.'
|
||||
});
|
||||
} else {
|
||||
toast.success(`Added ${added.length} → ${heap.Title}`);
|
||||
toast.success(`Added ${added.length} → ${heap.Title}`, { id: tid });
|
||||
}
|
||||
pushUndo(`Added ${added.length} to ${heap.Title}`, async () => {
|
||||
await removeFromHeap(heap.UID, added);
|
||||
@@ -350,7 +329,8 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Add-to-heap failed');
|
||||
failBulk(ids);
|
||||
toast.error(err instanceof Error ? err.message : 'Add-to-heap failed', { id: tid });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -498,6 +478,29 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
if (meta) {
|
||||
e.preventDefault();
|
||||
for (const id of selection.order) selection.ids.add(id);
|
||||
return;
|
||||
}
|
||||
if (shift) return;
|
||||
// Bare `a` on the EXIF Stripped review tab fires the same
|
||||
// "Accept date & Keep" flow as the bar button. Mirrors the
|
||||
// bar's all-targets-have-a-suggestion gate so the shortcut
|
||||
// can't silently approve photos without a date fix.
|
||||
if (
|
||||
filters.section === 'review' &&
|
||||
new URL(window.location.href).searchParams.get('tab') === 'stripped_exif'
|
||||
) {
|
||||
const ids = cullTargets();
|
||||
if (ids.length === 0) return;
|
||||
for (const id of ids) {
|
||||
const p = cachedPhoto(id);
|
||||
if (!p) return;
|
||||
const { fileName, path } = photoNameAndDir(p);
|
||||
if (!suggestDateFromPath({ fileName, originalName: p.OriginalName, path })) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
e.preventDefault();
|
||||
void acceptDateAndKeep(ids);
|
||||
}
|
||||
return;
|
||||
case 'x':
|
||||
|
||||
@@ -179,7 +179,7 @@
|
||||
bind:this={sectionEl}
|
||||
tabindex="0"
|
||||
role="application"
|
||||
aria-label={`Cross-folder duplicate · ${group.files.length} copies`}
|
||||
aria-label={`Duplicate group · ${group.files.length} copies`}
|
||||
onkeydown={onKeydown}
|
||||
class="space-y-2 rounded-md border border-border bg-card/30 p-3 outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
toast.error(
|
||||
crossQuery.error instanceof Error
|
||||
? crossQuery.error.message
|
||||
: 'Cross-folder scan failed'
|
||||
: 'Duplicates scan failed'
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -91,7 +91,7 @@
|
||||
<p>
|
||||
The library stacks byte-identical (or EXIF-identical) files. If you don't have
|
||||
any, this tab stays empty. Cross-folder copies dropped at index time live under
|
||||
the Cross-folder tab.
|
||||
the Duplicates tab.
|
||||
</p>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
@@ -105,9 +105,9 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Cross-folder tab ----------------------------------------------- -->
|
||||
<!-- Duplicates tab (cross-folder scan) ----------------------------- -->
|
||||
{#if activeTab === 'cross-folder'}
|
||||
<div role="tabpanel" aria-label="Cross-folder duplicates" class="space-y-3 px-6 py-4 pb-6">
|
||||
<div role="tabpanel" aria-label="Duplicates" class="space-y-3 px-6 py-4 pb-6">
|
||||
<header class="flex items-baseline justify-between gap-3">
|
||||
<p class="text-[11px] text-muted-foreground">
|
||||
Byte-identical files the indexer dropped at index time. Found by scanning the
|
||||
@@ -139,7 +139,7 @@
|
||||
: 'unknown error'}
|
||||
/>
|
||||
{:else if crossCount === 0}
|
||||
<EmptyState icon={CheckCircle2} title="No cross-folder duplicates found">
|
||||
<EmptyState icon={CheckCircle2} title="No duplicates found">
|
||||
{#snippet descriptionSnippet()}
|
||||
{#if crossQuery.data}
|
||||
<p class="text-[10px] text-muted-foreground/70">
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
<script lang="ts">
|
||||
import { filters } from '$lib/stores/filters.svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { untrack } from 'svelte';
|
||||
import { FolderPlus, Pencil, Trash2 } from 'lucide-svelte';
|
||||
import Self from './FolderTree.svelte';
|
||||
import KebabMenu, { Item, Separator } from './KebabMenu.svelte';
|
||||
@@ -105,6 +106,37 @@
|
||||
if (selectedPath !== undefined) return selectedPath === path;
|
||||
return filters.folderPath === path;
|
||||
}
|
||||
|
||||
// Auto-expand the ancestor chain of the active folder so the
|
||||
// highlighted row is actually visible after a deep-link navigation
|
||||
// (RightSidebar's open-folder icon, URL hydration, etc.). Each
|
||||
// FolderTree instance only owns the openSet entries for the nodes
|
||||
// rendered at its depth, but since the root instance expands the
|
||||
// top-level ancestor first, the child instance for that subtree is
|
||||
// then mounted and runs the same effect — the cascade naturally
|
||||
// reaches the leaf. Skipped in `readonly` mode (the heap-convert
|
||||
// picker has its own selectedPath and shouldn't drive the sidebar
|
||||
// state). Skipped for top-level paths (nothing to expand).
|
||||
$effect(() => {
|
||||
if (readonly || !browser) return;
|
||||
const fp = selectedPath ?? filters.folderPath;
|
||||
if (!fp || fp === '/' || !fp.includes('/')) return;
|
||||
untrack(() => {
|
||||
const parts = fp.split('/');
|
||||
let changed = false;
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const ancestor = parts.slice(0, i).join('/');
|
||||
if (ancestor && !openSet.has(ancestor)) {
|
||||
openSet.add(ancestor);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
openSet = new Set(openSet);
|
||||
persist();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<ul>
|
||||
|
||||
@@ -1,49 +1,8 @@
|
||||
<!--
|
||||
Compact status pill that appears in the header while PhotoPrism's
|
||||
indexer is doing work. Driven by the indexer store, which subscribes
|
||||
to PhotoPrism's WS channel. Renders nothing when idle so it never
|
||||
steals header real estate from the user.
|
||||
|
||||
The `detail` (current path/file) is exposed via `title` rather than
|
||||
rendered inline — the pill stays narrow even on slow flashes through
|
||||
a deep library, and hover surfaces the detail for users who care.
|
||||
-->
|
||||
<!-- PhotoPrism indexer status pill. Driven by the indexer store, which
|
||||
subscribes to PhotoPrism's WS channel. Delegates rendering to StatusPill. -->
|
||||
<script lang="ts">
|
||||
import { indexer } from '$lib/stores/indexer.svelte';
|
||||
import { Loader2 } from 'lucide-svelte';
|
||||
|
||||
// PhotoPrism's `fileName` arrives as the full relative path
|
||||
// (`subdir/IMG_0554.HEIC.jpg`). The basename is enough for inline
|
||||
// recognition; the full path stays in the `title` for users who hover.
|
||||
const basename = $derived.by(() => {
|
||||
const d = indexer.detail;
|
||||
if (!d) return '';
|
||||
const i = d.lastIndexOf('/');
|
||||
return i >= 0 ? d.slice(i + 1) : d;
|
||||
});
|
||||
import StatusPill from './StatusPill.svelte';
|
||||
</script>
|
||||
|
||||
{#if indexer.active || indexer.label}
|
||||
<div
|
||||
class="flex items-center gap-1.5 rounded-full border border-border bg-background/80 px-2.5 py-1 text-xs text-foreground shadow-sm backdrop-blur"
|
||||
title={indexer.detail ?? indexer.label}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{#if indexer.active}
|
||||
<Loader2 class="h-3 w-3 animate-spin text-primary" />
|
||||
{/if}
|
||||
<span class="whitespace-nowrap">{indexer.label}</span>
|
||||
{#if basename}
|
||||
<!-- Fixed-width slot so the pill stops shrinking/growing as
|
||||
PhotoPrism rattles through files of different name lengths.
|
||||
`w-[24ch]` locks the column; `truncate` ellipsises anything
|
||||
longer. The full path remains in the parent's `title`. -->
|
||||
<span
|
||||
class="w-[24ch] truncate text-left font-mono text-[10px] text-muted-foreground"
|
||||
>
|
||||
{basename}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<StatusPill active={indexer.active} label={indexer.label} detail={indexer.detail} />
|
||||
|
||||
@@ -7,38 +7,35 @@
|
||||
import { toast } from 'svelte-sonner';
|
||||
import {
|
||||
aggregateKeywords,
|
||||
countPhotos,
|
||||
createFolder,
|
||||
createHeap,
|
||||
deleteFolder,
|
||||
deleteHeap,
|
||||
duplicateHeap,
|
||||
getAllMarks,
|
||||
getConfig,
|
||||
heapDownloadUrl,
|
||||
listFolderCounts,
|
||||
listFolders,
|
||||
listGeo,
|
||||
listHeaps,
|
||||
logout,
|
||||
renameFolder,
|
||||
renameHeap,
|
||||
scanCrossFolderDuplicates,
|
||||
triggerDownload,
|
||||
type AggregatedKeyword,
|
||||
type CrossFolderScanResult,
|
||||
type PhotoMarksMap,
|
||||
type PpAlbum,
|
||||
type PpClientConfig,
|
||||
type PpFolder,
|
||||
type PpGeoCollection
|
||||
type PpFolder
|
||||
} from '$lib/services/photoprism';
|
||||
import {
|
||||
listDuplicateGroups,
|
||||
type DuplicateGroup
|
||||
} from '$lib/services/adapters/duplicates';
|
||||
import {
|
||||
listReviewGroups,
|
||||
type CauseKey,
|
||||
type ReviewGroup
|
||||
} from '$lib/services/adapters/review';
|
||||
import {
|
||||
filters,
|
||||
navigateToFolder,
|
||||
setFolderPath,
|
||||
setSection,
|
||||
TAG_CATEGORIES,
|
||||
@@ -80,107 +77,14 @@
|
||||
const foldersQuery = createQuery<PpFolder[]>(() => ({
|
||||
queryKey: ['folders'],
|
||||
queryFn: listFolders,
|
||||
enabled: isAuthenticated()
|
||||
}));
|
||||
|
||||
// View counts come from PhotoPrism's `/config` response, which carries a
|
||||
// precomputed counter for every common bucket (all/archived/labels/
|
||||
// places/…) updated incrementally on every mutation. Cheap to refetch,
|
||||
// and gives us a stable total — `/photos` only returns per-page row
|
||||
// counts via `X-Count`, never a total.
|
||||
//
|
||||
// The key sits under the `['photos', …]` prefix so it inherits the
|
||||
// existing `invalidateQueries({ queryKey: ['photos'] })` calls scattered
|
||||
// across mutations (archive, restore, delete, heap add) — the counter
|
||||
// map refreshes whenever the photo list does. Marks-derived counts
|
||||
// (ratings/colors) react through the shared `['marks']` cache.
|
||||
const configQuery = createQuery<PpClientConfig>(() => ({
|
||||
queryKey: ['photos', 'config'],
|
||||
queryFn: getConfig,
|
||||
enabled: isAuthenticated()
|
||||
}));
|
||||
|
||||
// PhotoPrism's /api/v1/config.count returns library-wide aggregates
|
||||
// to any authenticated session regardless of role — the timeline
|
||||
// itself IS scoped per-user, but the precomputed counters aren't.
|
||||
// `isAdminUser` controls the cheap path: an admin without a
|
||||
// BasePath gets the precomputed totals from /config directly. Every
|
||||
// other case (non-admin, or admin scoped to a subfolder) goes
|
||||
// through `countPhotos()` which appends a `path:<base>*` filter so
|
||||
// the badge matches what the user can actually see.
|
||||
const isAdminUser = $derived(session.user?.Role === 'admin');
|
||||
const wantScoped = $derived(!isAdminUser || userBasePath() !== '');
|
||||
|
||||
// Builds a DSL clause that mirrors PhotoPrism's ACL scoping. An
|
||||
// admin with `BasePath === ""` gets a no-op clause and the global
|
||||
// query; everyone else gets a `path:` clause anchored to their
|
||||
// BasePath so unrelated folders never contribute to the badge.
|
||||
// Non-admins with no BasePath have nothing they can see, so we
|
||||
// short-circuit to a query that returns zero (`uid:none`).
|
||||
function scoped(filter: string): string {
|
||||
const bp = userBasePath();
|
||||
if (isAdminUser && bp === '') return filter;
|
||||
if (!isAdminUser && bp === '') return 'uid:none';
|
||||
return `${filter} path:"${bp}*"`.trim();
|
||||
}
|
||||
|
||||
function scopedCountQuery(key: string, filter: string) {
|
||||
return createQuery<number>(() => ({
|
||||
queryKey: ['photos', 'scoped-count', key, userBasePath(), isAdminUser],
|
||||
queryFn: () => countPhotos(scoped(filter)),
|
||||
enabled: isAuthenticated() && wantScoped,
|
||||
staleTime: 60_000
|
||||
}));
|
||||
}
|
||||
|
||||
// One query per badge. Admins with no BasePath skip these
|
||||
// (enabled:false via `wantScoped`) and the configQuery numbers are
|
||||
// used directly — same chrome as before that fix, no extra
|
||||
// round-trips.
|
||||
const favoritesCountQuery = scopedCountQuery('favorites', 'favorite:true');
|
||||
const reviewCountQuery = scopedCountQuery('review', 'review:true');
|
||||
const hiddenCountQuery = scopedCountQuery('hidden', 'hidden:true');
|
||||
const archivedCountQuery = scopedCountQuery('archived', 'archived:true');
|
||||
// Labels is special: `configQuery.count.labels` is the number of distinct
|
||||
// label categories (PhotoPrism's roll-up), not the number of photos that
|
||||
// carry a label. The Tags surface wants picture counts everywhere, so we
|
||||
// always run a `countPhotos('label:*')` query regardless of the admin/
|
||||
// BasePath shape and never fall back to the category-count.
|
||||
const labelsCountQuery = createQuery<number>(() => ({
|
||||
queryKey: ['photos', 'scoped-count', 'labels', userBasePath(), isAdminUser],
|
||||
queryFn: () => countPhotos(scoped('label:*')),
|
||||
enabled: isAuthenticated(),
|
||||
staleTime: 60_000
|
||||
gcTime: 0
|
||||
}));
|
||||
|
||||
function bucketCount(
|
||||
key: 'favorites' | 'review' | 'hidden' | 'archived',
|
||||
query: { data: number | undefined; isPending: boolean }
|
||||
): number | undefined {
|
||||
if (wantScoped) {
|
||||
if (query.isPending) return undefined;
|
||||
return query.data;
|
||||
}
|
||||
// Admin + no BasePath: use the precomputed PhotoPrism counters
|
||||
// (no extra round-trip).
|
||||
const c = configQuery.data?.count;
|
||||
if (!c) return undefined;
|
||||
return c[key];
|
||||
}
|
||||
|
||||
const marksQuery = createQuery<PhotoMarksMap>(() => ({
|
||||
queryKey: ['marks'],
|
||||
queryFn: getAllMarks,
|
||||
enabled: isAuthenticated(),
|
||||
staleTime: 60_000
|
||||
}));
|
||||
|
||||
// Duplicates counts for the sidebar badge. Stacks is a cheap
|
||||
// PhotoPrism query so we always fetch it; cross-folder is an
|
||||
// O(disk) scan, so the sidebar only *observes* its cache
|
||||
// (enabled:false) and the duplicates page itself is what populates
|
||||
// it on first visit. Both share queryKeys with the /duplicates
|
||||
// view so cache is reused.
|
||||
// Stacks + cross-folder duplicate caches are warmed here so the
|
||||
// /duplicates view (and its review tab strip) hits a warm cache. The
|
||||
// sidebar only observes these — cross-folder is an O(disk) scan, so it
|
||||
// stays enabled:false and the duplicates page populates it on first visit.
|
||||
const stacksQuery = createQuery<DuplicateGroup[]>(() => ({
|
||||
queryKey: ['duplicates'],
|
||||
queryFn: listDuplicateGroups,
|
||||
@@ -194,117 +98,12 @@
|
||||
staleTime: 5 * 60_000
|
||||
}));
|
||||
|
||||
// Geotagged-photo count for the Map sidebar badge. PhotoPrism's
|
||||
// `count.places` is the number of distinct *locations* (cities/states),
|
||||
// not the number of geotagged photos — so the sidebar would disagree
|
||||
// with the "N geotagged" footer on /map. Sharing the `['geo']` cache
|
||||
// keeps both numbers in lockstep and is free after /map's first visit.
|
||||
const geoQuery = createQuery<PpGeoCollection>(() => ({
|
||||
queryKey: ['geo'],
|
||||
queryFn: () => listGeo(),
|
||||
enabled: isAuthenticated(),
|
||||
staleTime: 5 * 60_000
|
||||
}));
|
||||
|
||||
// Keywords contribution to the Tags badge. Aggregation is heavy
|
||||
// (1000-photo fan-out), so the sidebar observes the cache populated
|
||||
// by /tags?tab=keywords rather than triggering its own fetch — same
|
||||
// lazy pattern as the cross-folder duplicates count above.
|
||||
const keywordsQuery = createQuery<AggregatedKeyword[]>(() => ({
|
||||
queryKey: ['photos', 'keywords'],
|
||||
queryFn: aggregateKeywords,
|
||||
enabled: false,
|
||||
staleTime: 5 * 60_000
|
||||
}));
|
||||
|
||||
const ratingsCount = $derived(countRatings(marksQuery.data));
|
||||
const colorsCount = $derived(countColors(marksQuery.data));
|
||||
|
||||
function countRatings(marks: PhotoMarksMap | undefined): number {
|
||||
if (!marks) return 0;
|
||||
let n = 0;
|
||||
for (const m of Object.values(marks)) {
|
||||
if ((m.rating ?? 0) > 0) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function countColors(marks: PhotoMarksMap | undefined): number {
|
||||
if (!marks) return 0;
|
||||
let n = 0;
|
||||
for (const m of Object.values(marks)) {
|
||||
if (m.color) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
const folderTree = $derived(
|
||||
buildTree((foldersQuery.data ?? []).map((f) => f.Path))
|
||||
);
|
||||
|
||||
// Per-folder photo counts. PhotoPrism's /folders/originals reports
|
||||
// FileCount: 0 for every folder, so the sidecar /folders/counts
|
||||
// endpoint resolves them in one round-trip (see listFolderCounts).
|
||||
// Key the query off the folder-path list so it refetches when folders
|
||||
// are added/renamed/deleted, and share the ['photos', …] prefix so it
|
||||
// invalidates alongside the other photo caches whenever a mutation
|
||||
// lands.
|
||||
//
|
||||
// `countsReady` gates the query until just after the sidebar's first
|
||||
// paint. Even though the sidecar response is small, the per-folder
|
||||
// fan-out it does to PhotoPrism still takes a few hundred ms cold;
|
||||
// blocking it on idle means the folder list paints immediately and
|
||||
// the count badges fade in instead of holding back the whole tree.
|
||||
const folderPaths = $derived((foldersQuery.data ?? []).map((f) => f.Path));
|
||||
let countsReady = $state(false);
|
||||
if (browser) {
|
||||
const kick = () => (countsReady = true);
|
||||
// requestIdleCallback isn't in Safari yet; fall back to a short
|
||||
// timeout so the deferral is still bounded.
|
||||
const ric = (window as Window & { requestIdleCallback?: (cb: () => void) => number })
|
||||
.requestIdleCallback;
|
||||
if (typeof ric === 'function') ric(kick);
|
||||
else setTimeout(kick, 200);
|
||||
}
|
||||
const folderCountsQuery = createQuery<Record<string, number>>(() => ({
|
||||
queryKey: ['photos', 'folder-counts', [...folderPaths].sort()],
|
||||
queryFn: () => listFolderCounts(folderPaths),
|
||||
enabled: isAuthenticated() && folderPaths.length > 0 && countsReady,
|
||||
staleTime: 60_000
|
||||
}));
|
||||
const folderCounts = $derived(folderCountsQuery.data ?? {});
|
||||
|
||||
// Root entry shows "the user's library" — for admins without a
|
||||
// BasePath that's still the whole library, served cheaply from
|
||||
// /api/v1/config's `count.all`. For any user with a non-empty
|
||||
// BasePath the precomputed total is wrong (it's library-wide), so we
|
||||
// ask the sidecar for a recursive count rooted at the user's
|
||||
// BasePath — listFolderCounts maps `""` through toOriginalsPath, which
|
||||
// resolves to the BasePath itself, and the sidecar fan-out recurses.
|
||||
const scopedRootCountQuery = createQuery<Record<string, number>>(() => ({
|
||||
queryKey: ['photos', 'root-count', userBasePath()],
|
||||
queryFn: () => listFolderCounts(['']),
|
||||
enabled: isAuthenticated() && userBasePath() !== '',
|
||||
staleTime: 60_000
|
||||
}));
|
||||
const rootCount = $derived(
|
||||
userBasePath() === ''
|
||||
? isAdminUser
|
||||
? (configQuery.data?.count?.all ?? 0)
|
||||
: 0
|
||||
: (scopedRootCountQuery.data?.[''] ?? 0)
|
||||
);
|
||||
|
||||
// Favorites / Review / Hidden / Archive nav entries use these
|
||||
// derived values rather than peeking at configQuery directly so the
|
||||
// scoped path is invisible to the views[]/manageViews[] declarations.
|
||||
const favoritesBadge = $derived(bucketCount('favorites', favoritesCountQuery));
|
||||
const reviewBadge = $derived(bucketCount('review', reviewCountQuery));
|
||||
const hiddenBadge = $derived(bucketCount('hidden', hiddenCountQuery));
|
||||
const archivedBadge = $derived(bucketCount('archived', archivedCountQuery));
|
||||
const labelsBadge = $derived<number | undefined>(
|
||||
labelsCountQuery.isPending ? undefined : labelsCountQuery.data
|
||||
);
|
||||
// Gates admin-only entry points lower in the sidebar.
|
||||
const isAdminUser = $derived(session.user?.Role === 'admin');
|
||||
|
||||
const createMut = createMutation(() => ({
|
||||
mutationFn: (title: string) => createHeap(title),
|
||||
@@ -388,6 +187,53 @@
|
||||
if (browser) localStorage.setItem(TAGS_OPEN_KEY, tagsExpanded ? '1' : '0');
|
||||
}
|
||||
|
||||
// Review-submenu collapse state. Mirrors `tagsExpanded` so the Review
|
||||
// row in Manage can expose the same set of tabs the /review page shows
|
||||
// (cause groups + duplicates panels). Defaults to collapsed.
|
||||
const REVIEW_OPEN_KEY = 'mule_review_expanded';
|
||||
let reviewExpanded = $state(loadReviewExpanded());
|
||||
function loadReviewExpanded(): boolean {
|
||||
if (!browser) return false;
|
||||
return localStorage.getItem(REVIEW_OPEN_KEY) === '1';
|
||||
}
|
||||
function toggleReview() {
|
||||
reviewExpanded = !reviewExpanded;
|
||||
if (browser) localStorage.setItem(REVIEW_OPEN_KEY, reviewExpanded ? '1' : '0');
|
||||
}
|
||||
|
||||
// Cause-tab list is dynamic (only buckets with hits show up on /review),
|
||||
// so the sidebar mirrors that by reusing the same query. Gated on
|
||||
// `reviewExpanded` to avoid paying the /photos round-trip for users who
|
||||
// never expand the section; the queryKey is shared with the /review page
|
||||
// so visiting that route warms the cache for free.
|
||||
const reviewGroupsQuery = createQuery<ReviewGroup[]>(() => ({
|
||||
queryKey: ['review-groups'],
|
||||
queryFn: listReviewGroups,
|
||||
enabled: isAuthenticated() && reviewExpanded,
|
||||
staleTime: 30_000
|
||||
}));
|
||||
|
||||
type ReviewTabId = CauseKey | 'stacks' | 'cross-folder';
|
||||
// Stacks + Duplicates are always present on the /review tab strip
|
||||
// regardless of count (the cross-folder scan is lazy from its own
|
||||
// panel), so they tail every cause-tab list the sidebar renders.
|
||||
// The 'cross-folder' tab id is kept internal/URL-stable; the label
|
||||
// the user sees is "Duplicates".
|
||||
const reviewTabs = $derived<{ id: ReviewTabId; label: string }[]>([
|
||||
...(reviewGroupsQuery.data ?? []).map((g) => ({
|
||||
id: g.cause as ReviewTabId,
|
||||
label: g.meta.title
|
||||
})),
|
||||
{ id: 'stacks', label: 'Stacks' },
|
||||
{ id: 'cross-folder', label: 'Duplicates' }
|
||||
]);
|
||||
|
||||
const reviewActive = $derived(page.url.pathname === '/review');
|
||||
function isReviewTabActive(id: ReviewTabId): boolean {
|
||||
if (!reviewActive) return false;
|
||||
return page.url.searchParams.get('tab') === id;
|
||||
}
|
||||
|
||||
const TAG_CATEGORY_LABELS: Record<TagCategory, string> = {
|
||||
labels: 'Labels',
|
||||
keywords: 'Keywords',
|
||||
@@ -396,24 +242,6 @@
|
||||
ratings: 'Ratings'
|
||||
};
|
||||
|
||||
function tagCategoryCount(cat: TagCategory): number | undefined {
|
||||
// Labels reads PhotoPrism's pre-computed distinct-label counter
|
||||
// (`/api/v1/config` → count.labels), not the photo-count from
|
||||
// `countPhotos('label:*')`. The photo-count returned 0 on libraries
|
||||
// whose indexer hadn't surfaced labelled photos yet, leaving the
|
||||
// badge silently empty; the precomputed counter is always present
|
||||
// and reads as "how many labels you can pick from", matching the
|
||||
// Keywords sub-row's distinct-count semantics.
|
||||
if (cat === 'labels') return configQuery.data?.count?.labels;
|
||||
if (cat === 'keywords') return keywordsQuery.data?.length;
|
||||
// People follows the same distinct-count semantics as Labels —
|
||||
// `/api/v1/config.count.people` is the number of named subjects PP
|
||||
// has clustered, surfaced eagerly without a separate /subjects fetch.
|
||||
if (cat === 'people') return configQuery.data?.count?.people;
|
||||
if (cat === 'ratings') return ratingsCount;
|
||||
return colorsCount;
|
||||
}
|
||||
|
||||
function isTagCategoryActive(cat: TagCategory): boolean {
|
||||
return page.url.pathname.startsWith(`/tags/${cat}`);
|
||||
}
|
||||
@@ -518,14 +346,7 @@
|
||||
}
|
||||
|
||||
async function pickFolder(folderPath: string) {
|
||||
// Folder selection works on top of the All Photos section; clearing
|
||||
// the heap/section context mirrors mule-image's "drill into folder"
|
||||
// behaviour. The URL sync $effect on the timeline picks this up.
|
||||
setSection('all-photos');
|
||||
setFolderPath(folderPath);
|
||||
const params = new URLSearchParams();
|
||||
params.set('folder', folderPath);
|
||||
await goto(`/?${params.toString()}`, { keepFocus: true, noScroll: true });
|
||||
await navigateToFolder(folderPath);
|
||||
}
|
||||
|
||||
function onCreateHeap() {
|
||||
@@ -559,14 +380,12 @@
|
||||
//
|
||||
// `getCount` is a getter (not a snapshot) so the badge reads the latest
|
||||
// derived value on every render — the arrays themselves are constant.
|
||||
// `count.all` already excludes archived/review/hidden (PhotoPrism's
|
||||
// "everything visible in the main timeline" tally), so it matches what
|
||||
// the All photos view actually renders. Map uses the shared `['geo']`
|
||||
// cache so its badge matches /map's "N geotagged" footer exactly —
|
||||
// `count.places` would have shown distinct locations instead.
|
||||
// Review rolls in the duplicates tabs hosted under /review — stacks
|
||||
// always contributes; cross-folder only contributes once its tab has
|
||||
// been opened (the scan is lazy, not eager from the sidebar).
|
||||
// Map and Tags intentionally render without a count badge; the count
|
||||
// columns inside the TagsBrowserSidebar are the canonical surface for
|
||||
// per-tag totals. Review rolls in the duplicates tabs hosted under
|
||||
// /review — stacks always contributes; cross-folder only contributes
|
||||
// once its tab has been opened (the scan is lazy, not eager from the
|
||||
// sidebar).
|
||||
type ViewItem =
|
||||
| { kind: 'section'; id: Section; label: string; getCount: () => number | undefined }
|
||||
| { kind: 'route'; href: string; label: string; getCount: () => number | undefined };
|
||||
@@ -576,43 +395,25 @@
|
||||
// separate "everything regardless of folder" destination would just
|
||||
// duplicate it for users whose photos live under the root.
|
||||
const views: ViewItem[] = [
|
||||
// Map's `geoQuery` already returns the GeoJSON the user is
|
||||
// permitted to see (PhotoPrism's /geo applies the session ACL),
|
||||
// so the badge is per-user-correct without extra scoping.
|
||||
{ kind: 'route', href: '/map', label: 'Map', getCount: () => geoQuery.data?.features?.length }
|
||||
{ kind: 'route', href: '/map', label: 'Map', getCount: () => undefined }
|
||||
// Tags is rendered as a bespoke expandable block below the
|
||||
// `views` loop — it has sub-categories (Labels/Keywords/Colors/
|
||||
// Ratings) and a chevron, neither of which fits the flat
|
||||
// section/route ViewItem shape.
|
||||
// section/route ViewItem shape. Notes lives under that expandable
|
||||
// alongside the tag categories.
|
||||
];
|
||||
|
||||
// Total badge for the "Tags" header row. Rolls up labels + keywords +
|
||||
// people + ratings + colors. Labels flows through countPhotos (scoped);
|
||||
// keywords/people/ratings/colors are library-wide and only contribute
|
||||
// when we're in admin-without-BasePath mode (their sources don't scope).
|
||||
const tagsTotal = $derived.by<number | undefined>(() => {
|
||||
if (labelsBadge === undefined) return undefined;
|
||||
if (wantScoped) return labelsBadge;
|
||||
const keywords = keywordsQuery.data?.length ?? 0;
|
||||
const people = configQuery.data?.count?.people ?? 0;
|
||||
return labelsBadge + keywords + people + ratingsCount + colorsCount;
|
||||
});
|
||||
function isNotesActive(): boolean {
|
||||
return page.url.pathname === '/notes';
|
||||
}
|
||||
|
||||
// Review is rendered separately below as a pure expandable toggle
|
||||
// (mirroring Tags — no /review landing entry from the sidebar,
|
||||
// navigation only via subitems, with Hidden tucked in alongside the
|
||||
// tab subitems). This list carries the flat Manage entries that
|
||||
// follow it.
|
||||
const manageViews: ViewItem[] = [
|
||||
{
|
||||
kind: 'route',
|
||||
href: '/review',
|
||||
label: 'Review',
|
||||
getCount: () => {
|
||||
if (reviewBadge === undefined) return undefined;
|
||||
// The two duplicates queries are library-wide; only admins
|
||||
// without a BasePath roll them into the Review badge.
|
||||
if (wantScoped) return reviewBadge;
|
||||
return reviewBadge + (stacksQuery.data?.length ?? 0) + (crossFolderQuery.data?.groups.length ?? 0);
|
||||
}
|
||||
},
|
||||
{ kind: 'section', id: 'hidden', label: 'Hidden', getCount: () => hiddenBadge },
|
||||
{ kind: 'section', id: 'archive', label: 'Archive', getCount: () => archivedBadge }
|
||||
{ kind: 'section', id: 'archive', label: 'Archive', getCount: () => undefined }
|
||||
];
|
||||
|
||||
function isRouteActive(href: string): boolean {
|
||||
@@ -733,11 +534,6 @@
|
||||
peers, so labels share a common left edge across the sidebar. -->
|
||||
<span class="inline-block h-[18px] w-4" aria-hidden="true"></span>
|
||||
{/if}
|
||||
<!--
|
||||
Count badge lives INSIDE the button so the entire row (label
|
||||
+ badge) is one hit target — the badge is the most visually
|
||||
prominent element on the row and was previously a dead zone.
|
||||
-->
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-w-0 flex-1 items-center pl-1 text-left"
|
||||
@@ -745,15 +541,6 @@
|
||||
title={userBasePath() === '' ? 'Your library' : `Your library (${userBasePath()})`}
|
||||
>
|
||||
<span class="truncate">{rootLabel}</span>
|
||||
{#if configQuery.data}
|
||||
<span
|
||||
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {rootActive
|
||||
? 'bg-primary-foreground/15 text-primary-foreground'
|
||||
: 'bg-secondary text-muted-foreground'}"
|
||||
>
|
||||
{rootCount >= 1000 ? '1000+' : rootCount}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<!-- Root-row kebab. Only "New subfolder" applies — root itself
|
||||
can't be renamed or deleted, so those entries are omitted
|
||||
@@ -773,6 +560,8 @@
|
||||
</div>
|
||||
{#if foldersQuery.isPending}
|
||||
<InlineLoader size="sm" label="Loading folders…" />
|
||||
{:else if foldersQuery.isError}
|
||||
<EmptyState size="compact" tone="destructive" icon={FolderOpen} title="Failed to load folders" description="Try reloading the page." />
|
||||
{:else if !hasSubfolders}
|
||||
<EmptyState size="compact" icon={FolderOpen} title="No subfolders" />
|
||||
{:else if rootExpanded}
|
||||
@@ -789,7 +578,6 @@
|
||||
onRename={onRenameFolder}
|
||||
onDelete={onDeleteFolder}
|
||||
onCreateChild={(parent) => onCreateFolder(parent)}
|
||||
counts={folderCounts}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -836,16 +624,9 @@
|
||||
class="flex min-w-0 flex-1 items-center pl-6 text-left"
|
||||
onclick={() => navigateTo('heap', heap.UID)}
|
||||
ondblclick={() => onRenameHeap(heap)}
|
||||
title={`${heap.Title} (${heap.PhotoCount ?? 0})`}
|
||||
title={heap.Title}
|
||||
>
|
||||
<span class="truncate">{heap.Title}</span>
|
||||
<span
|
||||
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
|
||||
? 'bg-primary-foreground/15 text-primary-foreground'
|
||||
: 'bg-secondary text-muted-foreground'}"
|
||||
>
|
||||
{heap.PhotoCount ?? 0}
|
||||
</span>
|
||||
</button>
|
||||
<div class="ml-1 hidden group-hover:block has-[[data-state=open]]:block">
|
||||
<KebabMenu label="Heap actions">
|
||||
@@ -905,9 +686,10 @@
|
||||
{@render viewRow(v)}
|
||||
{/each}
|
||||
<!--
|
||||
Tags expandable. Whole row is a toggle (chevron + label + badge);
|
||||
there is no landing page at /tags — selecting a sub-category is the
|
||||
only way into a real view.
|
||||
Tags expandable. Whole row is a toggle (chevron + label); there is
|
||||
no landing page at /tags — selecting a sub-category is the only way
|
||||
into a real view. Counts intentionally live in the TagsBrowserSidebar
|
||||
(secondary sidebar) so this row stays a pure navigator.
|
||||
-->
|
||||
<button
|
||||
type="button"
|
||||
@@ -924,19 +706,28 @@
|
||||
</span>
|
||||
<span class="flex min-w-0 flex-1 items-center pl-1">
|
||||
<span class="truncate">Tags</span>
|
||||
{#if tagsTotal !== undefined}
|
||||
<span
|
||||
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded bg-secondary px-1 text-[10px] tabular-nums text-muted-foreground"
|
||||
>
|
||||
{tagsTotal}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
{#if tagsExpanded}
|
||||
<!--
|
||||
Notes lives alongside the tag categories — same indent and row
|
||||
chrome — but routes to /notes rather than /tags/*. Tucked at
|
||||
the top of the expandable so it's the first thing the user
|
||||
sees when opening Tags.
|
||||
-->
|
||||
{@const notesActive = isNotesActive()}
|
||||
<a
|
||||
href="/notes"
|
||||
class="flex h-[22px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
|
||||
class:bg-primary={notesActive}
|
||||
class:text-primary-foreground={notesActive}
|
||||
class:hover:bg-primary={notesActive}
|
||||
style="padding-left: 36px;"
|
||||
>
|
||||
<span class="truncate">Notes</span>
|
||||
</a>
|
||||
{#each TAG_CATEGORIES as cat (cat)}
|
||||
{@const active = isTagCategoryActive(cat)}
|
||||
{@const count = tagCategoryCount(cat)}
|
||||
<a
|
||||
href={`/tags/${cat}`}
|
||||
class="flex h-[22px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
|
||||
@@ -948,15 +739,6 @@
|
||||
onfocus={cat === 'keywords' ? prefetchKeywords : undefined}
|
||||
>
|
||||
<span class="truncate">{TAG_CATEGORY_LABELS[cat]}</span>
|
||||
{#if count !== undefined}
|
||||
<span
|
||||
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
|
||||
? 'bg-primary-foreground/15 text-primary-foreground'
|
||||
: 'bg-secondary text-muted-foreground'}"
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
@@ -971,6 +753,62 @@
|
||||
Manage
|
||||
</span>
|
||||
</div>
|
||||
<!--
|
||||
Review expandable. Mirrors the Tags affordance — pure toggle
|
||||
with no landing page; the only way into a tab is to expand and
|
||||
pick a subitem. Cause buckets are dynamic (only buckets with
|
||||
hits show up); Stacks/Cross-folder are always present.
|
||||
-->
|
||||
<button
|
||||
type="button"
|
||||
class="group flex h-[22px] w-full items-center rounded pr-2 text-left text-[12px] leading-tight hover:bg-accent"
|
||||
style="padding-left: 4px;"
|
||||
onclick={toggleReview}
|
||||
title={reviewExpanded ? 'Collapse review' : 'Expand review'}
|
||||
aria-expanded={reviewExpanded}
|
||||
>
|
||||
<span
|
||||
class="flex h-[18px] w-4 items-center justify-center text-[10px] text-muted-foreground"
|
||||
>
|
||||
{reviewExpanded ? '▾' : '▸'}
|
||||
</span>
|
||||
<span class="flex min-w-0 flex-1 items-center pl-1">
|
||||
<span class="truncate">Review</span>
|
||||
</span>
|
||||
</button>
|
||||
{#if reviewExpanded}
|
||||
{#each reviewTabs as t (t.id)}
|
||||
{@const active = isReviewTabActive(t.id)}
|
||||
<a
|
||||
href={`/review?tab=${t.id}`}
|
||||
class="flex h-[22px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
|
||||
class:bg-primary={active}
|
||||
class:text-primary-foreground={active}
|
||||
class:hover:bg-primary={active}
|
||||
style="padding-left: 36px;"
|
||||
>
|
||||
<span class="truncate">{t.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
<!--
|
||||
Hidden lives under Review since it's the resting place for
|
||||
photos dismissed during review. Section-nav (not a ?tab=),
|
||||
so it's a button that flips filters.section like the flat
|
||||
Manage entries — just with the subitem indent.
|
||||
-->
|
||||
{@const hiddenActive = isActive('hidden')}
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-[22px] w-full items-center rounded pr-2 text-left text-[12px] leading-tight hover:bg-accent"
|
||||
class:bg-primary={hiddenActive}
|
||||
class:text-primary-foreground={hiddenActive}
|
||||
class:hover:bg-primary={hiddenActive}
|
||||
style="padding-left: 36px;"
|
||||
onclick={() => navigateTo('hidden')}
|
||||
>
|
||||
<span class="truncate">Hidden</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#each manageViews as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
|
||||
{@render viewRow(v)}
|
||||
{/each}
|
||||
|
||||
46
web/src/lib/components/layout/StatusPill.svelte
Normal file
46
web/src/lib/components/layout/StatusPill.svelte
Normal file
@@ -0,0 +1,46 @@
|
||||
<!--
|
||||
Generic status pill used in the header for both the PhotoPrism indexer
|
||||
and bulk-action progress. Renders nothing when idle so it never steals
|
||||
header real estate.
|
||||
|
||||
`detail` is a full path or filename; only the basename is shown inline
|
||||
(fixed-width slot to stop the pill from resizing on every file). The
|
||||
full string is exposed via `title` for hover.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Loader2 } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
active: boolean;
|
||||
label: string;
|
||||
detail?: string;
|
||||
}
|
||||
let { active, label, detail }: Props = $props();
|
||||
|
||||
const basename = $derived.by(() => {
|
||||
if (!detail) return '';
|
||||
const i = detail.lastIndexOf('/');
|
||||
return i >= 0 ? detail.slice(i + 1) : detail;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if active || label}
|
||||
<div
|
||||
class="flex items-center gap-1.5 rounded-full border border-border bg-background/80 px-2.5 py-1 text-xs text-foreground shadow-sm backdrop-blur"
|
||||
title={detail ?? label}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{#if active}
|
||||
<Loader2 class="h-3 w-3 animate-spin text-primary" />
|
||||
{/if}
|
||||
<span class="whitespace-nowrap">{label}</span>
|
||||
{#if basename}
|
||||
<span
|
||||
class="w-[24ch] truncate text-left font-mono text-[10px] text-muted-foreground"
|
||||
>
|
||||
{basename}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -22,7 +22,7 @@
|
||||
setFocused,
|
||||
toggle
|
||||
} from '$lib/stores/selection.svelte';
|
||||
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||
import { isVideo, primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||
|
||||
const qc = useQueryClient();
|
||||
|
||||
@@ -30,22 +30,22 @@
|
||||
* any time, plenty to cover normal arrow-skim without bloating. */
|
||||
const WINDOW = 50;
|
||||
|
||||
function lookup(uid: string): string | null {
|
||||
function lookup(uid: string): PpPhoto | null {
|
||||
const direct = qc.getQueryData<PpPhoto>(['photo', uid]);
|
||||
if (direct) return primaryFile(direct).Hash ?? null;
|
||||
if (direct) return direct;
|
||||
const lists = qc.getQueriesData({ queryKey: ['photos'] });
|
||||
for (const [, data] of lists) {
|
||||
if (!data) continue;
|
||||
if (Array.isArray(data)) {
|
||||
const hit = (data as PpPhoto[]).find((p) => p.UID === uid);
|
||||
if (hit) return primaryFile(hit).Hash ?? null;
|
||||
if (hit) return hit;
|
||||
continue;
|
||||
}
|
||||
const pages = (data as { pages?: PpPhoto[][] }).pages;
|
||||
if (!Array.isArray(pages)) continue;
|
||||
for (const page of pages) {
|
||||
const hit = page?.find?.((p) => p.UID === uid);
|
||||
if (hit) return primaryFile(hit).Hash ?? null;
|
||||
if (hit) return hit;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -58,6 +58,7 @@
|
||||
interface Tile {
|
||||
uid: string;
|
||||
hash: string | null;
|
||||
video: boolean;
|
||||
idx: number;
|
||||
}
|
||||
const slice = $derived.by<Tile[]>(() => {
|
||||
@@ -66,7 +67,13 @@
|
||||
const hi = Math.min(order.length, focusedIdx + WINDOW + 1);
|
||||
const out: Tile[] = [];
|
||||
for (let i = lo; i < hi; i++) {
|
||||
out.push({ uid: order[i], hash: lookup(order[i]), idx: i });
|
||||
const photo = lookup(order[i]);
|
||||
out.push({
|
||||
uid: order[i],
|
||||
hash: photo ? (primaryFile(photo).Hash ?? null) : null,
|
||||
video: photo ? isVideo(photo) : false,
|
||||
idx: i
|
||||
});
|
||||
}
|
||||
return out;
|
||||
});
|
||||
@@ -148,12 +155,19 @@
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
fetchpriority="low"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{/if}
|
||||
{#if isSelected}
|
||||
<div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div>
|
||||
{/if}
|
||||
{#if tile.video}
|
||||
<span
|
||||
class="absolute left-1.5 top-1.5 rounded bg-background/80 px-1 text-[10px] font-medium text-foreground"
|
||||
>VIDEO</span
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
import PreviewCarousel from './PreviewCarousel.svelte';
|
||||
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
|
||||
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
|
||||
import { InlineLoader } from '$lib/components/feedback';
|
||||
|
||||
const focusedUid = $derived(selection.focused);
|
||||
|
||||
@@ -133,7 +134,11 @@
|
||||
Full-screen preview of the focused photo with metadata and a thumbnail carousel.
|
||||
</Dialog.Description>
|
||||
|
||||
<!-- Top row: preview pane (fills) + sidebar (fixed width). -->
|
||||
<!-- Top row: preview pane (fills) + sidebar (fixed width).
|
||||
BulkActionBar lives inside the main column — same shape as the
|
||||
timeline (+page.svelte) so the bar stays bounded by the
|
||||
column's width and doesn't stretch under the metadata
|
||||
sidebar. -->
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<div class="relative flex min-w-0 flex-1 flex-col">
|
||||
<button
|
||||
@@ -148,19 +153,24 @@
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<PreviewPane uid={focusedUid} order={selection.order} />
|
||||
</div>
|
||||
<!-- Action toolbar (acts on selection.ids; falls back to focused). -->
|
||||
<BulkActionBar />
|
||||
</div>
|
||||
{#if focusedPhotoQuery.data}
|
||||
<aside
|
||||
class="w-[300px] shrink-0 overflow-y-auto border-l border-border bg-card"
|
||||
>
|
||||
<!-- Sidebar stays mounted across photo changes so the preview
|
||||
pane doesn't reflow on arrow-skim; contents swap between
|
||||
the metadata panel and a small loader the same way the
|
||||
timeline's right-aside does. -->
|
||||
<aside
|
||||
class="w-[300px] shrink-0 overflow-y-auto border-l border-border bg-card"
|
||||
>
|
||||
{#if focusedPhotoQuery.data}
|
||||
<RightSidebar photo={focusedPhotoQuery.data} />
|
||||
</aside>
|
||||
{/if}
|
||||
{:else if focusedPhotoQuery.isFetching}
|
||||
<InlineLoader size="sm" label="Loading metadata…" />
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- Action toolbar (acts on selection.ids; falls back to focused). -->
|
||||
<BulkActionBar />
|
||||
|
||||
<!-- Bottom filmstrip across selection.order. -->
|
||||
<PreviewCarousel />
|
||||
</Dialog.Content>
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
throw away. Until the timer fires, the poster image stands in.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { getPhoto } from '$lib/services/photoprism';
|
||||
import { thumbUrl, videoUrl } from '$lib/stores/session.svelte';
|
||||
import { thumbSrc, thumbSrcSet, thumbUrl, videoUrl } from '$lib/stores/session.svelte';
|
||||
import { setAnchor, setFocused } from '$lib/stores/selection.svelte';
|
||||
import { view } from '$lib/stores/view.svelte';
|
||||
import VideoPlayer from '$lib/components/preview/VideoPlayer.svelte';
|
||||
import { isVideo, primaryFile, videoFile, type PpPhoto } from '$lib/types/photoprism';
|
||||
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
||||
@@ -27,6 +28,8 @@
|
||||
}
|
||||
let { uid, order, showChevrons = true }: Props = $props();
|
||||
|
||||
const qc = useQueryClient();
|
||||
|
||||
const photoQuery = createQuery<PpPhoto>(() => ({
|
||||
queryKey: ['photo', uid ?? ''],
|
||||
queryFn: () => getPhoto(uid as string),
|
||||
@@ -35,6 +38,48 @@
|
||||
|
||||
const currentIndex = $derived(uid ? order.indexOf(uid) : -1);
|
||||
|
||||
/** Mirrors PreviewCarousel.lookup — pulls a PpPhoto out of TanStack's
|
||||
* cache without firing a fetch, so we can resolve adjacent hashes for
|
||||
* prefetching without making the prefetch itself trigger more work. */
|
||||
function lookupCached(target: string): PpPhoto | null {
|
||||
const direct = qc.getQueryData<PpPhoto>(['photo', target]);
|
||||
if (direct) return direct;
|
||||
const lists = qc.getQueriesData({ queryKey: ['photos'] });
|
||||
for (const [, data] of lists) {
|
||||
if (!data) continue;
|
||||
if (Array.isArray(data)) {
|
||||
const hit = (data as PpPhoto[]).find((p) => p.UID === target);
|
||||
if (hit) return hit;
|
||||
continue;
|
||||
}
|
||||
const pages = (data as { pages?: PpPhoto[][] }).pages;
|
||||
if (!Array.isArray(pages)) continue;
|
||||
for (const page of pages) {
|
||||
const hit = page?.find?.((p) => p.UID === target);
|
||||
if (hit) return hit;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Prefetch fit_1280 for the ±2 neighbours of the focused photo so
|
||||
// arrow-skim feels instant. We `new Image()` rather than `<link
|
||||
// rel=preload>` because the URLs are runtime-derived and a throwaway
|
||||
// Image() reuses the browser's HTTP cache the same way.
|
||||
$effect(() => {
|
||||
if (!uid || currentIndex < 0) return;
|
||||
for (const offset of [-1, 1, -2, 2]) {
|
||||
const idx = currentIndex + offset;
|
||||
if (idx < 0 || idx >= order.length) continue;
|
||||
const photo = lookupCached(order[idx]);
|
||||
if (!photo) continue;
|
||||
const hash = primaryFile(photo).Hash;
|
||||
if (!hash) continue;
|
||||
const img = new Image();
|
||||
img.src = thumbUrl(hash, 'fit_1280');
|
||||
}
|
||||
});
|
||||
|
||||
const VIDEO_LOAD_DELAY_MS = 250;
|
||||
let armedUid = $state<string | null>(null);
|
||||
|
||||
@@ -86,28 +131,43 @@
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if isVideo(photoQuery.data)}
|
||||
{#if isVideo(photoQuery.data) && armedUid === uid}
|
||||
{@const vf = videoFile(photoQuery.data)}
|
||||
{#if armedUid === uid}
|
||||
{#key vf.Hash}
|
||||
<VideoPlayer
|
||||
src={videoUrl(vf.Hash)}
|
||||
poster={thumbUrl(pf.Hash, 'fit_1280')}
|
||||
title={photoQuery.data.OriginalName ?? pf.Name ?? ''}
|
||||
/>
|
||||
{/key}
|
||||
{:else}
|
||||
{#key vf.Hash}
|
||||
<VideoPlayer
|
||||
src={videoUrl(vf.Hash)}
|
||||
poster={thumbUrl(pf.Hash, 'fit_1280')}
|
||||
title={photoQuery.data.OriginalName ?? pf.Name ?? ''}
|
||||
/>
|
||||
{/key}
|
||||
{:else}
|
||||
{@const altText =
|
||||
photoQuery.data.OriginalName ??
|
||||
pf.Name ??
|
||||
(isVideo(photoQuery.data) ? 'Video' : 'Photo')}
|
||||
{#if pf.Width && pf.Height}
|
||||
<!-- LQIP layer: the same URL the grid loaded, blurred to mask
|
||||
the tile_*'s square center-crop against the sharp image's
|
||||
true aspect. Sized via aspect-ratio + max-* + m-auto so it
|
||||
lands in the exact same bounding box as the sharp <img>
|
||||
beside it (object-contain semantics, but expressible on a
|
||||
positioned element). Paints from the HTTP cache the moment
|
||||
the modal opens. -->
|
||||
<img
|
||||
src={thumbUrl(pf.Hash, 'fit_1280')}
|
||||
alt={photoQuery.data.OriginalName ?? pf.Name ?? 'Video'}
|
||||
class="max-h-full max-w-full rounded-md object-contain shadow-2xl"
|
||||
src={thumbSrc(pf.Hash, view.thumbnailSize)}
|
||||
srcset={thumbSrcSet(pf.Hash, view.thumbnailSize)}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-0 m-auto max-h-full max-w-full rounded-md object-cover blur-2xl"
|
||||
style="aspect-ratio: {pf.Width} / {pf.Height};"
|
||||
/>
|
||||
{/if}
|
||||
{:else}
|
||||
<img
|
||||
src={thumbUrl(pf.Hash, 'fit_1280')}
|
||||
alt={photoQuery.data.OriginalName ?? pf.Name ?? 'Photo'}
|
||||
class="max-h-full max-w-full rounded-md object-contain shadow-2xl"
|
||||
alt={altText}
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
class="relative max-h-full max-w-full rounded-md object-contain shadow-2xl"
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -1,246 +1,61 @@
|
||||
<!--
|
||||
One review-queue cause group rendered as a card. Mirrors StackGroupCard's
|
||||
chrome (focusable container, ResizeObserver column tracking, keyboard
|
||||
nav) but the per-tile semantics differ:
|
||||
One review-queue cause group, rendered as a thin wrapper around
|
||||
PhotoGrid. The page mounts the standard timeline chrome (gridKeyNav on
|
||||
main, BulkActionBar below, RightSidebar/BulkMetadataSidebar on the
|
||||
right) — this component just adds the per-group header + suggestion
|
||||
row above the grid, then delegates tiles to PhotoGrid so selection,
|
||||
keyboard nav, and previews work the same way they do everywhere else.
|
||||
|
||||
- Click a tile → emits `select` so the parent can open the metadata
|
||||
sidebar. Shift-click toggles bulk-select instead of opening.
|
||||
- Header has `Approve all` + `Archive all` for the whole group.
|
||||
- Suggestion line above the grid spotlights the likely-correct bulk
|
||||
action with an inline button (per the plan).
|
||||
- Keyboard: arrows move the focused tile; `S` approves the focused
|
||||
tile, `A` archives it, `Enter` opens detail, `Esc` blurs.
|
||||
The Low Resolution tab opts in to PhotoTile's dimension badge so the
|
||||
user can spot under-2-MP photos without opening each tile.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import {
|
||||
approvePhoto,
|
||||
batchArchive
|
||||
} from '$lib/services/photoprism';
|
||||
import { thumbUrl } from '$lib/stores/session.svelte';
|
||||
import { view } from '$lib/stores/view.svelte';
|
||||
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||
import {
|
||||
deriveCauses,
|
||||
type ReviewGroup
|
||||
} from '$lib/services/adapters/review';
|
||||
import CauseBadges from './CauseBadges.svelte';
|
||||
import { batchArchive } from '$lib/services/photoprism';
|
||||
import { startBulk, doneBulk, failBulk } from '$lib/stores/bulkAction.svelte';
|
||||
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
|
||||
import { type ReviewGroup } from '$lib/services/adapters/review';
|
||||
|
||||
interface Props {
|
||||
group: ReviewGroup;
|
||||
autoFocus?: boolean;
|
||||
/** Parent emits when the user picks a tile to inspect (Enter or
|
||||
* plain click). Parent owns the RightSidebar mount. */
|
||||
onSelect?: (photo: PpPhoto) => void;
|
||||
}
|
||||
let { group, autoFocus = false, onSelect }: Props = $props();
|
||||
let { group }: Props = $props();
|
||||
|
||||
const qc = useQueryClient();
|
||||
let sectionEl: HTMLElement | undefined = $state();
|
||||
let gridEl: HTMLElement | undefined = $state();
|
||||
let focusedIdx = $state(0);
|
||||
let cols = $state(1);
|
||||
let busy = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (autoFocus && sectionEl) sectionEl.focus({ preventScroll: true });
|
||||
});
|
||||
|
||||
// Match StackGroupCard's column-tracking trick so arrow Up/Down jump
|
||||
// by row width.
|
||||
$effect(() => {
|
||||
if (!gridEl) return;
|
||||
const measure = () => {
|
||||
if (!gridEl) return;
|
||||
const n = getComputedStyle(gridEl)
|
||||
.gridTemplateColumns.split(' ')
|
||||
.filter(Boolean).length;
|
||||
cols = Math.max(1, n);
|
||||
};
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(gridEl);
|
||||
return () => ro.disconnect();
|
||||
});
|
||||
$effect(() => {
|
||||
void view.thumbnailSize;
|
||||
queueMicrotask(() => {
|
||||
if (!gridEl) return;
|
||||
const n = getComputedStyle(gridEl)
|
||||
.gridTemplateColumns.split(' ')
|
||||
.filter(Boolean).length;
|
||||
cols = Math.max(1, n);
|
||||
});
|
||||
});
|
||||
|
||||
function moveFocus(delta: number) {
|
||||
if (group.photos.length === 0) return;
|
||||
focusedIdx = Math.min(
|
||||
Math.max(0, focusedIdx + delta),
|
||||
group.photos.length - 1
|
||||
);
|
||||
}
|
||||
|
||||
function dims(p: PpPhoto): string {
|
||||
const f = primaryFile(p);
|
||||
const w = p.Width ?? f.Width;
|
||||
const h = p.Height ?? f.Height;
|
||||
if (!w || !h) return '';
|
||||
return `${w}×${h}`;
|
||||
}
|
||||
|
||||
function thumb(p: PpPhoto): string {
|
||||
// list endpoint puts the hash on the photo itself; primaryFile is
|
||||
// the fallback for detail responses.
|
||||
const h = p.Hash ?? primaryFile(p).Hash;
|
||||
return h ? thumbUrl(h, 'tile_500') : '';
|
||||
}
|
||||
|
||||
async function approveOne(p: PpPhoto) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
await approvePhoto(p.UID);
|
||||
toast.success('Approved');
|
||||
void qc.invalidateQueries({ queryKey: ['review-groups'] });
|
||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Approve failed');
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveOne(p: PpPhoto) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
await batchArchive([p.UID]);
|
||||
toast.success('Archived');
|
||||
void qc.invalidateQueries({ queryKey: ['review-groups'] });
|
||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Archive failed');
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function approveAll() {
|
||||
if (busy || group.photos.length === 0) return;
|
||||
if (!confirm(`Approve all ${group.photos.length} photos in "${group.meta.title}"?`)) return;
|
||||
busy = true;
|
||||
const total = group.photos.length;
|
||||
let done = 0;
|
||||
const toastId = toast.loading(`Approving 0 / ${total}…`);
|
||||
try {
|
||||
// PhotoPrism has no batch-approve, so fan out one-at-a-time.
|
||||
// A small concurrency cap keeps the server responsive without
|
||||
// stalling for very large groups.
|
||||
const QUEUE = 4;
|
||||
const uids = group.photos.map((p) => p.UID);
|
||||
let idx = 0;
|
||||
async function worker() {
|
||||
while (idx < uids.length) {
|
||||
const my = idx++;
|
||||
try {
|
||||
await approvePhoto(uids[my]);
|
||||
} catch {
|
||||
// Carry on — partial success is better than abort.
|
||||
}
|
||||
done++;
|
||||
toast.loading(`Approving ${done} / ${total}…`, { id: toastId });
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(QUEUE, uids.length) }, worker));
|
||||
toast.success(`Approved ${done} / ${total}`, { id: toastId });
|
||||
void qc.invalidateQueries({ queryKey: ['review-groups'] });
|
||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Approve all failed', {
|
||||
id: toastId
|
||||
});
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveAll() {
|
||||
if (busy || group.photos.length === 0) return;
|
||||
if (!confirm(`Archive all ${group.photos.length} photos in "${group.meta.title}"?`))
|
||||
return;
|
||||
const uids = group.photos.map((p) => p.UID);
|
||||
const tid = toast.loading(`Archiving ${uids.length}…`);
|
||||
busy = true;
|
||||
startBulk(`Archiving…`, uids);
|
||||
try {
|
||||
await batchArchive(group.photos.map((p) => p.UID));
|
||||
toast.success(`Archived ${group.photos.length}`);
|
||||
await batchArchive(uids);
|
||||
doneBulk(`Archived ${uids.length}`, uids);
|
||||
toast.success(`Archived ${uids.length}`, { id: tid });
|
||||
void qc.invalidateQueries({ queryKey: ['review-groups'] });
|
||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
void qc.invalidateQueries({ queryKey: ['marks'] });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Archive all failed');
|
||||
failBulk(uids);
|
||||
toast.error(err instanceof Error ? err.message : 'Archive all failed', { id: tid });
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (busy) return;
|
||||
const p = group.photos[focusedIdx];
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
moveFocus(-1);
|
||||
return;
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
moveFocus(1);
|
||||
return;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
moveFocus(-cols);
|
||||
return;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
moveFocus(cols);
|
||||
return;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (p) onSelect?.(p);
|
||||
return;
|
||||
case 's':
|
||||
case 'S':
|
||||
e.preventDefault();
|
||||
if (p) void approveOne(p);
|
||||
return;
|
||||
case 'a':
|
||||
case 'A':
|
||||
e.preventDefault();
|
||||
if (p) void archiveOne(p);
|
||||
return;
|
||||
case 'Escape':
|
||||
(e.target as HTMLElement)?.blur();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function runSuggestion() {
|
||||
if (group.meta.suggestedAction === 'approve') void approveAll();
|
||||
else if (group.meta.suggestedAction === 'archive') void archiveAll();
|
||||
// 'manual' suggestion has no button — the suggestion line is text-only.
|
||||
// Only 'archive' suggestions are reachable through this button now —
|
||||
// the page's BulkActionBar handles per-photo / multi-select Keep.
|
||||
if (group.meta.suggestedAction === 'archive') void archiveAll();
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
bind:this={sectionEl}
|
||||
tabindex="0"
|
||||
role="application"
|
||||
aria-label={`Cause group ${group.meta.title} with ${group.photos.length} photos`}
|
||||
onkeydown={onKeydown}
|
||||
class="space-y-2 rounded-md border border-border bg-card/30 p-3 outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<header class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-foreground">
|
||||
@@ -248,128 +63,30 @@
|
||||
<span class="ml-1 text-muted-foreground">({group.photos.length})</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs hover:bg-accent disabled:opacity-50"
|
||||
disabled={busy || group.photos.length === 0}
|
||||
onclick={approveAll}
|
||||
title="Approve every photo in this group"
|
||||
>
|
||||
Approve all
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs hover:bg-accent disabled:opacity-50"
|
||||
disabled={busy || group.photos.length === 0}
|
||||
onclick={archiveAll}
|
||||
title="Archive every photo in this group"
|
||||
>
|
||||
Archive all
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Suggestion line — sits above the grid, surfaces the likely-correct
|
||||
bulk action with an inline trigger. 'manual' causes get no
|
||||
inline button; the user has to use the header bulk bar instead. -->
|
||||
<!-- Suggestion line — surfaces the likely-correct bulk action. Only
|
||||
'archive' renders a quick-button; 'manual' is text-only and
|
||||
'approve' is unused today. Per-photo Keep / Archive comes from the
|
||||
page's BulkActionBar (review section) once the user selects. -->
|
||||
<div
|
||||
class="flex items-center justify-between gap-3 rounded border border-dashed border-border/60 bg-muted/30 px-3 py-1.5 text-[11px] text-muted-foreground"
|
||||
>
|
||||
<span>{group.meta.suggestion}</span>
|
||||
{#if group.meta.suggestedAction !== 'manual'}
|
||||
{#if group.meta.suggestedAction === 'archive'}
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded border border-border bg-background px-2 py-0.5 text-[11px] font-medium text-foreground hover:bg-accent disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onclick={runSuggestion}
|
||||
>
|
||||
{group.meta.suggestedAction === 'approve' ? 'Approve all' : 'Archive all'}
|
||||
Archive all
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div
|
||||
bind:this={gridEl}
|
||||
class="grid gap-2"
|
||||
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
|
||||
>
|
||||
{#each group.photos as photo, i (photo.UID)}
|
||||
{@const causes = deriveCauses(photo)}
|
||||
{@const isFocused = i === focusedIdx}
|
||||
<!-- Tile is a <div> with role=button so the inner per-tile
|
||||
action buttons aren't nested inside another <button> (which
|
||||
is invalid HTML and trips a11y linters). -->
|
||||
<div
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
aria-label={`${photo.FileName ?? photo.Name ?? photo.UID} — press Enter to inspect`}
|
||||
onclick={() => onSelect?.(photo)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onSelect?.(photo);
|
||||
}
|
||||
}}
|
||||
class:ring-2={isFocused}
|
||||
class:ring-blue-500={isFocused}
|
||||
class:ring-offset-2={isFocused}
|
||||
class:ring-offset-background={isFocused}
|
||||
class="group relative flex cursor-pointer flex-col overflow-hidden rounded-md border border-border bg-secondary text-left transition-shadow"
|
||||
>
|
||||
<div class="relative aspect-square w-full overflow-hidden">
|
||||
<img
|
||||
src={thumb(photo)}
|
||||
alt={photo.FileName ?? photo.Name ?? ''}
|
||||
loading="lazy"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{#if dims(photo)}
|
||||
<span
|
||||
class="absolute right-1.5 top-1.5 rounded bg-background/80 px-1.5 py-0.5 text-[10px] text-foreground"
|
||||
>
|
||||
{dims(photo)}
|
||||
</span>
|
||||
{/if}
|
||||
<!-- Per-tile hover actions: stop propagation so a click
|
||||
here doesn't also open the sidebar. -->
|
||||
<div
|
||||
class="absolute bottom-1.5 right-1.5 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded bg-background/90 px-1.5 py-0.5 text-[10px] font-medium text-foreground hover:bg-background"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
void approveOne(photo);
|
||||
}}
|
||||
title="Approve (S)"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded bg-background/90 px-1.5 py-0.5 text-[10px] font-medium text-foreground hover:bg-background"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
void archiveOne(photo);
|
||||
}}
|
||||
title="Archive (A)"
|
||||
>
|
||||
Archive
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1 px-2 py-1.5">
|
||||
<CauseBadges {causes} />
|
||||
<div
|
||||
class="truncate text-[10px] leading-tight text-muted-foreground"
|
||||
title={photo.FileName ?? photo.Name ?? ''}
|
||||
>
|
||||
{photo.FileName ?? photo.Name ?? ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<PhotoGrid
|
||||
photos={group.photos}
|
||||
dimensionBadge={group.cause === 'low_resolution'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
type PhotoMarksMap,
|
||||
type UpdatePhotoBody
|
||||
} from '$lib/services/photoprism';
|
||||
import { patchTargets } from '$lib/services/bulk';
|
||||
import { patchTargets, invalidateFacets } from '$lib/services/bulk';
|
||||
import { startBulk, doneBulk, failBulk } from '$lib/stores/bulkAction.svelte';
|
||||
import { COLOR_SWATCHES } from '$lib/utils/tagGroups';
|
||||
|
||||
const qc = useQueryClient();
|
||||
|
||||
@@ -34,10 +36,19 @@
|
||||
let colorDraft = $state<string | null>(null);
|
||||
let busy = $state(false);
|
||||
|
||||
async function withBusy<T>(fn: () => Promise<T>): Promise<T> {
|
||||
// `label` drives the per-photo tile overlay (pending → done / error) via the
|
||||
// shared bulkAction store, so metadata applies show the same progress state
|
||||
// as the archive/keep actions in BulkActionBar.
|
||||
async function withBusy<T>(fn: () => Promise<T>, label?: string): Promise<T> {
|
||||
busy = true;
|
||||
if (label) startBulk(`${label}…`, ids);
|
||||
try {
|
||||
return await fn();
|
||||
const result = await fn();
|
||||
if (label) doneBulk(label, ids);
|
||||
return result;
|
||||
} catch (e) {
|
||||
if (label) failBulk(ids);
|
||||
throw e;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
@@ -46,13 +57,16 @@
|
||||
async function applyNote() {
|
||||
if (busy) return;
|
||||
const value = noteDraft;
|
||||
await withBusy(() =>
|
||||
patchTargets(
|
||||
ids,
|
||||
{ Caption: value, CaptionSrc: 'manual' },
|
||||
value ? `Note → ${ids.length}` : `Cleared note on ${ids.length}`,
|
||||
(p) => ({ Caption: p.Caption ?? '', CaptionSrc: 'manual' })
|
||||
)
|
||||
const label = value ? `Note → ${ids.length}` : `Cleared note on ${ids.length}`;
|
||||
await withBusy(
|
||||
() =>
|
||||
patchTargets(
|
||||
ids,
|
||||
{ Caption: value, CaptionSrc: 'manual' },
|
||||
label,
|
||||
(p) => ({ Caption: p.Caption ?? '', CaptionSrc: 'manual' })
|
||||
),
|
||||
label
|
||||
);
|
||||
noteDraft = '';
|
||||
}
|
||||
@@ -63,27 +77,28 @@
|
||||
// Date-only input — stamp midnight UTC and let PhotoPrism's backwrite
|
||||
// fill the local timezone field downstream.
|
||||
const iso = `${dateDraft}T00:00:00Z`;
|
||||
await withBusy(() =>
|
||||
patchTargets(
|
||||
ids,
|
||||
buildTakenAtPatch(iso),
|
||||
`Date → ${ids.length}`,
|
||||
(p) =>
|
||||
p.TakenAt
|
||||
? buildTakenAtPatch(p.TakenAt)
|
||||
: ({ TakenSrc: '' } as UpdatePhotoBody)
|
||||
)
|
||||
const label = `Date → ${ids.length}`;
|
||||
await withBusy(
|
||||
() =>
|
||||
patchTargets(
|
||||
ids,
|
||||
buildTakenAtPatch(iso),
|
||||
label,
|
||||
(p) =>
|
||||
p.TakenAt
|
||||
? buildTakenAtPatch(p.TakenAt)
|
||||
: ({ TakenSrc: '' } as UpdatePhotoBody)
|
||||
),
|
||||
label
|
||||
);
|
||||
dateDraft = '';
|
||||
}
|
||||
|
||||
async function applyMarks(patch: PhotoMark, label: string) {
|
||||
if (busy) return;
|
||||
const tid = toast.loading(`${label}…`);
|
||||
startBulk(`${label}…`, ids);
|
||||
await withBusy(async () => {
|
||||
// Optimistic: patch every selected photo's mark in the local
|
||||
// cache before round-tripping. Sidecar bulk endpoint is
|
||||
// authoritative; on failure we just invalidate so the next
|
||||
// list query overrides.
|
||||
qc.setQueryData<PhotoMarksMap>(['marks'], (prev) => {
|
||||
const map = { ...(prev ?? {}) };
|
||||
for (const id of ids) {
|
||||
@@ -97,9 +112,14 @@
|
||||
});
|
||||
try {
|
||||
await bulkSetMarks(ids, patch);
|
||||
toast.success(`${label} · ${ids.length}`);
|
||||
doneBulk(label, ids);
|
||||
// Refresh the Colors / Ratings facet panels — they sit on
|
||||
// `['marks']` + `['photos','marks-pool']`, not the optimistic write above.
|
||||
invalidateFacets();
|
||||
toast.success(`${label} · ${ids.length}`, { id: tid });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Save failed');
|
||||
failBulk(ids);
|
||||
toast.error(err instanceof Error ? err.message : 'Save failed', { id: tid });
|
||||
void qc.invalidateQueries({ queryKey: ['marks'] });
|
||||
}
|
||||
});
|
||||
@@ -119,35 +139,31 @@
|
||||
colorDraft = null;
|
||||
}
|
||||
|
||||
const COLOR_SWATCHES: { key: string; bg: string; title: string }[] = [
|
||||
{ key: 'red', bg: 'bg-red-500', title: 'Red — reject' },
|
||||
{ key: 'orange', bg: 'bg-orange-500', title: 'Orange — review' },
|
||||
{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow — pick' },
|
||||
{ key: 'green', bg: 'bg-green-500', title: 'Green — keep' }
|
||||
];
|
||||
|
||||
async function applyKeyword() {
|
||||
if (busy) return;
|
||||
const kw = keywordDraft.trim().replace(/,/g, '');
|
||||
if (!kw) return;
|
||||
keywordDraft = '';
|
||||
await withBusy(() =>
|
||||
patchTargets(
|
||||
ids,
|
||||
(p) => {
|
||||
const cur = (p.Details?.Keywords ?? '')
|
||||
.split(',')
|
||||
.map((k) => k.trim())
|
||||
.filter(Boolean);
|
||||
if (cur.includes(kw)) return {};
|
||||
const next = [...cur, kw].join(', ');
|
||||
return { Details: { Keywords: next, KeywordsSrc: 'manual' } };
|
||||
},
|
||||
`Tagged "${kw}" → ${ids.length}`,
|
||||
(p) => ({
|
||||
Details: { Keywords: p.Details?.Keywords ?? '', KeywordsSrc: 'manual' }
|
||||
})
|
||||
)
|
||||
const label = `Tagged "${kw}" → ${ids.length}`;
|
||||
await withBusy(
|
||||
() =>
|
||||
patchTargets(
|
||||
ids,
|
||||
(p) => {
|
||||
const cur = (p.Details?.Keywords ?? '')
|
||||
.split(',')
|
||||
.map((k) => k.trim())
|
||||
.filter(Boolean);
|
||||
if (cur.includes(kw)) return {};
|
||||
const next = [...cur, kw].join(', ');
|
||||
return { Details: { Keywords: next, KeywordsSrc: 'manual' } };
|
||||
},
|
||||
label,
|
||||
(p) => ({
|
||||
Details: { Keywords: p.Details?.Keywords ?? '', KeywordsSrc: 'manual' }
|
||||
})
|
||||
),
|
||||
label
|
||||
);
|
||||
}
|
||||
|
||||
@@ -270,16 +286,18 @@
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<!-- Color label — same pattern as Score. -->
|
||||
<!-- Colors — same pattern as Score. -->
|
||||
<section class="space-y-1">
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Color label</div>
|
||||
<div class="flex items-center gap-1" role="group" aria-label="Color label">
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Colors</div>
|
||||
<div class="flex flex-wrap items-center gap-1.5" role="group" aria-label="Colors">
|
||||
{#each COLOR_SWATCHES as c (c.key)}
|
||||
{@const picked = colorDraft === c.key}
|
||||
<button
|
||||
type="button"
|
||||
class="h-4 w-4 rounded-full ring-2 transition-all disabled:opacity-50 {c.bg}"
|
||||
class:ring-foreground={colorDraft === c.key}
|
||||
class:ring-transparent={colorDraft !== c.key}
|
||||
class="h-4 w-4 rounded-full border-2 transition-all disabled:opacity-50 {c.border} {picked
|
||||
? c.bg
|
||||
: 'bg-transparent'}"
|
||||
aria-pressed={picked}
|
||||
disabled={busy}
|
||||
onclick={() => (colorDraft = c.key)}
|
||||
title={`Pick ${c.title}`}
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
<!--
|
||||
One horizontal strip of related-photo thumbnails for the metadata
|
||||
sidebar. Used three times on the /review sidebar (folder / camera /
|
||||
year). Self-fetches via the PhotoPrism DSL so each strip stays
|
||||
independent.
|
||||
|
||||
The header is clickable: it navigates back to the timeline with the
|
||||
same DSL applied as a `?q=` param, so the user can drill into the
|
||||
full result set if they want to. Strips with zero hits collapse
|
||||
silently — no header, no whitespace.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { listPhotos } from '$lib/services/photoprism';
|
||||
import { thumbUrl } from '$lib/stores/session.svelte';
|
||||
import { setFocused } from '$lib/stores/selection.svelte';
|
||||
import type { PpPhoto } from '$lib/types/photoprism';
|
||||
import { Loader2 } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
/** PhotoPrism DSL fragment, e.g. `path:"2024/lyon"` or `year:2024`. */
|
||||
q: string;
|
||||
/** Cap on tiles rendered in the strip. Defaults to a small set
|
||||
* that fits one row in a typical sidebar width. */
|
||||
limit?: number;
|
||||
/** UID to filter out — usually the photo whose sidebar this strip
|
||||
* is on, so the user doesn't see itself in its own "related"
|
||||
* list. */
|
||||
excludeUid?: string;
|
||||
}
|
||||
let { title, q, limit = 12, excludeUid }: Props = $props();
|
||||
|
||||
const stripQuery = createQuery<PpPhoto[]>(() => ({
|
||||
queryKey: ['related', q, limit],
|
||||
queryFn: () => listPhotos({ q, count: limit + 1, order: 'newest' }),
|
||||
// Strips are cheap to refetch; the data behind them changes
|
||||
// rarely, but a stale-while-revalidate window keeps the sidebar
|
||||
// snappy when the user clicks through similar photos.
|
||||
staleTime: 60_000,
|
||||
enabled: q.length > 0
|
||||
}));
|
||||
|
||||
const photos = $derived(
|
||||
(stripQuery.data ?? []).filter((p) => p.UID !== excludeUid).slice(0, limit)
|
||||
);
|
||||
|
||||
function openTimeline() {
|
||||
// Same `?q=` param the timeline already accepts (see filters store)
|
||||
// — clicking the strip header pivots the main timeline into the
|
||||
// same filtered scope so the user can browse the full set.
|
||||
const params = new URLSearchParams({ q });
|
||||
void goto(`/?${params.toString()}`, { keepFocus: true });
|
||||
}
|
||||
|
||||
function openOne(uid: string) {
|
||||
// Focus the picked photo so the sidebar re-renders against it.
|
||||
// Useful for the "decide these together" workflow without leaving
|
||||
// the review page.
|
||||
setFocused(uid);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if stripQuery.isPending}
|
||||
<div
|
||||
class="flex items-center gap-1.5 text-[10px] text-muted-foreground/70"
|
||||
role="status"
|
||||
aria-busy="true"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Loader2 class="h-2.5 w-2.5 animate-spin" aria-hidden="true" />
|
||||
<span>Loading {title.toLowerCase()}…</span>
|
||||
</div>
|
||||
{:else if stripQuery.isError}
|
||||
<!-- Errors shouldn't break the sidebar; just hide the strip. -->
|
||||
{null}
|
||||
{:else if photos.length > 0}
|
||||
<div class="space-y-1">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-baseline justify-between text-[10px] uppercase tracking-wide text-muted-foreground hover:text-foreground"
|
||||
onclick={openTimeline}
|
||||
title={`Open the timeline filtered by ${q}`}
|
||||
>
|
||||
<span>{title}</span>
|
||||
<span class="text-muted-foreground/70">({photos.length}+)</span>
|
||||
</button>
|
||||
<div class="flex gap-1 overflow-x-auto">
|
||||
{#each photos as p (p.UID)}
|
||||
<button
|
||||
type="button"
|
||||
class="h-12 w-12 shrink-0 overflow-hidden rounded border border-border bg-secondary hover:border-primary"
|
||||
onclick={() => openOne(p.UID)}
|
||||
title={p.FileName ?? p.Name ?? p.UID}
|
||||
>
|
||||
{#if p.Hash}
|
||||
<img
|
||||
src={thumbUrl(p.Hash, 'tile_100')}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -6,17 +6,20 @@
|
||||
PUT (Details fields need the full body).
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import {
|
||||
Aperture,
|
||||
ArrowUpRight,
|
||||
Calendar,
|
||||
ExternalLink,
|
||||
File,
|
||||
Folder,
|
||||
HardDrive,
|
||||
ImageIcon,
|
||||
Loader2,
|
||||
Map as MapIcon,
|
||||
MapPin,
|
||||
Star,
|
||||
Tag,
|
||||
@@ -34,19 +37,20 @@
|
||||
type PhotoMarksMap,
|
||||
type UpdatePhotoBody
|
||||
} from '$lib/services/photoprism';
|
||||
import { invalidateFacets } from '$lib/services/bulk';
|
||||
import { startBulk, doneBulk, failBulk } from '$lib/stores/bulkAction.svelte';
|
||||
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||
import { push as pushUndo } from '$lib/stores/undo.svelte';
|
||||
import { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte';
|
||||
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||
import RelatedStrip from './RelatedStrip.svelte';
|
||||
import { photoNameAndDir, primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||
import { navigateToFolder } from '$lib/stores/filters.svelte';
|
||||
import { COLOR_SWATCHES } from '$lib/utils/tagGroups';
|
||||
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
|
||||
|
||||
interface Props {
|
||||
/** When true, append related-photo strips (folder/camera/year) below
|
||||
* Keywords. Used by the /review route; left off on the timeline. */
|
||||
showRelated?: boolean;
|
||||
photo: PpPhoto;
|
||||
}
|
||||
let { photo, showRelated = false }: Props = $props();
|
||||
let { photo }: Props = $props();
|
||||
|
||||
const qc = useQueryClient();
|
||||
|
||||
@@ -89,12 +93,20 @@
|
||||
const fresh = qc.getQueryData<PpPhoto>(['photo', photo.UID]) ?? photo;
|
||||
return updatePhoto(fresh, patch);
|
||||
},
|
||||
onMutate: () => {
|
||||
startBulk('Saving…', [photo.UID]);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
qc.setQueryData(['photo', data.UID], data);
|
||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
// Keep the keyword / notes facet panels in sync with the edit.
|
||||
invalidateFacets();
|
||||
doneBulk('Saved', [photo.UID]);
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : 'Save failed')
|
||||
onError: (err) => {
|
||||
failBulk([photo.UID]);
|
||||
toast.error(err instanceof Error ? err.message : 'Save failed');
|
||||
}
|
||||
}));
|
||||
|
||||
function commit(patch: UpdatePhotoBody) {
|
||||
@@ -130,6 +142,32 @@
|
||||
commit({ Caption: caption, CaptionSrc: 'manual' });
|
||||
}
|
||||
const takenAtValid = $derived(takenAt === '' || isValidISODate(takenAt));
|
||||
// Path-based date guess. Scoped to the EXIF Stripped review tab: those
|
||||
// are the photos with definitionally-untrusted dates, and showing the
|
||||
// row anywhere else would compete with the existing TakenAt.
|
||||
// PhotoPrism stores a TakenAt for stripped-EXIF photos too (filename
|
||||
// guess or file mtime), so a per-photo "needs date" heuristic would
|
||||
// silently hide the suggestion — the tab is the more reliable signal.
|
||||
const onExifStrippedTab = $derived(
|
||||
page.url.pathname === '/review' &&
|
||||
page.url.searchParams.get('tab') === 'stripped_exif'
|
||||
);
|
||||
const dateSuggestion = $derived.by(() => {
|
||||
const { fileName, path } = photoNameAndDir(photo);
|
||||
return suggestDateFromPath({
|
||||
fileName,
|
||||
originalName: photo.OriginalName,
|
||||
path
|
||||
});
|
||||
});
|
||||
const showDateSuggestion = $derived(
|
||||
onExifStrippedTab && !!dateSuggestion && dateSuggestion.iso !== takenAt
|
||||
);
|
||||
function applyDateSuggestion() {
|
||||
if (!dateSuggestion) return;
|
||||
takenAt = dateSuggestion.iso;
|
||||
commitTakenAt();
|
||||
}
|
||||
function commitTakenAt() {
|
||||
if (!takenAt) return;
|
||||
if (!isValidISODate(takenAt)) {
|
||||
@@ -206,12 +244,17 @@
|
||||
if (!optimistic.rating) delete optimistic.rating;
|
||||
if (!optimistic.color) delete optimistic.color;
|
||||
patchMarksCache(photo.UID, optimistic);
|
||||
startBulk('Saving…', [photo.UID]);
|
||||
try {
|
||||
const saved = await setMark(photo.UID, patch);
|
||||
patchMarksCache(photo.UID, saved);
|
||||
// Refresh the Colors / Ratings facet panels off the sidecar truth.
|
||||
invalidateFacets();
|
||||
doneBulk('Saved', [photo.UID]);
|
||||
} catch (err) {
|
||||
// Rollback on failure.
|
||||
patchMarksCache(photo.UID, prev);
|
||||
failBulk([photo.UID]);
|
||||
toast.error(err instanceof Error ? err.message : 'Save failed');
|
||||
}
|
||||
}
|
||||
@@ -225,29 +268,20 @@
|
||||
}
|
||||
|
||||
/** Click-to-toggle: clicking the current color clears it; clicking a
|
||||
* different swatch swaps. Same four-swatch palette as mule-image. */
|
||||
* different swatch swaps. */
|
||||
function setColor(next: string) {
|
||||
const value = currentColor === next ? '' : next;
|
||||
if (value === currentColor) return;
|
||||
void applyMark({ color: value });
|
||||
}
|
||||
|
||||
// Tooltips follow the Lightroom culling convention so the swatches
|
||||
// read as actions, not just colors. Red = reject, Yellow = pick,
|
||||
// Green = keep, Orange = review-later.
|
||||
const COLOR_SWATCHES: { key: string; bg: string; title: string }[] = [
|
||||
{ key: 'red', bg: 'bg-red-500', title: 'Red — reject' },
|
||||
{ key: 'orange', bg: 'bg-orange-500', title: 'Orange — review' },
|
||||
{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow — pick' },
|
||||
{ key: 'green', bg: 'bg-green-500', title: 'Green — keep' }
|
||||
];
|
||||
|
||||
const photoMark = $derived<PhotoMark>(marksQuery.data?.[photo.UID] ?? {});
|
||||
const currentRating = $derived(photoMark.rating ?? 0);
|
||||
const currentColor = $derived(photoMark.color ?? '');
|
||||
|
||||
const pf = $derived(primaryFile(photo));
|
||||
const dirPath = $derived(splitName(pf.Name ?? '').dir);
|
||||
const folderLabel = $derived(dirPath ? `${dirPath}/` : '/');
|
||||
const dims = $derived(pf.Width && pf.Height ? `${pf.Width}×${pf.Height}` : '—');
|
||||
const sizeStr = $derived(
|
||||
pf.Size
|
||||
@@ -266,12 +300,6 @@
|
||||
? photo.Country.toUpperCase()
|
||||
: ''
|
||||
);
|
||||
const mapsHref = $derived(
|
||||
photo.Lat && photo.Lng
|
||||
? `https://www.openstreetmap.org/?mlat=${photo.Lat}&mlon=${photo.Lng}&zoom=15`
|
||||
: ''
|
||||
);
|
||||
|
||||
function formatCameraLens(c?: { Make?: string; Model?: string; Name?: string }): string {
|
||||
if (!c) return '';
|
||||
const make = c.Make ?? '';
|
||||
@@ -333,19 +361,61 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Folder (read-only). The `px-1 py-0.5` mirrors the input
|
||||
padding on filename / date so the read-only text starts at the
|
||||
same x-offset as the editable rows above — otherwise spans
|
||||
hug the icon while inputs sit 4px in. -->
|
||||
{#if dirPath}
|
||||
<div class="flex items-center gap-2">
|
||||
<Folder class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate px-1 py-0.5 text-muted-foreground" title={dirPath}>
|
||||
{dirPath}/
|
||||
<!-- Date suggestion derived from filename / folder signals. Only
|
||||
shown on the EXIF Stripped review tab; amber styling marks
|
||||
it as unconfirmed. `(estimated day)` hint appears when the
|
||||
day was synthesised because only Y-M was available — same
|
||||
row, just so the user knows that part is fabricated. Apply
|
||||
writes the value into the date input above and commits as
|
||||
a manual TakenAt edit. -->
|
||||
{#if showDateSuggestion && dateSuggestion}
|
||||
<div
|
||||
class="flex items-center gap-2 rounded border border-amber-300/70 bg-amber-50/40 px-1.5 py-1 text-[11px] text-amber-700 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-300"
|
||||
>
|
||||
<Folder class="h-3.5 w-3.5 shrink-0" />
|
||||
<span class="min-w-0 flex-1 truncate">
|
||||
Suggested from path: <span class="font-medium">{dateSuggestion.iso}</span>
|
||||
{#if dateSuggestion.source === 'path-ym-default-day'}
|
||||
<span class="text-amber-600/80 dark:text-amber-400/70">(estimated day)</span>
|
||||
{/if}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded border border-amber-400/60 bg-amber-100/60 px-1.5 py-0.5 text-[10px] font-medium text-amber-800 hover:bg-amber-100 dark:border-amber-400/30 dark:bg-amber-500/20 dark:text-amber-200 dark:hover:bg-amber-500/30"
|
||||
onclick={applyDateSuggestion}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Folder (read-only label + open-in-timeline icon). The `px-1 py-0.5`
|
||||
mirrors the input padding on filename / date so the read-only
|
||||
text starts at the same x-offset as the editable rows above —
|
||||
otherwise spans hug the icon while inputs sit 4px in. Root-level
|
||||
files render as `/` so the row never disappears. The arrow-up-
|
||||
right icon navigates to the timeline filtered by this folder
|
||||
with the photo pre-focused. -->
|
||||
<div class="flex items-center gap-2">
|
||||
<Folder class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate px-1 py-0.5 text-muted-foreground" title={folderLabel}>
|
||||
{folderLabel}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-muted-foreground hover:text-foreground"
|
||||
onclick={() =>
|
||||
void navigateToFolder(dirPath || '/', {
|
||||
focusUid: photo.UID,
|
||||
focusTakenAt: photo.TakenAt ?? null
|
||||
})}
|
||||
title="Open folder in timeline"
|
||||
aria-label="Open folder in timeline"
|
||||
>
|
||||
<ArrowUpRight class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Dimensions -->
|
||||
<div class="flex items-center gap-2">
|
||||
<ImageIcon class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
@@ -362,45 +432,41 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Location -->
|
||||
<!-- Location (read-only label + open-on-map icon). The arrow-up-
|
||||
right icon flies the in-app map to the photo's coordinates at
|
||||
zoom 17 (close enough for the photo's marker to be its own,
|
||||
out of any cluster). Hidden when the photo has no
|
||||
coordinates. -->
|
||||
<div class="flex items-center gap-2">
|
||||
<MapPin class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate px-1 py-0.5 text-muted-foreground">
|
||||
{placeLabel || 'No location'}
|
||||
</span>
|
||||
{#if mapsHref}
|
||||
<a
|
||||
href={mapsHref}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
{#if photo.Lat && photo.Lng}
|
||||
<button
|
||||
type="button"
|
||||
class="text-muted-foreground hover:text-foreground"
|
||||
title="Open in OpenStreetMap"
|
||||
onclick={() =>
|
||||
void goto(
|
||||
`/map?lat=${photo.Lat}&lng=${photo.Lng}&zoom=17&focus=${photo.UID}`
|
||||
)}
|
||||
title="Open on map"
|
||||
aria-label="Open on map"
|
||||
>
|
||||
<ExternalLink class="h-3 w-3" />
|
||||
</a>
|
||||
<MapIcon class="h-3 w-3" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<!-- Note (PhotoPrism's Caption field — labelled "Note" to match
|
||||
mule-image's nomenclature). -->
|
||||
<div class="space-y-1">
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Note</div>
|
||||
<textarea
|
||||
rows="2"
|
||||
placeholder="Add a note…"
|
||||
class="w-full resize-y rounded border border-input bg-background px-1.5 py-1 text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
bind:value={caption}
|
||||
onblur={commitCaption}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Tags — score, color label, keywords, and auto-labels grouped under
|
||||
one collapsible section. Score + color are stored on the mule-
|
||||
sidecar (PhotoPrism's PUT can't persist them); keywords live on
|
||||
Details; auto-labels come from PhotoPrism's TF classifier and are
|
||||
read-only. Open by default since these are the culling marks the
|
||||
user reaches for first. -->
|
||||
<!-- Tags — note, score, color label, keywords, and auto-labels grouped
|
||||
under one collapsible section. Note (PhotoPrism's Caption field,
|
||||
labelled here to match mule-image's nomenclature) sits at the top
|
||||
of the group since it's the most-edited per-photo field. Score +
|
||||
color are stored on the mule-sidecar (PhotoPrism's PUT can't
|
||||
persist them); keywords live on Details; auto-labels come from
|
||||
PhotoPrism's TF classifier and are read-only. Open by default
|
||||
since these are the culling marks the user reaches for first. -->
|
||||
<details
|
||||
class="rounded border border-border"
|
||||
open={getMetadataSectionOpen('tags', true)}
|
||||
@@ -414,6 +480,17 @@
|
||||
</span>
|
||||
</summary>
|
||||
<div class="space-y-2 p-2 pt-1">
|
||||
<div class="space-y-1">
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Note</div>
|
||||
<textarea
|
||||
rows="2"
|
||||
placeholder="Add a note…"
|
||||
class="w-full resize-y rounded border border-input bg-background px-1.5 py-1 text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
bind:value={caption}
|
||||
onblur={commitCaption}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Score</div>
|
||||
<div class="flex items-center gap-0.5" role="group" aria-label="Rating">
|
||||
@@ -434,14 +511,16 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Color label</div>
|
||||
<div class="flex items-center gap-1" role="group" aria-label="Color label">
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Colors</div>
|
||||
<div class="flex flex-wrap items-center gap-1.5" role="group" aria-label="Colors">
|
||||
{#each COLOR_SWATCHES as c (c.key)}
|
||||
{@const picked = currentColor === c.key}
|
||||
<button
|
||||
type="button"
|
||||
class="h-4 w-4 rounded-full ring-2 transition-all {c.bg}"
|
||||
class:ring-foreground={currentColor === c.key}
|
||||
class:ring-transparent={currentColor !== c.key}
|
||||
class="h-4 w-4 rounded-full border-2 transition-all {c.border} {picked
|
||||
? c.bg
|
||||
: 'bg-transparent'}"
|
||||
aria-pressed={picked}
|
||||
onclick={() => setColor(c.key)}
|
||||
title={c.title}
|
||||
aria-label={`Color ${c.key}`}
|
||||
@@ -511,35 +590,6 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Related strips (only the /review route opts in). The three
|
||||
scopes match the three decisions the user usually makes here:
|
||||
"did all these come from the same shoot?" (folder), "same
|
||||
camera, EXIF-stripped together?" (camera), "right year?"
|
||||
(year). Strips with zero hits collapse silently. -->
|
||||
{#if showRelated}
|
||||
<div class="space-y-2 border-t border-border pt-2">
|
||||
<RelatedStrip
|
||||
title="Same folder"
|
||||
q={`path:"${photo.Path ?? ''}"`}
|
||||
excludeUid={photo.UID}
|
||||
/>
|
||||
{#if photo.CameraID && photo.CameraID !== 1}
|
||||
<RelatedStrip
|
||||
title="Same camera"
|
||||
q={`camera:${photo.CameraID}`}
|
||||
excludeUid={photo.UID}
|
||||
/>
|
||||
{/if}
|
||||
{#if photo.Year}
|
||||
<RelatedStrip
|
||||
title="Same year"
|
||||
q={`year:${photo.Year}`}
|
||||
excludeUid={photo.UID}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- GPS detail. Static default (closed); user's expand/collapse
|
||||
choice persists across photo switches via the view store.
|
||||
Avoid data-driven defaults here — they make the `open` attr
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
aggregateKeywords,
|
||||
getAllMarks,
|
||||
listLabels,
|
||||
listPhotos,
|
||||
listPhotosByUids,
|
||||
listSubjects,
|
||||
type AggregatedKeyword,
|
||||
type PhotoMarksMap,
|
||||
@@ -72,12 +72,17 @@
|
||||
}));
|
||||
|
||||
// Same marks-pool query the drill page uses — colors/ratings need a
|
||||
// representative photo per bucket for the count rollup. Cheap once
|
||||
// cached; the drill page kicks the same key.
|
||||
// representative photo per bucket for the count rollup. Resolved from the
|
||||
// marked UIDs (complete set, any age) so the rollup counts every marked
|
||||
// photo, not just those in the newest-N timeline slice.
|
||||
const markedUids = $derived(Object.keys(marksQuery.data ?? {}));
|
||||
const marksPoolQuery = createQuery<PpPhoto[]>(() => ({
|
||||
queryKey: ['photos', 'marks-pool'],
|
||||
queryFn: () => listPhotos({ count: 1000, order: 'newest', merged: true }),
|
||||
enabled: isAuthenticated() && (category === 'ratings' || category === 'colors')
|
||||
queryKey: ['photos', 'marks-pool', [...markedUids].sort()],
|
||||
queryFn: () => listPhotosByUids(markedUids),
|
||||
enabled:
|
||||
isAuthenticated() &&
|
||||
(category === 'ratings' || category === 'colors') &&
|
||||
markedUids.length > 0
|
||||
}));
|
||||
|
||||
// PhotoPrism returns labels in arbitrary order; sort by photo count
|
||||
@@ -465,7 +470,8 @@
|
||||
onclick={() => pickColor(swatch.key)}
|
||||
title={swatch.title}
|
||||
>
|
||||
<span class="h-3 w-3 shrink-0 rounded-full {swatch.bg}"></span>
|
||||
<span class="h-3 w-3 shrink-0 rounded-full border-2 bg-transparent {swatch.border}"
|
||||
></span>
|
||||
<span class="min-w-0 flex-1 truncate">{swatch.title}</span>
|
||||
<span
|
||||
class="flex h-4 min-w-[20px] shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import {
|
||||
@@ -12,6 +13,9 @@
|
||||
type PpAlbum
|
||||
} from '$lib/services/photoprism';
|
||||
import { batchEdit } from '$lib/services/batch';
|
||||
import { acceptDateAndKeep, cachedPhoto } from '$lib/services/photoActions';
|
||||
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
|
||||
import { photoNameAndDir } from '$lib/types/photoprism';
|
||||
import {
|
||||
clearBulkToFirst,
|
||||
clearSelection,
|
||||
@@ -22,6 +26,14 @@
|
||||
import { filters } from '$lib/stores/filters.svelte';
|
||||
import { push as pushUndo } from '$lib/stores/undo.svelte';
|
||||
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||
import {
|
||||
startBulk,
|
||||
setDetail,
|
||||
doneBulk,
|
||||
failBulk,
|
||||
markRemoved,
|
||||
clearRemoved
|
||||
} from '$lib/stores/bulkAction.svelte';
|
||||
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
||||
import { Layers } from 'lucide-svelte';
|
||||
|
||||
@@ -50,11 +62,50 @@
|
||||
selection.ids.size > 0 ? selection.ids.size : selection.focused ? 1 : 0
|
||||
);
|
||||
const isBulk = $derived(selection.ids.size > 0);
|
||||
// Filename for the single-focus label. Re-derives whenever
|
||||
// selection.focused flips — cachedPhoto reads from the same query
|
||||
// cache that drives the visible tiles, so the name resolves on the
|
||||
// same tick the tile renders.
|
||||
const focusedPhoto = $derived(selection.focused ? cachedPhoto(selection.focused) : undefined);
|
||||
const focusedName = $derived(
|
||||
focusedPhoto ? photoNameAndDir(focusedPhoto).fileName : ''
|
||||
);
|
||||
// Review section uses a two-button decision flow (Keep / Archive) —
|
||||
// every other action is hidden so the choice can't be confused with
|
||||
// heap-adding / restoring. The S keybinding is rerouted to approve
|
||||
// from gridKeyNav for the same reason.
|
||||
const isReview = $derived(filters.section === 'review');
|
||||
// "Accept date & Keep" is scoped to the EXIF Stripped review tab —
|
||||
// that's where path-derived dates are the most useful fix. Outside the
|
||||
// tab the button stays hidden even if a selected photo would otherwise
|
||||
// have a path-parseable date, to keep other tabs uncluttered.
|
||||
const onExifStrippedTab = $derived(
|
||||
isReview && page.url.searchParams.get('tab') === 'stripped_exif'
|
||||
);
|
||||
// Surface the button only when EVERY targeted photo has a derivable
|
||||
// suggestion — otherwise clicking it would silently approve some
|
||||
// photos without a date fix, which contradicts the verb. A uid not in
|
||||
// any cache also counts as "no suggestion" so we don't promise
|
||||
// something we can't verify.
|
||||
const allHaveSuggestion = $derived.by(() => {
|
||||
if (!onExifStrippedTab) return false;
|
||||
const ids =
|
||||
selection.ids.size > 0
|
||||
? Array.from(selection.ids)
|
||||
: selection.focused
|
||||
? [selection.focused]
|
||||
: [];
|
||||
if (ids.length === 0) return false;
|
||||
for (const id of ids) {
|
||||
const p = cachedPhoto(id);
|
||||
if (!p) return false;
|
||||
const { fileName, path } = photoNameAndDir(p);
|
||||
if (!suggestDateFromPath({ fileName, originalName: p.OriginalName, path })) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
// Archive section is the parallel two-button flow: Keep (restore back
|
||||
// to the timeline) or Delete (permanent, no undo). X is repurposed
|
||||
// from "archive" to "delete" since the photo is already archived;
|
||||
@@ -70,55 +121,93 @@
|
||||
setFocused(null);
|
||||
}
|
||||
|
||||
async function withBusy<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
|
||||
interface BulkConfig {
|
||||
ids: string[];
|
||||
label: string;
|
||||
doneLabel: string;
|
||||
}
|
||||
|
||||
async function withBusy<T>(fn: () => Promise<T>, bulk?: BulkConfig): Promise<T> {
|
||||
busy = true;
|
||||
if (bulk) startBulk(`${bulk.label}…`, bulk.ids);
|
||||
try {
|
||||
return await fn();
|
||||
const result = await fn();
|
||||
if (bulk) {
|
||||
doneBulk(bulk.doneLabel, bulk.ids);
|
||||
await delay(1000);
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
if (bulk) failBulk(bulk.ids);
|
||||
throw e;
|
||||
} finally {
|
||||
busy = false;
|
||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
const settled = Promise.all([
|
||||
qc.invalidateQueries({ queryKey: ['photos'] }),
|
||||
qc.invalidateQueries({ queryKey: ['marks'] }),
|
||||
qc.invalidateQueries({ queryKey: ['review-groups'] })
|
||||
]);
|
||||
// Clear the optimistic-removal overlay only once the refetch has
|
||||
// landed, so tiles never flash back in before the fresh (archived-
|
||||
// filtered) page replaces the old one.
|
||||
if (bulk) void settled.then(() => clearRemoved(bulk.ids));
|
||||
}
|
||||
}
|
||||
|
||||
async function onApprove() {
|
||||
const ids = snapshotIds();
|
||||
if (ids.length === 0) return;
|
||||
const tid = toast.loading(`Keeping ${ids.length}…`);
|
||||
await withBusy(async () => {
|
||||
// PhotoPrism's approve is one-way (Quality jumps to 3+); there's
|
||||
// no /unapprove route. We fan out per-photo because there's no
|
||||
// batch endpoint either. Errors are tallied rather than aborting
|
||||
// the loop so a single bad UID doesn't block the rest.
|
||||
const { updated, errors } = await batchEdit(ids, (id) => approvePhoto(id));
|
||||
const { updated, errors } = await batchEdit(ids, (id) => approvePhoto(id), {
|
||||
onProgress: (_done, _total, completedId) => {
|
||||
const p = cachedPhoto(completedId);
|
||||
setDetail(p?.FileName ?? completedId);
|
||||
}
|
||||
});
|
||||
if (errors.length) {
|
||||
toast.error(`Kept ${updated.length}; ${errors.length} failed`);
|
||||
toast.error(`Kept ${updated.length}; ${errors.length} failed`, { id: tid });
|
||||
} else {
|
||||
toast.success(`Kept ${ids.length}`);
|
||||
toast.success(`Kept ${ids.length}`, { id: tid });
|
||||
}
|
||||
// Approved photos leave the review section — hide them immediately.
|
||||
markRemoved(ids);
|
||||
focusAfter(ids);
|
||||
clearSelection();
|
||||
}, { ids, label: 'Keeping', doneLabel: `Kept ${ids.length}` });
|
||||
}
|
||||
|
||||
async function onAcceptDateAndKeep() {
|
||||
const ids = snapshotIds();
|
||||
if (ids.length === 0) return;
|
||||
await withBusy(() => acceptDateAndKeep(ids), {
|
||||
ids,
|
||||
label: 'Updating',
|
||||
doneLabel: `Updated ${ids.length}`
|
||||
});
|
||||
}
|
||||
|
||||
async function onArchive() {
|
||||
const ids = snapshotIds();
|
||||
if (ids.length === 0) return;
|
||||
const tid = toast.loading(`Archiving ${ids.length}…`);
|
||||
await withBusy(async () => {
|
||||
try {
|
||||
await batchArchive(ids);
|
||||
markRemoved(ids);
|
||||
pushUndo(`Archived ${ids.length}`, async () => {
|
||||
await batchRestore(ids);
|
||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
});
|
||||
// Advance focus to the photo immediately after the archived
|
||||
// set before the multi-selection is dropped — lets the user
|
||||
// keep stepping through the timeline with X.
|
||||
focusAfter(ids);
|
||||
clearSelection();
|
||||
toast.success(`Archived ${ids.length}`);
|
||||
toast.success(`Archived ${ids.length}`, { id: tid });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Archive failed');
|
||||
toast.error(err instanceof Error ? err.message : 'Archive failed', { id: tid });
|
||||
}
|
||||
});
|
||||
}, { ids, label: 'Archiving', doneLabel: `Archived ${ids.length}` });
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
@@ -129,61 +218,68 @@
|
||||
? 'Permanently delete this photo? This cannot be undone.'
|
||||
: `Permanently delete ${ids.length} photos? This cannot be undone.`;
|
||||
if (!confirm(msg)) return;
|
||||
const tid = toast.loading(`Deleting ${ids.length}…`);
|
||||
await withBusy(async () => {
|
||||
try {
|
||||
await batchDelete(ids);
|
||||
markRemoved(ids);
|
||||
focusAfter(ids);
|
||||
clearSelection();
|
||||
toast.success(`Deleted ${ids.length}`);
|
||||
toast.success(`Deleted ${ids.length}`, { id: tid });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Delete failed');
|
||||
toast.error(err instanceof Error ? err.message : 'Delete failed', { id: tid });
|
||||
}
|
||||
});
|
||||
}, { ids, label: 'Deleting', doneLabel: `Deleted ${ids.length}` });
|
||||
}
|
||||
|
||||
async function onRestore() {
|
||||
const ids = snapshotIds();
|
||||
if (ids.length === 0) return;
|
||||
const tid = toast.loading(`Restoring ${ids.length}…`);
|
||||
await withBusy(async () => {
|
||||
try {
|
||||
await batchRestore(ids);
|
||||
markRemoved(ids);
|
||||
pushUndo(`Restored ${ids.length}`, async () => {
|
||||
await batchArchive(ids);
|
||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
});
|
||||
focusAfter(ids);
|
||||
clearSelection();
|
||||
toast.success(`Restored ${ids.length}`);
|
||||
toast.success(`Restored ${ids.length}`, { id: tid });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Restore failed');
|
||||
toast.error(err instanceof Error ? err.message : 'Restore failed', { id: tid });
|
||||
}
|
||||
});
|
||||
}, { ids, label: 'Restoring', doneLabel: `Restored ${ids.length}` });
|
||||
}
|
||||
|
||||
async function onAddToHeap(heap: PpAlbum) {
|
||||
const ids = snapshotIds();
|
||||
if (!ids.length) return;
|
||||
heapPickerOpen = false;
|
||||
const tid = toast.loading(`Adding ${ids.length} → ${heap.Title}…`);
|
||||
startBulk(`Adding to ${heap.Title}…`, ids);
|
||||
await withBusy(async () => {
|
||||
try {
|
||||
const { added } = await addToHeap(heap.UID, ids);
|
||||
qc.invalidateQueries({ queryKey: ['heaps'] });
|
||||
// PhotoPrism returns 200 even when nothing was added (UIDs
|
||||
// already present or unknown to the index) — surface the
|
||||
// real delta so the user isn't fooled by a green toast over
|
||||
// a no-op.
|
||||
if (added.length === 0) {
|
||||
failBulk(ids);
|
||||
toast.error(`Nothing added to ${heap.Title}`, {
|
||||
id: tid,
|
||||
description: `The server rejected all ${ids.length} UIDs (already in heap, or not indexed).`
|
||||
});
|
||||
return;
|
||||
}
|
||||
doneBulk(`Added ${added.length} → ${heap.Title}`, ids);
|
||||
await delay(400);
|
||||
if (added.length < ids.length) {
|
||||
toast.success(`Added ${added.length}/${ids.length} → ${heap.Title}`, {
|
||||
id: tid,
|
||||
description: 'The rest were already in this heap.'
|
||||
});
|
||||
} else {
|
||||
toast.success(`Added ${added.length} → ${heap.Title}`);
|
||||
toast.success(`Added ${added.length} → ${heap.Title}`, { id: tid });
|
||||
}
|
||||
pushUndo(`Added ${added.length} to ${heap.Title}`, async () => {
|
||||
await removeFromHeap(heap.UID, added);
|
||||
@@ -191,7 +287,8 @@
|
||||
});
|
||||
clearSelection();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Add-to-heap failed');
|
||||
failBulk(ids);
|
||||
toast.error(err instanceof Error ? err.message : 'Add-to-heap failed', { id: tid });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -207,13 +304,19 @@
|
||||
<div
|
||||
class="flex min-h-9 shrink-0 items-center gap-2 border-t border-border bg-background px-3 py-1"
|
||||
>
|
||||
<span class="shrink-0 text-[11px] font-medium text-foreground">
|
||||
{#if isBulk}
|
||||
{#if isBulk}
|
||||
<span class="shrink-0 text-[11px] font-medium text-foreground">
|
||||
{targetCount} selected
|
||||
{:else}
|
||||
Focused photo
|
||||
{/if}
|
||||
</span>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="shrink-0 text-[11px] font-medium text-muted-foreground">Focused</span>
|
||||
<span
|
||||
class="min-w-0 truncate text-[11px] font-medium text-foreground"
|
||||
title={focusedName || undefined}
|
||||
>
|
||||
{focusedName || 'photo'}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!--
|
||||
`overflow-x-auto` would clip the heap-picker dropdown — CSS
|
||||
@@ -231,16 +334,31 @@
|
||||
the archive section. Everything else (heap, restore)
|
||||
is hidden so the choice reads as decisive. -->
|
||||
<button
|
||||
class="inline-flex items-center gap-1 rounded border border-primary/40 bg-primary/10 px-2 py-0.5 text-[11px] text-primary hover:bg-primary/20 disabled:opacity-50"
|
||||
class="inline-flex items-center gap-1 rounded bg-primary px-2 py-0.5 text-[11px] font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onclick={onApprove}
|
||||
title="Keep — accept into timeline"
|
||||
>
|
||||
✓ Keep
|
||||
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">S</kbd>
|
||||
<kbd class="rounded bg-primary-foreground/15 px-1 text-[9px] font-medium text-primary-foreground/90">S</kbd>
|
||||
</button>
|
||||
{#if allHaveSuggestion}
|
||||
<!-- Visible only when every selected photo has a path-
|
||||
derivable date. Clicking applies each photo's
|
||||
suggestion then approves it; mirrored by the bare
|
||||
`a` shortcut in gridKeyNav. -->
|
||||
<button
|
||||
class="inline-flex items-center gap-1 rounded border border-amber-400/60 bg-amber-100/40 px-2 py-0.5 text-[11px] text-amber-800 hover:bg-amber-100 disabled:opacity-50 dark:border-amber-400/40 dark:bg-amber-500/15 dark:text-amber-200 dark:hover:bg-amber-500/25"
|
||||
disabled={busy}
|
||||
onclick={onAcceptDateAndKeep}
|
||||
title="Accept the date suggested from the file/folder path, then keep"
|
||||
>
|
||||
📅 Accept date & Keep
|
||||
<kbd class="rounded bg-amber-200/40 px-1 text-[9px] font-medium text-amber-900 dark:bg-amber-500/30 dark:text-amber-100">A</kbd>
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
|
||||
class="inline-flex items-center gap-1 rounded border border-border bg-background px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onclick={onArchive}
|
||||
title="Archive"
|
||||
@@ -255,13 +373,13 @@
|
||||
photo is already archived; the destructive styling
|
||||
reinforces the irreversibility. -->
|
||||
<button
|
||||
class="inline-flex items-center gap-1 rounded border border-primary/40 bg-primary/10 px-2 py-0.5 text-[11px] text-primary hover:bg-primary/20 disabled:opacity-50"
|
||||
class="inline-flex items-center gap-1 rounded bg-primary px-2 py-0.5 text-[11px] font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onclick={onRestore}
|
||||
title="Keep — restore to timeline"
|
||||
>
|
||||
✓ Keep
|
||||
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">S</kbd>
|
||||
<kbd class="rounded bg-primary-foreground/15 px-1 text-[9px] font-medium text-primary-foreground/90">S</kbd>
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center gap-1 rounded border border-destructive/40 bg-destructive/5 px-2 py-0.5 text-[11px] text-destructive hover:bg-destructive/10 disabled:opacity-50"
|
||||
@@ -275,13 +393,13 @@
|
||||
{:else}
|
||||
<div class="relative">
|
||||
<button
|
||||
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
|
||||
class="inline-flex items-center gap-1 rounded bg-primary px-2 py-0.5 text-[11px] font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onclick={() => (heapPickerOpen = !heapPickerOpen)}
|
||||
title="Add to heap (S then 1–9 picks a heap)"
|
||||
>
|
||||
+ Add to heap
|
||||
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground"
|
||||
<kbd class="rounded bg-primary-foreground/15 px-1 text-[9px] font-medium text-primary-foreground/90"
|
||||
>S N</kbd
|
||||
>
|
||||
</button>
|
||||
@@ -320,7 +438,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
|
||||
class="inline-flex items-center gap-1 rounded border border-border bg-background px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onclick={onArchive}
|
||||
title="Archive"
|
||||
@@ -330,7 +448,7 @@
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent"
|
||||
class="inline-flex items-center gap-1 rounded px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
onclick={clearAll}
|
||||
title={isBulk ? 'Clear selection' : 'Clear focus'}
|
||||
>
|
||||
|
||||
81
web/src/lib/components/timeline/NotesPhotoGrid.svelte
Normal file
81
web/src/lib/components/timeline/NotesPhotoGrid.svelte
Normal file
@@ -0,0 +1,81 @@
|
||||
<!--
|
||||
Notes-view flat grid — same skeleton as PhotoGrid, but each cell pairs
|
||||
a photo with its note hint via NotesPhotoTile. Kept as a sibling
|
||||
component (rather than threading a `note` slot through PhotoGrid) so
|
||||
the Notes-view chrome stays out of the shared timeline/tags codepath.
|
||||
|
||||
The grid carries `data-photo-grid` and each tile (rendered inside
|
||||
PhotoTile) carries `data-tile`+`data-uid` — same contract the
|
||||
gridKeyNav action and shared selection helpers expect, so arrow-key
|
||||
nav, range select, and bulk action bar work for free.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import {
|
||||
isSelected,
|
||||
selection,
|
||||
setAnchor,
|
||||
setFocused,
|
||||
setOrder
|
||||
} from '$lib/stores/selection.svelte';
|
||||
import { openPreview, view } from '$lib/stores/view.svelte';
|
||||
import type { PhotoWithNote } from '$lib/services/photoprism';
|
||||
import NotesPhotoTile from './NotesPhotoTile.svelte';
|
||||
|
||||
interface Props {
|
||||
items: PhotoWithNote[];
|
||||
columns?: string;
|
||||
}
|
||||
let { items, columns }: Props = $props();
|
||||
const tracks = $derived(
|
||||
columns ?? `repeat(auto-fill, minmax(${view.thumbnailSize}px, 1fr))`
|
||||
);
|
||||
|
||||
const order = $derived(items.map((it) => it.photo.UID));
|
||||
$effect(() => {
|
||||
setOrder(order);
|
||||
untrack(() => {
|
||||
if (order.length === 0) {
|
||||
setFocused(null);
|
||||
selection.ids.clear();
|
||||
return;
|
||||
}
|
||||
const cur = selection.focused;
|
||||
if (cur && order.includes(cur)) return;
|
||||
setFocused(order[0]);
|
||||
setAnchor(order[0]);
|
||||
selection.ids.clear();
|
||||
});
|
||||
});
|
||||
|
||||
function onClick(e: MouseEvent, uid: string) {
|
||||
if (e.shiftKey || e.metaKey || e.ctrlKey) return;
|
||||
selection.ids.clear();
|
||||
selection.ids.add(uid);
|
||||
setFocused(uid);
|
||||
setAnchor(uid);
|
||||
}
|
||||
|
||||
function onDblclick(e: MouseEvent, uid: string) {
|
||||
if (e.shiftKey || e.metaKey || e.ctrlKey) return;
|
||||
e.preventDefault();
|
||||
selection.ids.clear();
|
||||
selection.ids.add(uid);
|
||||
setFocused(uid);
|
||||
setAnchor(uid);
|
||||
openPreview();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div data-photo-grid class="grid gap-2" style="grid-template-columns: {tracks};">
|
||||
{#each items as item (item.photo.UID)}
|
||||
{@const sel = isSelected(item.photo.UID) || selection.focused === item.photo.UID}
|
||||
<NotesPhotoTile
|
||||
photo={item.photo}
|
||||
note={item.note}
|
||||
selected={sel}
|
||||
onClick={(e) => onClick(e, item.photo.UID)}
|
||||
onDblclick={(e) => onDblclick(e, item.photo.UID)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
35
web/src/lib/components/timeline/NotesPhotoTile.svelte
Normal file
35
web/src/lib/components/timeline/NotesPhotoTile.svelte
Normal file
@@ -0,0 +1,35 @@
|
||||
<!--
|
||||
PhotoTile + footer card showing a hint of the photo's note. Only the
|
||||
Notes view (/notes) uses this — every other surface keeps the bare
|
||||
PhotoTile, so the note-card chrome doesn't leak into the timeline or
|
||||
tag drill-ins.
|
||||
|
||||
Composition: square PhotoTile on top, presentational note strip
|
||||
underneath. Clicks/dblclicks land on PhotoTile's own <button data-tile>
|
||||
so selection/keyboard/preview behave exactly like every other tile.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { PpPhoto } from '$lib/types/photoprism';
|
||||
import PhotoTile from './PhotoTile.svelte';
|
||||
|
||||
interface Props {
|
||||
photo: PpPhoto;
|
||||
selected: boolean;
|
||||
note: string;
|
||||
onClick: (e: MouseEvent) => void;
|
||||
onDblclick: (e: MouseEvent) => void;
|
||||
}
|
||||
let { photo, selected, note, onClick, onDblclick }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex h-full w-full flex-col">
|
||||
<div class="aspect-square">
|
||||
<PhotoTile {photo} {selected} {onClick} {onDblclick} />
|
||||
</div>
|
||||
<div
|
||||
class="line-clamp-2 rounded-b-md border border-t-0 border-border bg-card px-2 py-1.5 text-[11px] leading-snug text-muted-foreground"
|
||||
title={note}
|
||||
>
|
||||
{note}
|
||||
</div>
|
||||
</div>
|
||||
@@ -31,8 +31,12 @@
|
||||
* `view.thumbnailSize` so drill-in grids honour the same XS–XL
|
||||
* preset the timeline uses. */
|
||||
columns?: string;
|
||||
/** Forwarded to every PhotoTile. The Low Resolution review tab
|
||||
* opts in so users can spot pixel dimensions without opening
|
||||
* each tile. */
|
||||
dimensionBadge?: boolean;
|
||||
}
|
||||
let { photos, columns }: Props = $props();
|
||||
let { photos, columns, dimensionBadge = false }: Props = $props();
|
||||
const tracks = $derived(
|
||||
columns ?? `repeat(auto-fill, minmax(${view.thumbnailSize}px, 1fr))`
|
||||
);
|
||||
@@ -91,6 +95,7 @@
|
||||
<PhotoTile
|
||||
{photo}
|
||||
selected={sel}
|
||||
{dimensionBadge}
|
||||
onClick={(e) => onClick(e, photo.UID)}
|
||||
onDblclick={(e) => onDblclick(e, photo.UID)}
|
||||
/>
|
||||
|
||||
@@ -16,17 +16,32 @@
|
||||
import { thumbSrc, thumbSrcSet, videoUrl } from "$lib/stores/session.svelte";
|
||||
import { view } from "$lib/stores/view.svelte";
|
||||
import { isVideo, primaryFile, type PpPhoto } from "$lib/types/photoprism";
|
||||
import { bulkPhotoStates } from "$lib/stores/bulkAction.svelte";
|
||||
import { fade } from "svelte/transition";
|
||||
import { Loader2, Check, X } from "lucide-svelte";
|
||||
|
||||
interface Props {
|
||||
photo: PpPhoto;
|
||||
selected: boolean;
|
||||
onClick: (e: MouseEvent) => void;
|
||||
onDblclick: (e: MouseEvent) => void;
|
||||
/** Opt-in `WxH` overlay in the top-right corner. Used by the Low
|
||||
* Resolution review tab so the user can spot-check pixel dimensions
|
||||
* without opening each tile. Default off so other surfaces stay
|
||||
* uncluttered. */
|
||||
dimensionBadge?: boolean;
|
||||
}
|
||||
let { photo, selected, onClick, onDblclick }: Props = $props();
|
||||
let { photo, selected, onClick, onDblclick, dimensionBadge = false }: Props = $props();
|
||||
|
||||
const hash = $derived(photo.Hash ?? primaryFile(photo).Hash);
|
||||
const video = $derived(isVideo(photo));
|
||||
const dims = $derived.by(() => {
|
||||
if (!dimensionBadge) return '';
|
||||
const f = primaryFile(photo);
|
||||
const w = photo.Width ?? f.Width;
|
||||
const h = photo.Height ?? f.Height;
|
||||
return w && h ? `${w}×${h}` : '';
|
||||
});
|
||||
|
||||
// Hover preview: PhotoPrism plays a muted, looping preview of the actual
|
||||
// video when you hover the tile in the grid. We wait HOVER_DELAY ms
|
||||
@@ -39,7 +54,7 @@
|
||||
let hoverTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function onMouseEnter() {
|
||||
if (!video || selected) return;
|
||||
if (!video || selected || bulkState) return;
|
||||
if (hoverTimer) clearTimeout(hoverTimer);
|
||||
hoverTimer = setTimeout(() => {
|
||||
hoverPlaying = true;
|
||||
@@ -61,6 +76,7 @@
|
||||
const tilePx = $derived(view.thumbnailSize);
|
||||
const src1x = $derived(thumbSrc(hash, tilePx));
|
||||
const srcset = $derived(thumbSrcSet(hash, tilePx));
|
||||
const bulkState = $derived(bulkPhotoStates.get(photo.UID));
|
||||
</script>
|
||||
|
||||
<!--
|
||||
@@ -138,11 +154,34 @@
|
||||
{#if selected}
|
||||
<div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div>
|
||||
{/if}
|
||||
{#if bulkState === 'pending'}
|
||||
<div class="pointer-events-none absolute inset-0 bg-black/50"></div>
|
||||
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<Loader2 class="h-5 w-5 animate-spin text-white/80 drop-shadow" />
|
||||
</div>
|
||||
{:else if bulkState === 'done'}
|
||||
<div
|
||||
transition:fade={{ duration: 200 }}
|
||||
class="pointer-events-none absolute inset-0 flex items-center justify-center bg-emerald-500/70"
|
||||
>
|
||||
<Check class="h-7 w-7 text-white drop-shadow-md" />
|
||||
</div>
|
||||
{:else if bulkState === 'error'}
|
||||
<div class="pointer-events-none absolute inset-0 flex items-center justify-center bg-red-500/60">
|
||||
<X class="h-7 w-7 text-white drop-shadow-md" />
|
||||
</div>
|
||||
{/if}
|
||||
{#if isVideo(photo)}
|
||||
<span
|
||||
class="absolute left-1.5 top-1.5 rounded bg-background/80 px-1 text-[10px] font-medium text-foreground"
|
||||
>VIDEO</span
|
||||
>
|
||||
{/if}
|
||||
{#if dims}
|
||||
<span
|
||||
class="absolute right-1.5 top-1.5 rounded bg-background/80 px-1.5 py-0.5 text-[10px] text-foreground"
|
||||
>{dims}</span
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="grid gap-2"
|
||||
class="mt-2 grid gap-2"
|
||||
style="grid-template-columns: {tracks};"
|
||||
>
|
||||
{#each Array.from({ length: count }) as _, i (i)}
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface BatchResult<T> {
|
||||
|
||||
export interface BatchOptions {
|
||||
concurrency?: number;
|
||||
onProgress?: (done: number, total: number) => void;
|
||||
onProgress?: (done: number, total: number, completedId: string) => void;
|
||||
}
|
||||
|
||||
export async function batchEdit<T>(
|
||||
@@ -38,7 +38,7 @@ export async function batchEdit<T>(
|
||||
errors.push({ id, message: err instanceof Error ? err.message : String(err) });
|
||||
} finally {
|
||||
done++;
|
||||
opts.onProgress?.(done, ids.length);
|
||||
opts.onProgress?.(done, ids.length, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,31 @@ export function invalidatePhotos(uids: string[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the sidebar facet sections after a metadata mutation. The Colors /
|
||||
* Ratings panels read `['marks']` + `['photos','marks-pool']`; Notes reads
|
||||
* `['photos','with-notes']`; keywords / labels / people read their own keys.
|
||||
* Optimistic cache writes keep the active tile in sync, but the facet panels
|
||||
* sit on separate queries that otherwise stay stale until their staleTime
|
||||
* expires — so call this on the success path of any marks/keyword/note apply.
|
||||
*/
|
||||
export function invalidateFacets(): void {
|
||||
void queryClient.invalidateQueries({ queryKey: ['marks'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['photos', 'marks-pool'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['photos', 'with-notes'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['photos', 'keywords'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['labels'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['subjects'] });
|
||||
}
|
||||
|
||||
export function invalidateAllPhotoCaches(): void {
|
||||
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['marks'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['labels'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['review-groups'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['heaps'] });
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a patch to every uid. The patch can be a static body or a per-photo
|
||||
* function (used by keyword merges which need to read each photo's current
|
||||
@@ -57,20 +82,22 @@ export async function patchTargets(
|
||||
)
|
||||
: null;
|
||||
|
||||
const tid = toast.loading(`${label} · ${ids.length}…`);
|
||||
|
||||
const { updated, errors } = await batchEdit(ids, async (id) => {
|
||||
const p = await freshPhoto(id);
|
||||
const body = typeof patch === 'function' ? patch(p) : patch;
|
||||
// An empty body is a no-op signal — e.g. "keyword already present".
|
||||
if (Object.keys(body).length === 0) return p;
|
||||
return updatePhoto(p, body);
|
||||
});
|
||||
|
||||
invalidatePhotos(ids);
|
||||
invalidateFacets();
|
||||
|
||||
if (errors.length) {
|
||||
toast.error(`${label} · ${updated.length} ok, ${errors.length} failed`);
|
||||
toast.error(`${label} · ${updated.length} ok, ${errors.length} failed`, { id: tid });
|
||||
} else {
|
||||
toast.success(`${label} · ${ids.length}`);
|
||||
toast.success(`${label} · ${ids.length}`, { id: tid });
|
||||
}
|
||||
|
||||
if (inverses) {
|
||||
|
||||
@@ -14,10 +14,49 @@
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { batchEdit } from './batch';
|
||||
import { invalidatePhotos } from './bulk';
|
||||
import { approvePhoto, batchArchive, batchRestore } from './photoprism';
|
||||
import {
|
||||
approvePhoto,
|
||||
batchArchive,
|
||||
batchRestore,
|
||||
buildTakenAtPatch,
|
||||
updatePhoto
|
||||
} from './photoprism';
|
||||
import { queryClient } from '$lib/queryClient';
|
||||
import { clearSelection, focusAfter } from '$lib/stores/selection.svelte';
|
||||
import { push as pushUndo } from '$lib/stores/undo.svelte';
|
||||
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
|
||||
import { photoNameAndDir, type PpPhoto } from '$lib/types/photoprism';
|
||||
|
||||
/** Walk every cache that might hold a photo's metadata — timeline list
|
||||
* (flat or infinite), review-groups bucket, per-photo detail — without
|
||||
* forcing a refetch. Returns undefined when the uid hasn't been seen.
|
||||
* Shared by callers that need to look up photo state by uid from
|
||||
* outside a component (gridKeyNav, photoActions). */
|
||||
export function cachedPhoto(uid: string): PpPhoto | undefined {
|
||||
const lists = queryClient.getQueriesData({ queryKey: ['photos'] });
|
||||
for (const [, data] of lists) {
|
||||
if (!data) continue;
|
||||
if (Array.isArray(data)) {
|
||||
const hit = (data as PpPhoto[]).find((p) => p.UID === uid);
|
||||
if (hit) return hit;
|
||||
continue;
|
||||
}
|
||||
const pages = (data as { pages?: PpPhoto[][] }).pages;
|
||||
if (!Array.isArray(pages)) continue;
|
||||
for (const pg of pages) {
|
||||
const hit = pg?.find?.((p) => p.UID === uid);
|
||||
if (hit) return hit;
|
||||
}
|
||||
}
|
||||
const review = queryClient.getQueryData<{ photos?: PpPhoto[] }[]>(['review-groups']);
|
||||
if (review) {
|
||||
for (const group of review) {
|
||||
const hit = group.photos?.find((p) => p.UID === uid);
|
||||
if (hit) return hit;
|
||||
}
|
||||
}
|
||||
return queryClient.getQueryData<PpPhoto>(['photo', uid]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss photos out of the review queue by bumping their quality
|
||||
@@ -27,21 +66,59 @@ import { push as pushUndo } from '$lib/stores/undo.svelte';
|
||||
*/
|
||||
export async function dismissPhotos(uids: string[]): Promise<void> {
|
||||
if (uids.length === 0) return;
|
||||
const tid = toast.loading(`Dismissing ${uids.length}…`);
|
||||
const { updated, errors } = await batchEdit(uids, (id) => approvePhoto(id));
|
||||
// Advance focus past the dismissed set before the timeline refetches
|
||||
// so the cursor doesn't snap back to photo[0]; clear the now-stale
|
||||
// selection ring for the same reason.
|
||||
focusAfter(uids);
|
||||
clearSelection();
|
||||
invalidatePhotos(uids);
|
||||
void queryClient.invalidateQueries({ queryKey: ['review-groups'] });
|
||||
if (errors.length) {
|
||||
toast.error(`Dismissed ${updated.length}; ${errors.length} failed`, {
|
||||
id: tid,
|
||||
description: errors[0].message
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast.success(`Dismissed ${uids.length}`);
|
||||
toast.success(`Dismissed ${uids.length}`, { id: tid });
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the selected uids, applying each photo's path-derived date
|
||||
* suggestion (when one exists) before approving it. UIDs without a
|
||||
* suggestion fall through to a plain approve. Used by the EXIF Stripped
|
||||
* review tab — the `📅 Accept date & Keep` button and the bare `a`
|
||||
* keyboard shortcut both route here so wording / focus / toast
|
||||
* behaviour stay in lockstep.
|
||||
*/
|
||||
export async function acceptDateAndKeep(uids: string[]): Promise<void> {
|
||||
if (uids.length === 0) return;
|
||||
const tid = toast.loading(`Updating & keeping ${uids.length}…`);
|
||||
const { updated, errors } = await batchEdit(uids, async (id) => {
|
||||
const p = cachedPhoto(id);
|
||||
if (p) {
|
||||
const { fileName, path } = photoNameAndDir(p);
|
||||
const guess = suggestDateFromPath({
|
||||
fileName,
|
||||
originalName: p.OriginalName,
|
||||
path
|
||||
});
|
||||
if (guess) await updatePhoto(p, buildTakenAtPatch(`${guess.iso}T00:00:00Z`));
|
||||
}
|
||||
await approvePhoto(id);
|
||||
return id;
|
||||
});
|
||||
focusAfter(uids);
|
||||
clearSelection();
|
||||
invalidatePhotos(uids);
|
||||
void queryClient.invalidateQueries({ queryKey: ['review-groups'] });
|
||||
if (errors.length) {
|
||||
toast.error(`Kept ${updated.length}; ${errors.length} failed`, {
|
||||
id: tid,
|
||||
description: errors[0].message
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast.success(`Kept ${uids.length}`, { id: tid });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,10 +126,11 @@ export async function dismissPhotos(uids: string[]): Promise<void> {
|
||||
*/
|
||||
export async function archivePhotos(uids: string[]): Promise<void> {
|
||||
if (uids.length === 0) return;
|
||||
const tid = toast.loading(`Archiving ${uids.length}…`);
|
||||
try {
|
||||
await batchArchive(uids);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Archive failed');
|
||||
toast.error(err instanceof Error ? err.message : 'Archive failed', { id: tid });
|
||||
return;
|
||||
}
|
||||
pushUndo(`Archived ${uids.length}`, async () => {
|
||||
@@ -64,5 +142,5 @@ export async function archivePhotos(uids: string[]): Promise<void> {
|
||||
clearSelection();
|
||||
invalidatePhotos(uids);
|
||||
void queryClient.invalidateQueries({ queryKey: ['review-groups'] });
|
||||
toast.success(`Archived ${uids.length}`);
|
||||
toast.success(`Archived ${uids.length}`, { id: tid });
|
||||
}
|
||||
|
||||
@@ -28,6 +28,14 @@ const http: AxiosInstance = axios.create({
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
/** Axios instance for sidecar endpoints — no baseURL prefix so paths
|
||||
* like `/api/sidecar/timeline` resolve directly through Caddy's
|
||||
* `/api/sidecar/*` rule instead of becoming `/api/v1/api/sidecar/*`. */
|
||||
const sidecar: AxiosInstance = axios.create({
|
||||
baseURL: '',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
http.interceptors.request.use((config) => {
|
||||
if (session.accessToken) {
|
||||
config.headers = config.headers ?? {};
|
||||
@@ -36,6 +44,14 @@ http.interceptors.request.use((config) => {
|
||||
return config;
|
||||
});
|
||||
|
||||
sidecar.interceptors.request.use((config) => {
|
||||
if (session.accessToken) {
|
||||
config.headers = config.headers ?? {};
|
||||
(config.headers as Record<string, string>)['X-Auth-Token'] = session.accessToken;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
http.interceptors.response.use(
|
||||
(r) => r,
|
||||
(err: AxiosError) => {
|
||||
@@ -51,6 +67,20 @@ http.interceptors.response.use(
|
||||
}
|
||||
);
|
||||
|
||||
sidecar.interceptors.response.use(
|
||||
(r) => r,
|
||||
(err: AxiosError) => {
|
||||
if (err.response?.status === 401 && browser) {
|
||||
clearSession();
|
||||
const url = err.config?.url ?? '';
|
||||
if (!url.endsWith('/session')) {
|
||||
void goto('/login', { replaceState: true });
|
||||
}
|
||||
}
|
||||
return Promise.reject(err);
|
||||
}
|
||||
);
|
||||
|
||||
// ── Auth ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function login(username: string, password: string): Promise<PpSessionResponse> {
|
||||
@@ -144,7 +174,7 @@ export interface ListPhotosParams {
|
||||
}
|
||||
|
||||
export async function listPhotos(params: ListPhotosParams = {}): Promise<PpPhoto[]> {
|
||||
const { data } = await http.get<PpPhoto[]>('/photos', {
|
||||
const { data } = await sidecar.get<PpPhoto[]>('/api/sidecar/timeline', {
|
||||
params: {
|
||||
count: 60,
|
||||
offset: 0,
|
||||
@@ -156,6 +186,115 @@ export async function listPhotos(params: ListPhotosParams = {}): Promise<PpPhoto
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve photos for an explicit set of UIDs. Used by the Colors / Ratings
|
||||
* facets, whose member set comes from the mule-sidecar marks store and is NOT
|
||||
* bounded to the newest N photos — a marked photo anywhere in the library must
|
||||
* resolve. Fetches per-UID (concurrency-bounded) via the same `/photos/:uid`
|
||||
* endpoint the metadata panel uses, so it can't drift from PhotoPrism's search
|
||||
* DSL. Missing UIDs (deleted since marked) are skipped.
|
||||
*/
|
||||
export async function listPhotosByUids(uids: string[]): Promise<PpPhoto[]> {
|
||||
if (uids.length === 0) return [];
|
||||
const out: PpPhoto[] = [];
|
||||
const concurrency = 8;
|
||||
for (let i = 0; i < uids.length; i += concurrency) {
|
||||
const slice = uids.slice(i, i + concurrency);
|
||||
const fetched = await Promise.all(slice.map((uid) => getPhoto(uid).catch(() => null)));
|
||||
for (const p of fetched) if (p) out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a page of photos *anchored at* a specific TakenAt — `before`
|
||||
* older photos preceded by `after` newer ones, merged newest-first.
|
||||
* Uses PhotoPrism's `before:`/`after:` DSL clauses so the anchor's
|
||||
* neighbours can be loaded without paging through the whole filter.
|
||||
*
|
||||
* Used by the timeline's deep-link focus mode: an in-app navigation
|
||||
* stashes `{uid, takenAt}`, the timeline calls this with the anchor's
|
||||
* date for page 0, and the target photo lands ~`afterCount` tiles
|
||||
* down with `~beforeCount` older neighbours below it.
|
||||
*
|
||||
* Subsequent infinite-scroll pages use plain `listPhotos` with the
|
||||
* standard offset cursor — the anchor mode only matters for page 0.
|
||||
*/
|
||||
export interface AroundParams {
|
||||
/** Base DSL filter (e.g. `path:"2024/02*"`). Anchor clauses are appended. */
|
||||
q?: string;
|
||||
/** Anchor's TakenAt as ISO string (e.g. `'2026-01-31T18:26:40Z'`). */
|
||||
takenAt: string;
|
||||
/** How many photos newer than the anchor to fetch. */
|
||||
afterCount?: number;
|
||||
/** How many photos at-or-older-than the anchor to fetch (includes the anchor itself). */
|
||||
beforeCount?: number;
|
||||
merged?: boolean;
|
||||
}
|
||||
|
||||
export async function listPhotosAround(p: AroundParams): Promise<PpPhoto[]> {
|
||||
const afterCount = p.afterCount ?? 30;
|
||||
const beforeCount = p.beforeCount ?? 90;
|
||||
const baseQ = p.q?.trim() ?? '';
|
||||
// PhotoPrism's `before:`/`after:` operators take ISO timestamps.
|
||||
// `+1s` / `-1s` makes the bounds inclusive of the anchor itself in
|
||||
// the `before:` half (so the target tile is in the merged result).
|
||||
const anchorDate = new Date(p.takenAt);
|
||||
if (Number.isNaN(anchorDate.getTime())) {
|
||||
// Date parse failed — fall back to a plain newest-first page.
|
||||
return listPhotos({ q: baseQ, count: afterCount + beforeCount, order: 'newest', merged: p.merged });
|
||||
}
|
||||
// PhotoPrism's DSL accepts date-only bounds (`YYYY-MM-DD`). Round
|
||||
// up/down by a day so the anchor's own day is included in the
|
||||
// `before:` half — the bounds are inclusive day boundaries, so a
|
||||
// timestamp-precision anchor lands inside the `[beforeBound,
|
||||
// afterBound]` window.
|
||||
function ymd(d: Date): string {
|
||||
const y = d.getUTCFullYear();
|
||||
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getUTCDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${dd}`;
|
||||
}
|
||||
const dayMs = 86_400_000;
|
||||
const beforeBound = ymd(new Date(anchorDate.getTime() + dayMs));
|
||||
const afterBound = ymd(new Date(anchorDate.getTime() - dayMs));
|
||||
const newerQ = `${baseQ} after:${afterBound}`.trim();
|
||||
const olderQ = `${baseQ} before:${beforeBound}`.trim();
|
||||
|
||||
const [newerOldestFirst, older] = await Promise.all([
|
||||
listPhotos({
|
||||
q: newerQ,
|
||||
count: afterCount,
|
||||
order: 'oldest',
|
||||
merged: p.merged ?? true
|
||||
}),
|
||||
listPhotos({
|
||||
q: olderQ,
|
||||
count: beforeCount,
|
||||
order: 'newest',
|
||||
merged: p.merged ?? true
|
||||
})
|
||||
]);
|
||||
// `newerOldestFirst` is oldest→newest; reverse so it reads newest-first
|
||||
// to match the standard timeline order, then concat the older window.
|
||||
// Dedupe by UID in case the anchor itself shows up in both halves.
|
||||
const merged: PpPhoto[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const p of newerOldestFirst.slice().reverse()) {
|
||||
if (!seen.has(p.UID)) {
|
||||
merged.push(p);
|
||||
seen.add(p.UID);
|
||||
}
|
||||
}
|
||||
for (const p of older) {
|
||||
if (!seen.has(p.UID)) {
|
||||
merged.push(p);
|
||||
seen.add(p.UID);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count photos matching a DSL query, scoped to whatever the caller's
|
||||
* session ACL allows. PhotoPrism doesn't expose a dedicated "count
|
||||
@@ -169,10 +308,17 @@ export async function listPhotos(params: ListPhotosParams = {}): Promise<PpPhoto
|
||||
* the signed-in user actually sees, not the global library aggregate
|
||||
* exposed by `/config.count`.
|
||||
*/
|
||||
export async function countPhotos(q: string): Promise<number> {
|
||||
const resp = await http.get('/photos', {
|
||||
params: { count: 10000, offset: 0, merged: false, q }
|
||||
export async function countPhotos(q: string, opts: { merged?: boolean } = {}): Promise<number> {
|
||||
const merged = opts.merged ?? false;
|
||||
const resp = await sidecar.get<PpPhoto[]>('/api/sidecar/timeline', {
|
||||
params: { count: 10000, offset: 0, merged, q }
|
||||
});
|
||||
// PhotoPrism's `X-Count` header counts SQL file rows (one row per
|
||||
// `Files[]` entry), regardless of `merged`. With `merged: true` the
|
||||
// response body is one entry per logical photo — so the body length
|
||||
// is the canonical photo count when callers need to match what the
|
||||
// timeline displays (e.g. the LeftSidebar root badge vs `Cmd+A`).
|
||||
if (merged) return Array.isArray(resp.data) ? resp.data.length : 0;
|
||||
const header = resp.headers['x-count'];
|
||||
const n = typeof header === 'string' ? parseInt(header, 10) : NaN;
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
@@ -358,11 +504,14 @@ export interface PpFolder {
|
||||
* BasePath is empty (today's admin default) this is a no-op.
|
||||
*/
|
||||
export async function listFolders(): Promise<PpFolder[]> {
|
||||
const { data } = await http.get<{ folders?: PpFolder[] }>(
|
||||
'/folders/originals',
|
||||
const { data } = await sidecar.get<{ folders?: PpFolder[] }>(
|
||||
'/api/sidecar/folders',
|
||||
{ params: { recursive: true, uncached: true, files: false } }
|
||||
);
|
||||
const bp = userBasePath();
|
||||
// Sidecar already filters by BasePath; the frontend still applies the
|
||||
// filter + path rewrite as a safety net for admin (bp="") and for any
|
||||
// folders that might have slipped through.
|
||||
const folders = data.folders ?? [];
|
||||
if (bp === '') return folders;
|
||||
return folders
|
||||
@@ -400,7 +549,7 @@ export async function listFolderCounts(paths: string[]): Promise<Record<string,
|
||||
// on the way out, then re-key the response back to user-relative on
|
||||
// the way in so callers' map keys line up with their input array.
|
||||
const serverPaths = paths.map((p) => toOriginalsPath(p));
|
||||
const data = (await sidecar('POST', '/folders/counts', { paths: serverPaths })) as Record<
|
||||
const data = (await callSidecar('POST', '/folders/counts', { paths: serverPaths })) as Record<
|
||||
string,
|
||||
number
|
||||
>;
|
||||
@@ -478,6 +627,32 @@ export interface AggregatedKeyword {
|
||||
sampleHash: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Photos carrying a non-empty user note. mule-image's "Note" is
|
||||
* PhotoPrism's `Caption` field (see RightSidebar's Note textarea), which
|
||||
* is a top-level scalar — present on the list response, so a single
|
||||
* round-trip is enough.
|
||||
*/
|
||||
export interface PhotoWithNote {
|
||||
photo: PpPhoto;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export async function listPhotosWithNotes(): Promise<PhotoWithNote[]> {
|
||||
// The sidecar pages PhotoPrism to completion server-side and returns only
|
||||
// captioned, BasePath-scoped photos — paging client-side would stop early
|
||||
// because each page is BasePath-filtered before we see it (a full upstream
|
||||
// page can arrive short), silently hiding notes past the first slice.
|
||||
const { data } = await sidecar.get<PpPhoto[]>('/api/sidecar/notes');
|
||||
const out: PhotoWithNote[] = [];
|
||||
for (const p of data) {
|
||||
const note = p.Caption?.trim();
|
||||
if (!note) continue;
|
||||
out.push({ photo: p, note });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function aggregateKeywords(): Promise<AggregatedKeyword[]> {
|
||||
const list = await listPhotos({ count: 1000, merged: true });
|
||||
const buckets = new Map<string, AggregatedKeyword>();
|
||||
@@ -512,6 +687,35 @@ export async function aggregateKeywords(): Promise<AggregatedKeyword[]> {
|
||||
return Array.from(buckets.values()).sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
async function hasPhotosMatching(q: string): Promise<boolean> {
|
||||
const resp = await sidecar.get<PpPhoto[]>('/api/sidecar/timeline', {
|
||||
params: { count: 1, offset: 0, q }
|
||||
});
|
||||
return Array.isArray(resp.data) && resp.data.length > 0;
|
||||
}
|
||||
|
||||
async function filterByUserPhotos<T>(
|
||||
items: T[],
|
||||
queryFor: (item: T) => string
|
||||
): Promise<T[]> {
|
||||
if (userBasePath() === '') return items;
|
||||
const CONCURRENCY = 8;
|
||||
const out: T[] = [];
|
||||
for (let i = 0; i < items.length; i += CONCURRENCY) {
|
||||
const batch = items.slice(i, i + CONCURRENCY);
|
||||
const checks = await Promise.all(
|
||||
batch.map(async (item) => ({
|
||||
item,
|
||||
has: await hasPhotosMatching(queryFor(item))
|
||||
}))
|
||||
);
|
||||
for (const { item, has } of checks) {
|
||||
if (has) out.push(item);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function listLabels(): Promise<PpLabel[]> {
|
||||
// `all=true` includes labels PhotoPrism has soft-deleted (auto-hidden
|
||||
// low-confidence classifier hits, manually-removed labels). They're
|
||||
@@ -520,9 +724,16 @@ export async function listLabels(): Promise<PpLabel[]> {
|
||||
// them out and the tags page silently shows only ~40% of the user's
|
||||
// real tag set. `count` bumped to 1000 so a moderately tagged library
|
||||
// returns the full list in one round-trip.
|
||||
const { data } = await http.get<PpLabel[]>('/labels', {
|
||||
params: { count: 1000, order: 'count', all: true }
|
||||
//
|
||||
// Uses the sidecar proxy (/api/sidecar/labels) instead of PhotoPrism's
|
||||
// /api/v1/labels so PhotoCount reflects only photos under the user's
|
||||
// BasePath. The sidecar proxies the request through to PP then
|
||||
// post-filters each label's count.
|
||||
const { data } = await sidecar.get<PpLabel[]>('/api/sidecar/labels', {
|
||||
params: { count: 1000, order: 'count', all: true, perPage: 1000 }
|
||||
});
|
||||
// Sidecar already filters to the user's scope and sets correct counts +
|
||||
// thumbs in one DB query — no need to probe each label individually.
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -547,7 +758,7 @@ export async function listSubjects(): Promise<PpSubject[]> {
|
||||
const { data } = await http.get<PpSubject[]>('/subjects', {
|
||||
params: { count: 1000, order: 'count' }
|
||||
});
|
||||
return data ?? [];
|
||||
return filterByUserPhotos(data ?? [], (s) => `person:${s.Slug}`);
|
||||
}
|
||||
|
||||
export async function updateSubject(uid: string, patch: Partial<PpSubject>): Promise<PpSubject> {
|
||||
@@ -685,7 +896,7 @@ export interface RenameResult {
|
||||
newRelPath: string;
|
||||
}
|
||||
|
||||
async function sidecar(method: string, urlPath: string, body?: unknown): Promise<unknown> {
|
||||
async function callSidecar(method: string, urlPath: string, body?: unknown): Promise<unknown> {
|
||||
const res = await fetch(`/api/sidecar${urlPath}`, {
|
||||
method,
|
||||
headers: {
|
||||
@@ -703,20 +914,20 @@ async function sidecar(method: string, urlPath: string, body?: unknown): Promise
|
||||
}
|
||||
|
||||
export async function createFolder(relPath: string): Promise<{ path: string }> {
|
||||
return sidecar('POST', '/folders', { path: relPath }) as Promise<{ path: string }>;
|
||||
return callSidecar('POST', '/folders', { path: relPath }) as Promise<{ path: string }>;
|
||||
}
|
||||
|
||||
export async function renameFolder(
|
||||
relPath: string,
|
||||
newName: string
|
||||
): Promise<{ oldPath: string; newPath: string }> {
|
||||
return sidecar('POST', `/folders/${encodeURIComponent(relPath)}/rename`, {
|
||||
return callSidecar('POST', `/folders/${encodeURIComponent(relPath)}/rename`, {
|
||||
newName
|
||||
}) as Promise<{ oldPath: string; newPath: string }>;
|
||||
}
|
||||
|
||||
export async function deleteFolder(relPath: string): Promise<{ path: string }> {
|
||||
return sidecar('DELETE', `/folders/${encodeURIComponent(relPath)}`) as Promise<{
|
||||
return callSidecar('DELETE', `/folders/${encodeURIComponent(relPath)}`) as Promise<{
|
||||
path: string;
|
||||
}>;
|
||||
}
|
||||
@@ -749,7 +960,7 @@ export interface CrossFolderScanResult {
|
||||
}
|
||||
|
||||
export async function scanCrossFolderDuplicates(): Promise<CrossFolderScanResult> {
|
||||
return sidecar('GET', '/duplicates/scan') as Promise<CrossFolderScanResult>;
|
||||
return callSidecar('GET', '/duplicates/scan') as Promise<CrossFolderScanResult>;
|
||||
}
|
||||
|
||||
export interface ArchiveDuplicatesResult {
|
||||
@@ -760,7 +971,7 @@ export interface ArchiveDuplicatesResult {
|
||||
export async function archiveDuplicatePaths(
|
||||
paths: string[]
|
||||
): Promise<ArchiveDuplicatesResult> {
|
||||
return sidecar('POST', '/duplicates/archive', { paths }) as Promise<ArchiveDuplicatesResult>;
|
||||
return callSidecar('POST', '/duplicates/archive', { paths }) as Promise<ArchiveDuplicatesResult>;
|
||||
}
|
||||
|
||||
// ── Heap convert (move/copy heap photos to a folder) ────────────────────────
|
||||
@@ -792,7 +1003,7 @@ export async function convertHeap(
|
||||
uid: string,
|
||||
body: HeapConvertBody
|
||||
): Promise<HeapConvertResult> {
|
||||
return sidecar('POST', `/albums/${uid}/convert`, body) as Promise<HeapConvertResult>;
|
||||
return callSidecar('POST', `/albums/${uid}/convert`, body) as Promise<HeapConvertResult>;
|
||||
}
|
||||
|
||||
// ── Photo marks (rating + color) ─────────────────────────────────────────────
|
||||
@@ -808,19 +1019,19 @@ export interface PhotoMark {
|
||||
export type PhotoMarksMap = Record<string, PhotoMark>;
|
||||
|
||||
export async function getAllMarks(): Promise<PhotoMarksMap> {
|
||||
const data = await sidecar('GET', '/photos/marks');
|
||||
const data = await callSidecar('GET', '/photos/marks');
|
||||
return (data ?? {}) as PhotoMarksMap;
|
||||
}
|
||||
|
||||
export async function setMark(photoUid: string, patch: PhotoMark): Promise<PhotoMark> {
|
||||
return sidecar('PUT', `/photos/${photoUid}/marks`, patch) as Promise<PhotoMark>;
|
||||
return callSidecar('PUT', `/photos/${photoUid}/marks`, patch) as Promise<PhotoMark>;
|
||||
}
|
||||
|
||||
export async function bulkSetMarks(
|
||||
ids: string[],
|
||||
patch: PhotoMark
|
||||
): Promise<{ count: number; marks: PhotoMarksMap }> {
|
||||
return sidecar('POST', '/photos/marks/bulk', { ids, patch }) as Promise<{
|
||||
return callSidecar('POST', '/photos/marks/bulk', { ids, patch }) as Promise<{
|
||||
count: number;
|
||||
marks: PhotoMarksMap;
|
||||
}>;
|
||||
|
||||
82
web/src/lib/stores/bulkAction.svelte.ts
Normal file
82
web/src/lib/stores/bulkAction.svelte.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Bulk-action status, written by BulkActionBar and read by the header
|
||||
* StatusPill and individual PhotoTile overlays.
|
||||
*
|
||||
* State lifecycle:
|
||||
* startBulk → pill spins, all target tiles go "pending"
|
||||
* setDetail → pill shows the filename currently being processed (fan-out ops)
|
||||
* doneBulk → pill shows completion label, tiles flash green, auto-clears after 3 s
|
||||
* failBulk → tiles flash red, auto-clears after 2 s
|
||||
*/
|
||||
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
interface BulkActionState {
|
||||
active: boolean;
|
||||
label: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export const bulkAction = $state<BulkActionState>({ active: false, label: '' });
|
||||
// SvelteMap (not `$state(new Map())`) so a `.get(uid)` read in a PhotoTile
|
||||
// reliably re-runs when the entry flips — the plain-Map proxy form wasn't
|
||||
// re-rendering the timeline tiles' overlay.
|
||||
export const bulkPhotoStates = new SvelteMap<string, 'pending' | 'done' | 'error'>();
|
||||
|
||||
/**
|
||||
* UIDs hidden from the timeline grid the instant a removing action (archive /
|
||||
* delete / restore) succeeds, so tiles vanish without waiting on the ~1s
|
||||
* server-reconcile refetch. The caller clears each id once the refetch lands.
|
||||
* This is a pure UI overlay — it never touches the query cache, so it can't
|
||||
* corrupt the facet/drill caches the way a direct cache eviction did.
|
||||
*/
|
||||
export const removedIds = $state(new SvelteSet<string>());
|
||||
|
||||
export function markRemoved(ids: string[]): void {
|
||||
for (const id of ids) removedIds.add(id);
|
||||
}
|
||||
|
||||
export function clearRemoved(ids: string[]): void {
|
||||
for (const id of ids) removedIds.delete(id);
|
||||
}
|
||||
|
||||
let doneTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
export function startBulk(label: string, ids: string[]): void {
|
||||
if (doneTimer !== null) {
|
||||
clearTimeout(doneTimer);
|
||||
doneTimer = null;
|
||||
}
|
||||
bulkPhotoStates.clear();
|
||||
for (const id of ids) bulkPhotoStates.set(id, 'pending');
|
||||
bulkAction.active = true;
|
||||
bulkAction.label = label;
|
||||
bulkAction.detail = undefined;
|
||||
}
|
||||
|
||||
export function setDetail(path: string): void {
|
||||
bulkAction.detail = path;
|
||||
}
|
||||
|
||||
export function doneBulk(label: string, ids: string[]): void {
|
||||
for (const id of ids) bulkPhotoStates.set(id, 'done');
|
||||
bulkAction.active = false;
|
||||
bulkAction.label = label;
|
||||
bulkAction.detail = undefined;
|
||||
if (doneTimer !== null) clearTimeout(doneTimer);
|
||||
doneTimer = setTimeout(() => {
|
||||
bulkAction.label = '';
|
||||
bulkPhotoStates.clear();
|
||||
doneTimer = null;
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
export function failBulk(ids: string[]): void {
|
||||
for (const id of ids) bulkPhotoStates.set(id, 'error');
|
||||
bulkAction.active = false;
|
||||
bulkAction.label = '';
|
||||
bulkAction.detail = undefined;
|
||||
setTimeout(() => {
|
||||
for (const id of ids) bulkPhotoStates.delete(id);
|
||||
}, 2000);
|
||||
}
|
||||
@@ -86,6 +86,59 @@ export function setTagFilter(
|
||||
filters.tagValue = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot focus hand-off between an in-app navigation source (e.g. the
|
||||
* RightSidebar's Folder open icon) and the timeline. We deliberately
|
||||
* avoid encoding this in the URL — the store→URL effect on the
|
||||
* timeline strips any param that `filtersToUrlParams` doesn't emit, so
|
||||
* a `?focus=` param wouldn't survive the round-trip. A module-level
|
||||
* stash that's consumed once on the next pageCount=1 landing is the
|
||||
* simplest contract: not shareable, not replayed on refresh, but
|
||||
* matches the "deep-link click" UX we want.
|
||||
*
|
||||
* `takenAt` (when known) lets the timeline anchor its first-page
|
||||
* query around the target's date via PhotoPrism's `before:`/`after:`
|
||||
* DSL — so deep-link focus works even for photos that aren't in the
|
||||
* newest-120 page of the destination filter. Caller passes `null` if
|
||||
* the date isn't readily available; the timeline can still attempt a
|
||||
* page-1 match.
|
||||
*/
|
||||
export interface PendingFocus {
|
||||
uid: string;
|
||||
takenAt: string | null;
|
||||
}
|
||||
let pendingFocus: PendingFocus | null = null;
|
||||
export function setPendingFocus(uid: string, takenAt: string | null = null): void {
|
||||
pendingFocus = { uid, takenAt };
|
||||
}
|
||||
export function consumePendingFocus(): PendingFocus | null {
|
||||
const v = pendingFocus;
|
||||
pendingFocus = null;
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drill into a folder on the timeline. Mirrors the LeftSidebar tree's
|
||||
* click handler: clear heap/section context so the folder filter
|
||||
* applies on top of "all photos", then navigate. When `focusUid` is
|
||||
* provided, the timeline's focus effect consumes the pending-focus
|
||||
* stash on its first-page landing and pre-selects + scrolls to that
|
||||
* photo instead of snapping to `photos[0]`. `focusTakenAt` enables
|
||||
* the anchor-mode query so the photo can be found even when it would
|
||||
* otherwise be past page 1.
|
||||
*/
|
||||
export async function navigateToFolder(
|
||||
folderPath: string,
|
||||
opts: { focusUid?: string; focusTakenAt?: string | null } = {}
|
||||
): Promise<void> {
|
||||
setSection('all-photos');
|
||||
setFolderPath(folderPath);
|
||||
if (opts.focusUid) setPendingFocus(opts.focusUid, opts.focusTakenAt ?? null);
|
||||
const params = new URLSearchParams();
|
||||
params.set('folder', folderPath);
|
||||
await goto(`/?${params.toString()}`, { keepFocus: true, noScroll: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a tag-category browse URL. Path-segment shape
|
||||
* (`/tags/labels/sunset`) keeps the URL readable and lets SvelteKit's
|
||||
|
||||
@@ -213,6 +213,23 @@ export interface PpPhotoLabel {
|
||||
Label?: { Slug: string; Name: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a photo into its basename + directory portion using the
|
||||
* primary file's relative `Name` (`'2024/02/IMG.jpg'` → `{ fileName:
|
||||
* 'IMG.jpg', path: '2024/02' }`). Falls back to `photo.Path` when the
|
||||
* primary file's `Name` lacks a directory prefix — that pairs with the
|
||||
* list-endpoint shape where `Path` is its own field. Shared so date-
|
||||
* suggestion code paths in RightSidebar / photoActions / gridKeyNav
|
||||
* derive inputs the same way regardless of which cache shape they
|
||||
* have on hand (list vs detail).
|
||||
*/
|
||||
export function photoNameAndDir(p: PpPhoto): { fileName: string; path: string } {
|
||||
const full = primaryFile(p).Name ?? '';
|
||||
const i = full.lastIndexOf('/');
|
||||
if (i < 0) return { fileName: full, path: p.Path ?? '' };
|
||||
return { fileName: full.slice(i + 1), path: full.slice(0, i) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the photo's primary file (the one with `Primary: true`) or the
|
||||
* first file if no primary marker is set. Falls back to a synthetic entry
|
||||
|
||||
221
web/src/lib/utils/suggestDateFromPath.ts
Normal file
221
web/src/lib/utils/suggestDateFromPath.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Best-effort calendar date guessed from a photo's filename / parent
|
||||
* folders, with a confidence label so the UI can warn the user when
|
||||
* the day is fabricated.
|
||||
*
|
||||
* Conventions we support (no false-positive risk):
|
||||
* - Samsung / Android stock 20240226_135421.jpg
|
||||
* - Google Pixel PXL_20240226_135421.123.jpg
|
||||
* - WhatsApp IMG-20240226-WA0001.jpg
|
||||
* - Telegram photo_2024-02-26_13-54-21.jpg
|
||||
* - macOS screenshot Screen Shot 2024-02-26 at 1.54.21 PM.png
|
||||
* - Android screenshot Screenshot_20240226-135421.png
|
||||
* - Manual dot-format 2024.02.26 - title.jpg
|
||||
* - WeChat mmexport1645900000000.jpeg (13-digit ms)
|
||||
* - Facebook saves FB_IMG_1583926812.jpg (10-digit s)
|
||||
* - Path forms 2024/02/26, 2024-02-26, 2024_02_26,
|
||||
* 2024.02.26, plus Y-M-only 2024/02/
|
||||
*
|
||||
* Conventions we deliberately do NOT parse (locale-ambiguous or no
|
||||
* recoverable signal):
|
||||
* - DD-MM-YYYY / MM-DD-YYYY 26-02-2024.jpg, 02-26-2024.jpg
|
||||
* - 2-digit years 24-02-26.jpg
|
||||
* - Bare sequence numbers IMG_1234.HEIC, DSC_0123.NEF,
|
||||
* GOPR0123.JPG, DJI_0123.JPG
|
||||
*
|
||||
* Used by the EXIF Stripped review tab to surface a date suggestion
|
||||
* row in the metadata sidebar and to power the "Accept date & Keep"
|
||||
* bulk action.
|
||||
*/
|
||||
|
||||
import { isValidISODate } from '$lib/services/photoprism';
|
||||
|
||||
interface Input {
|
||||
fileName?: string; // basename, e.g. '20240226_000000_A6D42DF3.jpg'
|
||||
originalName?: string; // optional second filename signal (PpPhoto.OriginalName)
|
||||
path?: string; // directory portion, e.g. '2024/02'
|
||||
}
|
||||
|
||||
export interface DateGuess {
|
||||
iso: string;
|
||||
confidence: 'high' | 'medium';
|
||||
source:
|
||||
| 'filename-agrees-path'
|
||||
| 'filename-only'
|
||||
| 'unix-timestamp'
|
||||
| 'path-ymd'
|
||||
| 'path-ym-default-day';
|
||||
}
|
||||
|
||||
interface YMD {
|
||||
y: number;
|
||||
m: number;
|
||||
d: number;
|
||||
}
|
||||
|
||||
interface YM {
|
||||
y: number;
|
||||
m: number;
|
||||
}
|
||||
|
||||
function pad2(n: number): string {
|
||||
return n < 10 ? `0${n}` : String(n);
|
||||
}
|
||||
|
||||
function isoOf(ymd: YMD): string {
|
||||
return `${ymd.y}-${pad2(ymd.m)}-${pad2(ymd.d)}`;
|
||||
}
|
||||
|
||||
function tryYMD(y: number, m: number, d: number): YMD | null {
|
||||
if (y < 1900 || y > 2100) return null;
|
||||
if (m < 1 || m > 12) return null;
|
||||
if (d < 1 || d > 31) return null;
|
||||
if (!isValidISODate(`${y}-${pad2(m)}-${pad2(d)}`)) return null;
|
||||
return { y, m, d };
|
||||
}
|
||||
|
||||
function tryYM(y: number, m: number): YM | null {
|
||||
if (y < 1900 || y > 2100) return null;
|
||||
if (m < 1 || m > 12) return null;
|
||||
return { y, m };
|
||||
}
|
||||
|
||||
// `YYYY[sep]MM[sep]DD` for basenames. sep ∈ {nothing, -, _, ., space}. The
|
||||
// non-digit lookbehind/ahead keeps a leading prefix like `PXL_` and a
|
||||
// trailing time like `_135421` from polluting the match.
|
||||
const BASENAME_YMD = /(?<!\d)(\d{4})[-_. ]?(\d{2})[-_. ]?(\d{2})(?!\d)/;
|
||||
|
||||
// Path Y-M-D and Y-M. Includes `/` for directory separators and `.` for
|
||||
// rare dot-organised libraries (`Photos/2024.02/...`).
|
||||
const PATH_YMD = /(?<!\d)(\d{4})[-_/.](\d{2})[-_/.](\d{2})(?!\d)/;
|
||||
const PATH_YM = /(?<!\d)(\d{4})[-_/.](\d{2})(?!\d)/;
|
||||
|
||||
// 10- or 13-digit Unix epoch, anchored. Years widened to [1990, current+1]
|
||||
// to dodge accidental matches on phone numbers, hex hashes containing
|
||||
// digits, etc. — but 10-digit seconds still has to round-trip into a
|
||||
// plausible calendar year before we trust it.
|
||||
const BASENAME_EPOCH = /(?<!\d)(\d{10}|\d{13})(?!\d)/;
|
||||
|
||||
function parseFilenameYMD(name: string): YMD | null {
|
||||
const m = name.match(BASENAME_YMD);
|
||||
if (!m) return null;
|
||||
return tryYMD(Number(m[1]), Number(m[2]), Number(m[3]));
|
||||
}
|
||||
|
||||
function parseUnixTimestampInName(name: string): YMD | null {
|
||||
const m = name.match(BASENAME_EPOCH);
|
||||
if (!m) return null;
|
||||
const digits = m[1];
|
||||
const ms = digits.length === 13 ? Number(digits) : Number(digits) * 1000;
|
||||
if (!Number.isFinite(ms)) return null;
|
||||
const d = new Date(ms);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
const y = d.getUTCFullYear();
|
||||
if (y < 1990 || y > new Date().getUTCFullYear() + 1) return null;
|
||||
return tryYMD(y, d.getUTCMonth() + 1, d.getUTCDate());
|
||||
}
|
||||
|
||||
function parsePathYMD(path: string): YMD | null {
|
||||
const m = path.match(PATH_YMD);
|
||||
if (!m) return null;
|
||||
return tryYMD(Number(m[1]), Number(m[2]), Number(m[3]));
|
||||
}
|
||||
|
||||
function parsePathYM(path: string): YM | null {
|
||||
const m = path.match(PATH_YM);
|
||||
if (!m) return null;
|
||||
return tryYM(Number(m[1]), Number(m[2]));
|
||||
}
|
||||
|
||||
function ymdAgreesWithYM(ymd: YMD, ym: YM): boolean {
|
||||
return ymd.y === ym.y && ymd.m === ym.m;
|
||||
}
|
||||
|
||||
function ymdEqual(a: YMD, b: YMD): boolean {
|
||||
return a.y === b.y && a.m === b.m && a.d === b.d;
|
||||
}
|
||||
|
||||
/** Pick the filename-derived YMD that best aligns with the path. When
|
||||
* both `fileName` and `originalName` yield candidates, prefer the one
|
||||
* that matches the path's year+month; ties fall back to `fileName`. */
|
||||
function pickFilenameYMD(
|
||||
fileName: string,
|
||||
originalName: string,
|
||||
pathYM: YM | null
|
||||
): YMD | null {
|
||||
const candidates: YMD[] = [];
|
||||
const a = parseFilenameYMD(fileName);
|
||||
if (a) candidates.push(a);
|
||||
if (originalName && originalName !== fileName) {
|
||||
const b = parseFilenameYMD(originalName);
|
||||
if (b && !candidates.some((c) => ymdEqual(c, b))) candidates.push(b);
|
||||
}
|
||||
if (candidates.length === 0) return null;
|
||||
if (!pathYM) return candidates[0];
|
||||
const aligned = candidates.find((c) => ymdAgreesWithYM(c, pathYM));
|
||||
return aligned ?? candidates[0];
|
||||
}
|
||||
|
||||
function pickUnixTimestamp(fileName: string, originalName: string): YMD | null {
|
||||
return (
|
||||
parseUnixTimestampInName(fileName) ??
|
||||
(originalName && originalName !== fileName
|
||||
? parseUnixTimestampInName(originalName)
|
||||
: null)
|
||||
);
|
||||
}
|
||||
|
||||
export function suggestDateFromPath(input: Input): DateGuess | null {
|
||||
const fileName = (input.fileName ?? '').trim();
|
||||
const originalName = (input.originalName ?? '').trim();
|
||||
const path = (input.path ?? '').trim();
|
||||
|
||||
const pathYMD = path ? parsePathYMD(path) : null;
|
||||
const pathYM = path && !pathYMD ? parsePathYM(path) : null;
|
||||
|
||||
// 1. Filename Y-M-D corroborated by the path.
|
||||
const fnYMD = pickFilenameYMD(fileName, originalName, pathYM);
|
||||
if (fnYMD) {
|
||||
if (pathYMD && ymdEqual(fnYMD, pathYMD)) {
|
||||
return { iso: isoOf(fnYMD), confidence: 'high', source: 'filename-agrees-path' };
|
||||
}
|
||||
if (pathYM && ymdAgreesWithYM(fnYMD, pathYM)) {
|
||||
return { iso: isoOf(fnYMD), confidence: 'high', source: 'filename-agrees-path' };
|
||||
}
|
||||
// 2. Filename Y-M-D with no path signal at all.
|
||||
if (!pathYMD && !pathYM) {
|
||||
return { iso: isoOf(fnYMD), confidence: 'high', source: 'filename-only' };
|
||||
}
|
||||
// Filename present but disagrees with path → fall through.
|
||||
}
|
||||
|
||||
// 3. Unix epoch in filename, optionally corroborated.
|
||||
const epoch = pickUnixTimestamp(fileName, originalName);
|
||||
if (epoch) {
|
||||
if (!pathYM && !pathYMD) {
|
||||
return { iso: isoOf(epoch), confidence: 'high', source: 'unix-timestamp' };
|
||||
}
|
||||
if (pathYM && ymdAgreesWithYM(epoch, pathYM)) {
|
||||
return { iso: isoOf(epoch), confidence: 'high', source: 'unix-timestamp' };
|
||||
}
|
||||
if (pathYMD && ymdEqual(epoch, pathYMD)) {
|
||||
return { iso: isoOf(epoch), confidence: 'high', source: 'unix-timestamp' };
|
||||
}
|
||||
// disagreement → fall through to path
|
||||
}
|
||||
|
||||
// 4. Path Y-M-D standalone.
|
||||
if (pathYMD) {
|
||||
return { iso: isoOf(pathYMD), confidence: 'high', source: 'path-ymd' };
|
||||
}
|
||||
|
||||
// 5. Path Y-M with synthesised day = 01.
|
||||
if (pathYM) {
|
||||
const ymd = tryYMD(pathYM.y, pathYM.m, 1);
|
||||
if (ymd) {
|
||||
return { iso: isoOf(ymd), confidence: 'medium', source: 'path-ym-default-day' };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -10,15 +10,29 @@ import type { PhotoMarksMap } from '$lib/services/photoprism';
|
||||
import type { PpPhoto } from '$lib/types/photoprism';
|
||||
|
||||
/**
|
||||
* Lightroom culling convention: red rejects, orange reviews, yellow
|
||||
* picks, green keeps. Order here is the order the TagsBrowser renders
|
||||
* rows in — fixed so the user can build muscle memory.
|
||||
* Color labels are purely a marking dimension — no semantic meaning is
|
||||
* attached. Order here is the order the TagsBrowser renders rows in,
|
||||
* roughly rainbow-then-neutrals so the picker reads naturally.
|
||||
*
|
||||
* `bg` and `border` are paired Tailwind classes so a swatch can be
|
||||
* rendered either filled (e.g. to indicate selection) or as a colored
|
||||
* outline (the default display). Literal strings keep Tailwind's
|
||||
* content scanner happy — do not interpolate.
|
||||
*/
|
||||
export const COLOR_SWATCHES: readonly { key: string; bg: string; title: string }[] = [
|
||||
{ key: 'red', bg: 'bg-red-500', title: 'Red — reject' },
|
||||
{ key: 'orange', bg: 'bg-orange-500', title: 'Orange — review' },
|
||||
{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow — pick' },
|
||||
{ key: 'green', bg: 'bg-green-500', title: 'Green — keep' }
|
||||
export const COLOR_SWATCHES: readonly {
|
||||
key: string;
|
||||
bg: string;
|
||||
border: string;
|
||||
title: string;
|
||||
}[] = [
|
||||
{ key: 'red', bg: 'bg-red-500', border: 'border-red-500', title: 'Red' },
|
||||
{ key: 'orange', bg: 'bg-orange-500', border: 'border-orange-500', title: 'Orange' },
|
||||
{ key: 'yellow', bg: 'bg-yellow-400', border: 'border-yellow-400', title: 'Yellow' },
|
||||
{ key: 'green', bg: 'bg-green-500', border: 'border-green-500', title: 'Green' },
|
||||
{ key: 'teal', bg: 'bg-teal-500', border: 'border-teal-500', title: 'Teal' },
|
||||
{ key: 'blue', bg: 'bg-blue-500', border: 'border-blue-500', title: 'Blue' },
|
||||
{ key: 'purple', bg: 'bg-purple-500', border: 'border-purple-500', title: 'Purple' },
|
||||
{ key: 'pink', bg: 'bg-pink-500', border: 'border-pink-500', title: 'Pink' }
|
||||
] as const;
|
||||
|
||||
export interface RatingGroup {
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
import { resizable } from '$lib/actions/resizable';
|
||||
import { queryClient } from '$lib/queryClient';
|
||||
import { startIndexerWatch, stopIndexerWatch } from '$lib/stores/indexer.svelte';
|
||||
import { bulkAction } from '$lib/stores/bulkAction.svelte';
|
||||
import IndexerStatusPill from '$lib/components/layout/IndexerStatusPill.svelte';
|
||||
import StatusPill from '$lib/components/layout/StatusPill.svelte';
|
||||
import LeftSidebar from '$lib/components/layout/LeftSidebar.svelte';
|
||||
import AnimatedMule from '$lib/components/mule/AnimatedMule.svelte';
|
||||
import PreviewModal from '$lib/components/preview/PreviewModal.svelte';
|
||||
@@ -72,6 +74,7 @@
|
||||
<div class="flex h-screen flex-col overflow-hidden">
|
||||
<AnimatedMule>
|
||||
<IndexerStatusPill />
|
||||
<StatusPill active={bulkAction.active} label={bulkAction.label} detail={bulkAction.detail} />
|
||||
</AnimatedMule>
|
||||
<div class="flex min-h-0 flex-1">
|
||||
{#if !view.leftSidebarCollapsed}
|
||||
|
||||
@@ -13,15 +13,18 @@
|
||||
getPhoto,
|
||||
listHeaps,
|
||||
listPhotos,
|
||||
listPhotosAround,
|
||||
type PpAlbum,
|
||||
} from "$lib/services/photoprism";
|
||||
import {
|
||||
consumePendingFocus,
|
||||
filters,
|
||||
filtersToQ,
|
||||
filtersToUrlParams,
|
||||
parseUrlParams,
|
||||
setSearch,
|
||||
setSection,
|
||||
type PendingFocus,
|
||||
} from "$lib/stores/filters.svelte";
|
||||
import { isAuthenticated } from "$lib/stores/session.svelte";
|
||||
import { untrack } from "svelte";
|
||||
@@ -34,6 +37,7 @@
|
||||
setFocused,
|
||||
setOrder,
|
||||
} from "$lib/stores/selection.svelte";
|
||||
import { removedIds } from "$lib/stores/bulkAction.svelte";
|
||||
import {
|
||||
openPreview,
|
||||
setRightSidebarWidth,
|
||||
@@ -137,24 +141,90 @@
|
||||
// SQL rows, well below PhotoPrism's 1000-row server cap. Pages flatten
|
||||
// downstream into a single `photos` array consumers iterate.
|
||||
const PHOTOS_PAGE_SIZE = 120;
|
||||
|
||||
// Anchor mode: when an in-app deep link stashes a pending focus with a
|
||||
// TakenAt, the first page is fetched as a window around that date via
|
||||
// PhotoPrism's `before:`/`after:` DSL — so the target photo is in the
|
||||
// loaded page even when it would otherwise be hundreds of entries past
|
||||
// the newest-first cursor. Subsequent pages continue chronologically
|
||||
// with a `before:<oldest-loaded-TakenAt>` cursor instead of the
|
||||
// standard offset, so the listing stays in newest-first order without
|
||||
// jumping around the library. Cleared when the filter changes — a new
|
||||
// filter is a fresh listing, possibly with its own anchor.
|
||||
let anchor = $state<PendingFocus | null>(null);
|
||||
let lastFilterQ: string | null = null;
|
||||
// Watch every URL change so we catch pending-focus stashes even when the
|
||||
// filter didn't change (e.g. user clicks the open-folder icon for a photo
|
||||
// in the folder they're already on — the goto sets the same URL but the
|
||||
// user still expects to land on THAT photo). Pure filter changes with
|
||||
// no pending stash clear any stale anchor so a subsequent refetch
|
||||
// doesn't keep the old window.
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
void page.url.search;
|
||||
untrack(() => {
|
||||
const pending = consumePendingFocus();
|
||||
if (pending) {
|
||||
anchor = pending;
|
||||
lastFilterQ = filtersToQ(filters);
|
||||
return;
|
||||
}
|
||||
const q = filtersToQ(filters);
|
||||
if (q !== lastFilterQ) {
|
||||
anchor = null;
|
||||
lastFilterQ = q;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const photosQuery = createInfiniteQuery<PpPhoto[]>(() => ({
|
||||
queryKey: ["photos", "q", filtersToQ(filters), { count: PHOTOS_PAGE_SIZE }],
|
||||
queryFn: ({ pageParam }) =>
|
||||
listPhotos({
|
||||
q: filtersToQ(filters),
|
||||
queryKey: [
|
||||
"photos",
|
||||
"q",
|
||||
filtersToQ(filters),
|
||||
{ count: PHOTOS_PAGE_SIZE, anchor: anchor?.takenAt ?? null },
|
||||
],
|
||||
queryFn: ({ pageParam }) => {
|
||||
const offset = pageParam as number;
|
||||
const baseQ = filtersToQ(filters);
|
||||
// Page 0 + anchor → load a window around the anchor's date.
|
||||
// Subsequent pages aren't reachable in anchor mode (see
|
||||
// getNextPageParam).
|
||||
if (offset === 0 && anchor?.takenAt) {
|
||||
return listPhotosAround({
|
||||
q: baseQ,
|
||||
takenAt: anchor.takenAt,
|
||||
afterCount: 30,
|
||||
beforeCount: 90,
|
||||
merged: true,
|
||||
});
|
||||
}
|
||||
return listPhotos({
|
||||
q: baseQ,
|
||||
count: PHOTOS_PAGE_SIZE,
|
||||
offset: pageParam as number,
|
||||
offset,
|
||||
order: "newest",
|
||||
merged: true,
|
||||
}),
|
||||
});
|
||||
},
|
||||
initialPageParam: 0,
|
||||
// PhotoPrism's `count` limits SQL rows; with `merged=true` each
|
||||
// photo expands into its file rows, so a "full" page of count=120
|
||||
// typically returns ~60 photo entries. The only reliable end-of-
|
||||
// pagination signal is an empty page. Costs one extra fetch at the
|
||||
// tail (cheap; the empty response is small).
|
||||
getNextPageParam: (last, pages) =>
|
||||
last.length === 0 ? undefined : pages.length * PHOTOS_PAGE_SIZE,
|
||||
getNextPageParam: (last, pages) => {
|
||||
if (last.length === 0) return undefined;
|
||||
// Anchor mode terminates after page 0 — the user sees the 120-
|
||||
// photo window around the deep-linked photo. PhotoPrism's
|
||||
// `before:` cursor is day-precision, so paginating further
|
||||
// chronologically risks dense-day infinite loops (same-day
|
||||
// photos exceeding the page size keep the cursor at the same
|
||||
// value). To "see more," the user clears the anchor by
|
||||
// navigating fresh.
|
||||
if (anchor?.takenAt) return undefined;
|
||||
return pages.length * PHOTOS_PAGE_SIZE;
|
||||
},
|
||||
enabled: isAuthenticated(),
|
||||
}));
|
||||
|
||||
@@ -176,7 +246,12 @@
|
||||
const dedupedAll = $derived<PpPhoto[]>(
|
||||
dedupedPhotos(photosQuery.data?.pages),
|
||||
);
|
||||
const photos = $derived<PpPhoto[]>(applyFolderScope(dedupedAll, filters));
|
||||
// `removedIds` hides tiles the instant a removing action (archive / delete /
|
||||
// restore) succeeds, so the grid updates without waiting on the server-
|
||||
// reconcile refetch (see bulkAction store / BulkActionBar).
|
||||
const photos = $derived<PpPhoto[]>(
|
||||
applyFolderScope(dedupedAll, filters).filter((p) => !removedIds.has(p.UID)),
|
||||
);
|
||||
function dedupedPhotos(pages: PpPhoto[][] | undefined): PpPhoto[] {
|
||||
if (!pages) return [];
|
||||
const seen = new Set<string>();
|
||||
@@ -237,7 +312,22 @@
|
||||
// Only re-anchor focus on the very first page; later pages
|
||||
// must not pull focus back to photo[0].
|
||||
if (pages !== 1) return;
|
||||
setFocused(photos[0].UID);
|
||||
// Anchor (from an in-app deep link) takes precedence — its UID is
|
||||
// guaranteed in `photos` because page 0 was fetched as a window
|
||||
// around its TakenAt. Plain navigations leave anchor null and we
|
||||
// snap to photos[0] as before. `scrollToIndex` expands the
|
||||
// windowed render set + scrolls the tile into view (with sticky-
|
||||
// header peek) — same helper gridKeyNav uses for arrow nav.
|
||||
const targetIdx =
|
||||
anchor?.uid != null
|
||||
? photos.findIndex((p) => p.UID === anchor!.uid)
|
||||
: -1;
|
||||
if (targetIdx >= 0) {
|
||||
setFocused(photos[targetIdx].UID);
|
||||
void scrollToIndex(targetIdx);
|
||||
} else {
|
||||
setFocused(photos[0].UID);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -630,6 +720,7 @@
|
||||
return;
|
||||
}
|
||||
emptyingArchive = true;
|
||||
const tid = toast.loading("Emptying archive…");
|
||||
let total = 0;
|
||||
try {
|
||||
while (true) {
|
||||
@@ -645,9 +736,9 @@
|
||||
await batchDelete(uids);
|
||||
total += uids.length;
|
||||
}
|
||||
toast.success(total === 0 ? "Archive already empty" : `Deleted ${total}`);
|
||||
toast.success(total === 0 ? "Archive already empty" : `Deleted ${total}`, { id: tid });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Empty archive failed");
|
||||
toast.error(err instanceof Error ? err.message : "Empty archive failed", { id: tid });
|
||||
} finally {
|
||||
emptyingArchive = false;
|
||||
void qc.invalidateQueries({ queryKey: ["photos"] });
|
||||
|
||||
@@ -299,6 +299,37 @@
|
||||
markersOnScreen.clear();
|
||||
markers.clear();
|
||||
|
||||
// Deep-link from the RightSidebar's location open icon: `?lat=&lng=`
|
||||
// (+ optional `zoom`, `focus`) flies the map directly to the photo
|
||||
// rather than fitting to the full library extent. Strip the params
|
||||
// afterwards so a manual zoom-out + reload doesn't snap back. Falls
|
||||
// through to the default fitBounds when the params aren't present.
|
||||
const sp = new URL(window.location.href).searchParams;
|
||||
const latParam = Number(sp.get('lat'));
|
||||
const lngParam = Number(sp.get('lng'));
|
||||
if (
|
||||
(data.features?.length ?? 0) > 0 &&
|
||||
Number.isFinite(latParam) &&
|
||||
Number.isFinite(lngParam) &&
|
||||
sp.has('lat') &&
|
||||
sp.has('lng')
|
||||
) {
|
||||
const zoom = Number(sp.get('zoom')) || 17;
|
||||
map.jumpTo({ center: [lngParam, latParam], zoom });
|
||||
const stripped = new URL(window.location.href);
|
||||
stripped.searchParams.delete('lat');
|
||||
stripped.searchParams.delete('lng');
|
||||
stripped.searchParams.delete('zoom');
|
||||
stripped.searchParams.delete('focus');
|
||||
const qs = stripped.searchParams.toString();
|
||||
void goto(`/map${qs ? `?${qs}` : ''}`, {
|
||||
replaceState: true,
|
||||
keepFocus: true,
|
||||
noScroll: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fit to data extent on the first non-empty load — prefer the
|
||||
// server-provided bbox (PhotoPrism returns one), else compute from
|
||||
// the features.
|
||||
|
||||
122
web/src/routes/notes/+page.svelte
Normal file
122
web/src/routes/notes/+page.svelte
Normal file
@@ -0,0 +1,122 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import {
|
||||
getPhoto,
|
||||
listPhotosWithNotes,
|
||||
type PhotoWithNote
|
||||
} from '$lib/services/photoprism';
|
||||
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||
import { setRightSidebarWidth, view } from '$lib/stores/view.svelte';
|
||||
import { gridKeyNav } from '$lib/actions/gridKeyNav';
|
||||
import { resizable } from '$lib/actions/resizable';
|
||||
import { selection } from '$lib/stores/selection.svelte';
|
||||
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
|
||||
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
|
||||
import NotesPhotoGrid from '$lib/components/timeline/NotesPhotoGrid.svelte';
|
||||
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
|
||||
import SkeletonGrid from '$lib/components/timeline/SkeletonGrid.svelte';
|
||||
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
||||
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
||||
import { AlertCircle, MousePointerClick, StickyNote } from 'lucide-svelte';
|
||||
import type { PpPhoto } from '$lib/types/photoprism';
|
||||
|
||||
// Photos with a non-empty Details.Notes. The query is shared by the
|
||||
// LeftSidebar's count badge ($derived off the same key), so visiting
|
||||
// /notes warms the badge and vice-versa. Keyed under the ['photos', …]
|
||||
// prefix so the existing mutation invalidations cascade in.
|
||||
const notesQuery = createQuery<PhotoWithNote[]>(() => ({
|
||||
queryKey: ['photos', 'with-notes'],
|
||||
queryFn: listPhotosWithNotes,
|
||||
enabled: isAuthenticated(),
|
||||
staleTime: 60_000
|
||||
}));
|
||||
|
||||
const items = $derived<PhotoWithNote[]>(notesQuery.data ?? []);
|
||||
const count = $derived(items.length);
|
||||
|
||||
// Right-sidebar metadata for the focused tile. Same wiring as the tag
|
||||
// drill-in page so the metadata panel reads consistently.
|
||||
const focusedPhotoQuery = createQuery<PpPhoto | null>(() => ({
|
||||
queryKey: ['photo', selection.focused ?? ''],
|
||||
queryFn: () =>
|
||||
selection.focused ? getPhoto(selection.focused) : Promise.resolve(null),
|
||||
enabled: isAuthenticated() && Boolean(selection.focused),
|
||||
staleTime: 0
|
||||
}));
|
||||
</script>
|
||||
|
||||
<Toolbar showRightToggle>
|
||||
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
Notes
|
||||
</span>
|
||||
{#if !notesQuery.isPending && !notesQuery.isError}
|
||||
<span class="text-[11px] text-muted-foreground">
|
||||
{count} photo{count === 1 ? '' : 's'}
|
||||
</span>
|
||||
{/if}
|
||||
</Toolbar>
|
||||
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<main
|
||||
class="min-h-0 flex-1 overflow-y-auto p-6 outline-none focus:outline-none"
|
||||
use:gridKeyNav={{}}
|
||||
>
|
||||
{#if notesQuery.isPending}
|
||||
<SkeletonGrid />
|
||||
{:else if notesQuery.isError}
|
||||
<EmptyState tone="destructive" icon={AlertCircle} title="Failed to load notes" />
|
||||
{:else if items.length === 0}
|
||||
<EmptyState
|
||||
icon={StickyNote}
|
||||
title="No photos with notes"
|
||||
description="Add a note to a photo from its metadata sidebar and it will appear here."
|
||||
/>
|
||||
{:else}
|
||||
<NotesPhotoGrid {items} />
|
||||
{/if}
|
||||
</main>
|
||||
<BulkActionBar />
|
||||
</div>
|
||||
|
||||
{#if !view.rightSidebarCollapsed}
|
||||
<aside
|
||||
class="relative h-full shrink-0 border-l border-border bg-card/30"
|
||||
style="width: {view.rightSidebarWidth}px;"
|
||||
>
|
||||
<div class="h-full overflow-y-auto">
|
||||
{#if selection.ids.size >= 2}
|
||||
<BulkMetadataSidebar ids={Array.from(selection.ids)} />
|
||||
{:else if focusedPhotoQuery.data}
|
||||
<RightSidebar photo={focusedPhotoQuery.data} />
|
||||
{:else if focusedPhotoQuery.isFetching}
|
||||
<InlineLoader size="sm" label="Loading metadata…" />
|
||||
{:else}
|
||||
<EmptyState icon={MousePointerClick} title="No photo selected">
|
||||
{#snippet descriptionSnippet()}
|
||||
<p>
|
||||
Use arrow keys or <kbd class="rounded bg-muted px-1">⌘</kbd>+click on a
|
||||
thumbnail to view its metadata here.
|
||||
</p>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="group absolute -left-1.5 top-0 z-20 h-full w-3 cursor-col-resize"
|
||||
use:resizable={{
|
||||
edge: 'left',
|
||||
getWidth: () => view.rightSidebarWidth,
|
||||
setWidth: setRightSidebarWidth
|
||||
}}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize info panel"
|
||||
>
|
||||
<div
|
||||
class="ml-1 h-full w-0.5 bg-transparent transition-colors group-hover:bg-primary/40"
|
||||
></div>
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -15,7 +15,6 @@
|
||||
approve). The previous section is restored on unmount.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import {
|
||||
@@ -116,7 +115,7 @@
|
||||
count: g.photos.length as number | undefined
|
||||
})),
|
||||
{ id: 'stacks', label: 'Stacks', count: stacksCount },
|
||||
{ id: 'cross-folder', label: 'Cross-folder', count: crossFolderCount }
|
||||
{ id: 'cross-folder', label: 'Duplicates', count: crossFolderCount }
|
||||
]);
|
||||
|
||||
const requestedTab = $derived(page.url.searchParams.get('tab'));
|
||||
@@ -138,49 +137,18 @@
|
||||
});
|
||||
|
||||
const activeGroup = $derived(groups.find((g) => g.cause === activeTab));
|
||||
|
||||
function setTab(id: Tab) {
|
||||
const params = new URLSearchParams();
|
||||
// First cause tab (if any) is the default — same convention as
|
||||
// the old /review behaviour, so back-from-cross-folder lands on
|
||||
// the user's review queue rather than the empty Stacks panel.
|
||||
const defaultId = tabs[0]?.id;
|
||||
if (defaultId !== undefined && id !== defaultId) params.set('tab', id);
|
||||
void goto(`/review${params.size ? '?' + params : ''}`, {
|
||||
keepFocus: true,
|
||||
noScroll: true
|
||||
});
|
||||
}
|
||||
const activeTabSpec = $derived(tabs.find((t) => t.id === activeTab));
|
||||
</script>
|
||||
|
||||
<Toolbar>
|
||||
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
Review
|
||||
</span>
|
||||
{#if tabs.length > 0}
|
||||
<div class="flex items-center gap-1">
|
||||
{#each tabs as t (t.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded border px-2 py-0.5 text-[11px] {activeTab === t.id
|
||||
? 'border-primary/40 bg-primary/10 text-primary'
|
||||
: 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'}"
|
||||
onclick={() => setTab(t.id)}
|
||||
>
|
||||
<span>{t.label}</span>
|
||||
{#if t.count !== undefined}
|
||||
<span
|
||||
class="flex h-4 min-w-4.5 items-center justify-center rounded px-1 text-[10px] tabular-nums {activeTab ===
|
||||
t.id
|
||||
? 'bg-primary/15 text-primary'
|
||||
: 'bg-secondary text-muted-foreground'}"
|
||||
>
|
||||
{t.count}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if activeTabSpec}
|
||||
<span class="text-[11px] font-medium">{activeTabSpec.label}</span>
|
||||
{#if activeTabSpec.count !== undefined}
|
||||
<span class="text-[11px] text-muted-foreground">{activeTabSpec.count}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
{#snippet trailing()}
|
||||
<div
|
||||
@@ -244,7 +212,7 @@
|
||||
<p>
|
||||
The indexer flags photos with a low quality score for human review. New
|
||||
arrivals with missing EXIF, low resolution, or unknown cameras will land
|
||||
here. The Stacks and Cross-folder tabs above stay available for
|
||||
here. The Stacks and Duplicates tabs above stay available for
|
||||
duplicate cleanup.
|
||||
</p>
|
||||
{/snippet}
|
||||
@@ -267,7 +235,7 @@
|
||||
{#if selection.ids.size >= 2}
|
||||
<BulkMetadataSidebar ids={Array.from(selection.ids)} />
|
||||
{:else if focusedPhotoQuery.data}
|
||||
<RightSidebar photo={focusedPhotoQuery.data} showRelated />
|
||||
<RightSidebar photo={focusedPhotoQuery.data} />
|
||||
{:else if focusedPhotoQuery.isFetching}
|
||||
<InlineLoader size="sm" label="Loading metadata…" />
|
||||
{/if}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
getPhoto,
|
||||
listLabels,
|
||||
listPhotos,
|
||||
listPhotosByUids,
|
||||
listSubjects,
|
||||
type PhotoMarksMap,
|
||||
type PpLabel,
|
||||
@@ -97,10 +98,14 @@
|
||||
enabled: isAuthenticated() && useLocal,
|
||||
staleTime: 60_000
|
||||
}));
|
||||
// Resolve the pool from the marked UIDs themselves (complete set, any age)
|
||||
// rather than the newest-N timeline slice, so an old marked photo still
|
||||
// lands in its color/rating bucket.
|
||||
const markedUids = $derived(Object.keys(marksQuery.data ?? {}));
|
||||
const marksPoolQuery = createQuery<PpPhoto[]>(() => ({
|
||||
queryKey: ['photos', 'marks-pool'],
|
||||
queryFn: () => listPhotos({ count: 1000, order: 'newest', merged: true }),
|
||||
enabled: isAuthenticated() && useLocal
|
||||
queryKey: ['photos', 'marks-pool', [...markedUids].sort()],
|
||||
queryFn: () => listPhotosByUids(markedUids),
|
||||
enabled: isAuthenticated() && useLocal && markedUids.length > 0
|
||||
}));
|
||||
|
||||
const ratingGroups = $derived(
|
||||
@@ -226,6 +231,7 @@
|
||||
<PhotoGrid photos={drillPhotos} />
|
||||
{/if}
|
||||
</main>
|
||||
<BulkActionBar />
|
||||
</div>
|
||||
|
||||
{#if !view.rightSidebarCollapsed}
|
||||
@@ -270,5 +276,3 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<BulkActionBar />
|
||||
|
||||
10
web/static/favicon.svg
Normal file
10
web/static/favicon.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 73 100">
|
||||
<style>
|
||||
path { fill: #0f172a; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path { fill: #f8fafc; }
|
||||
}
|
||||
</style>
|
||||
<path d="m45.268 55.124q-13.062 14-22.732 14-6.206 0-11.546-4.7629v3.8248q0 6.6391 2.0193 13.351 2.3093 7.4328 2.3093 9.8145 0 3.3195-2.0925 5.4846-2.0924 2.165-5.1956 2.165-3.1753 0-5.0515-2.5257-1.8753-2.5257-1.8753-6.0618 0-2.598 1.8753-9.3814 2.1657-7.5053 2.1657-14.577v-65.452h11.907v42.792q0 6.495 1.0817 9.5259 1.1549 3.031 3.9692 4.9795 2.8867 1.8764 6.495 1.8764 6.4225 0 16.67-8.8042v-50.371h11.979v50.153q0 6.3504 1.299 8.8042 1.2989 2.3815 4.4023 2.3815 4.907 0 6.3504-9.5979h2.5979q-1.3721 16.381-13.856 16.381-5.4122 0-9.0207-3.6082-3.536-3.6804-3.7525-10.392z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 804 B |
Reference in New Issue
Block a user