
    id                    \   U d Z ddlZddlZddlZddlZddlZddlZddlZddlZddl	m
Z
mZ ddlmZmZ ddlmZ ddlmZ ddlmZ ddlZddlZddlmZ dd	lmZmZmZmZ dd
lmZmZm Z  ddl!m"Z" 	 ddl#Z# e#jH                  d      Z% e         ee'      jP                  jP                  dz  Z) e ejT                  dd            Z+ e ejT                  d e, ee'      jP                  jP                  dz                    Z- ejT                  dd      j]                         Z/ ejT                  dd      j]                         ja                         Z1ddddZ2 ejT                  dd      j]                         ja                         Z3 ejT                  dd      j]                         Z4 ejT                  dd      j]                         xs dZ5 ejT                  dd      j]                         xs dZ6 e7 ejT                  dd            Z8 ejT                  d d!      j]                         ja                         d"v Z9 ejT                  d#d      j]                         ju                  d$      Z; ejT                  d%d      j]                         Z< ejT                  d&d      j]                         Z= ejT                  d'd      j]                         Z> ejT                  d(d)      j                  d*      D  ch c]0  } | j]                         r| j]                         ja                         2 c} Z@ ejT                  d+d,      j]                         xs d,ZA ejT                  d-d.      j]                         ja                         xs d.ZB ejT                  d/d      j]                         ju                  d$      ZC e7 ejT                  d0d1            ZDe1d2v re1ZEn# ejT                  d3      d4k(  re;re=rd5ZEne/rd6ZEnd7ZEeEd6k(  rd8nd9ZFe3se;re<rd:Z3ne4rd;Z3nd<Z3g d=ZGh d>ZH ed?@      ZIi ZJeKe,eLeMe,f   f   eNdA<   e;r ej                  e; dB      ndZP G dC dDeK      ZQdE ZRdFe,dGe,fdHZS G dI dJ      ZTdK ZUddLeVdGeKe,e,f   fdMZWddNe,dOeeKe,e,f      dGeXeK   fdPZYddQe7dNe,dGeeK   fdRZZdQe7dSeKdGeeK   fdTZ[dUeXe7   dSeKdGeXeK   fdVZ\dW Z] e]        dGe,fdXZ^dYe,dGe,fdZZ_dGe,fd[Z`d\e,dGeVfd]Zad^edGeVfd_Zbd^edGe,fd`ZcdGe,fdaZdd\e,dGeKfdbZed\e,dGeKfdcZfd\e,dGeKfddZgd^edGeKfdeZhdfe,dGeVfdgZidGeKfdhZjdie,dGe,fdjZkdie,dGe,fdkZldie,dGe,fdlZmdie,dGe,fdmZndGe,fdnZodGe,fdoZpdGe,fdpZqdGe,fdqZrdre,dQe7dGe,fdsZsdte,dGeKfduZtdve,dGe,fdwZudGe,fdxZvdyeKdGeKfdzZwd{eKdGeKfd|Zxd{eKdGeKfd}Zyd{eKdGeLfd~ZzdeXeK   dGeKfdZ{deXeK   de,dGe,fdZ|dGe7fdZ}dd{eKdeVdGeKfdZ~ddeXeK   deVde7dGeKfdZdeXeK   dGeKe,eXeK   f   fdZddeXeK   deVde7dGeXeK   fdZdeXeK   de,de,dGeXeK   fdZdee,   dvee,   dee,   deeV   dGeLe,eXf   f
dZdeXeK   dee,   dvee,   dee,   deeV   dGeXeK   fdZde,dGe,fdZdee,   dvee,   dee,   deeV   dGeKe,e,f   f
dZdeXeK   de,de,dGeXeK   fdZdeXeK   dte,dGeXeK   fdZdeXeK   dGeKfdZh dZdZeIj                  d      d^efd       Z G d de"      ZeIj                  d      defd       ZeIj                  d      d        ZeIj%                  d      d^efd       Z G d de"      ZeIj                  d      ded^efd       ZeIj%                  d      d        ZeIj%                  d       ed       ed       ed       ed       ed       ed       edd       eddd      fdee,   dvee,   dee,   deeV   de,de,de7de7fd       ZeIj%                  d      dQe7fd       ZeIj%                  d       ed       ed       ed       ed       ed       ed       edd       eddd      fdee,   dvee,   dee,   deeV   de,de,de7de7fd       ZeIj%                  d      dte,fd       Z G d de"      ZeIj9                  d      dte,defd       ZeIj%                  d«      dÄ        ZdQe7de,dGeKfdńZeIj                  dƫ      dQe7fdǄ       ZeIj                  dȫ      dQe7fdɄ       Z G dʄ de"      ZeIj9                  d̫      dQe7defd̈́       ZeIj%                  dΫ      dτ        ZeIj%                  dЫ      dф        ZeIj%                  dҫ      dve,de,fdԄ       ZeIj%                  dի      de,fdׄ       Z G d؄ de"      ZeIj                  dګ      defdۄ       Z G d܄ de"      ZeIj                  dޫ      defd߄       Z G d de"      ZeIj                  d      defd       ZeIj%                  d$e      d        ZdZdZy# e&$ r dZ%Y 	w xY wc c} w )zT
Book Catalog - book-centered gallery with grouped photos and inline title editing.
    N)Counterdefaultdict)datetimetimezone)Path)Optional)quote)load_dotenv)FastAPIHTTPExceptionQueryRequest)FileResponseHTMLResponseJSONResponse)	BaseModelpsycopgzbooks.dbIMAGES_PATHz0C:\Users\ignac\OneDrive\Pictures\selected_librosOPTIMIZED_IMAGES_PATHoptimized_imagesDATABASE_URL DATABASE_ADAPTER      frontrootside	AUTH_MODEAPP_PASSWORDAPP_SECRET_KEYzchange-me-in-productionSESSION_COOKIE_NAMEbibliothek_sessionSESSION_TTL_SECONDS2592000COOKIE_SECUREfalse>   1onyestrueSUPABASE_URL/SUPABASE_ANON_KEYSUPABASE_SERVICE_ROLE_KEYSUPABASE_AUTH_REDIRECT_URLOWNER_EMAILSzignaciolagosruiz@gmail.com,IMAGE_BUCKETzcatalog-imagesIMAGE_ACCESS_MODElocalREMOTE_IMAGE_BASE_URLSIGNED_URL_TTL_SECONDS604800>   restsqlitepostgresVERCELr)   r:   r<   r;   ILIKELIKEsupabasepasswordnone)titlesubtitleauthor
translator	publisheryeareditionlanguageseriesvolume	conditionspecial_features
other_textraw_ocr_text>   flaggedpendingverified	correctedzBook Catalog Gallery)rC   SIGNED_URL_CACHEz/auth/v1/.well-known/jwks.jsonc                   (     e Zd Z fdZ fdZ xZS )	HybridRowc                 X    t         |   t        ||             t        |      | _        y N)super__init__ziptuple_values)selfcolumnsvalues	__class__s      '/home/ubuntu/bibliothek-app/app/main.pyr[   zHybridRow.__init__k   s"    Wf-.V}    c                 `    t        |t              r| j                  |   S t        |   |      S rY   )
isinstanceintr^   rZ   __getitem__)r_   keyrb   s     rc   rh   zHybridRow.__getitem__o   s,    c3<<$$w"3''rd   )__name__
__module____qualname__r[   rh   __classcell__)rb   s   @rc   rW   rW   j   s    %( (rd   rW   c                 d    | j                   D cg c]  }|j                   c}fd}|S c c}w )Nc                     t        |       S rY   )rW   )ra   r`   s    rc   make_rowz pg_row_factory.<locals>.make_rowx   s    &))rd   )descriptionname)cursorcolumnrp   r`   s      @rc   pg_row_factoryru   u   s.    )/););<vv{{<G* O =s   -urlreturnc                 N    | j                  d      r| j                  ddd      S | S )Nzpostgres://zpostgresql://r   )
startswithreplace)rv   s    rc   normalize_database_urlr{   ~   s&    
~~m${{=/1==Jrd   c                   H    e Zd Zd ZdedefdZd
defdZdefdZd Zd Z	y	)CompatConnectionc                 B   t         | _        | j                  dk(  r?t        t        d      t        j	                  t        t              t              | _        y t        j                  t        t                    | _        t        j                  | j                  _        y )Nr<   z3psycopg is required when DATABASE_URL is configured)row_factory)DB_MODEbackendr   RuntimeErrorconnectr{   r   ru   connsqlite3strDB_PATHRowr   r_   s    rc   r[   zCompatConnection.__init__   se    <<:%"#XYY(>|(LZhiDIG5DI$+KKDII!rd   queryrw   c                 H    | j                   dk(  r|j                  dd      S |S )Nr<   ?z%s)r   rz   )r_   r   s     rc   _convert_queryzCompatConnection._convert_query   s$    <<:%==d++rd   c                     | j                   j                         }|j                  | j                  |      |xs d       |S )N )r   rs   executer   )r_   r   paramsrs   s       rc   r   zCompatConnection.execute   s6    !!#t**516<R@rd   c                 |    | j                   j                         }|j                  | j                  |      |       |S rY   )r   rs   executemanyr   )r_   r   seq_of_paramsrs   s       rc   r   zCompatConnection.executemany   s4    !!#4..u5}Erd   c                 8    | j                   j                          y rY   )r   commitr   s    rc   r   zCompatConnection.commit   s    		rd   c                 8    | j                   j                          y rY   )r   closer   s    rc   r   zCompatConnection.close   s    		rd   N)r   )
rj   rk   rl   r[   r   r   r   r   r   r   r   rd   rc   r}   r}      s<    0C C 
S 
 
rd   r}   c                      t               S rY   )r}   r   rd   rc   get_dbr      s    rd   prefer_representationc                 d    t         rt        st        d      dt         t        dd}| rd|d<   |S )NzFSupabase REST mode requires SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEYBearer application/jsonAuthorizationapikeyzContent-Typezreturn=representationPrefer)r-   r0   r   )r   headerss     rc   rest_headersr      sA    8cdd"#<"=>+*G
 3Nrd   selectextra_paramsc                     | ddd}|r|j                  |       t        j                  t         dt	               |d      }|j                          |j                         S )Nzid.asc1000)r   orderlimit/rest/v1/book_images      N@r   r   timeout)updatehttpxgetr-   r   raise_for_statusjson)r   r   r   responses       rc   rest_fetch_rowsr      sa    F
 l#yy.,-	H ==?rd   image_idc                     t        j                  t         dt               |d|  ddd      }|j	                          |j                         }|r|d   S d S )Nr   eq.r)   )r   idr         >@r   r   )r   r   r-   r   r   r   )r   r   r   payloads       rc   rest_fetch_rowr      sd    yy.,-z"

 	H mmoG 71:*d*rd   ra   c                     t        j                  t         dt        d      dd|  i|d      }|j	                          |j                         }|r|d   S d S )	Nr   Tr   r   r   r   r   r   r   r   r   )r   patchr-   r   r   r   )r   ra   r   r   s       rc   rest_update_rowr      sb    {{.,-48H:&'H mmoG 71:*d*rd   	image_idsc                     | sg S dj                  d | D              }t        j                  t         dt	        d      dd| di|d	
      }|j                          |j                         S )Nr3   c              3   2   K   | ]  }t        |        y wrY   )r   ).0r   s     rc   	<genexpr>z#rest_update_rows.<locals>.<genexpr>   s     ;X3x=;s   r   Tr   r   zin.()r   r   )joinr   r   r-   r   r   r   )r   ra   idsr   s       rc   rest_update_rowsr      so    	
((;;
;C{{.,-48SEm$H ==?rd   c                  L   t         dk7  ry t        j                  t        t                    } | j                  d      j                         D ch c]  }|d   	 }}d|vr| j                  d       d|vr| j                  d       d|vr| j                  d	       d
|vr| j                  d       d|vr| j                  d       d|vr| j                  d       d|vr| j                  d       | j                  d       | j                          | j                          y c c}w )Nr;   zPRAGMA table_info(book_images)r   rq   z>ALTER TABLE book_images ADD COLUMN description TEXT DEFAULT ""review_statuszGALTER TABLE book_images ADD COLUMN review_status TEXT DEFAULT 'pending'review_updated_atzDALTER TABLE book_images ADD COLUMN review_updated_at TEXT DEFAULT ""	image_urlz<ALTER TABLE book_images ADD COLUMN image_url TEXT DEFAULT ""	thumb_urlz<ALTER TABLE book_images ADD COLUMN thumb_url TEXT DEFAULT ""storage_pathz?ALTER TABLE book_images ADD COLUMN storage_path TEXT DEFAULT ""thumb_storage_pathzEALTER TABLE book_images ADD COLUMN thumb_storage_path TEXT DEFAULT ""zhUPDATE book_images SET review_status = 'pending' WHERE review_status IS NULL OR TRIM(review_status) = '')	r   r   r   r   r   r   fetchallr   r   )r   rowcolss      rc   ensure_book_image_columnsr      s   (??3w<(D"ll+KLUUWXsCFXDXD UVd"^_$&[\$ST$STT!VW4'\]LLr 	KKMJJL+ Ys   D!c                     | yt        | t              rdj                  d | D              } t        |       j	                         S )Nr   , c              3      K   | ]7  }t        |      j                         st        |      j                          9 y wrY   )r   strip)r   items     rc   r   zclean_text.<locals>.<genexpr>  s(     STAR#d)//+Ss   ??)rf   listr   r   r   values    rc   
clean_textr     s;    }%		SSSu:rd   r   c                     t        j                  t        j                  d      | j                  d      t        j
                        j                         S )Nutf-8)hmacnewr"   encodehashlibsha256	hexdigest)r   s    rc   session_signaturer     s6    88N))'2GNN74KW^^\ffhhrd   c                  t    t        t        j                               t        z   } d|  }| dt        |       S )Nzshared::)rg   timer%   r   )
expires_atr   s     rc   create_session_tokenr   "  s<    TYY[!$77J
|$GYa)'2344rd   tokenc                    	 | j                  dd      \  }}|j                  dd      \  }}t        |      }t	        |      }t        j                  ||      sy|t        t        j                               k\  S # t        $ r Y yw xY w)Nr   r   F)rsplitsplitrg   
ValueErrorr   r   compare_digestr   )r   r   	signature_expires_at_textr   expecteds          rc   is_valid_session_tokenr   (  s    "\\#q1$]]32?)
 !)Hy(3TYY[)))  s   5A9 9	BBrequestc                     t         dk7  st        syt        | j                  j	                  t
                    }t        |      xr t        |      S )NrA   T)r    r!   r   cookiesr   r#   boolr   )r   r   s     rc   request_is_authenticatedr   6  s>    Jlw**+>?@E;81%88rd   c                     t        | j                  j                  d            }|j                         j	                  d      r|dd  j                         S y)Nr   zbearer    r   )r   r   r   lowerry   r   )r   auth_headers     rc   extract_bearer_tokenr  =  sJ    W__00ABK%%i012$$&&rd   c                  $    t         r	t          dS dS )Nz/auth/v1r   )r-   r   rd   rc   supabase_issuerr  D  s    (4l^8$<"<rd   c           	      |   | rt         rt        t        dd      t        j                  |       }t        |j                  d            j                         }|j                  d      rt        d      t        j                  |       }t        j                  | |j                  |gt               dg dd	
      }|S )N  Authentication requiredstatus_codedetailalgHSz1Legacy symmetric JWTs require fallback validationF)expisssub)
verify_audrequire)
algorithmsissueroptions)r-   JWKS_CLIENTr   jwtget_unverified_headerr   r   upperry   r   get_signing_key_from_jwtdecoderi   r  )r   headerr
  signing_keyclaimss        rc   verify_supabase_token_locallyr  H  s    (;4MNN&&u-F
VZZ&
'
-
-
/C
~~dNOO66u=KZZ5 $1FGF Mrd   c                    t         rt        st        dd      t        j                  t          dd|  t        dd      }|j
                  d	k7  rt        dd
      |j                         }|j	                  d      t        |j	                  d            t        |j	                  d      xs! |j	                  di       j	                  d            |dS )Nr  zSupabase auth is not configuredr  z/auth/v1/userr   )r   r   g      .@)r   r      zInvalid sessionr   emailroleapp_metadatar  r   r!  r  )r-   r/   r   r   r   r  r   r   )r   r   r   s      rc   "verify_supabase_token_via_userinfor$  \  s    04UVVyy.&&ug.'
 H s"4EFFmmoG{{4 GKK017;;v.]'++nb2Q2U2UV\2]^	 rd   c                 
   | st        dd      	 t        |       }|j                  d      t        |j                  d            t        |j                  d            |dS # t         $ r  t        $ r t        |       cY S w xY w)Nr  r  r  r  r   r!  r#  )r   r  r   r   	Exceptionr$  )r   r  s     rc   verify_supabase_access_tokenr'  s  s    4MNN9.u5::e$

7 34vzz&12	
 	
   91%889s   AA" "BBc                 R    t        | j                  dd       }|st        dd      |S )Nuserr  r  r  )getattrstater   r   r)  s     rc   current_supabase_userr-    s*    7==&$/D4MNNKrd   r   c                 @    t        |       j                         t        v S rY   )r   r   r2   r   s    rc   is_owner_emailr0    s    e""$44rd   c                  L    t         t        t        t        t	        t
              dS )N)authModesupabaseUrlsupabaseAnonKeyauthRedirectUrlinviteEnabled)r    r-   r/   r1   r   r0   r   rd   rc   
app_configr7    s!    #,578 rd   pathc                 :    t        | j                  d      d      S )Nr.   )safe)r	   lstripr8  s    rc   quote_storage_pathr=    s    S!,,rd   c                     t         rt          dt        |        S t        rt        syt         dt         dt        |        S )Nr.   r   z/storage/v1/object/public/)r7   r=  r-   r4   r<  s    rc   get_public_image_urlr?    sF    '(*<T*B)CDD|^5l^1EWX\E]D^__rd   c           	         t         rt        rt        syt         d|  }t        j	                  |      }|r"|d   t        j
                         dz   kD  r|d   S t        j                  t          dt         dt        |        dt         t        d	d
dt        id      }|j                          |j                         }t        |j	                  d      xs$ |j	                  d      xs |j	                  d            }|sy|j                  d      r|n
t          d| }t        j
                         t        z   |ft        |<   |S )Nr   r   r   ,  r   z/storage/v1/object/sign/r.   r   r   r   	expiresIn      4@)r   r   r   	signedURL	signedUrl
