소스 검색

Add photo rating, grid zoom, and date grouping features (#246)

* Photo: wheel zoom, date-grouped grid, download & rating filter

Add four user-facing capabilities to the Photo app:

- Mouse-wheel zoom. Ctrl/Cmd + wheel (and trackpad pinch) over the grid
  resizes every thumbnail by changing the column count (2-12); a plain
  wheel inside the viewer zooms the open photo, anchored at the cursor.
- Year / Month grid sections, Google-Photos style. The grid is split into
  contiguous date headers driven by EXIF taken date (search) or file mtime
  (folder listing), with a sticky header per section and a "Group by date"
  sidebar toggle (on by default). listFolder.js now returns mtime so folder
  views can group without hitting the index.
- Download the current photo from the viewer toolbar via the media server's
  download mode (serves the original, RAW included).
- Filter by star rating. User-assigned ratings are stored per-user in a new
  photo_ratings table that survives re-indexing/schema rebuilds; set them
  from a star widget in the viewer (setRating.js / getRating.js) and filter
  with a sidebar star selector, a rating: search token, and autocomplete
  suggestions. searchPhotos.js LEFT JOINs the ratings so results carry them.

https://claude.ai/code/session_01LJecFtVoDYKDoZMUGMaRPf

* Photo: group folder views by EXIF shoot time, not file mtime

Year / Month grid sections were driven by the EXIF taken date only for
search results; plain folder browsing grouped by the file's last-modified
time. Folder listings now resolve each photo's shoot time from the
per-user photo index (which stores EXIF DateTimeOriginal) with a single
folder-scoped query, falling back to mtime only for photos the background
indexer has not reached yet. When auto-indexing or a rebuild finishes with
newly indexed photos, the grid reloads so the sections pick up the EXIF
shoot times without a manual refresh.

https://claude.ai/code/session_01LJecFtVoDYKDoZMUGMaRPf

* Photo: skip .metadata cache folders when indexing

ArozOS generates thumbnail caches under <folder>/.metadata/.cache/ inside
every browsed directory. The recursive walk in indexPhotos.js was indexing
those cached JPEGs, polluting search results, date grouping and the photo
count. Add db_isHiddenPath() to imagedb.js (true for any dot-prefixed path
segment) and skip such paths during the index walk — checked before the
file is marked present, so cache rows already in the index are pruned
automatically by the existing delete sweep on the next pass.

Also hide dot-prefixed files (e.g. AppleDouble "._IMG.jpg" sidecars) from
folder listings, matching the existing rule for dot-folders.

https://claude.ai/code/session_01LJecFtVoDYKDoZMUGMaRPf

---------

Co-authored-by: Claude <noreply@anthropic.com>
Alan Yeung 1 주 전
부모
커밋
ceccb3947c

+ 44 - 0
src/web/Photo/backend/getRating.js

@@ -0,0 +1,44 @@
+/*
+    getRating.js
+
+    Return the user's star rating (0 = unrated) for a single photo. Used by the
+    viewer to show the current rating when a photo is opened from a folder (search
+    results already carry the rating inline).
+
+    Request body (JSON):
+      { "filepath": "user:/Photo/a.jpg" }
+
+    Response (JSON):
+      { filepath, rating }   // rating is 0..5
+*/
+
+includes("imagedb.js");
+
+function main() {
+    var rawBody = (typeof POST_data !== "undefined") ? POST_data : "{}";
+    var payload = {};
+    try {
+        payload = JSON.parse(rawBody) || {};
+    } catch (e) {
+        payload = {};
+    }
+
+    var filepath = payload.filepath || "";
+    if (!filepath) {
+        sendJSONResp(JSON.stringify({ rating: 0 }));
+        return;
+    }
+
+    var db = openIndexDB();
+    if (db == null) {
+        sendJSONResp(JSON.stringify({ rating: 0 }));
+        return;
+    }
+
+    var rating = db_getRating(db, filepath);
+    db.close();
+
+    sendJSONResp(JSON.stringify({ filepath: filepath, rating: rating }));
+}
+
+main();

+ 87 - 2
src/web/Photo/backend/imagedb.js

@@ -68,6 +68,22 @@ function db_dirname(filepath) {
     return t.join("/");
 }
 
+// Hidden path: any dot-prefixed segment, e.g. the ArozOS ".metadata/.cache"
+// thumbnail folders that the file manager generates inside every browsed
+// directory, or AppleDouble "._*" files. These are caches — never user
+// photos — and the Photo UI hides dot-folders from browsing, so the indexer
+// must skip them too or cached thumbnails pollute search and date grouping.
+function db_isHiddenPath(filepath) {
+    var parts = ("" + filepath).split("/");
+    // parts[0] is the vroot ("user:"), which is never dot-prefixed.
+    for (var i = 0; i < parts.length; i++) {
+        if (parts[i].charAt(0) === ".") {
+            return true;
+        }
+    }
+    return false;
+}
+
 /* ------------------------------------------------------------------ *
  *  Schema + open/migrate
  * ------------------------------------------------------------------ */
@@ -110,6 +126,68 @@ function ensureSchema(db) {
     db.exec("CREATE INDEX IF NOT EXISTS idx_photos_folder ON photos(folder)");
 
     db.exec("CREATE TABLE IF NOT EXISTS index_meta (key TEXT PRIMARY KEY, value TEXT)");
+
+    // User-assigned star ratings (0-5). Kept in a *separate* table keyed by the
+    // virtual path so it survives a photos-table rebuild / schema bump / full
+    // re-index — those only ever drop & repopulate the derived `photos` cache,
+    // never the user's own ratings.
+    db.exec(
+        "CREATE TABLE IF NOT EXISTS photo_ratings (" +
+        "filepath TEXT PRIMARY KEY NOT NULL," +
+        "rating INTEGER NOT NULL," +          // 1..5 (a 0 rating is stored as "no row")
+        "updated_at INTEGER" +                // unix sec the rating was last set
+        ")"
+    );
+}
+
+/* ------------------------------------------------------------------ *
+ *  User-assigned star ratings
+ * ------------------------------------------------------------------ */
+
+// Clamp an arbitrary input to an integer in the 0..5 star range.
+function db_clampRating(v) {
+    var n = parseInt(v, 10);
+    if (isNaN(n)) {
+        return 0;
+    }
+    if (n < 0) {
+        return 0;
+    }
+    if (n > 5) {
+        return 5;
+    }
+    return n;
+}
+
+// Return the star rating (0 = unrated) for a single photo.
+function db_getRating(db, filepath) {
+    if (db == null || !filepath) {
+        return 0;
+    }
+    var row = db.queryRow("SELECT rating FROM photo_ratings WHERE filepath = ?", [filepath]);
+    if (row && row.rating) {
+        return db_clampRating(row.rating);
+    }
+    return 0;
+}
+
+// Set (or, when rating <= 0, clear) the star rating for a single photo.
+// Returns the stored rating (0 when cleared).
+function db_setRating(db, filepath, rating) {
+    if (db == null || !filepath) {
+        return 0;
+    }
+    var r = db_clampRating(rating);
+    if (r <= 0) {
+        db.exec("DELETE FROM photo_ratings WHERE filepath = ?", [filepath]);
+        return 0;
+    }
+    db.exec(
+        "INSERT INTO photo_ratings (filepath, rating, updated_at) VALUES (?,?,?) " +
+        "ON CONFLICT(filepath) DO UPDATE SET rating = excluded.rating, updated_at = excluded.updated_at",
+        [filepath, r, Math.floor(Date.now() / 1000)]
+    );
+    return r;
 }
 
 function metaGet(db, key, fallback) {
@@ -639,7 +717,7 @@ function newFilter() {
         text: [], filename: [], ext: [], raw: false,
         model: [], make: [], lens: [], orientation: [], month: [],
         iso: [], aperture: [], focal: [], mp: [], width: [], height: [],
-        taken: [], modified: []
+        taken: [], modified: [], rating: []
     };
 }
 
@@ -714,6 +792,11 @@ function classifyToken(filter, token) {
             case "iso":
                 pushRange(filter, "iso", parseRange(val));
                 return;
+            case "rating":
+            case "stars":
+            case "star":
+                pushRange(filter, "rating", parseRange(val.replace(/\*/g, "")));
+                return;
             case "f":
             case "aperture":
             case "fnumber":
@@ -834,7 +917,7 @@ function applyExplicitFilters(filter, f) {
             filter.ext.push(("" + f.ext[i]).toLowerCase().replace(/^\./, ""));
         }
     }
-    var ranges = ["iso", "aperture", "focal", "mp", "width", "height"];
+    var ranges = ["iso", "aperture", "focal", "mp", "width", "height", "rating"];
     for (var r = 0; r < ranges.length; r++) {
         var rv = f[ranges[r]];
         if (rv) {
@@ -994,6 +1077,8 @@ function buildWhere(filter) {
     addRangeGroup(clauses, args, "height", filter.height);
     addRangeGroup(clauses, args, "taken_date", filter.taken);
     addRangeGroup(clauses, args, "modified_date", filter.modified);
+    // Rating lives in the joined photo_ratings table; unrated photos count as 0.
+    addRangeGroup(clauses, args, "IFNULL(photo_ratings.rating, 0)", filter.rating);
 
     return { clause: clauses.length ? clauses.join(" AND ") : "1=1", args: args };
 }

+ 6 - 0
src/web/Photo/backend/indexPhotos.js

@@ -92,6 +92,12 @@ function main() {
             if (!db_isImageFile(fp)) {
                 continue;
             }
+            // Skip cache / hidden paths (e.g. the ".metadata/.cache" thumbnail
+            // folders). Checked before `present` is marked so previously indexed
+            // cache files are pruned by the delete sweep below.
+            if (db_isHiddenPath(fp)) {
+                continue;
+            }
             if (isExcluded(fp, excludeList)) {
                 continue;
             }

+ 32 - 3
src/web/Photo/backend/listFolder.js

@@ -1,4 +1,5 @@
 requirelib("filelib")
+includes("imagedb.js")
 
 
 function getExt(filename){
@@ -112,7 +113,9 @@ function main(){
             }
 
         }else{
-            if (isImage(thisFile)){
+            // Hidden dot-files (e.g. AppleDouble "._IMG.jpg" sidecars) are
+            // cache/system artifacts, not photos — same rule as for folders.
+            if (isImage(thisFile) && !isHiddenFile(thisFile)){
                 files.push(thisFile);
             }
         }
@@ -121,14 +124,40 @@ function main(){
     // Filter out JPG duplicates when RAW files exist
     files = filterDuplicates(files);
 
-    // Add filesize information to each file
+    // Year / Month grouping must follow the EXIF shoot time, not the file's
+    // last-modified time. The per-user photo index (imagedb.js) already stores
+    // taken_date = EXIF DateTimeOriginal for every indexed photo, so resolve it
+    // with one folder-scoped query instead of decoding EXIF per file per request.
+    var takenMap = {};
+    var db = openIndexDB();
+    if (db != null) {
+        // `folder` is the request wildcard ("user:/Photo/*"); its dirname is the
+        // folder being listed, which is exactly how the index keys its rows.
+        var rows = db.query("SELECT filepath, taken_date FROM photos WHERE folder = ?", [dirname(folder)]);
+        for (var ri = 0; ri < rows.length; ri++) {
+            if (rows[ri].taken_date) {
+                takenMap[rows[ri].filepath] = rows[ri].taken_date;
+            }
+        }
+        db.close();
+    }
+
+    // Add filesize + dates to each file. taken_date (unix seconds, from EXIF)
+    // drives the Year / Month grid sections; mtime is the fallback for photos
+    // the background indexer has not reached yet.
     var filesWithSize = [];
     for (var i = 0; i < files.length; i++){
         var filepath = files[i];
         var filesize = filelib.filesize(filepath);
+        var mtime = filelib.mtime(filepath, true);
+        if (mtime === false){
+            mtime = 0;
+        }
         filesWithSize.push({
             filepath: filepath,
-            filesize: filesize
+            filesize: filesize,
+            mtime: mtime,
+            taken_date: takenMap[filepath] || null
         });
     }
 

+ 10 - 3
src/web/Photo/backend/searchPhotos.js

@@ -39,6 +39,7 @@
       2023  year:2023    taken in year
       taken:2023-01..2023-06   taken date range  (before:/after: also work)
       modified:>2024-01-01     file modified date
+      rating:>=4  rating:5  rating:3-5   user star rating (0 = unrated)
 */
 
 includes("imagedb.js");
@@ -80,13 +81,19 @@ function main() {
     var w = buildWhere(filter);
     var orderBy = buildOrderBy(sort);
 
-    var countRow = db.queryRow("SELECT COUNT(*) AS c FROM photos WHERE " + w.clause, w.args);
+    // LEFT JOIN the per-user ratings so a `rating:` filter resolves and every
+    // result carries its star rating (0 = unrated). `filepath` exists in both
+    // tables, so it must be qualified.
+    var from = "FROM photos LEFT JOIN photo_ratings ON photo_ratings.filepath = photos.filepath";
+
+    var countRow = db.queryRow("SELECT COUNT(*) AS c " + from + " WHERE " + w.clause, w.args);
     var total = countRow ? countRow.c : 0;
 
     var rows = db.query(
-        "SELECT filepath, filename, filesize, ext, width, height, megapixels, orientation," +
+        "SELECT photos.filepath AS filepath, filename, filesize, ext, width, height, megapixels, orientation," +
         " taken_date, modified_date, camera_make, camera_model, lens_model, focal_length," +
-        " aperture, shutter, shutter_label, iso, has_exif FROM photos WHERE " + w.clause +
+        " aperture, shutter, shutter_label, iso, has_exif," +
+        " IFNULL(photo_ratings.rating, 0) AS rating " + from + " WHERE " + w.clause +
         " ORDER BY " + orderBy + " LIMIT ? OFFSET ?",
         w.args.concat([limit, offset])
     );

+ 13 - 0
src/web/Photo/backend/searchSuggest.js

@@ -57,6 +57,12 @@ function buildSuggestions(db, q) {
         for (var k = 0; k < exts.length; k++) {
             add("type", "." + exts[k].ext, "." + exts[k].ext, exts[k].c + " photos");
         }
+
+        // Top-rated quick facet — only shown once the user has rated something.
+        var topRated = db.queryRow("SELECT COUNT(*) AS c FROM photo_ratings WHERE rating >= 4");
+        if (topRated && topRated.c > 0) {
+            add("rating", "4★ & up", "rating:>=4", topRated.c + " photos");
+        }
         return out;
     }
 
@@ -86,6 +92,13 @@ function buildSuggestions(db, q) {
             add("filter", "f/" + av, withPrefix("f/" + av), "");
         }
     }
+    // Star-rating completions: "rating", "star(s)" or a run of asterisks.
+    if (lastToken.indexOf("rating") === 0 || lastToken.indexOf("star") === 0 || /^\*+$/.test(lastToken)) {
+        for (var rs = 5; rs >= 1; rs--) {
+            var rc = db.queryRow("SELECT COUNT(*) AS c FROM photo_ratings WHERE rating >= ?", [rs]);
+            add("rating", rs + "★ & up", withPrefix("rating:>=" + rs), (rc && rc.c ? rc.c + " photos" : ""));
+        }
+    }
 
     // Camera models.
     list = db.query("SELECT camera_model AS m, COUNT(*) AS c FROM photos" +

+ 46 - 0
src/web/Photo/backend/setRating.js

@@ -0,0 +1,46 @@
+/*
+    setRating.js
+
+    Set (or clear) the user's star rating for a single photo. Ratings are stored
+    per-user in the photo_ratings table (see imagedb.js) keyed by the photo's
+    virtual path, so they persist across re-indexing and schema rebuilds.
+
+    Request body (JSON):
+      { "filepath": "user:/Photo/a.jpg", "rating": 0..5 }
+
+    A rating of 0 (or less) clears the rating.
+
+    Response (JSON):
+      { ok: true, filepath, rating }   // rating is the stored value (0 = cleared)
+*/
+
+includes("imagedb.js");
+
+function main() {
+    var rawBody = (typeof POST_data !== "undefined") ? POST_data : "{}";
+    var payload = {};
+    try {
+        payload = JSON.parse(rawBody) || {};
+    } catch (e) {
+        payload = {};
+    }
+
+    var filepath = payload.filepath || "";
+    if (!filepath) {
+        sendJSONResp(JSON.stringify({ error: "missing filepath" }));
+        return;
+    }
+
+    var db = openIndexDB();
+    if (db == null) {
+        sendJSONResp(JSON.stringify({ error: "index unavailable" }));
+        return;
+    }
+
+    var stored = db_setRating(db, filepath, payload.rating);
+    db.close();
+
+    sendJSONResp(JSON.stringify({ ok: true, filepath: filepath, rating: stored }));
+}
+
+main();

+ 260 - 55
src/web/Photo/index.html

@@ -165,6 +165,13 @@
             outline: none;
         }
 
+        .sidebar-select:disabled {
+            opacity: 0.45;
+            cursor: not-allowed;
+            border-color: #3a3a3a;
+            color: #777;
+        }
+
         .sidebar-select option {
             background: #252525;
             color: #bbb;
@@ -824,6 +831,144 @@
             z-index: 10;
             display: none;
         }
+
+        /* ── Date-grouped grid sections (Google-Photos style) ─────────────── */
+        #viewbox {
+            display: block;
+        }
+        .photo-date-group {
+            margin-bottom: 0.3em;
+        }
+        .photo-date-header {
+            position: sticky;
+            top: 0;
+            z-index: 5;
+            background: #1a1a1a;
+            color: #e3e3e3;
+            font-size: 0.95em;
+            font-weight: 600;
+            letter-spacing: 0.02em;
+            padding: 0.75em 0.6em 0.5em 0.6em;
+            border-bottom: 1px solid #2a2a2a;
+        }
+        .photo-date-cards {
+            margin: 0 !important;
+        }
+
+        /* ── Grid zoom hint snackbar (Ctrl/⌘ + wheel) ────────────────────── */
+        #grid-zoom-snackbar {
+            position: fixed;
+            bottom: 24px;
+            left: 50%;
+            transform: translateX(-50%) translateY(10px);
+            background: rgba(0, 0, 0, 0.78);
+            color: #fff;
+            padding: 7px 16px;
+            border-radius: 18px;
+            font-size: 13px;
+            border: 1px solid rgba(255, 255, 255, 0.18);
+            backdrop-filter: blur(4px);
+            opacity: 0;
+            pointer-events: none;
+            transition: opacity 0.18s ease, transform 0.18s ease;
+            z-index: 900;
+        }
+        #grid-zoom-snackbar.visible {
+            opacity: 1;
+            transform: translateX(-50%) translateY(0);
+        }
+
+        /* ── Sidebar: view toggle + rating filter ────────────────────────── */
+        .sidebar-toggle {
+            display: flex;
+            align-items: center;
+            gap: 0.5em;
+            padding: 0.25em 0.5em;
+            color: #bbb;
+            font-size: 0.87em;
+            cursor: pointer;
+            user-select: none;
+        }
+        .sidebar-toggle:hover { color: #fff; }
+        .sidebar-toggle input { cursor: pointer; accent-color: #f76c5d; }
+        .sidebar-zoom-hint {
+            padding: 0.4em 0.5em 0;
+            color: #666;
+            font-size: 0.74em;
+            line-height: 1.4;
+        }
+        .sidebar-rating {
+            display: flex;
+            align-items: center;
+            gap: 0.05em;
+            padding: 0.1em 0.4em 0;
+        }
+        .sidebar-rating .star.icon {
+            cursor: pointer;
+            margin: 0;
+            color: #f6c350;
+            transition: transform 0.08s;
+        }
+        .sidebar-rating .star.outline.icon { color: #666; }
+        .sidebar-rating .star.icon:hover { transform: scale(1.18); }
+        .sidebar-rating-clear {
+            margin-left: 0.5em;
+            color: #f76c5d;
+            font-size: 0.78em;
+            cursor: pointer;
+        }
+        .sidebar-rating-clear:hover { text-decoration: underline; }
+        .sidebar-rating-hint {
+            padding: 0.35em 0.5em 0;
+            color: #888;
+            font-size: 0.78em;
+        }
+
+        /* ── Viewer: download button + star rating ───────────────────────── */
+        .download-photo-btn {
+            position: absolute;
+            top: 1.3em;
+            right: 9.7em;
+            background: #222222;
+            color: white;
+            border: none;
+            width: 30px;
+            height: 30px;
+            border-radius: 50%;
+            cursor: pointer;
+            z-index: 500;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+        }
+        .download-photo-btn svg { width: 18px; height: 18px; }
+        .download-photo-btn:hover { background: #f76c5d; }
+
+        .photo-rating-row {
+            display: flex;
+            align-items: center;
+            gap: 0.6em;
+            margin: 0.1em 0 1.1em 0;
+        }
+        .photo-rating-stars {
+            display: inline-flex;
+            gap: 2px;
+            cursor: pointer;
+            font-size: 1.45em;
+            line-height: 1;
+        }
+        .photo-rating-stars .photo-star {
+            color: #555;
+            font-style: normal;
+            transition: color 0.12s, transform 0.08s;
+        }
+        .photo-rating-stars .photo-star:hover { transform: scale(1.15); }
+        .photo-rating-stars .photo-star.filled { color: #f6c350; }
+        .photo-rating-stars .photo-star.hover { color: #f7d98a; }
+        .photo-rating-label {
+            color: #aaa;
+            font-size: 0.85em;
+        }
     </style>
 
 </head>
@@ -846,7 +991,8 @@
                 <div class="sidebar-section">
                     <div class="sidebar-section-title">Sort By</div>
                     <div style="padding: 0 0.4em;">
-                        <select class="sidebar-select" x-model="sortOrder" x-on:change="getFolderInfo();">
+                        <select class="sidebar-select" x-model="sortOrder" x-on:change="getFolderInfo();"
+                                :disabled="groupByDate" :title="groupByDate ? 'Turn off Group by date to sort manually' : ''">
                             <option value="smart">Natural Order</option>
                             <option value="mostRecent">Newest First</option>
                             <option value="leastRecent">Oldest First</option>
@@ -862,6 +1008,34 @@
 
                 <div class="sidebar-divider"></div>
 
+                <div class="sidebar-section">
+                    <div class="sidebar-section-title">View</div>
+                    <label class="sidebar-toggle" title="Split the grid into Year / Month sections">
+                        <input type="checkbox" x-model="groupByDate" x-on:change="onGroupByDateChange();">
+                        <span>Group by date</span>
+                    </label>
+                    <div class="sidebar-zoom-hint">Hold Ctrl / ⌘ and scroll to resize photos</div>
+                </div>
+
+                <div class="sidebar-divider"></div>
+
+                <div class="sidebar-section">
+                    <div class="sidebar-section-title">Filter by Rating</div>
+                    <div class="sidebar-rating">
+                        <template x-for="n in [1,2,3,4,5]" :key="n">
+                            <i class="star icon" :class="{ 'outline': n > ratingFilter }"
+                               x-on:click="setRatingFilter(n)"
+                               :title="'Rating ' + n + ' and up'"></i>
+                        </template>
+                        <span class="sidebar-rating-clear" x-show="ratingFilter > 0"
+                              x-on:click="setRatingFilter(0)">Clear</span>
+                    </div>
+                    <div class="sidebar-rating-hint" x-show="ratingFilter > 0"
+                         x-text="'Showing ' + ratingFilter + '★ & up'"></div>
+                </div>
+
+                <div class="sidebar-divider"></div>
+
                 <div class="sidebar-section">
                     <div class="sidebar-section-title">Library</div>
                     <template x-for="vroot in vroots">
@@ -973,12 +1147,22 @@
                     </h4>
                 </div>
                 <div id="viewboxContainer">
-                    <div id="viewbox" class="ui six cards viewbox">
-                        <template x-for="image in images">
-                            <div class="imagecard" style="cursor: pointer;" x-on:click="showImage($el); ShowModal();" :style="{width: renderSize + 'px', height: renderSize + 'px'}" :filedata="encodeURIComponent(JSON.stringify({'filename':image.filepath.split('/').pop(),'filepath':image.filepath,'filesize':image.filesize}))">
-                                <a class="image" x-init="updateImageSizes();">
-                                    <img :src="'../system/file_system/loadThumbnail?bytes=true&vpath=' + image.filepath">
-                                </a>
+                    <div id="viewbox">
+                        <!-- Photos are split into Year / Month sections (Google-Photos style).
+                             When "Group by date" is off, groupedImages() returns one
+                             unlabelled group, so this renders as a single flat grid. -->
+                        <template x-for="group in groupedImages()" :key="group.key">
+                            <div class="photo-date-group">
+                                <div class="photo-date-header" x-show="group.label !== ''" x-text="group.label"></div>
+                                <div class="ui six cards photo-date-cards">
+                                    <template x-for="image in group.images" :key="image.filepath">
+                                        <div class="imagecard" style="cursor: pointer;" x-on:click="showImage($el); ShowModal();" :style="{width: renderSize + 'px', height: renderSize + 'px'}" :filedata="encodeURIComponent(JSON.stringify({'filename':image.filepath.split('/').pop(),'filepath':image.filepath,'filesize':image.filesize}))">
+                                            <a class="image" x-init="updateImageSizes();">
+                                                <img :src="'../system/file_system/loadThumbnail?bytes=true&vpath=' + image.filepath">
+                                            </a>
+                                        </div>
+                                    </template>
+                                </div>
                             </div>
                         </template>
                     </div>
@@ -987,6 +1171,8 @@
                         <i class="loading spinner icon"></i> Loading more photos...
                     </div>
                 </div>
+                <!-- Grid zoom hint (shown briefly while Ctrl/⌘ + wheel resizes the grid) -->
+                <div id="grid-zoom-snackbar"></div>
             </div>
 
         </div><!-- /#content-area -->
@@ -1003,6 +1189,9 @@
                 <img id="fullImage" src="img/loading.png" />
                 <button class="close-btn" onclick="closeViewer()">×</button>
                 <button class="show-info-btn" onclick="showInfoPanel()">ℹ</button>
+                <button class="download-photo-btn" id="download-photo-btn" onclick="downloadCurrentPhoto()" title="Download photo">
+                    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="#F3F3F3"><path d="M480-320 280-520l56-58 104 104v-326h80v326l104-104 56 58-200 200ZM240-160q-33 0-56.5-23.5T160-240v-120h80v120h480v-120h80v120q0 33-23.5 56.5T720-160H240Z"/></svg>
+                </button>
                 <button class="cast-photo-btn" id="cast-photo-btn" onclick="openPhotoCastDialog()" title="Cast to Arozcast">
                     <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="#F3F3F3"><path d="M480-480Zm320 320H600q0-20-1.5-40t-4.5-40h206v-480H160v46q-20-3-40-4.5T80-680v-40q0-33 23.5-56.5T160-800h640q33 0 56.5 23.5T880-720v480q0 33-23.5 56.5T800-160Zm-720 0v-120q50 0 85 35t35 85H80Zm200 0q0-83-58.5-141.5T80-360v-80q117 0 198.5 81.5T360-160h-80Zm160 0q0-75-28.5-140.5t-77-114q-48.5-48.5-114-77T80-520v-80q91 0 171 34.5T391-471q60 60 94.5 140T520-160h-80Z"/></svg>
                 </button>
@@ -1029,6 +1218,16 @@
                 </div>
                 <div class="info-panel">
                     <h3>Photo Information</h3>
+                    <div class="photo-rating-row">
+                        <div id="photo-rating-stars" class="photo-rating-stars">
+                            <i class="photo-star">★</i>
+                            <i class="photo-star">★</i>
+                            <i class="photo-star">★</i>
+                            <i class="photo-star">★</i>
+                            <i class="photo-star">★</i>
+                        </div>
+                        <span id="photo-rating-label" class="photo-rating-label">Rate</span>
+                    </div>
                     <table class="ui very basic compact table inverted">
                         <tbody>
                             <tr>
@@ -1382,60 +1581,66 @@
         panY = Math.max(-maxPanY, Math.min(maxPanY, panY));
     }
 
-    // Initialize zoom and pan when photo viewer is shown
+    // Initialize zoom and pan when photo viewer is shown. The pointer listeners
+    // are attached once (they read live module-level state), so re-opening the
+    // viewer only resets the zoom rather than stacking duplicate handlers.
+    let viewerInteractionsInit = false;
     function initZoomPan() {
+        // Reset zoom state on every open
+        resetZoom();
+        if (viewerInteractionsInit) return;
+
         const img = document.getElementById('fullImage');
         const container = document.querySelector('.viewer-left');
         const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
-        
-        // Reset zoom state
-        resetZoom();
 
         // Only enable custom zoom/pan on desktop
-        if (!isMobile) {
-            let handleMouseDown = function(e) {
-                if (zoomLevel > 1) {
-                    e.preventDefault();
-                    isDragging = true;
-                    lastMouseX = e.clientX;
-                    lastMouseY = e.clientY;
-                    img.classList.add('dragging');
-                }
-            };
-
-            let handleMouseMove = function(e) {
-                if (isDragging && zoomLevel > 1) {
-                    const deltaX = e.clientX - lastMouseX;
-                    const deltaY = e.clientY - lastMouseY;
-                    
-                    panX += deltaX / zoomLevel;
-                    panY += deltaY / zoomLevel;
-                    
-                    constrainPan();
-                    updateImageTransform();
-                    
-                    lastMouseX = e.clientX;
-                    lastMouseY = e.clientY;
-                }
-            };
-
-            let handleMouseUp = function() {
-                if (isDragging) {
-                    isDragging = false;
-                    img.classList.remove('dragging');
-                }
-            };
-
-            // Remove existing listeners to prevent double triggering
-            img.removeEventListener('mousedown', handleMouseDown);
-            document.removeEventListener('mousemove', handleMouseMove);
-            document.removeEventListener('mouseup', handleMouseUp);
-
-            // Add listeners back
-            img.addEventListener('mousedown', handleMouseDown);
-            document.addEventListener('mousemove', handleMouseMove);
-            document.addEventListener('mouseup', handleMouseUp);
-        }
+        if (isMobile) { viewerInteractionsInit = true; return; }
+
+        img.addEventListener('mousedown', function(e) {
+            if (zoomLevel > 1) {
+                e.preventDefault();
+                isDragging = true;
+                lastMouseX = e.clientX;
+                lastMouseY = e.clientY;
+                img.classList.add('dragging');
+            }
+        });
+
+        document.addEventListener('mousemove', function(e) {
+            if (isDragging && zoomLevel > 1) {
+                const deltaX = e.clientX - lastMouseX;
+                const deltaY = e.clientY - lastMouseY;
+
+                panX += deltaX / zoomLevel;
+                panY += deltaY / zoomLevel;
+
+                constrainPan();
+                updateImageTransform();
+
+                lastMouseX = e.clientX;
+                lastMouseY = e.clientY;
+            }
+        });
+
+        document.addEventListener('mouseup', function() {
+            if (isDragging) {
+                isDragging = false;
+                img.classList.remove('dragging');
+            }
+        });
+
+        // Mouse-wheel zoom, anchored at the cursor (complements the +/- buttons).
+        container.addEventListener('wheel', function(e) {
+            e.preventDefault();
+            const step = e.deltaY < 0 ? 0.2 : -0.2;
+            const newZoom = Math.max(1, Math.min(5, zoomLevel + step));
+            if (newZoom !== zoomLevel) {
+                zoomAtPoint(newZoom, e.clientX, e.clientY);
+            }
+        }, { passive: false });
+
+        viewerInteractionsInit = true;
     }
 
     // Initialize zoom and pan when modal is shown

+ 280 - 24
src/web/Photo/photo.js

@@ -14,6 +14,8 @@ let prePhoto = "";
 let nextPhoto = "";
 let currentModel = "";
 let currentPhotoAllIndex = -1; // index of current photo in allImages (full server list)
+let currentPhotoFilepath = null; // filepath of the photo open in the viewer (download / rating)
+let currentPhotoRating = 0;      // star rating (0-5) of the open photo
 let isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
 
 // Check if image should use compression (only JPG/PNG)
@@ -31,33 +33,42 @@ function getViewableImageUrl(filepath, callback) {
     callback(imageUrl, true, false, isRawImage(filepath) ? 'backend_raw' : 'direct');
 }
 
+// Grid zoom: 0 = responsive (auto column count by width); a positive value pins
+// the number of columns, set by the user with Ctrl/⌘ + mouse wheel over the grid
+// (Google-Photos-style gallery zoom). Smaller column count => larger thumbnails.
+let photoGridColumns = 0;
+const PHOTO_GRID_MIN_COLS = 2;
+const PHOTO_GRID_MAX_COLS = 12;
+
+function getContainerWidth(){
+    const container = document.getElementById('viewboxContainer');
+    return container ? container.clientWidth : (window.innerWidth - 210);
+}
+
+// Responsive column count for a given container width (used until the user
+// pins a zoom level via the mouse wheel).
+function autoColumnCount(containerWidth){
+    if (containerWidth < 400) return 2;
+    if (containerWidth < 600) return 3;
+    if (containerWidth < 900) return 4;
+    if (containerWidth < 1100) return 5;
+    if (containerWidth < 1400) return 6;
+    return 8;
+}
+
+function getColumnCount(){
+    if (photoGridColumns > 0) return photoGridColumns;
+    return autoColumnCount(getContainerWidth());
+}
+
 function getImageWidth(){
     // Use the actual viewbox container width so the sidebar and scrollbar are
     // already subtracted — this prevents gaps when the window is resized.
-    const container = document.getElementById('viewboxContainer');
-    const containerWidth = container ? container.clientWidth : (window.innerWidth - 210);
-
-    let boxCount;
-    if (containerWidth < 400) {
-        boxCount = 2;
-    } else if (containerWidth < 600) {
-        boxCount = 3;
-    } else if (containerWidth < 900) {
-        boxCount = 4;
-    } else if (containerWidth < 1100) {
-        boxCount = 5;
-    } else if (containerWidth < 1400) {
-        boxCount = 6;
-    } else {
-        boxCount = 8;
-    }
-
-    return Math.floor(containerWidth / boxCount);
+    return Math.floor(getContainerWidth() / getColumnCount());
 }
 
 function updateImageSizes(){
     let newImageWidth = getImageWidth();
-    console.log(newImageWidth, $("#viewbox").width());
     //Updates all the size of the images
     $(".imagecard").css({
         width: newImageWidth,
@@ -65,6 +76,45 @@ function updateImageSizes(){
     });
 }
 
+// Briefly show the current columns-per-row while zooming the grid.
+let _gridZoomHintTimer = null;
+function showGridZoomHint(cols){
+    const el = document.getElementById('grid-zoom-snackbar');
+    if (!el) return;
+    el.textContent = cols + ' per row';
+    el.classList.add('visible');
+    clearTimeout(_gridZoomHintTimer);
+    _gridZoomHintTimer = setTimeout(function(){ el.classList.remove('visible'); }, 1100);
+}
+
+// ── Date grouping (Google-Photos-style Year / Month sections) ──────────────────
+// Capture date for a grid item: the EXIF shoot time (taken_date, supplied by
+// both search results and folder listings via the photo index) with file mtime
+// as the fallback for photos the background indexer has not reached yet.
+function photoImageDateUnix(img){
+    if (!img) return null;
+    if (img.taken_date) return img.taken_date;
+    if (img.mtime) return img.mtime;
+    if (img.modified_date) return img.modified_date;
+    return null;
+}
+
+// "June 2023" style header for a unix-second timestamp (PHOTO_MONTHS: search.js).
+function photoGroupLabel(unixSec){
+    if (!unixSec) return 'Undated';
+    var d = new Date(unixSec * 1000);
+    if (isNaN(d.getTime())) return 'Undated';
+    return PHOTO_MONTHS[d.getMonth()] + ' ' + d.getFullYear();
+}
+
+// Newest-first ordering so each Year/Month section is contiguous regardless of
+// the folder's own sort order.
+function sortImagesByDateDesc(arr){
+    return arr.slice().sort(function(a, b){
+        return (photoImageDateUnix(b) || 0) - (photoImageDateUnix(a) || 0);
+    });
+}
+
 function extractFolderName(folderpath){
     return folderpath.split("/").pop();
 }
@@ -103,6 +153,8 @@ function photoListObject() {
         images: [],           // currently displayed slice
         folders: [],
         sortOrder: 'smart',
+        groupByDate: true,    // Google-Photos-style Year / Month grid sections
+        ratingFilter: 0,      // active "rating ≥ N stars" quick filter (0 = off)
         restored: false,
         hasMoreImages: false,
         isLoadingMore: false, // guard: blocks new batch until DOM has updated
@@ -128,7 +180,7 @@ function photoListObject() {
             this.renderSize = getImageWidth();
             updateImageSizes();
             this.restored = false;
-            this.$nextTick(() => { this.setupInfiniteScroll(); });
+            this.$nextTick(() => { this.setupInfiniteScroll(); this.setupGridZoom(); });
 
             // Kick off background (auto) indexing shortly after the first paint so
             // it doesn't compete with the initial folder load.
@@ -194,6 +246,9 @@ function photoListObject() {
                     console.log(data);
                     this.folders = data[0];
                     this.allImages = data[1];
+                    // Date grouping reads cleanest newest-first; sort the loaded
+                    // list so Year/Month sections are contiguous as you scroll.
+                    if (this.groupByDate) { this.allImages = sortImagesByDateDesc(this.allImages); }
                     this.images = this.allImages.slice(0, PAGE_SIZE);
                     this.hasMoreImages = this.allImages.length > PAGE_SIZE;
                     this.isLoadingMore = false;
@@ -262,6 +317,106 @@ function photoListObject() {
             });
         },
 
+        // ── Grid zoom (Ctrl/⌘ + mouse wheel) ───────────────────────────────────
+
+        // Recompute the tile size from the current column count and push it to
+        // both the reactive binding and the already-rendered cards.
+        applyRenderSize() {
+            this.renderSize = getImageWidth();
+            updateImageSizes();
+        },
+
+        // Ctrl/⌘ + wheel (and trackpad pinch, which also sets ctrlKey) zooms the
+        // whole grid; a plain wheel keeps scrolling the gallery.
+        setupGridZoom() {
+            const container = document.getElementById('viewboxContainer');
+            if (!container) return;
+            container.addEventListener('wheel', (e) => {
+                if (!e.ctrlKey && !e.metaKey) return;
+                e.preventDefault();
+                this.zoomGrid(e.deltaY < 0 ? 1 : -1);
+            }, { passive: false });
+        },
+
+        // direction: +1 = zoom in (fewer columns, larger photos), -1 = zoom out.
+        zoomGrid(direction) {
+            const cur = getColumnCount();
+            let next = cur - direction;
+            if (next < PHOTO_GRID_MIN_COLS) next = PHOTO_GRID_MIN_COLS;
+            if (next > PHOTO_GRID_MAX_COLS) next = PHOTO_GRID_MAX_COLS;
+            if (next === cur) return;
+            photoGridColumns = next;
+            this.applyRenderSize();
+            showGridZoomHint(next);
+        },
+
+        // ── Date grouping ──────────────────────────────────────────────────────
+
+        // Split the loaded slice into contiguous Year/Month sections for the grid.
+        // Returns a single unlabelled group when grouping is disabled.
+        groupedImages() {
+            const imgs = this.images;
+            if (!this.groupByDate) {
+                return [{ key: 'all', label: '', images: imgs }];
+            }
+            const groups = [];
+            let cur = null;
+            for (let i = 0; i < imgs.length; i++) {
+                const img = imgs[i];
+                const d = photoImageDateUnix(img);
+                let key, label;
+                if (d) {
+                    const dt = new Date(d * 1000);
+                    key = dt.getFullYear() + '-' + (dt.getMonth() + 1);
+                    label = photoGroupLabel(d);
+                } else {
+                    key = 'undated';
+                    label = 'Undated';
+                }
+                if (!cur || cur.key !== key) {
+                    cur = { key: key, label: label, images: [] };
+                    groups.push(cur);
+                }
+                cur.images.push(img);
+            }
+            return groups;
+        },
+
+        // Re-fetch the current view so the new ordering / headers take effect.
+        onGroupByDateChange() {
+            if (this.searchMode) { this.runSearch(); }
+            else { this.getFolderInfo(); }
+        },
+
+        // ── Rating quick-filter (sidebar stars) ────────────────────────────────
+
+        // Select "rating ≥ n" (tapping the active level again clears it). Driven
+        // through the same search-chip pipeline as every other filter.
+        setRatingFilter(n) {
+            n = parseInt(n, 10) || 0;
+            if (n === this.ratingFilter) n = 0;
+            this.ratingFilter = n;
+            this.searchTags = this.searchTags.filter(function (t) { return t.type !== 'rating'; });
+            if (n > 0) {
+                this.searchTags.push({ label: '★ ≥ ' + n, value: 'rating:>=' + n, type: 'rating' });
+            }
+            this.runSearch();
+        },
+
+        // Keep the sidebar stars in step with whatever rating chip is present
+        // (a chip may also be typed, picked from autocomplete or removed by hand).
+        syncRatingFilterFromTags() {
+            let found = 0;
+            for (let i = 0; i < this.searchTags.length; i++) {
+                const t = this.searchTags[i];
+                if (t.type === 'rating') {
+                    const m = ('' + t.value).match(/(\d)/);
+                    if (m) found = parseInt(m[1], 10);
+                }
+            }
+            this.ratingFilter = found;
+        },
+
         // ── Search ────────────────────────────────────────────────────────────
 
         suggestIcon(type) { return photoSuggestIcon(type); },
@@ -347,6 +502,7 @@ function photoListObject() {
             // (pick/commit/Escape/click-outside/clear) hide it. Closing it here made
             // the dropdown flash and vanish ~400ms after each keystroke.
             clearTimeout(this._searchTimer);
+            this.syncRatingFilterFromTags();
             const q = this.currentQuery();
             if (q.length === 0) {
                 // Nothing to search — fall back to normal folder browsing.
@@ -361,8 +517,13 @@ function photoListObject() {
             }).then(data => {
                 const results = (data && data.results) ? data.results : [];
                 this.searchTotal = (data && typeof data.total === 'number') ? data.total : results.length;
-                // Reuse the existing grid: it only needs {filepath, filesize}.
-                this.allImages = results.map(r => ({ filepath: r.filepath, filesize: r.filesize }));
+                // Carry the date + rating through so the grid can group by month
+                // and the viewer can show the star rating without a refetch.
+                this.allImages = results.map(r => ({
+                    filepath: r.filepath, filesize: r.filesize,
+                    taken_date: r.taken_date, modified_date: r.modified_date, rating: r.rating
+                }));
+                if (this.groupByDate) { this.allImages = sortImagesByDateDesc(this.allImages); }
                 this.images = this.allImages.slice(0, PAGE_SIZE);
                 this.hasMoreImages = this.allImages.length > PAGE_SIZE;
                 this.isLoadingMore = false;
@@ -380,6 +541,7 @@ function photoListObject() {
             this.suggestIndex = -1;
             this.searchTotal = 0;
             this.searchMode = false;
+            this.ratingFilter = 0;
             clearTimeout(this._searchTimer);
             this.getFolderInfo();   // restore normal folder browsing
         },
@@ -427,16 +589,24 @@ function photoListObject() {
         startAutoIndex() {
             if (this.indexing) return;
             this.indexing = true;
+            let indexedThisRun = 0;
             const step = () => {
                 aoPhotoBackend("Photo/backend/indexPhotos.js", { mode: 'incremental' }).then(data => {
                     if (data && data.error) { this.indexing = false; this.indexStatusText = ''; return; }
                     const total = (data && data.total) ? data.total : 0;
+                    indexedThisRun += (data && data.indexed) ? data.indexed : 0;
                     if (data && data.hasMore) {
                         this.indexStatusText = 'Indexing… ' + total + ' photos';
                         setTimeout(step, 50);
                     } else {
                         this.indexing = false;
                         this.indexStatusText = total ? (total + ' photos indexed') : '';
+                        // Newly indexed photos may carry EXIF shoot times the
+                        // current grid grouped without (mtime fallback) — reload
+                        // the view so the Year/Month sections use them.
+                        if (indexedThisRun > 0 && this.groupByDate && !this.searchMode) {
+                            this.getFolderInfo();
+                        }
                         setTimeout(() => { if (!this.indexing) this.indexStatusText = ''; }, 4000);
                     }
                 }).catch(() => { this.indexing = false; this.indexStatusText = ''; });
@@ -461,7 +631,8 @@ function photoListObject() {
                     } else {
                         this.indexing = false;
                         this.indexStatusText = total ? (total + ' photos indexed') : '';
-                        if (this.searchMode) this.runSearch();
+                        if (this.searchMode) { this.runSearch(); }
+                        else if (this.groupByDate) { this.getFolderInfo(); } // refresh EXIF shoot times
                         setTimeout(() => { if (!this.indexing) this.indexStatusText = ''; }, 4000);
                     }
                 }).catch(() => { this.indexing = false; this.indexStatusText = ''; });
@@ -538,6 +709,8 @@ function showImage(object){
     
     var fd = JSON.parse(decodeURIComponent($(object).attr("filedata")));
     _currentCastFilepath = fd.filepath;
+    currentPhotoFilepath = fd.filepath;
+    fetchPhotoRating(fd.filepath);
     if (_photoCastConnected()) _photoCastSendPhoto(fd.filepath);
     $("#info-dimensions").text("Calculating...");
     // Check if we should use compression (only for JPG/PNG > 5MB)
@@ -692,6 +865,89 @@ function loadFullSizeImageInBackground(fullSizeUrl, fileData) {
     fullImage.src = fullSizeUrl;
 }
 
+// ── Download current photo ─────────────────────────────────────────────────────
+// Streams the *original* file (RAW included) via the media server's download
+// mode, which sets a Content-Disposition: attachment header.
+function downloadCurrentPhoto() {
+    if (!currentPhotoFilepath) return;
+    var url = ao_root + 'media?download=true&file=' + encodeURIComponent(currentPhotoFilepath);
+    var a = document.createElement('a');
+    a.href = url;
+    a.download = currentPhotoFilepath.split('/').pop();
+    document.body.appendChild(a);
+    a.click();
+    document.body.removeChild(a);
+}
+
+// ── Star rating (viewer) ───────────────────────────────────────────────────────
+
+// Paint the 1-5 star widget to reflect `rating` (and remember it).
+function renderPhotoRating(rating) {
+    currentPhotoRating = rating || 0;
+    var container = document.getElementById('photo-rating-stars');
+    if (container) {
+        var stars = container.querySelectorAll('.photo-star');
+        for (var i = 0; i < stars.length; i++) {
+            stars[i].classList.toggle('filled', (i + 1) <= currentPhotoRating);
+            stars[i].classList.remove('hover');
+        }
+    }
+    var label = document.getElementById('photo-rating-label');
+    if (label) label.textContent = currentPhotoRating ? (currentPhotoRating + ' / 5') : 'Rate';
+}
+
+// Persist a new rating for the open photo (tapping the current value clears it).
+function setPhotoRating(n) {
+    if (!currentPhotoFilepath) return;
+    if (n === currentPhotoRating) n = 0;
+    renderPhotoRating(n); // optimistic update
+    var target = currentPhotoFilepath;
+    fetch(ao_root + "system/ajgi/interface?script=Photo/backend/setRating.js", {
+        method: 'POST',
+        cache: 'no-cache',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ filepath: target, rating: n })
+    }).then(function (r) { return r.json(); }).then(function (data) {
+        if (target !== currentPhotoFilepath) return; // user already moved on
+        if (data && typeof data.rating === 'number') renderPhotoRating(data.rating);
+    }).catch(function () { /* keep the optimistic value */ });
+}
+
+// Load the stored rating for a freshly-opened photo.
+function fetchPhotoRating(filepath) {
+    renderPhotoRating(0);
+    fetch(ao_root + "system/ajgi/interface?script=Photo/backend/getRating.js", {
+        method: 'POST',
+        cache: 'no-cache',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ filepath: filepath })
+    }).then(function (r) { return r.json(); }).then(function (data) {
+        if (filepath !== currentPhotoFilepath) return; // raced past this photo
+        renderPhotoRating(data && data.rating ? data.rating : 0);
+    }).catch(function () { renderPhotoRating(0); });
+}
+
+// Wire click + hover-preview on the star widget once.
+function initPhotoRatingWidget() {
+    var container = document.getElementById('photo-rating-stars');
+    if (!container) return;
+    var stars = container.querySelectorAll('.photo-star');
+    for (var i = 0; i < stars.length; i++) {
+        (function (star, value) {
+            star.addEventListener('click', function () { setPhotoRating(value); });
+            star.addEventListener('mouseenter', function () {
+                for (var j = 0; j < stars.length; j++) {
+                    stars[j].classList.toggle('hover', j < value);
+                }
+            });
+        })(stars[i], i + 1);
+    }
+    container.addEventListener('mouseleave', function () {
+        for (var k = 0; k < stars.length; k++) stars[k].classList.remove('hover');
+    });
+}
+document.addEventListener('DOMContentLoaded', initPhotoRatingWidget);
+
 $(document).on("keydown", function(e){
     if (e.keyCode == 27){ // Escape
         if ($('#photo-viewer').is(':visible')) {

+ 13 - 0
src/web/Photo/search.js

@@ -40,6 +40,8 @@ function photoSuggestIcon(type) {
             return 'image outline';
         case 'filter':
             return 'filter';
+        case 'rating':
+            return 'star';
         default:
             return 'search';
     }
@@ -93,6 +95,17 @@ function photoParseTagToken(token) {
     if (colon > 0) {
         switch (key) {
             case 'iso': return { label: 'ISO ' + val, value: 'iso:' + val, type: 'filter' };
+            case 'rating': case 'stars': case 'star': {
+                var rv = unq(val).replace(/★|\*/g, '').trim();
+                var label;
+                var mge = rv.match(/^>=?\s*(\d)/);
+                var mle = rv.match(/^<=?\s*(\d)/);
+                if (mge) { label = '★ ≥ ' + mge[1]; }
+                else if (mle) { label = '★ ≤ ' + mle[1]; }
+                else if (/^\d$/.test(rv)) { label = rv + '★'; }
+                else { label = 'Rating ' + rv; }
+                return { label: label, value: 'rating:' + rv, type: 'rating' };
+            }
             case 'f': case 'aperture': case 'fnumber': return { label: 'f/' + val.replace(/^\//, ''), value: token, type: 'filter' };
             case 'focal': case 'fl': return { label: val.replace(/mm$/i, '') + 'mm', value: token, type: 'filter' };
             case 'mp': case 'megapixels': return { label: val + ' MP', value: token, type: 'filter' };