diff options
| -rw-r--r-- | app.rb | 157 | ||||
| -rw-r--r-- | public/css/style.css | 27 | ||||
| -rw-r--r-- | views/layout.erb | 5 | ||||
| -rw-r--r-- | views/search.erb | 53 |
4 files changed, 242 insertions, 0 deletions
@@ -18,6 +18,11 @@ THUMB_SIZE = 300 UPDATE_JOBS = {} UPDATE_JOBS_MUTEX = Mutex.new +SEARCH_TTL = 300 # seconds before rebuilding search index +MONTH_NAMES = %w[january february march april may june july + august september october november december].freeze +$search_cache = { index: nil, built_at: nil, mutex: Mutex.new } + IMAGE_EXTS = %w[jpg jpeg png gif webp heic heif tiff bmp].freeze VIDEO_EXTS = %w[mp4 mov avi mkv webm m4v ogv].freeze AUDIO_EXTS = %w[mp3 flac ogg wav m4a aac].freeze @@ -270,6 +275,142 @@ helpers do end end +# ── Search index ────────────────────────────────────────────────────────────── + +def search_index + $search_cache[:mutex].synchronize do + if $search_cache[:index].nil? || + (Time.now - $search_cache[:built_at]) > SEARCH_TTL + $search_cache[:index] = build_search_index + $search_cache[:built_at] = Time.now + end + $search_cache[:index] + end +end + +def build_search_index + # people lookup: rel → [person name, ...] + people_by_rel = {} + (load_people_data['people'] || {}).each do |_, p| + next unless p['name'] + (p['members'] || []).each do |m| + (people_by_rel[m['rel']] ||= []) << p['name'] + end + end + + docs = [] + dirs = [MEDIA_ROOT] + Dir.glob("#{MEDIA_ROOT}/**/*/").sort + dirs.each do |dir| + dir_rel = dir.delete_prefix(MEDIA_ROOT).delete_prefix('/') + data = load_album(dir) + dir_words = dir_rel.split('/').flat_map { |s| s.downcase.split(/[-_]/) } + album_title = (data['title'] || (dir_rel.empty? ? '' : File.basename(dir))).downcase + album_vis = data['visible'] != false + + (data['files'] || {}).each do |filename, meta| + meta ||= {} + rel = dir_rel.empty? ? filename : "#{dir_rel}/#{filename}" + + parts = (dir_words + [album_title, + filename.downcase, + File.basename(filename, '.*').downcase, + meta['title'].to_s.downcase, + meta['caption'].to_s.downcase, + meta['camera'].to_s.downcase]).reject(&:empty?) + + if (ts = meta['taken_at']) + parts << ts[0..3] + if ts.length >= 7 + m_idx = ts[5..6].to_i - 1 + parts << ts[5..6] + parts << MONTH_NAMES[m_idx] if m_idx.between?(0, 11) + end + parts << ts[0..9] if ts.length >= 10 + end + + names = people_by_rel[rel] || [] + parts.concat(names.map(&:downcase)) + + docs << { + rel: rel, + filename: filename, + dir_rel: dir_rel, + text: parts.join(' '), + taken_at: meta['taken_at'], + people: names, + visible: album_vis && meta.fetch('visible', true) != false, + } + end + end + docs +end + +# ── Boolean query parser ─────────────────────────────────────────────────────── +# Grammar: expr = or_expr +# or_expr = and_expr ('OR' and_expr)* +# and_expr = not_expr ('AND'? not_expr)* ← AND implicit between terms +# not_expr = 'NOT' not_expr | atom +# atom = TERM + +def search_tokenize(query) + query.downcase.scan(/\S+/).map do |t| + case t.upcase + when 'AND' then :AND + when 'OR' then :OR + when 'NOT' then :NOT + else t + end + end +end + +def search_parse(query) + toks = search_tokenize(query) + pos = [0] + search_or(toks, pos) +end + +def search_or(toks, pos) + left = search_and(toks, pos) + while toks[pos[0]] == :OR + pos[0] += 1 + left = [:or, left, search_and(toks, pos)] + end + left +end + +def search_and(toks, pos) + left = search_not(toks, pos) + loop do + t = toks[pos[0]] + break if t.nil? || t == :OR + pos[0] += 1 if t == :AND # consume explicit AND; implicit falls through + left = [:and, left, search_not(toks, pos)] + end + left +end + +def search_not(toks, pos) + if toks[pos[0]] == :NOT + pos[0] += 1 + [:not, search_not(toks, pos)] + else + t = toks[pos[0]] + return [:lit, true] if t.nil? || %i[AND OR NOT].include?(t) + pos[0] += 1 + [:term, t] + end +end + +def eval_search(node, text) + case node[0] + when :or then eval_search(node[1], text) || eval_search(node[2], text) + when :and then eval_search(node[1], text) && eval_search(node[2], text) + when :not then !eval_search(node[1], text) + when :term then text.include?(node[1]) + when :lit then node[1] + end +end + # ── Public routes ────────────────────────────────────────────────────────────── get '/' do @@ -402,6 +543,22 @@ def slideshow_view(rel) erb :slideshow, layout: false end +get '/search' do + q = params[:q].to_s.strip + @title = q.empty? ? 'Search' : "Search: #{q}" + @search_query = q + + unless q.empty? + ast = search_parse(q) + index = search_index + all = index.select { |d| (admin? || d[:visible]) && eval_search(ast, d[:text]) } + @total = all.length + @results = all.first(300) + end + + erb :search +end + # ── Admin routes ─────────────────────────────────────────────────────────────── get '/admin' do diff --git a/public/css/style.css b/public/css/style.css index ba9b822..47147f4 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -366,6 +366,32 @@ legend { padding: 0 8px; color: var(--text-dim); font-size: .85rem; } .sub-album-list { list-style: none; display: flex; flex-wrap: wrap; gap: 8px; } .sub-album-list a { padding: 4px 12px; background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius); font-size: .9rem; } +/* ── Header search ─────────────────────────────────────────────────────── */ +.header-search { flex: 1; display: flex; justify-content: center; padding: 0 20px; } +.header-search-input { + width: 200px; + background: var(--bg3); border: 1px solid var(--border); border-radius: var(--radius); + color: var(--text); padding: 5px 12px; font-size: .85rem; + transition: width .2s, border-color .15s; +} +.header-search-input:focus { width: 300px; border-color: var(--accent); outline: none; } + +/* ── Search results page ────────────────────────────────────────────────── */ +.search-form { display: flex; gap: 8px; margin: 14px 0 8px; } +.search-input { + flex: 1; max-width: 540px; + background: var(--bg3); border: 1px solid var(--border); border-radius: var(--radius); + color: var(--text); padding: 8px 12px; font-size: 1rem; +} +.search-input:focus { border-color: var(--accent); outline: none; } +.search-hint { font-size: .8rem; color: var(--text-dim); margin: 0 0 18px; } +.search-hint code { background: var(--bg3); padding: 1px 5px; border-radius: 3px; font-size: .85em; } +.search-card .card-meta { padding: 6px 8px 8px; } +.search-album-path { font-size: .72rem; color: var(--text-dim); margin-top: 3px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.search-album-path a { color: var(--text-dim); } +.search-album-path a:hover { color: var(--accent); } + /* ── Responsive ────────────────────────────────────────────────────────── */ @media (max-width: 600px) { :root { --thumb: 140px; --gap: 8px; } @@ -373,6 +399,7 @@ legend { padding: 0 8px; color: var(--text-dim); font-size: .85rem; } .lb-prev { left: 4px; } .lb-next { right: 4px; } .files-table { font-size: .78rem; } + .header-search { display: none; } } /* ── Unplayable originals (admin-only) ─────────────────────────────────── */ diff --git a/views/layout.erb b/views/layout.erb index e0c5399..7069e76 100644 --- a/views/layout.erb +++ b/views/layout.erb @@ -16,6 +16,11 @@ <body> <header class="site-header"> <a href="/browse/" class="site-logo">Albumen</a> + <form action="/search" method="get" class="header-search"> + <input type="search" name="q" placeholder="Search…" autocomplete="off" + value="<%= defined?(@search_query) ? ERB::Util.html_escape(@search_query.to_s) : '' %>" + class="header-search-input"> + </form> <nav class="site-nav"> <% if FACES_ENABLED %><a href="/people">People</a><% end %> <% if admin? %> diff --git a/views/search.erb b/views/search.erb new file mode 100644 index 0000000..1010284 --- /dev/null +++ b/views/search.erb @@ -0,0 +1,53 @@ +<div class="album-header"> + <h1>Search</h1> + <form action="/search" method="get" class="search-form"> + <input type="search" name="q" value="<%= ERB::Util.html_escape(@search_query.to_s) %>" + placeholder="e.g. ken 2004 OR carol AND NOT vacation" + class="search-input" autofocus autocomplete="off"> + <button type="submit" class="btn">Search</button> + </form> + <p class="search-hint"> + Searches filename, album, caption, date, and people names. + Consecutive terms are AND'd. Operators: <code>AND</code> <code>OR</code> <code>NOT</code>. + Example: <code>ken OR carol AND NOT vacation</code> + </p> +</div> + +<% if @search_query && !@search_query.empty? %> + <% if @results %> + <p class="update-hint"> + <%= @total %> result<%= @total == 1 ? '' : 's' %> + <% if @total > @results.length %>(showing first <%= @results.length %>)<% end %> + </p> + <% if @results.empty? %> + <p class="empty-album">No photos matched "<%= ERB::Util.html_escape(@search_query) %>".</p> + <% else %> + <div class="grid"> + <% @results.each do |r| %> + <% album_url = r[:dir_rel].empty? ? '/browse/' : "/browse/#{ERB::Util.html_escape(r[:dir_rel])}" %> + <a href="<%= album_url %>?photo=<%= ERB::Util.url_encode(r[:filename]) %>" + class="card search-card" style="text-decoration:none"> + <div class="thumb-wrap"> + <img src="/thumb/<%= ERB::Util.html_escape(r[:rel]) %>" loading="lazy" + alt="<%= ERB::Util.html_escape(r[:filename]) %>"> + </div> + <div class="card-meta"> + <div class="filename"><%= ERB::Util.html_escape(r[:filename]) %></div> + <% if r[:taken_at] %> + <div class="card-caption"><%= ERB::Util.html_escape(r[:taken_at][0..9]) %></div> + <% end %> + <% unless r[:people].empty? %> + <div class="card-caption"><%= ERB::Util.html_escape(r[:people].join(', ')) %></div> + <% end %> + <div class="search-album-path"> + <a href="<%= album_url %>" onclick="event.stopPropagation()"> + <%= ERB::Util.html_escape(r[:dir_rel].empty? ? '(root)' : r[:dir_rel]) %> + </a> + </div> + </div> + </a> + <% end %> + </div> + <% end %> + <% end %> +<% end %> |