signed_urlhttpz/storage/v1)r-   r4   r0   rU   r   r   r   postr=  r8   r   r   r   ry   )r8  	cache_keycachedr   r   signedrF  s          rc   get_signed_image_urlrL    s5   |3L.$(I!!),F&)diikC//ayzz.0a@RSW@X?YZ&'@&AB/.

 12	H mmoGK0iGKK4LiPWP[P[\hPijF!,,V4\N+V\U]:^J#'99;1G#G"TYrd   c                     t        |       }|syt        dk(  rdt        |       S t        dk(  rt        |      S t        dk(  st        rt        |      S y)Nr   local_optimized/media/rK  public)r   r5   r=  rL  r7   r?  )r8  cleaneds     rc   resolve_hosted_urlrR    s[    G--+G4566H$#G,,H$(=#G,,rd   c                 H    t        |       }|r|j                         dk(  ry|S )Nunknownr   r   r   r   texts     rc   
known_textrX    s$    eD4::<9,Krd   c                 L    t        |       j                         }|t        v r|S dS )NrR   )r   r   REVIEW_STATESrV  s     rc   normalize_review_statusr[    s'    e""$D=(47i7rd   c                  h    t        j                  t        j                        j	                  d      S )Nseconds)timespec)r   nowr   utc	isoformatr   rd   rc   review_timestamprb    s#    <<%///CCrd   c                     t        |       }|syt        j                  dd|j                         t        j                        }t        j                  dd|      j                         }|S )Nr   z[^\w\s] )flagsz\s+)rX  rer  r   UNICODEr   )r   rC   s     rc   normalize_titlerh    sP    uEFF:sEKKMDEFF63&,,.ELrd   normalized_titlec                     | rGt        j                  | j                  d            j                  d      }d|j	                  d       S d| S )Nr   asciititle-=image-)base64urlsafe_b64encoder   r  rstrip)ri  r   r   s      rc   encode_group_keyrr    sQ    (()9)@)@)IJQQRYZS)*++H:rd   	group_keyc                    | j                  d      r"	 dt        | j                  dd      d         dS | j                  d	      rc| j                  dd      d   }d
t        |       dz  z  }	 t        j                  ||z   j                  d            j                  d      }d|dS t	        dd      # t        $ r}t	        dd      |d }~ww xY w# t        $ r}t	        dd      |d }~ww xY w)Nrn  image-r   )typer     zInvalid image group keyr  rl  rm     rk  r   zInvalid title group keyrC   )rw  ri  zUnknown group key format)ry   rg   r   r   r   lenro  urlsafe_b64decoder   r  r&  )rs  excr   paddingri  s        rc   decode_group_keyr~    s   H%	\#Y__S!5LQ5O1PQQ H%Q'*#e*q)	\%778P8PQX8YZaabij  5EFF
C0J
KK  	\C8QRX[[	\  	\C8QRX[[	\s/    B5 -6C 5	C>CC	C0C++C0folderc                 .    ddddj                  | d      S )NzFront coverzTitle page / inside pageSpiner   z
Book photor   r  s    rc   photo_labelr    s#    * 
c&,	 rd   c                    t        | t              r| j                         S | yt        | t              rg }| D ]  }t        |t              r|j                         }nUt	        t        |dd            }|s=t        |t              r-t	        |j                  d      xs |j                  d            }|s||j                  |        dj                  |      j                         S t	        |       S )Nr   rW  content
)
rf   r   r   r   r   r*  dictr   appendr   )r  partschunkrW  s       rc   extract_message_textr    s    '3}}'4  	#E%%{{}!'%"<=
5$ 7%eii&7&O599Y;OPDT"	# yy%%''grd   r   c                    t        | j                  d            }t        | j                  d            }t        | j                  d            }t        | j                  d            xs |}|xs t        |      }|xs t        |      xs |}|s(d| j                  dd       d| j                  d	d       }|s|}|| d
<   || d<   | S )Nr   r   r   r   /photos/source_folderr   r.   source_filenamedisplay_url)r   r   rR  )r   r   r   r   r   r  preview_urls          rc   attach_image_urlsr    s    488K01I488K01Idhh~67L#DHH-A$BCS|?1,?KT12DETK /2!> ?qJ[]_A`@ab!%D#DKrd   r   c                 J   t        |       }t        |j                  d            }t        |      |d<   t	        |j                  d            |d<   t        |j                  d            |d<   t        t        |j                  d            |d         |d<   t        |       |S )Nr  r  r   r   rC   r   rs  )r  r   r   r  r[  rr  rh  r  )r   r   r  s      rc   serialize_review_imager  2  s    9D12F%f-D3DHH_4MND *4884G+H ID	('9J)KTRVZXDdKrd   c                 p   t        |       }|d   t        |j                  d            xs d|d    t        |j                  d            t        |j                  d            |j                  dd      |j                  dd      |j                  d	d      |j                  d
d      |d   |d   |d   dS )Nr   rC   zImage #rE   rJ   r  r   r  r  r   r  r   r   )r   rC   rE   rJ   r  r  r  r   r  r   r   )r  rX  r   )r   r   s     rc   review_queue_itemr  =  s    !#&D4jDHHW-.HGDJ<2HTXXh/0txx
34/2688$5r:xxr2XXk2.M*o.!"56 rd   c                 h    t         j                  t        | j                  d            d      | d   fS )Nr  	   r   FOLDER_PRIORITYr   r   r   s    rc   photo_sort_keyr  N  s,    
377?+C DaH#d)TTrd   rowsc                 *    t        | t              d   S )Nri   r   )sortedr  )r  s    rc   pick_cover_imager  R  s    $N+A..rd   fieldc                     | D cg c]  }t        |j                  |             }}|D cg c]  }|s|	 }}|syt        |      j                  d      d   d   S c c}w c c}w )Nr   r   r   )rX  r   r   most_common)r  r  r   ra   r   s        rc   most_common_valuer  V  sd    489Sj(9F9!'15e1F16?&&q)!,Q//	 :1s   !AA"A"c                 ~    t        |       }t        j                  d|      }|rt        |j	                  d            S y)Nz(1[6-9]\d{2}|20\d{2}|2100)r   i'  )r   rf  searchrg   group)r   rW  matchs      rc   year_sort_valuer  ^  s6    eDII3T:E5;;q>""rd   	with_urlsc                     | d   | d   | d   | j                  dd      | j                  dd      | j                  dd      | j                  dd      t        | d         d	}|rt        |      S |S )
Nr   r  r  r   r   r   r   r   )r   r  r  r   r   r   r   r  )r   r  r  )r   r  r   s      rc   image_briefr  f  s|    $i_-01WW["-WW["-3!gg&:B?"3#78	D '0T"9T9rd   sample_limitc           
      2   t        | t              }t        |      }t        |d   j	                  d            }t        |d      }|xs d|d    }t        d |D              }t        d |D              }	t        |D 
ch c]7  }
t        |
j	                  d            st        |
j	                  d            9 c}
d	       }i d
t        ||d         d|d|dt        |d      dt        |d      dt        |d      dt        |d      dt        |      d|d|	d   |	d   |	d   |	d   dd|	d   |	d   z   d|	d   |	d   z   d|dt        ||      d|d | D 
cg c]  }
t        |
|       c}
dt        d |D              S c c}
w c c}
w ) Nr  r   rC   z
Untitled #r   c              3   V   K   | ]!  }t        |j                  d             sd # ywrq   r   Nr   r   r   r   s     rc   r   z"summarize_group.<locals>.<genexpr>z  s      Zz#''-BX7Y!Z   ))c              3   P   K   | ]  }t        |j                  d                ywr   Nr[  r   r  s     rc   r   z"summarize_group.<locals>.<genexpr>{  s      fRU3CGGO4LMf   $&r  c                 .    t         j                  | d      S )Nr  )r  r   r  s    rc   <lambda>z!summarize_group.<locals>.<lambda>~  s    ?..vq9 rd   rs  editable_titlerE   rG   rH   rJ   image_countdescribed_countreview_countsrR   rS   rQ   rT   )rR   rS   rQ   rT   needs_review_countdone_review_countfolderscover_image)r  sample_imagesmin_image_idc              3   &   K   | ]	  }|d      ywr   Nr   r  s     rc   r   z"summarize_group.<locals>.<genexpr>  s     >#CI>   )r  r  r  rh  r   r  sumr   r   rr  rz  r  min)r  r  r  ordered_rowscoverri  r  display_titler  r  r   r  s               rc   summarize_groupr  t  s#   $N3L\*E&|A':':7'CD&|W=N"@
5;-&@MZ|ZZOfYeffM9En#TWT[T[\kTlImCGGO,	-n9G
%&6dD 	. 	#L(;	
 	&|[A 	!,7 	%lJ? 	s<( 	? 	$Y/%j1$Y/&{3	
  	mI6y9QQ!" 	]:6{9SS#$ 	7%& 	{5I>'( 	<XeYeKfgC+cY?g)* 	>>>+ 	 	o0 hs   ?FF$Fc                     t        t              }| D ]H  }t        |      }t        t	        |j                  d            |d         }||   j                  |       J |S )NrC   r   )r   r   r  rr  rh  r   r  )r  groupedr   r   ri   s        rc   
group_rowsr    s[    $G "Cytxx/@A4:ND!" Nrd   c                 r    t        |       j                         D cg c]  }t        |||       c}S c c}w )Nr  r  )r  ra   r  )r  r  r  r  s       rc   build_groupsr    s/    `jko`p`w`w`yzW\OEY\Rzzzs   4groupssortr   c                     |dk(  }dt         fd|dk(  rfd}n$|dk(  rfd}n|dk(  rfd	}n|d
k(  rfd}nd }t        | ||      S )Ndescrw   c                 <    t        |       j                         xs dS )Nz~~~~)rX  r   r   s    rc   text_keyzsort_groups.<locals>.text_key  s    % &&(2F2rd   rC   c                 $     | d         | d   fS )NrC   r  r   r  r  s    rc   r  zsort_groups.<locals>.<lambda>  s    (5>":E.<Q!R rd   rE   c                 8     | d          | d         | d   fS )NrE   rC   r  r   r  s    rc   r  zsort_groups.<locals>.<lambda>  s'    (5?";XeGn=UW\]kWl!m rd   rH   c                 >    t        | d          | d         | d   fS )NrH   rC   r  )r  r  s    rc   r  zsort_groups.<locals>.<lambda>  s(    /%-"@(5QX>BZ\abp\q!r rd   r  c                 ,    | d    | d         | d   fS )Nr  rC   r  r   r  s    rc   r  zsort_groups.<locals>.<lambda>  s#    %"6w8PRWXfRg!h rd   c                     | d   S )Nr  r   )r  s    rc   r  zsort_groups.<locals>.<lambda>  s    ~!6 rd   ri   reverser   r  )r  r  r   r  key_funcr  s        @rc   sort_groupsr    s`    voG33 3 wR		m	r		h6&h88rd   r  rJ   	has_titlec                    g }g }| rI|j                  dt         dt         dt         dt         d	       d|  d}|j                  ||||g       |r"|j                  d       |j                  |       |r.|j                  dt         d	       |j                  d| d       |d
u r|j                  d       n|du r|j                  d       |rddj                  |      z   nd}||fS )Nz(title z ? OR author z ? OR publisher z ? OR raw_ocr_text z ?)%zsource_folder = ?z	language z ?Tz"title != 'Unknown' AND title != ''Fz!(title = 'Unknown' OR title = '')z WHERE z AND r   )r  SEARCH_OPERATORextendr   )r  r  rJ   r  
conditionsr   termwheres           rc   build_filtersr    s    JFo&mO3DDTUdTeex  zI  yJ  JM  N	
 6(!}tT4./-.fIo%6b9:(1o&D>?	e	=>4>IZ00BE&=rd   c                    g }t        |      j                         }| D ]4  }|rt        |j                  d            |k7  r$|r9|j                         t        |j                  d            j                         vr_t        t	        |j                  d                  }|du r|s|du r|r|rdj                  t        |j                  d            t        |j                  d            t        |j                  d            t        |j                  d	            g      j                         }	||	vr|j                  t        |             7 |S )
Nr  rJ   rC   TFrd  rE   rG   rP   )r   r   r   r   rX  r   r  r  )
r  r  r  rJ   r  filteredneedler   title_knownhaystacks
             rc   filter_rows_pythonr    s-    H%%'F #j!9:fD(
377:;N0O0U0U0WW:cggg&678[+xxswww/0swwx01sww{34sww~67	 eg  X%S	"+#, Ord   r   c                     t        |       }|j                  dd      j                  dd      j                  dd      j                  dd      S )Nr  r   r3   rd  (r   )r   rz   rV  s     rc   postgrest_escape_liker    sE    eD<<R ((c2::3DLLSRUVVrd   c                 |   i }g }| r)t        |       }|j                  d| d| d| d| d	       |r|j                  d|        |r|j                  dt        |       d       |d	u r#|j                  d
       |j                  d       n|du r|j                  d       |rddj                  |       d|d<   |S )Nzor(title.ilike.*z*,author.ilike.*z*,publisher.ilike.*z*,raw_ocr_text.ilike.*z*)zsource_folder.eq.zlanguage.ilike.**Tztitle.not.eq.Unknownztitle.not.eq.Fzor(title.eq.Unknown,title.eq.)r  r3   r   and)r  r  r   )r  r  rJ   r  r   clausesr  s          rc   rest_filter_paramsr    s      FG&v.vh&6vh>QRXQYYopvowwyz	
 *6(34)*?*I)J!LMD-.'	e	78CHHW-.a0uMrd   c                     |dk(  }dt         fd|dk(  rfd}n-|dk(  rfd}n"|dk(  rfd	}n|d
k(  rd }n|dk(  rfd}nd }t        | ||      S )Nr  rw   c                 4    t        |       j                         S rY   rU  r   s    rc   r  z$sort_images_python.<locals>.text_key!  s    % &&((rd   rC   c                 V     | j                  d            | j                  dd      fS )NrC   r   r   r  r   r  s    rc   r  z$sort_images_python.<locals>.<lambda>%  s%    )9 :CGGD!<LM rd   rE   c                      | j                  d             | j                  d            | j                  dd      fS )NrE   rC   r   r   r  r   s    rc   r  z$sort_images_python.<locals>.<lambda>'  s:    ): ;XcgggFV=WY\Y`Y`aeghYij rd   rH   c                     t        | j                  d             | j                  d            | j                  dd      fS )NrH   rC   r   r   )r  r   r   s    rc   r  z$sort_images_python.<locals>.<lambda>)  s:     @(377SZK[B\^a^e^efjlm^no rd   r  c                     t         j                  t        | j                  d            d      | j                  dd      fS )Nr  r  r   r   r  r  s    rc   r  z$sort_images_python.<locals>.<lambda>+  s7     3 3Jsww?W4XZ[ \^a^e^efjlm^no rd   r  c                 V     | j                  d            | j                  dd      fS )Nr  r   r   r  r   s    rc   r  z$sort_images_python.<locals>.<lambda>-  s'    1B)C DcggdTUFVW rd   c                 &    | j                  dd      S )Nr   r   r  r  s    rc   r  z$sort_images_python.<locals>.<lambda>/  s    swwtQ/ rd   r  r  )r  r  r   r  r  r  s        @rc   sort_images_pythonr    sn    voG)3 ) wM		j	o		 o	"	"W/$Hg66rd   c                     t        |      }|d   dk(  r| D cg c]  }|d   |d   k(  s| c}S |d   }| D cg c]"  }t        |j                  d            |k(  s!|$ c}S c c}w c c}w )Nrw  ru  r   r   ri  rC   )r~  rh  r   )r  rs  decodedr   ri  s        rc   rows_for_group_keyr	  4  sx    y)Gv'!#Hs4yGJ4G'GHH12YC?37773C#DHX#XCYY I Zs   A#A#"A(A(c                    t        | t              }t        |      }|d   d   g }|D ]'  }t        |      }|j	                  t        |             ) ||d<   |d<   t        fdt        |      D        d      |d<   |S )	Nr  r  r   imagesprimary_image_idc              3   :   K   | ]  \  }}|d    k(  s|  ywr  r   )r   indexru  
primary_ids      rc   r   z%build_group_detail.<locals>.<genexpr>J  s      R<5%dz8QRs   r   primary_image_index)r  r  r  r  r  r  next	enumerate)r  r  summaryr  r   r   r  s         @rc   build_group_detailr  =  s    $N3Ll+G'-JF 4Cy,T234 GH",G%)R9V#4R	&G!" Nrd   >   /login/logout/healthz)z/_vercelz/faviconz/.well-knownrG  c                 N  K   | j                   j                  dk(  rt        dv r ||        d {   S t        v st	        fdt
        D              r ||        d {   S t        dk(  rnt        |       r ||        d {   S j                  d      s"j                  d      sj                  d      rt        dd	id
      S t        t        d
      S t        dk(  rij                  d      s"j                  d      sj                  d      r&t        |       }	 t        |      | j                  _         ||        d {   S  ||        d {   S 7 .7 7 # t        $ r-}t        d|j                   i|j"                        cY d }~S d }~ww xY w7 U7 Gw)Nr.   >   rB   r@   c              3   @   K   | ]  }j                  |        y wrY   )ry   )r   prefixr8  s     rc   r   z password_gate.<locals>.<genexpr>Y  s     "Yv4??6#:"Ys   rA   z/api/r  rO  r	  r  r  )r  r@   )rv   r8  r    PUBLIC_PATHSanyPUBLIC_PREFIXESr   ry   r   r   
LOGIN_HTMLr  r'  r+  r)  r   r	  r  )r   	call_nextr   r|  r8  s       @rc   password_gater   T  sf    ;;Ds{y$88w'''|s"Y"YYw'''J#G,"7+++??7#tz'BdooV_F`+D ESVWWJC88J??7#tz'BdooV_F`(1EY%A%%H" w'''7###) (' , ! Y#Xszz$:XXY'#s   /F%E /F%"E###F%E&BF%&E(  F%F!F%F#F%#F%&F%(	F1"FFF%FF%#F%c                       e Zd ZU eed<   y)LoginRequestrA   Nrj   rk   rl   r   __annotations__r   rd   rc   r"  r"  o      Mrd   r"  r  reqc           	          t         dk7  rt        dd      t        r | j                  t        k7  rt        dd      t	        ddi      }|j                  t        t               dt        t        d	d
       |S )NrA     zPassword login is disabledr  r  zIncorrect passwordokTlaxr.   )httponlymax_agesecuresamesiter8  )
r    r   r!   rA   r   
set_cookier#   r   r%   r'   )r&  r   s     rc   loginr0  s  st    J4PQQ44HIIT4L)H#   Ord   r  c                  N    t        ddi      } | j                  t        d       | S )Nr)  Tr.   r<  )r   delete_cookier#   )r   s    rc   logoutr3    s)    T4L)H.S9Ord   z/api/auth/mec           	          t         dk(  rPt        |       }d|j                  dd      |j                  dd      t        |j                  dd            t         dS dt         ddS )Nr@   Tr   r   r  )authenticatedr   user_idis_owner	auth_mode)r5  r8  r7  )r    r-  r   r0  r,  s     rc   auth_mer9    sb    J$W-!XXgr*xxr*&txx'<="
 	
 "	tLLrd   c                       e Zd ZU eed<   y)InviteMemberRequestr   Nr#  r   rd   rc   r;  r;        Jrd   r;  z/api/invite-memberc           	         t         dk7  rt        dd      t        rt        st        dd      t	        |      }t        |j                  d            j                         }t        |      st        dd	      t        | j                        j                         }|rd
|vrt        dd      i }t        r	t        |d<   t        j                  t         d|dt         t        dd|d|ddd      }|j                  dk(  rt        dd      |j                  dk\  r)t        |j                        xs d}t        d|d d       d|dS )Nr@   rx  zSupabase Auth is not enabledr  i  z$Supabase service role key is missingr   i  zOnly owners can invite members@zPlease enter a valid emailredirect_toz/auth/v1/inviter   r   r   member)r!  
invited_by)r   datarC  )r   r   r   r   i  i  z3That email is already invited or already has accesszFailed to send inviterA  T)r)  r   )r    r   r0   r-   r-  r   r   r   r0  r   r1   r   rH  r  rW  )r&  r   r)  requester_emailinvite_emailr   r   r	  s           rc   invite_memberrE    sR   J4RSS$L4Z[[ )D '!2399;O/*4TUUcii(..0L3l24PQQF! :}zz.(&'@&AB/.
 " -
 H$ s"4ijjs"HMM*E.EF4CLAA..rd   r  c                      dt         t        dS )NT)r)  db_moder8  )r   r    r   rd   rc   healthzrH    s    7CCrd   z/api/imagesr   asc)ge   d   )rJ  lepageper_pagec           	         t         dk(  rtt        | |||      }t        d|      }	t        |	||      }	t	        |	      }
|dz
  |z  }|	|||z    D cg c]  }t        |       }}||
||t        d|
|z   dz
  |z        dS t               }t        | |||      \  }}|j                  d| |      j                         d   }
h d}||v r|nd	}|d
k(  rdnd}|dz
  |z  }d| d| d| d}|j                  ||||gz         j                         }	|j                          g }|	D ]%  }|j                  t        t        |                   ' ||
||t        d|
|z   dz
  |z        dS c c}w )Nr:   zid,title,subtitle,author,publisher,year,language,source_folder,source_filename,description,review_status,review_updated_at,image_url,thumb_url,storage_path,thumb_storage_path,raw_ocr_textr   r   )r  totalrN  rO  pagesz SELECT COUNT(*) FROM book_imagesr   >   r   rH   rC   rE   r  r  r   r  DESCASCa  
        SELECT id, title, subtitle, author, publisher, year, language,
               source_folder, source_filename, description, review_status, review_updated_at,
               image_url, thumb_url, storage_path, thumb_storage_path
        FROM book_imagesz
        ORDER BY rd  z
        LIMIT ? OFFSET ?
    )r   r  r   r  rz  r  maxr   r  r   fetchoner   r   r  r  )r  r  rJ   r  r  r   rN  rO  r   r  rR  offsetr   r  r   r  r   allowed_sortssort_col	order_dirr   s                        rc   list_imagesr\    s    &)&&(IN J%
 "$e4D	(h&9=ffxFW9XY#(-YY UX-1h>?
 	
 8D!&&(IFME6LL;E7CVLUUWXYZEYM},t$H6/uIQh("F   1YK (E <<v6(::;DDFDJJLF 9,T#Y789 Q)A-(:; C Zs   E$z/api/images/{image_id}c           
      H   t         dk(  rt        |       }|st        dd      t        |      }|d   }t	        d      }g }t        t        ||      t              D ]2  }|d   | k(  r|j                  |d   |d	   |d
   |d   |d   dd       4 |d d |d<   |S t               }|j                  d| f      j                         }|s|j                          t        dd      t        t        |            }|d   }|j                  d      j                         D cg c]  }t        |       }}|j                          g }t        t        ||      t              D ]2  }|d   | k(  r|j                  |d   |d	   |d
   |d   |d   dd       4 |d d |d<   |S c c}w )Nr:   r(  Image not foundr  rs  zaid,title,author,source_folder,source_filename,image_url,thumb_url,storage_path,thumb_storage_pathr  r   rC   rE   r  r  z	same book)r   rC   rE   r  filenamereason   related&SELECT * FROM book_images WHERE id = ?zSELECT id, title, author, source_folder, source_filename, image_url, thumb_url, storage_path, thumb_storage_path FROM book_images)r   r   r   r  r   r  r	  r  r  r   r   rW  r   r  r   )	r   r   ru  rs  all_rowsrb  related_rowr   rs	            rc   get_image_detailrg    s   &X&C8IJJ&s++&	"  $G  H!"4Xy"I~^ 	K4 H,NN%d+(1)(3)/: +,= >)		 #3B<i8D
,,?(
M
V
V
XC

4EFF"49-Ek"I  P

(*	 	QH  	JJLG09E>Z 
t(!$'$W-%h/%o6'(9:%		

 s|E)L1s   Fz
/api/booksrC      c           
         t         dk(  rt        | |||      }t        d|      }	t        |	      }
t	        |
j                         D cg c]  }t        |dd       c}||      }t        |      }|dz
  |z  }||||z    D cg c]  }t        |
|d      d	d       }}||||t        d||z   dz
  |z        d
S t               }t        | |||      \  }}|j                  d| d|      j                         D cg c]  }t        |       }	}|j                          t	        t        |	d      ||      }t        |      }|dz
  |z  }||||z    |||t        d||z   dz
  |z        d
S c c}w c c}w c c}w )Nr:   zid,title,author,publisher,year,language,source_folder,source_filename,description,review_status,review_updated_at,image_url,thumb_url,storage_path,thumb_storage_pathrQ  Fr   r  r   rs  T)booksrR  rN  rO  rS  a
  
            SELECT id, title, author, publisher, year, language,
                   source_folder, source_filename, description, review_status, review_updated_at,
                   image_url, thumb_url, storage_path, thumb_storage_path
            FROM book_imagesz
            )r  )r   r  r   r  r  ra   r  rz  rV  r   r  r   r   r  r   r  )r  r  rJ   r  r  r   rN  rO  r   r  grouped_mapr  r  rR  rX  pagedr   r  r   rf  s                       rc   
list_booksrm  R  s    &)&&(IN t%
 !&cncucucwxZ_oeuSTUxz~  AF  GF(h&ouv|  E  HP  P  pQ  RfkU;-?!@D_`a  R  R UX-1h>?
 	
 8D!&&(IFME6  #G $	 
 (* 	QD  	JJLd;T5IFKEQh("F  12Q)A-(:; A y Rs   E+E0;E5z/api/books/{group_key}c                 h   t         dk(  r0t               }t        ||       }|st        dd      t	        |      S t               }|j                  d      j                         D cg c]  }t        |       }}|j                          t        ||       }|st        dd      t	        |      S c c}w )Nr:   r(  Book group not foundr  zSELECT * FROM book_images)
r   r   r	  r   r  r   r   r   r  r   )rs  r  grouped_rowsr   rf  s        rc   get_book_grouprq    s    & )$	:C8NOO!,//8D!\\*EFOOQRDGRDRJJL%dI6L4JKKl++ Ss   %B/c                       e Zd ZU eed<   y)UpdateGroupTitleRequestrC   Nr#  r   rd   rc   rs  rs    r<  rd   rs  z"/api/book-groups/{group_key}/titlec                 8   t        |j                        }|r|j                         dk(  rt        dd      t        dk(  rqt        d      }t        ||       }|st        dd      |D cg c]  }|d	   	 }}t        |d
|i       |d   d	   }dt        |      |t        t        |      |      dS t               }|j                  d      j                         D 	cg c]  }	t        |	       }}	t        ||       }|s|j                          t        dd      |D cg c]	  }||d	   f }
}|j!                  d|
       |j#                          |j                          |d   d	   }dt        |      |t        t        |      |      dS c c}w c c}	w c c}w )NrT  rx  zPlease enter a real titler  r:   zid,titler(  ro  r   rC   r   T)r)  updatedrC   rs  z!SELECT id, title FROM book_imagesz-UPDATE book_images SET title = ? WHERE id = ?)r   rC   r   r   r   r   r	  r   rz  rr  rh  r   r   r   r  r   r   r   )rs  r&  	new_titler  rp  r   r   focus_image_idr   rf  updatess              rc   update_book_group_titlery    s   399%I	)Y64OPP&z*)$	:C8NOO*673SY7	7Wi$89%a.<()/)*DnU	
 	
 8D!\\*MNWWYZDGZDZ%dI6L

4JKK1=>#	3t9%>G>DgNKKMJJL!!_T*N|$%oi&@.Q	 / 8 [ ?s   +FF Fz/api/review/dashboardc            	         t         dk(  rt               } t        d | D              }t        d | D        d       }| D cg c]"  }t	        |j                  d            dk(  s!|$ }}t        |       |d   |d   |d   |d   |d   |d   z   |d   |d   z   d	|rt        |      nd |D cg c]  }t        |       c}d
S t               }|j                  d      j                         D cg c]  }t        |       } }|j                          t        d | D              }t        d | D        d       }| D cg c]"  }t	        |j                  d            dk(  s!|$ }}t        |       |d   |d   |d   |d   |d   |d   z   |d   |d   z   d	|rt        |      nd |D cg c]  }t        |       c}d
S c c}w c c}w c c}w c c}w c c}w )Nr:   c              3   P   K   | ]  }t        |j                  d                ywr  r  r  s     rc   r   z'get_review_dashboard.<locals>.<genexpr>  s     [s01IJ[r  c              3   \   K   | ]$  }t        |j                  d             dk(  s!| & ywr   rR   Nr  r  s     rc   r   z'get_review_dashboard.<locals>.<genexpr>  s(     o0GP_H`0aen0no   ",,r   rQ   rR   rS   rT   )rR  rR   rS   rQ   rT   done	remaining)counts
next_imagerQ   z%SELECT * FROM book_images ORDER BY idc              3   P   K   | ]  }t        |j                  d                ywr  r  r  s     rc   r   z'get_review_dashboard.<locals>.<genexpr>  s     W3,SWW_-EFWr  c              3   \   K   | ]$  }t        |j                  d             dk(  s!| & ywr}  r  r  s     rc   r   z'get_review_dashboard.<locals>.<genexpr>  s'     _ 78P QU^ ^_r~  )r   r   r   r  r[  r   rz  r  r  r   r   r   r  r   )r  r  next_rowr   flagged_rowsr   rf  s          rc   get_review_dashboardr    s   & [VZ[[ooquv'+n/FswwG_/`dm/mnn T!),":.!),#K0z*VK-@@#I.	1BB ?G0:D:FG3)#.G
 	
 8D!\\*QR[[]^DG^D^JJLWRVWWF__H $(jC+B377?C[+\`i+iCjLj Yi(z*i(,:&)<<	*VI->>
 ;C,X66BCs%c*C 5 o H _ k Ds)   "F9 F9(F>*G7"GG"Greview_statec                    t         dk(  r@t        |       }|st        dd      t        | |t	               d      }t        |xs |      S t               }|j                  d| f      j                         }|s|j                          t        dd      t	               }|j                  d||| f       |j                          |j                  d| f      j                         }|j                          t        t        |            S )Nr:   r(  r^  r  )r   r   rc  zLUPDATE book_images SET review_status = ?, review_updated_at = ? WHERE id = ?)r   r   r   r   rb  r  r   r   rW  r   r   r  )r   r  r   ru  r   
updated_ats         rc   update_review_stater    s    &X&C8IJJ!(laqas,tu%gn558D
,,?(
M
V
V
XC

4EFF!#JLLV	z8, 	KKMllCh[QZZ\GJJL!$w-00rd   z/api/review/{image_id}/verifyc                      dt        | d      dS )NTrS   r)  ru  r  r   s    rc   verify_review_imager    s    !4Xz!JKKrd   z/api/review/{image_id}/flagc                      dt        | d      dS )NTrQ   r  r  r  s    rc   flag_review_imager    s    !4Xy!IJJrd   c                       e Zd ZU dZeed<   dZeed<   dZeed<   dZeed<   dZ	eed<   dZ
eed<   dZeed<   dZeed	<   dZeed
<   dZeed<   dZeed<   dZeed<   dZeed<   dZeed<   y)ReviewCorrectionRequestr   rC   rD   rE   rF   rG   rH   rI   rJ   rK   rL   rM   rN   rO   rP   N)rj   rk   rl   rC   r   r$  rD   rE   rF   rG   rH   rI   rJ   rK   rL   rM   rN   rO   rP   r   rd   rc   r  r     s    E3OHcFCJIsD#NGSHcFCFCIscJL#rd   r  z/api/review/{image_id}c           
      n   t         dk(  rqt        |       }|st        dd      t        D ci c]  }|t	        t        ||             }}d|d<   t               |d<   t        | |      }dt        |xs |      d	S t               }|j                  d
| f      j                         }|s|j                          t        dd      t        D ci c]  }|t	        t        ||             }}dj                  d t        D              }t        D cg c]  }||   	 }}|j                  dt               | g       |j                  d| d|       |j                          |j                  d
| f      j                         }|j                          dt        t!        |            d	S c c}w c c}w c c}w )Nr:   r(  r^  r  rT   r   r   Tr  rc  r   c              3   &   K   | ]	  }| d   yw)z = ?Nr   )r   r  s     rc   r   z)save_review_correction.<locals>.<genexpr>D  s     FuugTNFr  zUPDATE book_images SET z7, review_status = ?, review_updated_at = ? WHERE id = ?)r   r   r   REVIEW_FIELDSr   r*  rb  r   r  r   r   rW  r   r   r  r   r  )	r   r&  r   r  r   ru  r   assignmentsra   s	            rc   save_review_correctionr  1  s   &X&C8IJJGTUe5*WS%%899UU#. '7'9#$!(G4%;GNs%KLL8D
,,?(
M
V
V
XC

4EFFCPQ%uje!455QGQ))FFFK8EFuGENFFF
MM; 0 2H=>LL
!+.ef 	KKMllCh[QZZ\GJJL!7W!FGG1 V RFs   F(F-F2z
/api/statsc                     t         dk(  rt        d      } t        |       }t        d | D              }t        d | D              }t        d | D              }t	        d | D              }t	        d | D              }t	        d | D              }|t        t        |             ||||d	   |d
   |d   |d   |d
   |d   z   dt        |j                               D 	cg c]
  \  }}	||	d c}	}t        |j                         d       D 
	cg c]
  \  }
}	|
|	d c}	}
dS t               }|j                  d      j                         D cg c]  }t        |       } }t        |       }t        d | D              }t        d | D              }t        d | D              }t	        d | D              }|j                  d      j                         }|j                  d      j                         }|j                          |t        t        |             ||||d	   |d
   |d   |d   |d
   |d   z   d|D cg c]  }|d   |d   d c}|D cg c]  }|d   |d   d c}dS c c}	}w c c}	}
w c c}w c c}w c c}w )Nr:   zid,title,author,language,source_folder,source_filename,description,review_status,image_url,thumb_url,storage_path,thumb_storage_pathc              3   V   K   | ]!  }t        |j                  d             sd # ywrC   r   NrX  r   r  s     rc   r   zget_stats.<locals>.<genexpr>W  s      Ksj9I.JKr  c              3   V   K   | ]!  }t        |j                  d             sd # ywrE   r   Nr  r  s     rc   r   zget_stats.<locals>.<genexpr>X  s      Mz#''(:K/L!Mr  c              3   V   K   | ]!  }t        |j                  d             sd # ywr  r  r  s     rc   r   zget_stats.<locals>.<genexpr>Y  s      PcZ8N-OPr  c              3   P   K   | ]  }t        |j                  d                ywr  r  r  s     rc   r   zget_stats.<locals>.<genexpr>Z  s      bVY 78P Qbr  c              3      K   | ]9  }t        |j                  d             st        |j                  d              ; yw)r  Nr  r  s     rc   r   zget_stats.<locals>.<genexpr>[  s3     }Xbcfcjcjkzc{X|
377?+C D}
   AAc              3      K   | ]9  }t        |j                  d             st        |j                  d              ; yw)rJ   Nr  r  s     rc   r   zget_stats.<locals>.<genexpr>\  s3     !ucU_`c`g`ghr`sUt*SWWZ-@"A!ur  rR   rS   rQ   rT   )rR   rS   rQ   rT   r  )r  countc                     | d    | d   fS )Nr   r   r   )r   s    rc   r  zget_stats.<locals>.<lambda>m  s    Z^_`ZaYacghicjXk rd   r  )rJ   r  )rR  book_groups
with_titlewith_authorwith_descriptionreviewr  	languageszoSELECT id, title, author, language, source_folder, source_filename, description, review_status FROM book_imagesc              3   V   K   | ]!  }t        |j                  d             sd # ywr  r  r  s     rc   r   zget_stats.<locals>.<genexpr>y  s      G3*SWWW5E*FQGr  c              3   V   K   | ]!  }t        |j                  d             sd # ywr  r  r  s     rc   r   zget_stats.<locals>.<genexpr>z  s      IC:cggh6G+HaIr  c              3   V   K   | ]!  }t        |j                  d             sd # ywr  r  r  s     rc   r   zget_stats.<locals>.<genexpr>{  s      L#CGGM4J)KALr  c              3   P   K   | ]  }t        |j                  d                ywr  r  r  s     rc   r   zget_stats.<locals>.<genexpr>|  s      ^RU3CGGO4LM^r  z]SELECT source_folder, COUNT(*) FROM book_images GROUP BY source_folder ORDER BY source_folderzSELECT language, COUNT(*) as cnt FROM book_images WHERE language != '' AND language != 'Unknown' GROUP BY language ORDER BY cnt DESCr   r   )r   r   rz  r  r   r  r  itemsr   r   r   r  r   )r  rR  r  r  	with_descr  folder_countslanguage_countsr  r  rJ   r   rf  r  r  r   s                   rc   	get_statsr  R  s   &   f  gD	KdKK
MtMMPTPP	b]abb}PT}}!!uT!uuz$/0$& )(3)*5(3*;7%j1M+4NN QWWdWjWjWlPmn}vu6E:n (.o.C.C.EKk'l#He &6
 	
( 8D }

(*	 	QD  IEG$GGJI4IIKLLLI^Y]^^Mllghj   	Ohj  	JJL :d+, "%$Y/%j1$Y/&{3!*-k0JJ
 DKKCs1vA7KGPQ3q6CF;Q 7 o@ LQs   I(I.I49I9I>z/api/languagesc            
      n   t         dk(  rWt        d      } t        | D ch c]7  }t        |j	                  d            st        |j	                  d            9 c}      S t               }|j                  d      j                         } |j                          | D cg c]  }|d   	 c}S c c}w c c}w )Nr:   rJ   zjSELECT DISTINCT language FROM book_images WHERE language != '' AND language != 'Unknown' ORDER BY languager   )	r   r   r  rX  r   r   r   r   r   )r  r   r   s      rc   get_languagesr    s    &z*$j3*UXU\U\]gUhJiz#''*"56jkk8D<<thj 	 	JJL"#sCF## k $s   B-B-B2z/photos/{folder}/{filename}r_  c                     | dk(  r
t         |z  }nt         | z  |z  }|j                         st        dd      t        t	        |      d      S )Nr   r(  zImage file not foundr  
image/jpeg
media_type)IMAGES_BASEexistsr   r   r   )r  r_  r8  s      rc   serve_photor    sK    X%V#h.;;=4JKKD	l;;rd   z/media/{asset_path:path}
asset_pathc                    t         j                         }|| z  j                         }||k7  r||j                  vrt        dd      |j	                         r|j                         st        dd      t        t        |      d      S )Nrx  zInvalid asset pathr  r(  zOptimized asset not foundr  r  )OPTIMIZED_IMAGES_BASEresolveparentsr   r  is_filer   r   )r  baser8  s      rc   serve_optimized_mediar    ss     ((*D:&&(Dt|DLL04HII;;=4OPPD	l;;rd   c                       e Zd ZU eed<   y)GenerateRequestr   N)rj   rk   rl   rg   r$  r   rd   rc   r  r    r%  rd   r  z/api/generate-descriptionc           
         d }t         dk(  r$t        | j                        }|slt        dd      t	               }|j                  d| j                  f      j                         }|s|j                          t        dd      t        |      }g }dD ]6  \  }}|j                  |d      }|s|dk7  s!|j                  | d	|        8 d
j                  |      }|d   xs dd d }	|d   }
ddddj                  |
d      }	 ddlm}  |t        j                  d            }|j                   j#                  ddd| d| d|	 dgd      }t%        |j&                  d   j(                  j*                        }|st-        d      	 t         dk(  rt3        | j                  d0|i       d0|iS |J |j                  d1|| j                  f       |j5                          |j                          d0|iS # t.        $ r}g }|d    r |d    dk7  r|j                  d!|d     d!       |d"   r|d"   dk7  r|j                  d#|d"           |d$   r|j                  d%|d$           |d&   r|j                  d'|d&    d(       |rd)| d*d+j                  |      z   nd,| d(}|d-t1        |      d d.  d/z  }Y d }~3d }~ww xY w)2Nr:   r(  r^  r  rc  ))rC   Title)rD   Subtitle)rE   Author)rG   	Publisher)rH   Year)rI   Edition)rJ   Language)rF   
Translator)rK   Series)rL   Volume)rM   	Condition)rN   zSpecial features)rO   zOther detailsr   Unknownz: r  rP   i  r  ztitle page or inner pagezfront coverspine)r   r   r   z
book photor   MistralMISTRAL_API_KEYapi_keymistral-small-latestr)  ztYou are writing a brief, appealing description for an antiquarian/second-hand book listing.

This photo shows the **a  ** of a book. Based on the OCR text and extracted metadata below, write:
1. A one-line description of what this photo shows (e.g. "Title page of a 1902 English edition published in Buenos Aires")
2. A 2-3 sentence selling description highlighting what makes this book interesting or collectible (age, rarity, publisher, condition, historical significance, etc.)

Keep it professional but warm - like a knowledgeable bookseller. Write in English.

EXTRACTED METADATA:
z

RAW OCR TEXT FROM PHOTO:
r!  r  rx  modelmessages
max_tokensz%Mistral returned an empty descriptionrC   "rE   by rG   zpublished by rH   r  r   z	Photo of z. rd  zBook photo (z

[AI generation failed: rL  ]rq   z3UPDATE book_images SET description = ? WHERE id = ?)r   r   r   r   r   r   rW  r   r  r   r  r   mistralai.clientr  osgetenvchatcompleter  choicesmessager  r   r&  r   r   r   )r&  db_connru  r   
info_partsri   labelr   	book_infoocr_text
photo_type
photo_descr  clientr   rq   r|  r  s                     rc   generate_descriptionr    s7   G&s||,C8IJJ(ooFXaacMMOC8IJJS	J 3
U 		#r"Ui'r%12#3& 		*%In%+Ud3H'J* 
c*l#	 ,G,+<!=>;;''( #$"| $   

 $ ) ( 
, +8+;+;A+>+F+F+N+NOFGG & &}k&BC ;''	 """MP[]`]i]iOjk;''1  G>eGn	9LL1U7^,A./?uX);LL3uX/01LL={);(<=>=LL1U6]O1-.  
|2&%8
|1- 	
 	4SXds^4DAFFGs   BG7 7	K B9J??Kc                   *    e Zd ZU dZeed<   dZeed<   y)BatchGenerateRequestr   start_id
   r  N)rj   rk   rl   r  rg   r$  r  r   rd   rc   r  r  '  s    HcE3Ord   r  z /api/generate-descriptions-batchc                    t         dk(  ret        d      }|D cg c]?  }t        |j                  d            r|j                  dd      | j                  k\  s>|A }}|d | j
                   }nPt               }|j                  d| j                  | j
                  f      j                         }|j                          g }|D ][  }t        |t              r|d   n|d   }	 t        t        |             |j                  |dd	       t        j                   d
       ] t         dk(  rt%        d t        d      D              }n<t               }|j                  d      j'                         d   }|j                          t)        |      ||dS c c}w # t"        $ r!}|j                  |d| d	       Y d }~d }~ww xY w)Nr:   zid,descriptionrq   r   r   zjSELECT id FROM book_images WHERE (description IS NULL OR description = '') AND id >= ? ORDER BY id LIMIT ?r  r)  )r   statusg      ?zerror: c              3   V   K   | ]!  }t        |j                  d             rd # ywr  r  r  s     rc   r   z.generate_descriptions_batch.<locals>.<genexpr>E  s$     nc:VYV]V]^kVlKmnr  zNSELECT COUNT(*) FROM book_images WHERE description IS NULL OR description = '')	generatedresultsr  )r   r   r   r   r  r  r   r   r   r   rf   r  r  r  r  r   sleepr&  r  rW  rz  )r&  r  r   r   r  r   r|  r  s           rc   generate_descriptions_batchr  ,  s   &/0#s:cggm6L+MRURYRYZ^`aRbfifrfrRrssKcii x||x\\399%
 (* 	 	

G H *3 53t93q6	H (!CDNN(d;<JJsOH &n_]%Cnn	xLL\

(*Q	 	

W'	RR; t"  	HNN(uoFGG	Hs(   FFF!>F	G F;;G c                       e Zd ZU eed<   y)ChatRequestquestionNr#  r   rd   rc   r  r  R  r%  rd   r  z	/api/chatc                    t         dk(  rlt        d      D cg c]I  }t        |j                  d            r-t        |j                  d            j	                         dk7  r|K }}t        |d       }n9t               }|j                  d      j                         }|j                          g }|D ]O  }t        |t              rx|j                  d      }|j                  d      }|j                  d	      }|j                  d
      }	|j                  d      }
|j                  d      }|j                  d      }n|dd \  }}}}	}
}}d| g}|r|j                  d| d       |r|dk7  r|j                  d|        |	r|j                  d|	 d       |
r|j                  d|
 d       |r|dk7  r|j                  d|        |r|j                  d|        |j                  dj                  |             R dj                  |      }	 ddlm}  |t!        j"                  d            }|j$                  j'                  dd d!t)        |       d"| d#d$| j*                  d#gd%&      }t-        |j.                  d   j0                  j2                        }d'|xs d(iS c c}w # t4        $ r}d'd)| d*icY d }~S d }~ww xY w)+Nr:   z<id,title,author,publisher,year,language,series,source_folderrC   rT  c                 R    t        | j                  d            j                         S )NrC   )r   r   r   r  s    rc   r  z"chat_about_books.<locals>.<lambda>^  s    j9I.J.P.P.R rd   r  zSELECT id, title, author, publisher, year, language, series, source_folder FROM book_images WHERE title != 'Unknown' AND title != '' ORDER BY titler   rE   rG   rH   rJ   rK   r   r   #r  r  r  r  r   [r  zlang:zseries:rd  r  r  r  r  r  systemzYou are a helpful librarian/bookseller assistant. Answer questions about this book photo collection.
Be specific - cite book titles, authors, and image IDs (#N) when relevant.

CATALOG (z identified images):
r  r)  i  r  answerz#AI chat returned an empty response.zAI chat unavailable: z'. Try searching in the gallery instead.)r   r   r   r   r   r  r   r   r   r   rf   r  r  r   r  r  r  r  r  r  rz  r  r  r  r  r  r&  )r&  r   rj  r   catalog_linesbookbook_idrC   rE   rG   rH   rJ   rK   r  catalog_textr  r  r   r  r|  s                       rc   chat_about_booksr  V  s   & ''ef
#'''*+
3777;K0L0R0R0TXa0a 
 

 u"RSx b

(* 	 	

M .dD!hhtnGHHW%EXXh'F-I88F#Dxx
+HXXh'FHLQq	EGUFItXvWILL1UG1&f	)LL3vh(LL1YKq)*LL1TF!%I-LL5
+,LL76(+,SXXe_-3.6 99]+L`,+<!=>;;''( %$
 e* 	   CLL9
  ( 
 &h&6&6q&9&A&A&I&IJ&I$IJJ
@  `1#6]^__`s%   AJ-BJ2 2	K;KKK)response_classc                      t        j                  t                     j                  dd      } t        j                  d|       }t        |      S )Nz</z<\/__APP_CONFIG_JSON__)r   dumpsr7  rz   FRONTEND_HTMLr   )config_jsonhtmls     rc   serve_frontendr    s=    **Z\*224@K  !6DDrd   as  <!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Bibliothek - Family Access</title>
<style>
  :root {
    --bg: #09110f;
    --panel: rgba(19, 34, 29, 0.96);
    --line: rgba(213, 178, 107, 0.2);
    --line-strong: rgba(213, 178, 107, 0.45);
    --text: #ecede5;
    --muted: #b9bfaf;
    --accent: #d5b26b;
    --accent-2: #89c0a7;
    --danger: #ef7d72;
  }
  * { box-sizing: border-box; }
  body {
    margin: 0;
    min-height: 100vh;
    display: grid;
    place-items: center;
    padding: 18px;
    color: var(--text);
    font-family: Aptos, "Segoe UI", sans-serif;
    background:
      radial-gradient(circle at top left, rgba(213,178,107,0.12), transparent 28%),
      radial-gradient(circle at top right, rgba(137,192,167,0.12), transparent 24%),
      linear-gradient(180deg, #10211c 0%, #09110f 66%, #060b09 100%);
  }
  .card {
    width: min(440px, 100%);
    border-radius: 28px;
    padding: 28px;
    border: 1px solid var(--line);
    background: var(--panel);
    box-shadow: 0 24px 70px rgba(0,0,0,0.32);
  }
  .eyebrow {
    color: var(--accent-2);
    font-size: 11px;
    letter-spacing: 0.12em;
    text-transform: uppercase;
  }
  h1 {
    margin: 10px 0 8px;
    font-family: "Baskerville Old Face", Baskerville, "Palatino Linotype", Georgia, serif;
    font-size: 38px;
    line-height: 1.05;
  }
  p {
    margin: 0 0 20px;
    color: var(--muted);
    line-height: 1.65;
  }
  input {
    width: 100%;
    padding: 14px 16px;
    border-radius: 16px;
    border: 1px solid var(--line);
    background: rgba(255,255,255,0.04);
    color: var(--text);
    outline: none;
    font: inherit;
  }
  input:focus { border-color: var(--line-strong); }
  button {
    width: 100%;
    margin-top: 12px;
    padding: 14px 16px;
    border: none;
    border-radius: 16px;
    background: linear-gradient(135deg, rgba(213,178,107,0.92), rgba(137,192,167,0.82));
    color: #10211c;
    font: inherit;
    font-weight: 700;
    cursor: pointer;
  }
  button:disabled { opacity: 0.6; cursor: wait; }
  .status {
    margin-top: 12px;
    min-height: 20px;
    color: var(--muted);
    font-size: 13px;
  }
  .status.error { color: var(--danger); }
</style>
</head>
<body>
  <form class="card" onsubmit="login(event)">
    <div class="eyebrow">Private family link</div>
    <h1>Bibliothek</h1>
    <p>Enter the shared family password to browse the catalog, review OCR, and help clean up the collection.</p>
    <input id="passwordInput" type="password" placeholder="Shared password" autocomplete="current-password" autofocus>
    <button id="loginBtn" type="submit">Enter catalog</button>
    <div class="status" id="status"></div>
  </form>

<script>
async function login(event) {
  event.preventDefault();
  const input = document.getElementById('passwordInput');
  const button = document.getElementById('loginBtn');
  const status = document.getElementById('status');
  button.disabled = true;
  button.textContent = 'Checking...';
  status.textContent = '';
  status.className = 'status';
  try {
    const response = await fetch('/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ password: input.value }),
    });
    const data = await response.json().catch(() => ({}));
    if (!response.ok) throw new Error(data.detail || 'Wrong password');
    window.location.href = '/';
  } catch (error) {
    status.textContent = error.message;
    status.className = 'status error';
    button.disabled = false;
    button.textContent = 'Enter catalog';
    input.select();
  }
}
</script>
</body>
</html>
u&  <!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#10211c">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="Bibliothek">
<title>Bibliothek - Book Catalog</title>
<style>
  :root {
    --bg: #09110f;
    --bg-2: #10211c;
    --panel: rgba(17, 30, 26, 0.9);
    --panel-2: rgba(23, 40, 35, 0.92);
    --panel-3: rgba(32, 54, 47, 0.96);
    --line: rgba(184, 157, 103, 0.18);
    --line-strong: rgba(214, 180, 117, 0.34);
    --text: #ecede5;
    --muted: #b9bfaf;
    --soft: #879486;
    --accent: #d5b26b;
    --accent-2: #89c0a7;
    --danger: #ef7d72;
    --shadow: 0 22px 70px rgba(0, 0, 0, 0.28);
    --radius: 18px;
  }

  * { box-sizing: border-box; }
  html { background: var(--bg); }
  body {
    margin: 0;
    min-height: 100vh;
    color: var(--text);
    font-family: Aptos, "Segoe UI", "Trebuchet MS", sans-serif;
    background:
      radial-gradient(circle at top left, rgba(213, 178, 107, 0.12), transparent 32%),
      radial-gradient(circle at top right, rgba(137, 192, 167, 0.1), transparent 28%),
      linear-gradient(180deg, #10211c 0%, #09110f 62%, #060b09 100%);
  }

  body::before {
    content: "";
    position: fixed;
    inset: 0;
    pointer-events: none;
    background:
      linear-gradient(135deg, rgba(255,255,255,0.02), transparent 35%),
      repeating-linear-gradient(90deg, rgba(255,255,255,0.015), rgba(255,255,255,0.015) 1px, transparent 1px, transparent 110px);
    opacity: 0.55;
  }

  button, input, select { font: inherit; }

  .shell {
    position: relative;
    z-index: 1;
    min-height: 100vh;
    padding-bottom: calc(28px + env(safe-area-inset-bottom));
  }

  .header {
    position: sticky;
    top: 0;
    z-index: 40;
    display: flex;
    align-items: center;
    gap: 18px;
    padding: 18px 24px;
    background: rgba(8, 16, 14, 0.84);
    border-bottom: 1px solid var(--line);
    backdrop-filter: blur(18px);
  }

  .brand {
    display: flex;
    align-items: center;
    gap: 12px;
    min-width: 0;
  }

  .brand-mark {
    width: 42px;
    height: 42px;
    border-radius: 12px;
    display: grid;
    place-items: center;
    background: linear-gradient(135deg, rgba(213, 178, 107, 0.28), rgba(137, 192, 167, 0.18));
    border: 1px solid var(--line-strong);
    box-shadow: inset 0 1px 0 rgba(255,255,255,0.05);
  }

  .brand-text { min-width: 0; }
  .brand-title {
    margin: 0;
    font-family: "Baskerville Old Face", Baskerville, "Palatino Linotype", Georgia, serif;
    font-size: 24px;
    letter-spacing: 0.02em;
  }
  .brand-note {
    margin-top: 2px;
    color: var(--soft);
    font-size: 12px;
    letter-spacing: 0.04em;
    text-transform: uppercase;
  }

  .nav-tabs {
    display: inline-flex;
    gap: 6px;
    padding: 5px;
    border-radius: 999px;
    background: rgba(255,255,255,0.03);
    border: 1px solid var(--line);
  }

  .nav-tab {
    border: none;
    background: transparent;
    color: var(--muted);
    padding: 10px 16px;
    border-radius: 999px;
    cursor: pointer;
    transition: background 0.16s ease, color 0.16s ease, transform 0.16s ease;
  }

  .nav-tab:hover { color: var(--text); }
  .nav-tab.active {
    background: linear-gradient(135deg, rgba(213, 178, 107, 0.18), rgba(137, 192, 167, 0.2));
    color: var(--text);
    transform: translateY(-1px);
  }

  .stats-bar {
    margin-left: auto;
    display: flex;
    flex-wrap: wrap;
    justify-content: flex-end;
    gap: 10px;
  }

  .header-tools {
    margin-left: auto;
    display: flex;
    align-items: center;
    gap: 12px;
    flex-wrap: wrap;
    justify-content: flex-end;
  }

  .stat-pill {
    padding: 8px 12px;
    border-radius: 999px;
    background: rgba(255,255,255,0.03);
    border: 1px solid var(--line);
    font-size: 12px;
    color: var(--muted);
    white-space: nowrap;
  }

  .stat-pill strong { color: var(--accent); }

  .panel { display: none; }
  .panel.active { display: block; }

  .toolbar,
  .batch-bar,
  .chat-wrap {
    width: min(1400px, calc(100% - 32px));
    margin: 0 auto;
  }

  .toolbar {
    margin-top: 22px;
    display: grid;
    grid-template-columns: minmax(220px, 1.8fr) repeat(4, minmax(130px, 0.7fr));
    gap: 12px;
  }

  .search-box,
  .filter-select,
  .chat-input {
    width: 100%;
    padding: 14px 16px;
    border-radius: 14px;
    border: 1px solid var(--line);
    background: var(--panel);
    color: var(--text);
    outline: none;
    box-shadow: inset 0 1px 0 rgba(255,255,255,0.03);
  }

  .search-box::placeholder,
  .chat-input::placeholder { color: var(--soft); }
  .search-box:focus,
  .filter-select:focus,
  .chat-input:focus { border-color: var(--line-strong); }

  .batch-bar {
    margin-top: 14px;
    padding: 16px 18px;
    border-radius: var(--radius);
    background: linear-gradient(135deg, rgba(23, 40, 35, 0.95), rgba(15, 27, 24, 0.94));
    border: 1px solid var(--line);
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 12px;
    box-shadow: var(--shadow);
  }

  .bar-copy {
    flex: 1 1 260px;
    color: var(--muted);
    font-size: 13px;
    line-height: 1.5;
  }

  .bar-copy strong { color: var(--accent); }

  .gen-btn,
  .ghost-btn,
  .chat-send,
  .page-btn,
  .photo-nav,
  .detail-close,
  .stage-nav {
    border: none;
    cursor: pointer;
    transition: transform 0.15s ease, opacity 0.15s ease, background 0.15s ease;
  }

  .gen-btn,
  .chat-send {
    padding: 12px 18px;
    border-radius: 12px;
    background: linear-gradient(135deg, rgba(213, 178, 107, 0.86), rgba(153, 207, 175, 0.78));
    color: #10211c;
    font-weight: 700;
    box-shadow: 0 12px 28px rgba(0,0,0,0.2);
  }

  .gen-btn:hover,
  .chat-send:hover,
  .page-btn:hover,
  .ghost-btn:hover,
  .photo-nav:hover,
  .detail-close:hover,
  .stage-nav:hover {
    transform: translateY(-1px);
  }

  .gen-btn:disabled { opacity: 0.5; cursor: wait; transform: none; }

  .batch-status {
    font-size: 12px;
    color: var(--soft);
  }

  .library-grid-wrap,
  .pagination {
    width: min(1400px, calc(100% - 32px));
    margin: 0 auto;
  }

  .library-grid {
    margin-top: 20px;
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
    gap: 18px;
  }

  .book-card {
    position: relative;
    overflow: hidden;
    border-radius: 24px;
    background: linear-gradient(180deg, rgba(28, 45, 39, 0.95), rgba(14, 23, 20, 0.98));
    border: 1px solid var(--line);
    cursor: pointer;
    box-shadow: var(--shadow);
  }

  .book-card::after {
    content: "";
    position: absolute;
    inset: 0;
    background: linear-gradient(180deg, transparent 30%, rgba(0,0,0,0.18));
    pointer-events: none;
  }

  .book-card:hover { border-color: var(--line-strong); }

  .card-media {
    position: relative;
    aspect-ratio: 3 / 4;
    overflow: hidden;
    background: rgba(0,0,0,0.22);
  }

  .card-image {
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
  }

  .card-count {
    position: absolute;
    top: 14px;
    right: 14px;
    padding: 8px 10px;
    border-radius: 999px;
    background: rgba(6, 11, 9, 0.72);
    border: 1px solid rgba(255,255,255,0.1);
    color: var(--text);
    font-size: 11px;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    z-index: 1;
  }

  .card-body {
    position: relative;
    z-index: 1;
    padding: 18px;
  }

  .card-overline {
    color: var(--accent-2);
    font-size: 11px;
    letter-spacing: 0.08em;
    text-transform: uppercase;
  }

  .card-title {
    margin-top: 8px;
    font-family: "Baskerville Old Face", Baskerville, "Palatino Linotype", Georgia, serif;
    font-size: 25px;
    line-height: 1.12;
  }

  .card-meta {
    margin-top: 10px;
    color: var(--muted);
    font-size: 13px;
    line-height: 1.5;
    min-height: 40px;
  }

  .card-strip {
    margin-top: 14px;
    display: flex;
    gap: 8px;
  }

  .mini-thumb {
    width: 54px;
    height: 68px;
    border-radius: 10px;
    object-fit: cover;
    border: 1px solid rgba(255,255,255,0.08);
    background: rgba(0,0,0,0.25);
  }

  .tag-row {
    margin-top: 16px;
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
  }

  .badge {
    padding: 6px 10px;
    border-radius: 999px;
    border: 1px solid rgba(255,255,255,0.08);
    background: rgba(255,255,255,0.035);
    color: var(--muted);
    font-size: 11px;
  }

  .badge.has-desc {
    color: #10211c;
    background: rgba(137, 192, 167, 0.92);
    border-color: transparent;
  }

  .empty-state {
    margin-top: 24px;
    padding: 56px 24px;
    border-radius: 22px;
    text-align: center;
    color: var(--soft);
    border: 1px dashed var(--line-strong);
    background: rgba(15, 27, 24, 0.74);
  }

  .pagination {
    margin-top: 20px;
    display: flex;
    justify-content: center;
    align-items: center;
    gap: 10px;
  }

  .page-btn,
  .ghost-btn {
    padding: 11px 16px;
    border-radius: 12px;
    background: rgba(255,255,255,0.04);
    border: 1px solid var(--line);
    color: var(--text);
  }

  .page-btn:disabled,
  .ghost-btn:disabled {
    opacity: 0.38;
    cursor: default;
    transform: none;
  }

  .logout-btn {
    white-space: nowrap;
  }

  .page-info {
    color: var(--muted);
    font-size: 13px;
  }

  .detail-overlay {
    position: fixed;
    inset: 0;
    z-index: 80;
    display: none;
    background: rgba(5, 9, 8, 0.9);
    backdrop-filter: blur(18px);
    padding: 18px;
  }

  .detail-overlay.open { display: block; }

  .detail-shell {
    position: relative;
    width: min(1500px, 100%);
    height: 100%;
    margin: 0 auto;
    display: grid;
    grid-template-columns: minmax(0, 1.35fr) 430px;
    gap: 16px;
  }

  .detail-close {
    position: absolute;
    top: 18px;
    right: 18px;
    width: 44px;
    height: 44px;
    border-radius: 999px;
    background: rgba(0,0,0,0.55);
    color: var(--text);
    z-index: 2;
  }

  .detail-stage,
  .detail-panel {
    min-height: 0;
    border-radius: 28px;
    border: 1px solid var(--line);
    box-shadow: var(--shadow);
  }

  .detail-stage {
    display: flex;
    flex-direction: column;
    background: linear-gradient(180deg, rgba(12, 21, 18, 0.96), rgba(7, 12, 10, 0.98));
    overflow: hidden;
  }

  .stage-topbar {
    display: flex;
    justify-content: space-between;
    align-items: center;
    gap: 12px;
    padding: 16px 18px;
    border-bottom: 1px solid var(--line);
    color: var(--muted);
    font-size: 13px;
  }

  .stage-nav-row {
    display: flex;
    gap: 8px;
  }

  .stage-nav,
  .photo-nav {
    width: 44px;
    height: 44px;
    border-radius: 999px;
    background: rgba(255,255,255,0.05);
    border: 1px solid rgba(255,255,255,0.08);
    color: var(--text);
  }

  .stage-frame {
    position: relative;
    flex: 1;
    min-height: 360px;
    display: grid;
    place-items: center;
    padding: 24px 68px;
    background:
      radial-gradient(circle at center, rgba(213,178,107,0.08), transparent 44%),
      linear-gradient(180deg, rgba(255,255,255,0.02), rgba(0,0,0,0.08));
  }

  .stage-image {
    max-width: 100%;
    max-height: 100%;
    object-fit: contain;
    border-radius: 20px;
    box-shadow: 0 24px 65px rgba(0, 0, 0, 0.36);
    background: rgba(0,0,0,0.3);
  }

  .photo-nav {
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
  }

  .photo-nav.prev { left: 16px; }
  .photo-nav.next { right: 16px; }

  .stage-caption {
    padding: 0 18px 16px;
    color: var(--muted);
    font-size: 13px;
  }

  .thumb-row {
    padding: 0 18px 18px;
    display: flex;
    gap: 10px;
    overflow-x: auto;
  }

  .thumb-btn {
    flex: 0 0 auto;
    width: 76px;
    border: 1px solid rgba(255,255,255,0.08);
    border-radius: 14px;
    overflow: hidden;
    background: rgba(255,255,255,0.03);
    padding: 0;
    cursor: pointer;
  }

  .thumb-btn.active { border-color: var(--line-strong); }
  .thumb-btn img {
    width: 100%;
    aspect-ratio: 3 / 4;
    display: block;
    object-fit: cover;
  }

  .detail-panel {
    overflow-y: auto;
    background: linear-gradient(180deg, rgba(21, 36, 31, 0.98), rgba(11, 18, 15, 0.98));
    padding: 22px;
  }

  .eyebrow {
    color: var(--accent-2);
    font-size: 11px;
    text-transform: uppercase;
    letter-spacing: 0.1em;
  }

  .title-input {
    width: 100%;
    margin-top: 10px;
    padding: 0;
    border: none;
    background: transparent;
    color: var(--text);
    outline: none;
    font-family: "Baskerville Old Face", Baskerville, "Palatino Linotype", Georgia, serif;
    font-size: 36px;
    line-height: 1.05;
  }

  .title-input::placeholder { color: rgba(236, 237, 229, 0.38); }

  .title-hint {
    margin-top: 8px;
    color: var(--soft);
    font-size: 12px;
    line-height: 1.5;
  }

  .title-hint.error { color: var(--danger); }
  .title-hint.ok { color: var(--accent-2); }

  .detail-actions {
    margin-top: 18px;
    display: flex;
    gap: 10px;
    flex-wrap: wrap;
  }

  .byline {
    margin-top: 14px;
    color: var(--muted);
    font-size: 14px;
    line-height: 1.6;
  }

  .meta-grid {
    margin-top: 20px;
    display: grid;
    grid-template-columns: 110px minmax(0, 1fr);
    gap: 8px 14px;
    font-size: 13px;
  }

  .meta-label {
    color: var(--soft);
    text-transform: uppercase;
    letter-spacing: 0.05em;
    font-size: 11px;
  }

  .meta-value {
    min-width: 0;
    color: var(--text);
    word-break: break-word;
  }

  .section-title {
    margin-top: 24px;
    margin-bottom: 10px;
    color: var(--soft);
    text-transform: uppercase;
    letter-spacing: 0.08em;
    font-size: 11px;
  }

  .desc-box,
  .ocr-box {
    border-radius: 16px;
    border: 1px solid rgba(255,255,255,0.05);
    background: rgba(255,255,255,0.04);
    padding: 14px 15px;
    line-height: 1.65;
    white-space: pre-wrap;
  }

  .desc-box { font-size: 13px; color: var(--text); }
  .desc-empty { color: var(--soft); font-style: italic; }

  .ocr-box {
    max-height: 240px;
    overflow-y: auto;
    font-size: 12px;
    color: var(--muted);
    font-family: Consolas, "Cascadia Code", monospace;
  }

  .photo-chip-row {
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
    margin-top: 14px;
  }

  .photo-chip {
    padding: 6px 10px;
    border-radius: 999px;
    background: rgba(255,255,255,0.04);
    border: 1px solid rgba(255,255,255,0.07);
    color: var(--muted);
    font-size: 11px;
  }

  .badge.review-done {
    color: #10211c;
    background: rgba(137, 192, 167, 0.9);
    border-color: transparent;
  }

  .badge.review-flagged {
    background: rgba(239, 125, 114, 0.14);
    border-color: rgba(239, 125, 114, 0.25);
    color: #f2a49a;
  }

  .game-shell {
    width: min(1400px, calc(100% - 32px));
    margin: 22px auto 0;
    display: grid;
    grid-template-columns: minmax(0, 1.08fr) 360px;
    gap: 18px;
  }

  .game-board,
  .queue-panel,
  .modal-shell {
    border-radius: 26px;
    border: 1px solid var(--line);
    background: linear-gradient(180deg, rgba(20, 35, 30, 0.98), rgba(10, 18, 15, 0.98));
    box-shadow: var(--shadow);
  }

  .game-board {
    padding: 22px;
  }

  .game-title {
    margin: 10px 0 8px;
    font-family: "Baskerville Old Face", Baskerville, "Palatino Linotype", Georgia, serif;
    font-size: 34px;
    line-height: 1.08;
  }

  .game-copy,
  .modal-note {
    color: var(--muted);
    font-size: 14px;
    line-height: 1.6;
  }

  .game-stage {
    margin-top: 18px;
    min-height: 560px;
    display: grid;
  }

  .review-card,
  .review-empty {
    border-radius: 24px;
    border: 1px solid rgba(255,255,255,0.06);
    background: linear-gradient(180deg, rgba(28, 46, 40, 0.98), rgba(14, 22, 19, 0.98));
    overflow: hidden;
  }

  .review-card {
    display: grid;
    grid-template-columns: minmax(0, 1fr) 350px;
    transition: transform 0.28s ease, opacity 0.28s ease, filter 0.28s ease;
  }

  .review-card.swipe-right { animation: swipeRight 0.28s ease forwards; }
  .review-card.swipe-left { animation: swipeLeft 0.28s ease forwards; }

  @keyframes swipeRight {
    from { opacity: 1; transform: translateX(0) rotate(0deg); filter: saturate(1); }
    to { opacity: 0; transform: translateX(80px) rotate(3deg); filter: saturate(1.3); }
  }

  @keyframes swipeLeft {
    from { opacity: 1; transform: translateX(0) rotate(0deg); filter: saturate(1); }
    to { opacity: 0; transform: translateX(-80px) rotate(-3deg); filter: saturate(0.7); }
  }

  .review-media {
    position: relative;
    min-height: 100%;
    background: radial-gradient(circle at center, rgba(213, 178, 107, 0.1), transparent 48%), rgba(0,0,0,0.2);
    display: grid;
    place-items: center;
    padding: 24px;
  }

  .review-media img {
    width: 100%;
    max-height: 100%;
    object-fit: contain;
    border-radius: 20px;
    box-shadow: 0 24px 65px rgba(0, 0, 0, 0.38);
    background: rgba(0,0,0,0.18);
  }

  .review-badge {
    position: absolute;
    top: 18px;
    left: 18px;
    padding: 8px 11px;
    border-radius: 999px;
    background: rgba(6, 11, 9, 0.78);
    border: 1px solid rgba(255,255,255,0.09);
    color: var(--text);
    font-size: 11px;
    letter-spacing: 0.08em;
    text-transform: uppercase;
  }

  .review-side {
    padding: 22px;
    border-left: 1px solid rgba(255,255,255,0.05);
    display: flex;
    flex-direction: column;
  }

  .review-title {
    margin: 8px 0 10px;
    font-family: "Baskerville Old Face", Baskerville, "Palatino Linotype", Georgia, serif;
    font-size: 30px;
    line-height: 1.08;
  }

  .review-subcopy {
    color: var(--muted);
    font-size: 13px;
    line-height: 1.6;
  }

  .review-grid {
    margin-top: 18px;
    display: grid;
    grid-template-columns: 88px minmax(0, 1fr);
    gap: 8px 12px;
    font-size: 13px;
  }

  .review-label {
    color: var(--soft);
    font-size: 11px;
    text-transform: uppercase;
    letter-spacing: 0.06em;
  }

  .review-value {
    color: var(--text);
    min-width: 0;
    word-break: break-word;
  }

  .review-actions {
    margin-top: auto;
    padding-top: 20px;
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 12px;
  }

  .review-btn {
    min-height: 70px;
    border-radius: 18px;
    font-size: 17px;
    font-weight: 700;
    letter-spacing: 0.03em;
    color: #10211c;
  }

  .review-btn.reject {
    background: linear-gradient(135deg, rgba(239,125,114,0.98), rgba(233,157,108,0.92));
  }

  .review-btn.approve {
    background: linear-gradient(135deg, rgba(112, 213, 154, 0.96), rgba(213, 178, 107, 0.86));
  }

  .review-btn small {
    display: block;
    margin-top: 4px;
    font-size: 12px;
    font-weight: 600;
    opacity: 0.72;
  }

  .review-empty {
    padding: 42px 28px;
    display: grid;
    place-items: center;
    text-align: center;
    color: var(--muted);
  }

  .queue-panel {
    padding: 18px;
    display: flex;
    flex-direction: column;
    min-height: 560px;
  }

  .queue-head {
    display: flex;
    justify-content: space-between;
    gap: 12px;
    align-items: baseline;
    margin-bottom: 14px;
  }

  .queue-title {
    font-family: "Baskerville Old Face", Baskerville, "Palatino Linotype", Georgia, serif;
    font-size: 24px;
  }

  .queue-count {
    color: var(--soft);
    font-size: 12px;
  }

  .queue-list {
    display: flex;
    flex-direction: column;
    gap: 10px;
    overflow-y: auto;
  }

  .queue-item {
    display: grid;
    grid-template-columns: 68px minmax(0, 1fr);
    gap: 12px;
    padding: 10px;
    border-radius: 18px;
    background: rgba(255,255,255,0.035);
    border: 1px solid rgba(255,255,255,0.06);
  }

  .queue-item img {
    width: 68px;
    height: 90px;
    border-radius: 12px;
    object-fit: cover;
    background: rgba(0,0,0,0.2);
  }

  .queue-item-title {
    font-size: 14px;
    font-weight: 700;
    line-height: 1.3;
  }

  .queue-item-meta {
    margin-top: 5px;
    color: var(--muted);
    font-size: 12px;
    line-height: 1.5;
  }

  .queue-item-actions {
    margin-top: 8px;
    display: flex;
    gap: 8px;
    flex-wrap: wrap;
  }

  .modal-overlay {
    position: fixed;
    inset: 0;
    z-index: 130;
    display: none;
    align-items: center;
    justify-content: center;
    padding: 18px;
    background: rgba(5, 8, 7, 0.88);
    backdrop-filter: blur(14px);
  }

  .modal-overlay.open { display: flex; }

  .modal-shell {
    position: relative;
    width: min(1180px, 100%);
    max-height: calc(100vh - 36px);
    overflow: hidden;
  }

  .modal-close {
    position: absolute;
    top: 16px;
    right: 16px;
    width: 42px;
    height: 42px;
    border-radius: 999px;
    background: rgba(255,255,255,0.06);
    border: 1px solid rgba(255,255,255,0.08);
    color: var(--text);
  }

  .modal-header {
    padding: 22px 22px 12px;
    border-bottom: 1px solid rgba(255,255,255,0.05);
  }

  .modal-heading {
    margin: 8px 0 8px;
    font-family: "Baskerville Old Face", Baskerville, "Palatino Linotype", Georgia, serif;
    font-size: 32px;
    line-height: 1.08;
  }

  .modal-body {
    display: grid;
    grid-template-columns: 280px minmax(0, 1fr);
    gap: 0;
    max-height: calc(100vh - 170px);
  }

  .modal-preview {
    padding: 20px;
    border-right: 1px solid rgba(255,255,255,0.05);
    overflow-y: auto;
  }

  .modal-preview img {
    width: 100%;
    border-radius: 18px;
    object-fit: cover;
    background: rgba(0,0,0,0.2);
    margin-bottom: 12px;
  }

  .modal-form-wrap {
    padding: 20px;
    overflow-y: auto;
  }

  .field-grid {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 12px;
  }

  .form-field {
    display: flex;
    flex-direction: column;
    gap: 6px;
  }

  .form-field.wide { grid-column: 1 / -1; }

  .form-field span {
    color: var(--soft);
    font-size: 11px;
    text-transform: uppercase;
    letter-spacing: 0.06em;
  }

  .form-field input,
  .form-field textarea {
    width: 100%;
    border-radius: 14px;
    border: 1px solid rgba(255,255,255,0.08);
    background: rgba(255,255,255,0.04);
    color: var(--text);
    padding: 12px 14px;
    outline: none;
    resize: vertical;
  }

  .form-field textarea { min-height: 110px; }

  .form-field input:focus,
  .form-field textarea:focus { border-color: var(--line-strong); }

  .chat-wrap {
    margin-top: 22px;
    max-width: 980px;
  }

  .suggestions {
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
    margin-bottom: 14px;
  }

  .sug-chip {
    padding: 8px 12px;
    border-radius: 999px;
    background: rgba(255,255,255,0.035);
    border: 1px solid var(--line);
    color: var(--muted);
    cursor: pointer;
  }

  .sug-chip:hover { color: var(--text); border-color: var(--line-strong); }

  .chat-msgs {
    min-height: 320px;
    max-height: 58vh;
    overflow-y: auto;
    border-radius: 24px;
    background: linear-gradient(180deg, rgba(18, 31, 27, 0.96), rgba(10, 17, 15, 0.98));
    border: 1px solid var(--line);
    padding: 20px;
    box-shadow: var(--shadow);
  }

  .chat-msg { margin-bottom: 14px; }
  .chat-msg.user { text-align: right; }

  .chat-bubble {
    display: inline-block;
    max-width: min(760px, 85%);
    padding: 13px 16px;
    border-radius: 18px;
    line-height: 1.7;
    font-size: 14px;
    text-align: left;
  }

  .chat-msg.user .chat-bubble {
    background: linear-gradient(135deg, rgba(213,178,107,0.92), rgba(153,207,175,0.82));
    color: #10211c;
  }

  .chat-msg.ai .chat-bubble {
    background: rgba(255,255,255,0.05);
    color: var(--text);
    border: 1px solid rgba(255,255,255,0.06);
  }

  .chat-row {
    display: flex;
    gap: 10px;
    margin-top: 12px;
  }

  .notice {
    position: fixed;
    left: 50%;
    bottom: 18px;
    transform: translate(-50%, 20px);
    opacity: 0;
    pointer-events: none;
    padding: 12px 16px;
    border-radius: 14px;
    background: rgba(6, 11, 9, 0.9);
    border: 1px solid var(--line-strong);
    color: var(--text);
    z-index: 120;
    transition: opacity 0.18s ease, transform 0.18s ease;
    box-shadow: var(--shadow);
  }

  .notice.show {
    opacity: 1;
    transform: translate(-50%, 0);
  }

  .notice.error { border-color: rgba(239, 125, 114, 0.5); }

  .auth-screen {
    position: fixed;
    inset: 0;
    z-index: 160;
    display: none;
    align-items: center;
    justify-content: center;
    padding: 18px;
    background: rgba(6, 10, 9, 0.88);
    backdrop-filter: blur(16px);
  }

  .auth-screen.show { display: flex; }

  .auth-card {
    width: min(460px, 100%);
    border-radius: 28px;
    padding: 28px;
    border: 1px solid var(--line);
    background: linear-gradient(180deg, rgba(21, 36, 31, 0.98), rgba(11, 18, 15, 0.98));
    box-shadow: var(--shadow);
  }

  .auth-title {
    margin: 10px 0 8px;
    font-family: "Baskerville Old Face", Baskerville, "Palatino Linotype", Georgia, serif;
    font-size: 38px;
    line-height: 1.06;
  }

  .auth-copy,
  .invite-help {
    color: var(--muted);
    line-height: 1.65;
  }

  .auth-status,
  .invite-status {
    min-height: 22px;
    margin-top: 12px;
    color: var(--soft);
    font-size: 13px;
  }

  .auth-status.error,
  .invite-status.error { color: var(--danger); }
  .auth-status.ok,
  .invite-status.ok { color: var(--accent-2); }

  .user-pill {
    padding: 8px 12px;
    border-radius: 999px;
    background: rgba(255,255,255,0.04);
    border: 1px solid var(--line);
    color: var(--muted);
    font-size: 12px;
    white-space: nowrap;
  }

  .invite-form {
    display: flex;
    flex-direction: column;
    gap: 12px;
  }

  code {
    padding: 2px 5px;
    border-radius: 6px;
    background: rgba(255,255,255,0.06);
    font-family: Consolas, "Cascadia Code", monospace;
  }

  @media (max-width: 1120px) {
    .header { flex-wrap: wrap; }
    .stats-bar {
      width: 100%;
      margin-left: 0;
      justify-content: flex-start;
    }
    .toolbar {
      grid-template-columns: minmax(220px, 1fr) repeat(2, minmax(140px, 1fr));
    }
    .detail-shell { grid-template-columns: minmax(0, 1fr) 380px; }
    .game-shell { grid-template-columns: 1fr; }
    .queue-panel { min-height: 0; }
  }

  @media (max-width: 900px) {
    .toolbar {
      grid-template-columns: 1fr 1fr;
    }
    .detail-overlay { padding: 10px; }
    .detail-shell {
      grid-template-columns: 1fr;
      height: auto;
      max-height: calc(100vh - 20px);
      overflow-y: auto;
    }
    .detail-panel {
      max-height: none;
      overflow: visible;
    }
    .detail-close {
      top: 14px;
      right: 14px;
    }
    .review-card,
    .modal-body {
      grid-template-columns: 1fr;
    }
    .review-side,
    .modal-preview {
      border-left: none;
      border-right: none;
      border-top: 1px solid rgba(255,255,255,0.05);
    }
  }

  @media (max-width: 720px) {
    .header,
    .toolbar,
    .batch-bar,
    .library-grid-wrap,
    .pagination,
    .chat-wrap {
      width: min(100%, calc(100% - 20px));
    }
    .header { padding: 16px 14px; }
    .toolbar { grid-template-columns: 1fr; }
    .library-grid { grid-template-columns: 1fr; }
    .card-title { font-size: 22px; }
    .title-input { font-size: 30px; }
    .meta-grid { grid-template-columns: 1fr; gap: 4px; }
    .chat-row { flex-direction: column; }
    .stage-frame { min-height: 300px; padding: 18px 54px; }
    .photo-nav.prev { left: 10px; }
    .photo-nav.next { right: 10px; }
    .game-shell,
    .modal-shell {
      width: min(100%, calc(100% - 20px));
    }
    .game-title,
    .modal-heading { font-size: 28px; }
    .review-actions,
    .field-grid { grid-template-columns: 1fr; }
  }
</style>
</head>
<body>
<div class="auth-screen" id="authScreen">
  <div class="auth-card">
    <div class="eyebrow">Private family library</div>
    <div class="auth-title">Bibliothek</div>
    <div class="auth-copy">Sign in with your invited email and I will send you a private magic link. Only approved family members can access the catalog.</div>
    <input type="email" class="search-box" id="authEmailInput" placeholder="your@email.com" autocomplete="email">
    <button class="gen-btn" id="authBtn" onclick="sendMagicLink()">Email me a magic link</button>
    <div class="auth-status" id="authStatus"></div>
  </div>
</div>

<div class="shell">
  <div class="header">
    <div class="brand">
      <div class="brand-mark">&#128218;</div>
      <div class="brand-text">
        <h1 class="brand-title">Bibliothek</h1>
        <div class="brand-note">Book-centered catalog for desktop and phone</div>
      </div>
    </div>
    <div class="nav-tabs">
      <button class="nav-tab active" onclick="showPanel('library', this)">Books</button>
      <button class="nav-tab" onclick="showPanel('game', this)">Game</button>
      <button class="nav-tab" onclick="showPanel('chat', this)">Ask AI</button>
    </div>
    <div class="header-tools">
      <div class="user-pill" id="userPill" hidden></div>
      <button class="ghost-btn" id="inviteBtn" onclick="openInviteModal()" hidden>Invite</button>
      <div class="stats-bar" id="statsBar"></div>
      <button class="ghost-btn logout-btn" onclick="logoutApp()">Sign out</button>
    </div>
  </div>

  <div id="libraryPanel" class="panel active">
    <div class="toolbar">
      <input type="text" class="search-box" id="searchInput" placeholder="Search title, author, publisher, OCR text..." oninput="debounceSearch()">
      <select class="filter-select" id="folderFilter" onchange="resetLoad()">
        <option value="">All photo folders</option>
        <option value="root">Root (title pages)</option>
        <option value="front">Front covers</option>
        <option value="side">Spines</option>
      </select>
      <select class="filter-select" id="langFilter" onchange="resetLoad()">
        <option value="">All languages</option>
      </select>
      <select class="filter-select" id="sortSelect" onchange="resetLoad()">
        <option value="title:asc">Title A-Z</option>
        <option value="author:asc">Author A-Z</option>
        <option value="year:asc">Year (oldest)</option>
        <option value="image_count:desc">Most photos</option>
        <option value="id:desc">Newest image ID</option>
      </select>
      <select class="filter-select" id="titleFilter" onchange="resetLoad()">
        <option value="">All titles</option>
        <option value="true">Identified only</option>
        <option value="false">Needs title</option>
      </select>
    </div>

    <div class="batch-bar">
      <div class="bar-copy">
        <strong id="descProgress">0/0 described</strong><br>
        Batch descriptions still run per photo, but the gallery now groups matching titles into books.
      </div>
      <button class="gen-btn" id="batchBtn" onclick="batchGenerate()">Generate 10 descriptions</button>
      <div class="batch-status" id="batchStatus"></div>
    </div>

    <div class="library-grid-wrap">
      <div class="library-grid" id="libraryGrid"></div>
    </div>

    <div class="pagination" id="pagination"></div>
  </div>

  <div id="gamePanel" class="panel">
    <div class="game-shell">
      <section class="game-board">
        <div class="eyebrow">Verification game</div>
        <h2 class="game-title">Swipe right if the OCR is right. Swipe left if it needs fixing.</h2>
        <div class="game-copy" id="gameSummary">Loading the review queue...</div>
        <div class="game-stage" id="gameStage"></div>
      </section>

      <aside class="queue-panel">
        <div class="queue-head">
          <div class="queue-title">Flagged for later</div>
          <div class="queue-count" id="flaggedCount">0 queued</div>
        </div>
        <div class="queue-list" id="flaggedQueue"></div>
      </aside>
    </div>
  </div>

  <div id="chatPanel" class="panel">
    <div class="chat-wrap">
      <div class="suggestions">
        <span class="sug-chip" onclick="askQ(this.innerText)">How many books are in Spanish?</span>
        <span class="sug-chip" onclick="askQ(this.innerText)">Which books are from before 1900?</span>
        <span class="sug-chip" onclick="askQ(this.innerText)">List books with multiple photos</span>
        <span class="sug-chip" onclick="askQ(this.innerText)">What authors appear most often?</span>
        <span class="sug-chip" onclick="askQ(this.innerText)">Which titles still need descriptions?</span>
      </div>
      <div class="chat-msgs" id="chatMsgs">
        <div class="chat-msg ai"><div class="chat-bubble">Ask me anything about the collection and I will answer from the catalog.</div></div>
      </div>
      <div class="chat-row">
        <input class="chat-input" id="chatInput" placeholder="Ask about your collection..." onkeydown="if(event.key==='Enter')sendChat()">
        <button class="chat-send" onclick="sendChat()">Send</button>
      </div>
    </div>
  </div>
</div>

<div class="detail-overlay" id="detailOverlay">
  <div class="detail-shell">
    <button class="detail-close" onclick="closeDetail()">&#10005;</button>
    <section class="detail-stage">
      <div class="stage-topbar">
        <div id="stageMeta">Loading...</div>
        <div class="stage-nav-row">
          <button class="stage-nav" onclick="navBook(-1)" title="Previous book">&#8592;</button>
          <button class="stage-nav" onclick="navBook(1)" title="Next book">&#8594;</button>
        </div>
      </div>
      <div class="stage-frame">
        <button class="photo-nav prev" onclick="navPhoto(-1)" title="Previous photo">&#8249;</button>
        <img class="stage-image" id="detailImg" src="" alt="Book photo">
        <button class="photo-nav next" onclick="navPhoto(1)" title="Next photo">&#8250;</button>
      </div>
      <div class="stage-caption" id="stageCaption"></div>
      <div class="thumb-row" id="thumbRow"></div>
    </section>
    <aside class="detail-panel" id="detailPanel"></aside>
  </div>
</div>

<div class="notice" id="notice"></div>

<div class="modal-overlay" id="correctionModal">
  <div class="modal-shell">
    <button class="modal-close" onclick="closeCorrectionModal()">&#10005;</button>
    <div class="modal-header">
      <div class="eyebrow">Correction queue</div>
      <div class="modal-heading" id="modalTitle">Fix OCR extract</div>
      <div class="modal-note" id="modalNote">You can save now or keep it in the queue for later.</div>
    </div>
    <div class="modal-body">
      <div class="modal-preview" id="modalPreview"></div>
      <div class="modal-form-wrap">
        <div class="field-grid" id="correctionFields"></div>
        <div class="detail-actions">
          <button class="ghost-btn" onclick="closeCorrectionModal()">Keep in queue</button>
          <button class="gen-btn" id="saveCorrectionBtn" onclick="saveCorrection()">Save correction</button>
        </div>
      </div>
    </div>
  </div>
</div>

<div class="modal-overlay" id="inviteModal">
  <div class="modal-shell" style="max-width:620px;">
    <button class="modal-close" onclick="closeInviteModal()">&#10005;</button>
    <div class="modal-header">
      <div class="eyebrow">Family access</div>
      <div class="modal-heading">Invite a member</div>
      <div class="invite-help">This sends a Supabase invite email. The person can then sign in with magic links and access the private catalog.</div>
    </div>
    <div class="modal-form-wrap">
      <div class="invite-form">
        <input type="email" class="search-box" id="inviteEmailInput" placeholder="family@example.com" autocomplete="email">
        <button class="gen-btn" id="inviteSubmitBtn" onclick="inviteMember()">Send invite</button>
        <div class="invite-status" id="inviteStatus"></div>
      </div>
    </div>
  </div>
</div>

<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2"></script>
<script>
window.APP_CONFIG = __APP_CONFIG_JSON__;
</script>
<script>
const state = {
  currentPage: 1,
  currentGroups: [],
  currentGroupIdx: -1,
  currentGroup: null,
  currentPhotoIdx: 0,
  searchTimer: null,
  savingTitle: false,
  toastTimer: null,
  reviewDashboard: null,
  correctionItem: null,
  gameAnimating: false,
  authUser: null,
  appLoaded: false,
};

const appConfig = window.APP_CONFIG || {};
let supabaseClient = null;

const reviewFields = [
  { key: 'title', label: 'Title' },
  { key: 'subtitle', label: 'Subtitle' },
  { key: 'author', label: 'Author' },
  { key: 'translator', label: 'Translator' },
  { key: 'publisher', label: 'Publisher' },
  { key: 'year', label: 'Year' },
  { key: 'edition', label: 'Edition' },
  { key: 'language', label: 'Language' },
  { key: 'series', label: 'Series' },
  { key: 'volume', label: 'Volume' },
  { key: 'condition', label: 'Condition' },
  { key: 'special_features', label: 'Special features', wide: true, textarea: true },
  { key: 'other_text', label: 'Other text', wide: true, textarea: true },
  { key: 'raw_ocr_text', label: 'Raw OCR transcript', wide: true, textarea: true },
];

function authEnabled() {
  return appConfig.authMode === 'supabase';
}

function setAuthStatus(message, tone) {
  const status = document.getElementById('authStatus');
  if (!status) return;
  status.textContent = message || '';
  status.className = 'auth-status' + (tone ? ' ' + tone : '');
}

function setInviteStatus(message, tone) {
  const status = document.getElementById('inviteStatus');
  if (!status) return;
  status.textContent = message || '';
  status.className = 'invite-status' + (tone ? ' ' + tone : '');
}

function updateAuthChrome() {
  const authScreen = document.getElementById('authScreen');
  const userPill = document.getElementById('userPill');
  const inviteBtn = document.getElementById('inviteBtn');
  const logoutBtn = document.querySelector('.logout-btn');
  if (!authEnabled()) {
    authScreen.classList.remove('show');
    userPill.hidden = true;
    inviteBtn.hidden = true;
    logoutBtn.hidden = appConfig.authMode !== 'password';
    return;
  }

  const user = state.authUser;
  if (!user) {
    authScreen.classList.add('show');
    userPill.hidden = true;
    inviteBtn.hidden = true;
    logoutBtn.hidden = true;
    return;
  }

  authScreen.classList.remove('show');
  userPill.hidden = false;
  userPill.textContent = user.email || 'Signed in';
  inviteBtn.hidden = !user.is_owner;
  logoutBtn.hidden = false;
}

function clearAppData() {
  state.currentGroups = [];
  state.currentGroup = null;
  state.currentGroupIdx = -1;
  state.currentPhotoIdx = 0;
  state.reviewDashboard = null;
  document.getElementById('libraryGrid').innerHTML = '';
  document.getElementById('pagination').innerHTML = '';
  document.getElementById('statsBar').innerHTML = '';
  document.getElementById('gameStage').innerHTML = '';
  document.getElementById('flaggedQueue').innerHTML = '';
  document.getElementById('chatMsgs').innerHTML = '<div class="chat-msg ai"><div class="chat-bubble">Ask me anything about the collection and I will answer from the catalog.</div></div>';
}

async function authHeaders(extraHeaders) {
  const headers = new Headers(extraHeaders || {});
  if (authEnabled() && supabaseClient) {
    const sessionData = await supabaseClient.auth.getSession();
    const session = sessionData.data.session;
    if (session && session.access_token) {
      headers.set('Authorization', 'Bearer ' + session.access_token);
    }
  }
  return headers;
}

async function fetchJson(url, options) {
  const nextOptions = { ...(options || {}) };
  nextOptions.headers = await authHeaders(nextOptions.headers);
  const response = await fetch(url, nextOptions);
  const data = await response.json().catch(() => ({}));
  if (!response.ok) {
    if (response.status === 401) {
      if (authEnabled() && supabaseClient) {
        await supabaseClient.auth.signOut();
        state.authUser = null;
        state.appLoaded = false;
        clearAppData();
        updateAuthChrome();
        setAuthStatus('Your session expired. Request a new magic link.', 'error');
      } else {
        window.location.href = '/';
      }
      throw new Error('Authentication required');
    }
    throw new Error(data.detail || 'Request failed');
  }
  return data;
}

async function sendMagicLink() {
  if (!authEnabled()) return;
  const input = document.getElementById('authEmailInput');
  const button = document.getElementById('authBtn');
  const email = input.value.trim();
  if (!email) {
    setAuthStatus('Enter the invited email address first.', 'error');
    return;
  }

  button.disabled = true;
  button.textContent = 'Sending...';
  setAuthStatus('', '');
  try {
    const { error } = await supabaseClient.auth.signInWithOtp({
      email,
      options: {
        shouldCreateUser: false,
        emailRedirectTo: appConfig.authRedirectUrl || window.location.origin,
      },
    });
    if (error) throw error;
    setAuthStatus('Magic link sent. Open the email on this device to enter the catalog.', 'ok');
  } catch (error) {
    setAuthStatus(error.message || 'Could not send the magic link.', 'error');
  } finally {
    button.disabled = false;
    button.textContent = 'Email me a magic link';
  }
}

async function loadAuthUser() {
  if (!authEnabled()) return null;
  const me = await fetchJson('/api/auth/me');
  state.authUser = me;
  updateAuthChrome();
  return me;
}

async function ensureSupabaseAuth() {
  if (!authEnabled()) {
    updateAuthChrome();
    return true;
  }
  if (!window.supabase || !appConfig.supabaseUrl || !appConfig.supabaseAnonKey) {
    setAuthStatus('Supabase Auth is not configured correctly for the frontend.', 'error');
    updateAuthChrome();
    return false;
  }

  if (!supabaseClient) {
    supabaseClient = window.supabase.createClient(appConfig.supabaseUrl, appConfig.supabaseAnonKey);
    supabaseClient.auth.onAuthStateChange(async (event, session) => {
      if (event === 'SIGNED_OUT' || !session) {
        state.authUser = null;
        state.appLoaded = false;
        clearAppData();
        updateAuthChrome();
        return;
      }
      try {
        await loadAuthUser();
        if (!state.appLoaded) {
          await loadAppData();
        }
      } catch (error) {
        setAuthStatus(error.message, 'error');
      }
    });
  }

  const sessionData = await supabaseClient.auth.getSession();
  if (!sessionData.data.session) {
    updateAuthChrome();
    return false;
  }

  await loadAuthUser();
  return true;
}

function openInviteModal() {
  document.getElementById('inviteModal').classList.add('open');
  setInviteStatus('', '');
  syncBodyLock();
}

function closeInviteModal() {
  document.getElementById('inviteModal').classList.remove('open');
  syncBodyLock();
}

async function inviteMember() {
  const input = document.getElementById('inviteEmailInput');
  const button = document.getElementById('inviteSubmitBtn');
  const email = input.value.trim();
  if (!email) {
    setInviteStatus('Enter a valid email address.', 'error');
    return;
  }

  button.disabled = true;
  button.textContent = 'Sending...';
  setInviteStatus('', '');
  try {
    const result = await fetchJson('/api/invite-member', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email }),
    });
    setInviteStatus('Invite sent to ' + result.email + '.', 'ok');
    input.value = '';
  } catch (error) {
    setInviteStatus(error.message, 'error');
  } finally {
    button.disabled = false;
    button.textContent = 'Send invite';
  }
}

function showPanel(name, btn) {
  document.querySelectorAll('.panel').forEach(panel => panel.classList.remove('active'));
  document.querySelectorAll('.nav-tab').forEach(tab => tab.classList.remove('active'));
  document.getElementById(name + 'Panel').classList.add('active');
  btn.classList.add('active');
  if (name === 'game') {
    loadReviewDashboard().catch(error => showNotice(error.message, 'error'));
  }
}

function debounceSearch() {
  clearTimeout(state.searchTimer);
  state.searchTimer = setTimeout(() => {
    state.currentPage = 1;
    loadBooks();
  }, 280);
}

function resetLoad() {
  state.currentPage = 1;
  loadBooks();
}

function showNotice(message, tone) {
  const notice = document.getElementById('notice');
  notice.textContent = message;
  notice.className = 'notice show' + (tone === 'error' ? ' error' : '');
  clearTimeout(state.toastTimer);
  state.toastTimer = setTimeout(() => {
    notice.className = 'notice';
  }, 2200);
}

function syncBodyLock() {
  const detailOpen = document.getElementById('detailOverlay').classList.contains('open');
  const modalOpen = document.getElementById('correctionModal').classList.contains('open');
  const inviteOpen = document.getElementById('inviteModal').classList.contains('open');
  document.body.style.overflow = detailOpen || modalOpen || inviteOpen ? 'hidden' : '';
}

function esc(value) {
  if (value === null || value === undefined) return '';
  const div = document.createElement('div');
  div.textContent = String(value);
  return div.innerHTML;
}

function escAttr(value) {
  return esc(value).replace(/"/g, '&quot;');
}

function bookCardMeta(book) {
  const parts = [];
  if (book.author) parts.push(book.author);
  if (book.publisher) parts.push(book.publisher);
  if (book.year) parts.push(book.year);
  if (book.language) parts.push(book.language);
  return parts.length ? parts.join(' • ') : 'Metadata still needs cleanup';
}

function reviewLabel(status) {
  return {
    pending: 'Pending',
    verified: 'Verified',
    flagged: 'Flagged',
    corrected: 'Corrected',
  }[status] || 'Pending';
}

function clipText(value, limit) {
  const text = (value || '').trim();
  if (!text) return '';
  return text.length > limit ? text.slice(0, limit - 1) + '…' : text;
}

function editableValue(value, fieldKey) {
  if (!value) return '';
  if (fieldKey !== 'raw_ocr_text' && value === 'Unknown') return '';
  return value;
}

function photoDisplaySrc(photo) {
  return photo.display_url || photo.image_url || photo.thumb_url || ('/photos/' + photo.source_folder + '/' + photo.source_filename);
}

function photoThumbSrc(photo) {
  return photo.thumb_url || photo.display_url || photo.image_url || ('/photos/' + photo.source_folder + '/' + photo.source_filename);
}

function photoSrc(photo) {
  return photoThumbSrc(photo);
}

async function logoutApp() {
  if (authEnabled() && supabaseClient) {
    await supabaseClient.auth.signOut();
    state.authUser = null;
    state.appLoaded = false;
    clearAppData();
    updateAuthChrome();
    setAuthStatus('Signed out. Use your invite email to request another magic link.', 'ok');
    return;
  }
  try {
    await fetch('/logout', { method: 'POST' });
  } finally {
    window.location.href = '/';
  }
}

function buildQuery() {
  const params = new URLSearchParams({
    page: state.currentPage,
    per_page: 24,
  });
  const search = document.getElementById('searchInput').value.trim();
  const folder = document.getElementById('folderFilter').value;
  const language = document.getElementById('langFilter').value;
  const titleFilter = document.getElementById('titleFilter').value;
  const [sort, order] = document.getElementById('sortSelect').value.split(':');
  params.set('sort', sort);
  params.set('order', order);
  if (search) params.set('search', search);
  if (folder) params.set('folder', folder);
  if (language) params.set('language', language);
  if (titleFilter) params.set('has_title', titleFilter);
  return params;
}

async function loadBooks() {
  const data = await fetchJson('/api/books?' + buildQuery().toString());
  state.currentGroups = data.books;
  renderBooks(data);
}

function renderBooks(data) {
  const grid = document.getElementById('libraryGrid');
  if (!data.books.length) {
    grid.innerHTML = '<div class="empty-state">No book groups match the current filters.</div>';
    document.getElementById('pagination').innerHTML = '';
    return;
  }

  grid.innerHTML = data.books.map((book, index) => {
    const cover = photoThumbSrc(book.cover_image);
    const thumbs = book.sample_images.map(image => '<img class="mini-thumb" src="' + escAttr(photoThumbSrc(image)) + '" alt="">').join('');
    const strip = thumbs ? '<div class="card-strip">' + thumbs + '</div>' : '';
    const badges = [
      '<span class="badge">' + book.image_count + ' photo' + (book.image_count === 1 ? '' : 's') + '</span>',
      ...book.folders.map(folder => '<span class="badge">' + esc(folder) + '</span>'),
      (book.described_count ? '<span class="badge has-desc">' + book.described_count + '/' + book.image_count + ' described</span>' : ''),
      (book.needs_review_count ? '<span class="badge review-flagged">' + book.needs_review_count + ' to review</span>' : '<span class="badge review-done">reviewed</span>'),
      (book.review_counts && book.review_counts.flagged ? '<span class="badge review-flagged">' + book.review_counts.flagged + ' flagged</span>' : ''),
    ].filter(Boolean).join('');

    return `
      <article class="book-card" onclick="openBook(${index})">
        <div class="card-media">
          <img class="card-image" src="${escAttr(cover)}" loading="lazy" alt="">
          <div class="card-count">${book.image_count} photo${book.image_count === 1 ? '' : 's'}</div>
        </div>
        <div class="card-body">
          <div class="card-overline">Book group</div>
          <div class="card-title">${esc(book.title)}</div>
          <div class="card-meta">${esc(bookCardMeta(book))}</div>
          ${strip}
          <div class="tag-row">${badges}</div>
        </div>
      </article>
    `;
  }).join('');

  document.getElementById('pagination').innerHTML =
    '<button class="page-btn" onclick="goPage(' + (data.page - 1) + ')"' + (data.page <= 1 ? ' disabled' : '') + '>Prev</button>' +
    '<span class="page-info">Page ' + data.page + ' of ' + data.pages + ' (' + data.total + ' book groups)</span>' +
    '<button class="page-btn" onclick="goPage(' + (data.page + 1) + ')"' + (data.page >= data.pages ? ' disabled' : '') + '>Next</button>';
}

function goPage(page) {
  state.currentPage = page;
  loadBooks();
  window.scrollTo({ top: 0, behavior: 'smooth' });
}

async function openBook(index, preferredImageId) {
  state.currentGroupIdx = index;
  const summary = state.currentGroups[index];
  await loadBookDetail(summary.group_key, preferredImageId);
  document.getElementById('detailOverlay').classList.add('open');
  syncBodyLock();
}

async function loadBookDetail(groupKey, preferredImageId) {
  const group = await fetchJson('/api/books/' + encodeURIComponent(groupKey));
  state.currentGroup = group;
  const preferredIndex = group.images.findIndex(image => image.id === preferredImageId);
  state.currentPhotoIdx = preferredIndex >= 0 ? preferredIndex : (group.primary_image_index || 0);
  renderDetail();
}

function closeDetail() {
  document.getElementById('detailOverlay').classList.remove('open');
  syncBodyLock();
}

function navBook(direction) {
  if (!state.currentGroups.length) return;
  if (state.currentGroupIdx < 0) return;
  let next = state.currentGroupIdx + direction;
  if (next < 0) next = state.currentGroups.length - 1;
  if (next >= state.currentGroups.length) next = 0;
  openBook(next);
}

function navPhoto(direction) {
  if (!state.currentGroup || !state.currentGroup.images.length) return;
  let next = state.currentPhotoIdx + direction;
  if (next < 0) next = state.currentGroup.images.length - 1;
  if (next >= state.currentGroup.images.length) next = 0;
  state.currentPhotoIdx = next;
  renderDetail();
}

function selectPhoto(index) {
  state.currentPhotoIdx = index;
  renderDetail();
}

function currentImage() {
  if (!state.currentGroup || !state.currentGroup.images.length) return null;
  return state.currentGroup.images[state.currentPhotoIdx];
}

function metaRow(label, value) {
  if (!value) return '';
  return '<div class="meta-label">' + esc(label) + '</div><div class="meta-value">' + esc(value) + '</div>';
}

function renderDetail() {
  const group = state.currentGroup;
  const photo = currentImage();
  if (!group || !photo) return;

  document.getElementById('detailImg').src = photoDisplaySrc(photo);
  document.getElementById('stageMeta').textContent = group.title + ' • ' + (state.currentPhotoIdx + 1) + '/' + group.images.length + ' photos';
  document.getElementById('stageCaption').textContent = photo.photo_label + ' • Image #' + photo.id + ' • ' + photo.source_filename;
  document.getElementById('thumbRow').innerHTML = group.images.map((image, index) =>
    '<button class="thumb-btn' + (index === state.currentPhotoIdx ? ' active' : '') + '" onclick="selectPhoto(' + index + ')">' +
      '<img src="' + escAttr(photoThumbSrc(image)) + '" loading="lazy" alt="">' +
    '</button>'
  ).join('');

  const bookSummary = [group.author, group.publisher, group.year, group.language].filter(Boolean).join(' • ');
  const saveHint = group.image_count === 1
    ? 'Click the title, edit it, then press Enter or tap away. This saves directly to books.db for this photo.'
    : 'Click the title, edit it, then press Enter or tap away. This saves directly to books.db for all ' + group.image_count + ' grouped photos.';

  const photoTags = [photo.photo_label, photo.source_folder, photo.language].filter(Boolean).map(tag => '<span class="photo-chip">' + esc(tag) + '</span>').join('');
  const descriptionHtml = photo.description
    ? '<div class="desc-box">' + esc(photo.description) + '</div>'
    : '<div class="desc-box desc-empty">No selling description generated yet for this photo.</div>';

  document.getElementById('detailPanel').innerHTML = `
    <div class="eyebrow">Book-centered detail</div>
    <input
      id="titleInput"
      class="title-input"
      type="text"
      value="${escAttr(group.editable_title || '')}"
      placeholder="Enter book title"
      onkeydown="handleTitleKey(event)"
      onblur="saveTitle()"
    >
    <div class="title-hint" id="titleHint">${esc(saveHint)}</div>
    <div class="detail-actions">
      <button class="ghost-btn" onclick="navBook(-1)">Prev book</button>
      <button class="ghost-btn" onclick="navBook(1)">Next book</button>
    </div>
    <div class="byline">${esc(bookSummary || 'Use the title field above to clean up unidentified groups and merge matching photos.')}</div>

    <div class="section-title">Book Summary</div>
    <div class="meta-grid">
      ${metaRow('Author', group.author)}
      ${metaRow('Publisher', group.publisher)}
      ${metaRow('Year', group.year)}
      ${metaRow('Language', group.language)}
      ${metaRow('Photos', String(group.image_count))}
      ${metaRow('Described', group.described_count + '/' + group.image_count)}
    </div>

    <div class="section-title">Selected Photo</div>
    <div class="photo-chip-row">${photoTags}</div>
    <div class="meta-grid">
      ${metaRow('Image ID', String(photo.id))}
      ${metaRow('Review', reviewLabel(photo.review_status))}
      ${metaRow('Folder', photo.source_folder)}
      ${metaRow('File', photo.source_filename)}
      ${metaRow('Subtitle', photo.subtitle && photo.subtitle !== 'Unknown' ? photo.subtitle : '')}
      ${metaRow('Edition', photo.edition && photo.edition !== 'Unknown' ? photo.edition : '')}
      ${metaRow('Translator', photo.translator && photo.translator !== 'Unknown' ? photo.translator : '')}
      ${metaRow('Series', photo.series && photo.series !== 'Unknown' ? photo.series : '')}
      ${metaRow('Volume', photo.volume && photo.volume !== 'Unknown' ? photo.volume : '')}
      ${metaRow('Condition', photo.condition && photo.condition !== 'Unknown' ? photo.condition : '')}
      ${metaRow('Features', photo.special_features && photo.special_features !== 'Unknown' ? photo.special_features : '')}
      ${metaRow('Other', photo.other_text && photo.other_text !== 'Unknown' ? photo.other_text : '')}
    </div>

    <div class="section-title">Selling Description</div>
    ${descriptionHtml}
    <button class="gen-btn" id="genBtn" onclick="generateDesc(${photo.id})">${photo.description ? 'Regenerate description' : 'Generate description'}</button>

    <div class="section-title">OCR Transcript</div>
    <div class="ocr-box">${esc(photo.raw_ocr_text || 'No OCR text available for this photo.')}</div>
  `;
}

function handleTitleKey(event) {
  if (event.key === 'Enter') {
    event.preventDefault();
    event.target.blur();
  }
  if (event.key === 'Escape') {
    event.preventDefault();
    event.target.value = state.currentGroup ? (state.currentGroup.editable_title || '') : '';
    event.target.blur();
  }
}

function setTitleHint(message, tone) {
  const hint = document.getElementById('titleHint');
  if (!hint) return;
  hint.textContent = message;
  hint.className = 'title-hint' + (tone ? ' ' + tone : '');
}

async function saveTitle() {
  const input = document.getElementById('titleInput');
  if (!input || !state.currentGroup || state.savingTitle) return;

  const nextTitle = input.value.trim();
  const currentTitle = (state.currentGroup.editable_title || '').trim();
  if (nextTitle === currentTitle) return;
  if (!nextTitle || nextTitle.toLowerCase() === 'unknown') {
    input.value = currentTitle;
    setTitleHint('Please enter a real title so the grouping stays useful.', 'error');
    return;
  }

  state.savingTitle = true;
  setTitleHint('Saving title to the database...', 'ok');
  const focusImage = currentImage();
  try {
    const result = await fetchJson('/api/book-groups/' + encodeURIComponent(state.currentGroup.group_key) + '/title', {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title: nextTitle }),
    });
    await Promise.all([loadBooks(), loadStats()]);
    await loadBookDetail(result.group_key, focusImage ? focusImage.id : null);
    const nextIndex = state.currentGroups.findIndex(book => book.group_key === result.group_key);
    if (nextIndex >= 0) state.currentGroupIdx = nextIndex;
    setTitleHint('Saved to books.db. Matching titles will regroup automatically.', 'ok');
    showNotice('Saved title to ' + result.updated + ' photo' + (result.updated === 1 ? '' : 's') + '.', 'ok');
  } catch (error) {
    input.value = currentTitle;
    setTitleHint(error.message, 'error');
    showNotice(error.message, 'error');
  } finally {
    state.savingTitle = false;
  }
}

async function generateDesc(imageId) {
  const button = document.getElementById('genBtn');
  if (!button) return;
  button.disabled = true;
  button.textContent = 'Generating...';
  try {
    await fetchJson('/api/generate-description', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ image_id: imageId }),
    });
    await Promise.all([loadBookDetail(state.currentGroup.group_key, imageId), loadBooks(), loadStats()]);
    showNotice('Description generated for image #' + imageId + '.', 'ok');
  } catch (error) {
    button.textContent = error.message;
    showNotice(error.message, 'error');
  }
}

async function batchGenerate() {
  const button = document.getElementById('batchBtn');
  const status = document.getElementById('batchStatus');
  button.disabled = true;
  button.textContent = 'Generating...';
  status.textContent = '';
  try {
    const data = await fetchJson('/api/generate-descriptions-batch', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ count: 10 }),
    });
    status.textContent = 'Generated ' + data.generated + '. ' + data.remaining + ' photos still need descriptions.';
    await Promise.all([loadBooks(), loadStats()]);
    showNotice('Batch generation finished.', 'ok');
  } catch (error) {
    status.textContent = error.message;
    showNotice(error.message, 'error');
  } finally {
    button.disabled = false;
    button.textContent = 'Generate 10 descriptions';
  }
}

function reviewMetaRow(label, value) {
  if (!value) return '';
  return '<div class="review-label">' + esc(label) + '</div><div class="review-value">' + esc(value) + '</div>';
}

function renderFlaggedQueue(items) {
  const queue = document.getElementById('flaggedQueue');
  document.getElementById('flaggedCount').textContent = items.length + ' queued';
  if (!items.length) {
    queue.innerHTML = '<div class="review-empty">Flag the mismatches and they will stack up here for later editing.</div>';
    return;
  }

  queue.innerHTML = items.map(item => {
    const meta = [item.author, item.language, item.photo_label].filter(Boolean).join(' • ');
    return `
      <div class="queue-item">
        <img src="${escAttr(photoThumbSrc(item))}" loading="lazy" alt="">
        <div>
          <div class="queue-item-title">${esc(item.title)}</div>
          <div class="queue-item-meta">${esc(meta || 'Needs manual correction')}</div>
          <div class="queue-item-meta">Image #${item.id} • ${esc(item.source_filename)}</div>
          <div class="queue-item-actions">
            <button class="ghost-btn" onclick="openCorrectionModal(${item.id})">Edit now</button>
          </div>
        </div>
      </div>
    `;
  }).join('');
}

function renderGame() {
  const stage = document.getElementById('gameStage');
  const dashboard = state.reviewDashboard;
  if (!dashboard) {
    stage.innerHTML = '<div class="review-empty">Loading review queue...</div>';
    return;
  }

  const counts = dashboard.counts;
  document.getElementById('gameSummary').innerHTML =
    '<strong>' + counts.done + '</strong> done • ' +
    '<strong>' + counts.pending + '</strong> new cards left • ' +
    '<strong>' + counts.flagged + '</strong> parked for later';
  renderFlaggedQueue(dashboard.flagged || []);

  if (!dashboard.next_image) {
    if (counts.flagged) {
      stage.innerHTML = '<div class="review-empty"><div><div class="eyebrow">No fresh cards left</div><div class="game-title" style="font-size:28px;margin-top:10px">Nice. Only the flagged queue remains.</div><div class="game-copy">Open any queued mismatch on the right, correct it, and the game will be finished.</div></div></div>';
    } else {
      stage.innerHTML = '<div class="review-empty"><div><div class="eyebrow">Game complete</div><div class="game-title" style="font-size:28px;margin-top:10px">Every photo is now verified or corrected.</div><div class="game-copy">Once you are happy with the catalog, this tab can be removed from the UI.</div></div></div>';
    }
    return;
  }

  const image = dashboard.next_image;
  const summary = [image.author, image.publisher, image.year, image.language].filter(Boolean).join(' • ');
  const excerpt = clipText(image.raw_ocr_text || image.other_text || '', 260);
  const reviewGrid = [
    reviewMetaRow('Title', image.title && image.title !== 'Unknown' ? image.title : 'Missing title'),
    reviewMetaRow('Subtitle', image.subtitle && image.subtitle !== 'Unknown' ? image.subtitle : ''),
    reviewMetaRow('Author', image.author && image.author !== 'Unknown' ? image.author : ''),
    reviewMetaRow('Publisher', image.publisher),
    reviewMetaRow('Year', image.year),
    reviewMetaRow('Edition', image.edition && image.edition !== 'Unknown' ? image.edition : ''),
    reviewMetaRow('Language', image.language && image.language !== 'Unknown' ? image.language : ''),
    reviewMetaRow('Series', image.series && image.series !== 'Unknown' ? image.series : ''),
    reviewMetaRow('Condition', image.condition && image.condition !== 'Unknown' ? image.condition : ''),
    reviewMetaRow('Photo', image.photo_label),
    reviewMetaRow('File', image.source_filename),
  ].join('');

  stage.innerHTML = `
    <div class="review-card" id="reviewCard">
      <div class="review-media">
        <div class="review-badge">Image #${image.id}</div>
        <img src="${escAttr(photoDisplaySrc(image))}" alt="Review photo">
      </div>
      <div class="review-side">
        <div class="eyebrow">Quick verification</div>
        <div class="review-title">${esc(image.title && image.title !== 'Unknown' ? image.title : 'Untitled photo')}</div>
        <div class="review-subcopy">${esc(summary || 'Look at the extracted data, then approve or flag it for correction.')}</div>
        <div class="review-grid">${reviewGrid}</div>
        ${excerpt ? '<div class="section-title">OCR excerpt</div><div class="ocr-box">' + esc(excerpt) + '</div>' : ''}
        <div class="review-actions">
          <button class="review-btn reject" onclick="flagCurrent()">&#10005; Needs fixing<small>Queue it and open correction form</small></button>
          <button class="review-btn approve" onclick="verifyCurrent()">&#10003; Looks right<small>Mark this photo verified</small></button>
        </div>
      </div>
    </div>
  `;
}

async function loadReviewDashboard() {
  const dashboard = await fetchJson('/api/review/dashboard');
  state.reviewDashboard = dashboard;
  renderGame();
}

function animateReviewCard(direction) {
  const card = document.getElementById('reviewCard');
  if (!card) return Promise.resolve();
  card.classList.remove('swipe-left', 'swipe-right');
  void card.offsetWidth;
  card.classList.add(direction === 'left' ? 'swipe-left' : 'swipe-right');
  return new Promise(resolve => window.setTimeout(resolve, 290));
}

async function verifyCurrent() {
  if (!state.reviewDashboard || !state.reviewDashboard.next_image || state.gameAnimating) return;
  const imageId = state.reviewDashboard.next_image.id;
  state.gameAnimating = true;
  try {
    await animateReviewCard('right');
    await fetchJson('/api/review/' + imageId + '/verify', { method: 'POST' });
    await Promise.all([loadReviewDashboard(), loadStats(), loadBooks()]);
    showNotice('Image #' + imageId + ' verified.', 'ok');
  } catch (error) {
    await loadReviewDashboard();
    showNotice(error.message, 'error');
  } finally {
    state.gameAnimating = false;
  }
}

async function flagCurrent() {
  if (!state.reviewDashboard || !state.reviewDashboard.next_image || state.gameAnimating) return;
  const imageId = state.reviewDashboard.next_image.id;
  state.gameAnimating = true;
  try {
    await animateReviewCard('left');
    await fetchJson('/api/review/' + imageId + '/flag', { method: 'POST' });
    await Promise.all([loadReviewDashboard(), loadStats(), loadBooks()]);
    showNotice('Image #' + imageId + ' added to the correction queue.', 'ok');
    await openCorrectionModal(imageId);
  } catch (error) {
    await loadReviewDashboard();
    showNotice(error.message, 'error');
  } finally {
    state.gameAnimating = false;
  }
}

async function openCorrectionModal(imageId) {
  const image = await fetchJson('/api/images/' + imageId);
  state.correctionItem = image;
  document.getElementById('modalTitle').textContent = 'Correct image #' + image.id;
  document.getElementById('modalNote').textContent = 'Save now or close this modal to keep the image in the queue for later.';
  document.getElementById('modalPreview').innerHTML =
    '<img src="' + escAttr(photoDisplaySrc(image)) + '" alt="">' +
    '<div class="queue-item-title">' + esc(image.title && image.title !== 'Unknown' ? image.title : 'Untitled photo') + '</div>' +
    '<div class="queue-item-meta">' + esc([image.author, image.publisher, image.year, image.photo_label].filter(Boolean).join(' • ') || 'Correct the extracted fields on the right.') + '</div>';

  document.getElementById('correctionFields').innerHTML = reviewFields.map(field => {
    const tag = field.textarea ? 'textarea' : 'input';
    const className = 'form-field' + (field.wide ? ' wide' : '');
    if (tag === 'textarea') {
      return '<label class="' + className + '"><span>' + esc(field.label) + '</span><textarea data-field="' + escAttr(field.key) + '"></textarea></label>';
    }
    return '<label class="' + className + '"><span>' + esc(field.label) + '</span><input type="text" data-field="' + escAttr(field.key) + '"></label>';
  }).join('');

  for (const field of reviewFields) {
    const input = document.querySelector('[data-field="' + field.key + '"]');
    if (input) {
      input.value = editableValue(image[field.key], field.key);
    }
  }

  document.getElementById('correctionModal').classList.add('open');
  syncBodyLock();
}

function closeCorrectionModal() {
  document.getElementById('correctionModal').classList.remove('open');
  state.correctionItem = null;
  syncBodyLock();
}

async function saveCorrection() {
  if (!state.correctionItem) return;
  const button = document.getElementById('saveCorrectionBtn');
  const imageId = state.correctionItem.id;
  const payload = {};
  for (const field of reviewFields) {
    const input = document.querySelector('[data-field="' + field.key + '"]');
    payload[field.key] = input ? input.value.trim() : '';
  }

  button.disabled = true;
  button.textContent = 'Saving...';
  try {
    const result = await fetchJson('/api/review/' + imageId, {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload),
    });
    closeCorrectionModal();
    await Promise.all([loadReviewDashboard(), loadStats(), loadBooks()]);
    if (state.currentGroup && state.currentGroup.images.some(image => image.id === imageId)) {
      await loadBookDetail(result.image.group_key, imageId);
      const nextIndex = state.currentGroups.findIndex(book => book.group_key === result.image.group_key);
      if (nextIndex >= 0) state.currentGroupIdx = nextIndex;
    }
    showNotice('Corrections saved for image #' + imageId + '.', 'ok');
  } catch (error) {
    showNotice(error.message, 'error');
  } finally {
    button.disabled = false;
    button.textContent = 'Save correction';
  }
}

function askQ(question) {
  document.getElementById('chatInput').value = question;
  sendChat();
}

async function sendChat() {
  const input = document.getElementById('chatInput');
  const question = input.value.trim();
  if (!question) return;
  input.value = '';

  const messages = document.getElementById('chatMsgs');
  messages.innerHTML += '<div class="chat-msg user"><div class="chat-bubble">' + esc(question) + '</div></div>';
  messages.innerHTML += '<div class="chat-msg ai" id="pendingChat"><div class="chat-bubble" style="color:var(--soft)">Thinking...</div></div>';
  messages.scrollTop = messages.scrollHeight;

  try {
    const data = await fetchJson('/api/chat', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ question }),
    });
    document.querySelector('#pendingChat .chat-bubble').innerHTML = fmtMd(data.answer);
  } catch (error) {
    document.querySelector('#pendingChat .chat-bubble').textContent = error.message;
  }
  document.getElementById('pendingChat').id = '';
  messages.scrollTop = messages.scrollHeight;
}

function fmtMd(text) {
  let safe = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
  safe = safe.replace(/[*][*](.+?)[*][*]/g, '<strong>$1</strong>');
  safe = safe.replace(/[*](.+?)[*]/g, '<em>$1</em>');
  safe = safe.replace(/`(.+?)`/g, '<code>$1</code>');
  safe = safe.replace(/\n/g, '<br>');
  return safe;
}

async function loadStats() {
  const stats = await fetchJson('/api/stats');
  document.getElementById('statsBar').innerHTML = [
    '<span class="stat-pill"><strong>' + stats.book_groups + '</strong> book groups</span>',
    '<span class="stat-pill"><strong>' + stats.total + '</strong> photos</span>',
    '<span class="stat-pill"><strong>' + stats.with_title + '</strong> titled</span>',
    '<span class="stat-pill"><strong>' + stats.review.pending + '</strong> pending</span>',
    '<span class="stat-pill"><strong>' + stats.review.flagged + '</strong> flagged</span>',
    '<span class="stat-pill"><strong>' + stats.with_description + '</strong> described</span>',
  ].join('');
  document.getElementById('descProgress').textContent = stats.with_description + '/' + stats.total + ' described';
}

async function loadAppData() {
  if (state.appLoaded) return;
  const languages = await fetchJson('/api/languages');
  const languageSelect = document.getElementById('langFilter');
  if (!languageSelect.dataset.loaded) {
    for (const language of languages) {
      const option = document.createElement('option');
      option.value = language;
      option.textContent = language;
      languageSelect.appendChild(option);
    }
    languageSelect.dataset.loaded = 'true';
  }
  state.appLoaded = true;
  await Promise.all([loadStats(), loadBooks()]);
}

async function init() {
  document.getElementById('correctionModal').addEventListener('click', event => {
    if (event.target.id === 'correctionModal') closeCorrectionModal();
  });
  document.getElementById('inviteModal').addEventListener('click', event => {
    if (event.target.id === 'inviteModal') closeInviteModal();
  });

  const ready = await ensureSupabaseAuth();
  if (!authEnabled() || ready) {
    await loadAppData();
  }
}

document.addEventListener('keydown', event => {
  const modalOpen = document.getElementById('correctionModal').classList.contains('open');
  if (modalOpen && event.key === 'Escape') {
    closeCorrectionModal();
    return;
  }
  const inviteOpen = document.getElementById('inviteModal').classList.contains('open');
  if (inviteOpen && event.key === 'Escape') {
    closeInviteModal();
    return;
  }
  const overlayOpen = document.getElementById('detailOverlay').classList.contains('open');
  if (!overlayOpen) return;
  const activeTag = document.activeElement ? document.activeElement.tagName : '';
  const editingInput = activeTag === 'INPUT' || activeTag === 'TEXTAREA';
  if (event.key === 'Escape') {
    closeDetail();
    return;
  }
  if (editingInput) return;
  if (event.key === 'ArrowLeft') navPhoto(-1);
  if (event.key === 'ArrowRight') navPhoto(1);
  if (event.key === 'ArrowUp') navBook(-1);
  if (event.key === 'ArrowDown') navBook(1);
});

init();
</script>
</body>
</html>
)F)r  N)r  )T)Try  )__doc__ro  r   r   r   r  rf  r   r   collectionsr   r   r   r   pathlibr   typingr   urllib.parser	   r   r  dotenvr
   fastapir   r   r   r   fastapi.responsesr   r   r   pydanticr   	importlibimport_moduler   ImportError__file__parentr   r  r  r   r  r   r   r   r   r  r    r!   r"   r#   rg   r%   r'   rq  r-   r/   r0   r1   r   r2   r4   r5   r7   r8   r   r  r  rZ  apprU   r  r]   floatr$  PyJWKClientr  rW   ru   r{   r}   r   r   r   r   r   r   r   r   r   r   r   r   r   r   r  r  r  r$  r'  r-  r0  r7  r=  r?  rL  rR  rX  r[  rb  rh  rr  r~  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r	  r  r  r  
middlewarer   r"  rH  r0  r3  r   r9  r;  rE  rH  r\  rg  rm  rq  rs  r   ry  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r/  s   0rc   <module>r(     s       	 	   , '     
  : : F F %i%%i0G 
 x.


&
&
39299],_`aYRYY'>DNDYDYD`D`cuDu@vwx ryy,224299/4::<BBD q!4BIIk2&,,.446	ryy,224+-FGMMOlSlbii 57KLRRTlXl )"))$99EF 		/7399;AACGaaryy,224;;C@BII126<<> %BII&A2FLLN &RYY'CRHNNP  >+GHNNsS{{} 
KKM
 ryy)9:@@BVFVBII17;AACIIKVw !		"92>DDFMMcR YRYY'?JK 55GRYYxCL5NGGG$
2')				  @*+13 $sE%*--. 3R^ocoo.LMNdh( (   B
 
c3h 
C Xd38n5M Y]^bYc $+S +# + + 
+c 
+4 
+HTN 
+S	 4 DJ :  
 is is i5c 5*# *$ *9g 9$ 9' c = =  (c d .9 9 9"7 t 5# 5$ 5D -S -S -`s `s `s s <
S 
S 
 8c 8
D# Dc s c c L L L&     S (D T &  4 D "U U U/4: /$ /0DJ 0s 0s 0c :T :d :d :#$t* # #C #X\ #LT$Z Dd4j$9 {tDz {d { {UYZ^U_ {9T
 9# 9c 9d4j 9(SMSM sm ~	
 39B
t*SM SM sm	
 ~ 
$ZDW W W
SMSM sm ~	
 
#s(^67T$Z 7s 73 74: 7,ZT$Z ZC ZDJ ZT$Z D & 1: $ $ $49  (|  & )  
MW 
M 
M)  
+/* +/W +/  +/\ D D !$K!$K#Dk %dduaA"s+:SM:SM: sm: ~	:
 : : : : :z 	!":s : #:z !$K!$K#Dk %dguaA"s+7SM7SM7 sm7 ~	7
 7 7 7 7 7t 	!",c , #,$i  /0&s &1H & 1&R 	 !+ "+\1# 1S 1T 12 
)*L# L +L 
'(K K )Ki " #$HS H/F H %H@ A AH 	
$ 
$ 	&'< <s < (< 	#$<c < %<i  
%&`(o `( '`(F9 
 
,- S%9  S . SJ)  +C`+ C` C`P \* +B
Jh&[8  G0s   1h 35h)h&%h&