templates/website/page/indexes/reviews.html.twig line 1

  1. <!DOCTYPE html>
  2. <html lang="en" data-theme="dark">
  3. <head>
  4.     <meta charset="UTF-8" />
  5.     <meta name="viewport" content="width=device-width, initial-scale=1"/>
  6.     <title>Vrshikyans — Reviews</title>
  7.     <meta name="description" content="Real student reviews for TOEFL, SAT, IELTS, ACT. Scores, photos, and honest feedback."/>
  8.     <link rel="preconnect" href="https://fonts.googleapis.com">
  9.     <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  10.     <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800;900&display=swap" rel="stylesheet">
  11.     <link rel="icon" href="{{ asset('students/Vrshikyan_logo.ico') }}" />
  12.     <meta name="color-scheme" content="dark light">
  13.     <style>
  14.         :root{
  15.             --bg:#0e1220; --bg-2:#0c152b; --surface:rgba(255,255,255,.07);
  16.             --surface-strong:rgba(12,18,40,.72); --card:rgba(20,26,52,.85); --border:rgba(140,170,255,.18);
  17.             --text:#f5f8ff; --muted:#c7d1f2; --accent:#78a6ff; --accent-2:#66e6d2; --green:#35d49f; --violet:#a06bff;
  18.             --shadow-1:0 10px 24px rgba(0,0,0,.28); --shadow-2:0 22px 60px rgba(0,0,0,.40);
  19.             --radius:18px; --blur:14px; --speed:.25s; --fast:.15s; --g1:#1c2550; --g2:#142762; --nav-h:64px;
  20.         }
  21.         html[data-theme="light"]{
  22.             --bg:#f7f8fd; --bg-2:#eef2ff; --surface:rgba(255,255,255,.65); --surface-strong:rgba(255,255,255,.92);
  23.             --card:rgba(255,255,255,.95); --border:rgba(23,32,58,.12); --text:#0f1428; --muted:#3e4a74;
  24.             --shadow-1:0 8px 20px rgba(23,32,58,.10); --shadow-2:0 24px 60px rgba(23,32,58,.16);
  25.             --g1:#dfe7ff; --g2:#cfe0ff;
  26.         }
  27.         *{box-sizing:border-box}
  28.         html,body{height:100%;margin:0}
  29.         body{
  30.             font-family:Inter,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans";
  31.             color:var(--text); line-height:1.65; overflow-x:hidden;
  32.             background:
  33.                     radial-gradient(160vw 120vh at -20vw -20vh, var(--g1) 0%, rgba(0,0,0,0) 60%),
  34.                     radial-gradient(160vw 120vh at 120vw -10vh, var(--g2) 0%, rgba(0,0,0,0) 55%),
  35.                     linear-gradient(180deg, var(--bg) 0%, var(--bg-2) 100%);
  36.             background-attachment:fixed,fixed,fixed;
  37.             -webkit-font-smoothing:antialiased; -moz-osx-font-smoothing:grayscale;
  38.         }
  39.         /* Film grain */
  40.         body::before{
  41.             content:""; position:fixed; inset:0; z-index:0; pointer-events:none; opacity:.05;
  42.             background-image:url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0nNjQnIGhlaWdodD0nNjQnIHhtbG5zPSdodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2Zyc+PGZpbHRlciBpZD0nbi1uJz48ZmVUdXJidWxlbmNlIHR5cGU9J2ZyYWN0YWxOb2lzZScgYmFzZUZyZXF1ZW5jeT0nMC44JyBudW1PY3RhdmVzPScyJy8+PC9maWx0ZXI+PHJlY3Qgd2lkdGg9JzEwMCUnIGhlaWdodD0nMTAwJScgZmlsbD0nd2hpdGUnIGZpbHRlcj0ndXJsKCMnKyc+PC9yZWN0Pjwvc3ZnPg==");
  43.             background-size:240px 240px;
  44.         }
  45.         /* Nav */
  46.         .nav{ position:sticky; top:0; z-index:10; height:var(--nav-h); background:var(--surface-strong);
  47.             border-bottom:1px solid var(--border); backdrop-filter:blur(var(--blur)) saturate(140%) }
  48.         .nav-inner{ width:min(1120px,92%); margin:0 auto; height:100%; display:flex; align-items:center; gap:14px }
  49.         .brand{ display:flex; gap:12px; align-items:center; text-decoration:none; color:var(--text); font-weight:900; letter-spacing:.2px }
  50.         .logo{ width:38px; height:38px; border-radius:12px; background:linear-gradient(180deg,#6ea3ff,#4b82ff); box-shadow:0 10px 22px rgba(76,130,255,.22) }
  51.         .spacer{flex:1}
  52.         .theme-btn,.cta{ display:inline-flex; align-items:center; justify-content:center; height:40px; border-radius:12px }
  53.         .theme-btn{ width:40px; border:1px solid var(--border); background:rgba(255,255,255,.08); color:var(--text); cursor:pointer }
  54.         .cta{ padding:0 22px; background:linear-gradient(180deg,#35d49f,#18b98a); color:#061a11; font-weight:900; text-decoration:none; border:0; box-shadow:0 10px 24px rgba(24,185,138,.22) }
  55.         .inner{ width:min(1120px,92%); margin:28px auto }
  56.         .h1{ margin:10px 0 4px; font-size:clamp(34px,4.8vw,58px); font-weight:900; text-shadow:0 2px 16px rgba(0,0,0,.28) }
  57.         .lead{ margin:0; color:var(--muted) }
  58.         .band{ margin-top:16px; border:1px solid var(--border); border-radius:var(--radius);
  59.             background:linear-gradient(180deg,rgba(255,255,255,.08),rgba(255,255,255,.04));
  60.             padding:16px; display:flex; align-items:center; justify-content:space-between; gap:14px; flex-wrap:wrap }
  61.         /* CARD / FORM */
  62.         .card{ padding:16px; border-radius:var(--radius); border:1px solid var(--border);
  63.             background:var(--card); box-shadow:var(--shadow-1) }
  64.         .card h3{ margin:0 0 8px }
  65.         label{ font-weight:800; display:block; margin:10px 0 6px }
  66.         input[type="text"], input[type="number"], select, textarea{
  67.             width:100%; border:1px solid var(--border); background:rgba(255,255,255,.06);
  68.             padding:10px 12px; color:var(--text); border-radius:12px; outline:none;
  69.         }
  70.         textarea{ min-height:120px; resize:vertical }
  71.         .row{ display:grid; grid-template-columns:1fr 1fr; gap:10px }
  72.         @media (max-width:640px){ .row{ grid-template-columns:1fr } }
  73.         .chips{ display:flex; flex-wrap:wrap; gap:8px }
  74.         .chip{ padding:6px 10px; border-radius:999px; border:1px solid var(--border);
  75.             background:rgba(255,255,255,.08); font-weight:800; font-size:13px }
  76.         .help{ color:var(--muted); font-size:14px; margin-top:6px }
  77.         .btn{ height:44px; padding:0 16px; border-radius:12px; border:0; font-weight:900; cursor:pointer }
  78.         .btn.primary{ background:linear-gradient(180deg,#78a6ff,#4b82ff); color:#08152c; box-shadow:0 10px 26px rgba(120,166,255,.26) }
  79.         .btn.ghost{ background:rgba(255,255,255,.06); border:1px solid var(--border); color:var(--text) }
  80.         /* Uploader */
  81.         .uploader{ border:1px dashed var(--border); border-radius:14px; padding:12px; text-align:center; background:rgba(255,255,255,.04) }
  82.         .uploader input{ display:none }
  83.         .uploader .hint{ color:var(--muted); font-size:14px }
  84.         .thumbs{ display:flex; flex-wrap:wrap; gap:8px; margin-top:10px }
  85.         .thumb{
  86.             position:relative; width:100px; height:100px; border-radius:10px; overflow:hidden; border:1px solid var(--border);
  87.             background:rgba(255,255,255,.06); display:grid; place-items:center;
  88.         }
  89.         .thumb img{ width:100%; height:100%; object-fit:cover }
  90.         .thumb .pin{
  91.             position:absolute; top:6px; right:6px; background:rgba(0,0,0,.45); border:1px solid var(--border);
  92.             color:#fff; font-size:12px; border-radius:999px; padding:2px 6px; cursor:pointer;
  93.         }
  94.         option{
  95.             color: navy;
  96.         }
  97.         /* Phone-like single column stack */
  98.         #mainStack { display:block; }
  99.         /* --- Mobile overflow fixes for reviews page --- */
  100.         /* Let the header row wrap on small screens */
  101.         .review .head { flex-wrap: wrap; }
  102.         /* Allow children to actually shrink instead of pushing the card wider */
  103.         .review .head > div { min-width: 0; }
  104.         /* The right-hand badges container: allow wrapping and prevent overflow */
  105.         .review .head > div:last-child{
  106.             display: flex;           /* it already is, but ensure here */
  107.             flex-wrap: wrap;
  108.             gap: 6px;
  109.             margin-left: 0;          /* stop forcing it off-screen on tiny widths */
  110.             max-width: 100%;
  111.         }
  112.         @media (min-width: 600px){
  113.             .review .head > div:last-child{ margin-left: auto; } /* restore on wider */
  114.         }
  115.         /* Long text can break lines instead of widening the layout */
  116.         .review p,
  117.         .meta-line { overflow-wrap: anywhere; word-break: break-word; }
  118.         /* Global safety: no media should exceed its container */
  119.         img, video { max-width: 100%; height: auto; }
  120.         /* Review photos: wrap nicely and never overflow */
  121.         .review .photos{
  122.             display: flex;
  123.             flex-wrap: wrap;
  124.             gap: 8px;
  125.             max-width: 100%;
  126.         }
  127.         .review .photos img{
  128.             width: 100%;            /* keep your aesthetic */
  129.             /*height: 120px;*/
  130.             object-fit: cover;
  131.         }
  132.         /* Pinned grid: make it denser on small phones to avoid horizontal push */
  133.         @media (max-width: 480px){
  134.             .pinned { grid-template-columns: repeat(2, 1fr); }
  135.             .pinned figure{ height: 120px; }
  136.         }
  137.         /* Absolute safety net: if anything still overflows, clip it */
  138.         .feed, .review, .pinned { overflow: hidden; max-width: 100%; }
  139.         /* Pinned gallery */
  140.         .pinned{
  141.           display: grid;
  142.           grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  143.           gap: 12px;
  144.         }
  145.         
  146.         .pinned figure{
  147.           margin: 0;
  148.           border-radius: 14px;
  149.           overflow: hidden;
  150.           border: 1px solid var(--border);
  151.           background: rgba(255,255,255,.06);
  152.           aspect-ratio: 16 / 9;
  153.           display: flex;
  154.           align-items: center;
  155.           justify-content: center;
  156.         }
  157.         
  158.         .pinned img{
  159.           width: 100%;
  160.           height: 100%;
  161.           object-fit: contain; /* 👈 KEY CHANGE */
  162.         }
  163.         /* Feed one column */
  164.         .feed.onecol { display:grid; grid-template-columns:1fr; gap:14px; margin-top:14px }
  165.         .review{max-width: 100% ;position:relative; padding:18px; border-radius:var(--radius); border:1px solid var(--border); background:linear-gradient(180deg, rgba(255,255,255,.10), rgba(255,255,255,.05)); box-shadow:var(--shadow-1) }
  166.         .review .head{ display:flex; align-items:center; gap:12px }
  167.         .avatar{ width:44px; height:44px; border-radius:12px; overflow:hidden; border:1px solid var(--border); background:rgba(255,255,255,.06); display:grid; place-items:center; font-weight:900; color:var(--muted) }
  168.         .badge{ display:inline-flex; gap:8px; align-items:center; padding:6px 10px; border:1px solid var(--border); border-radius:999px; background:rgba(255,255,255,.1); font-weight:900; color:var(--muted) }
  169.         .rating{ font-weight:900 }
  170.         .score{ font-weight:900; color:var(--green) }
  171.         .review .photos{ display:flex; gap:8px; margin-top:8px; flex-wrap:wrap }
  172.         .review .photos img{ width: 290px; border-radius:12px; border:1px solid var(--border); object-fit:cover }
  173.         .meta-line{ color:var(--muted); font-size:14px }
  174.         /* Footer */
  175.         footer{ margin:34px 0 18px }
  176.         .foot{ width:min(1120px,92%); margin:0 auto; border:1px solid var(--border); border-radius:var(--radius);
  177.             background:linear-gradient(180deg, rgba(255,255,255,.08), rgba(255,255,255,.04)); padding:16px;
  178.             display:grid; grid-template-columns:1fr 1fr; gap:14px }
  179.     </style>
  180.     <link rel="stylesheet" href="{{ asset('css/navbar.css') }}" />
  181.     <script>
  182.   (() => {
  183.     const saved = localStorage.getItem('theme');
  184.     if (saved) {
  185.       document.documentElement.setAttribute('data-theme', saved);
  186.     } else {
  187.       document.documentElement.setAttribute(
  188.         'data-theme',
  189.         matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
  190.       );
  191.     }
  192.   })();
  193. </script>
  194. </head>
  195. <body>
  196.   <nav class="nav" role="navigation" aria-label="Primary">
  197.   <div class="nav-inner">
  198.     <a class="brand" href="/" aria-label="Exam-hub">
  199.       <span class="logo" aria-hidden="true" style="overflow: hidden;">
  200.         <img src="../students/Vrshikyan_logo.ico" width="39px" alt="Vrshikyans logo">
  201.       </span>
  202.       <span>Vrshikyans</span>&nbsp;&nbsp;
  203.     </a>
  204.     <div class="nav-links">
  205.       <a class="nav-link" data-section="toefl" href="./test-selection?type=toefl" id="toeflNav">TOEFL</a>
  206.       <a class="nav-link" data-section="sat" href="./test-sat-selection#sat" >SAT</a>
  207.       <a class="nav-link" data-section="act" href="./test-sat-selection#act">ACT</a>
  208.       <a class="nav-link" data-section="reading" href="./test-selection?type=readingPractice" id="readingPrNav">Reading Practice</a>
  209.       <a class="nav-link" data-section="support" href="./support">Support</a>
  210.       <a class="nav-link active" data-section="reviews" href="./reviews">Reviews</a>
  211.     </div>
  212.     <button class="mobile-menu-btn" id="mobileMenuBtn">☰</button>
  213.     <div class="spacer"></div>
  214.     <button id="themeToggle" class="theme-btn" aria-label="Toggle theme">🌑</button>
  215.     <a class="cta" id="startTop" href="../indexes/account" aria-label="Account"
  216.       style="width: 40px; height: 40px; background: whitesmoke;">👤</a>
  217.   </div>
  218. </nav>
  219. <div class="mobile-menu" id="mobileMenu">
  220.   <div class="mobile-nav-links">
  221.     <a class="mobile-nav-link" href="./test-selection?type=toefl">TOEFL</a>
  222.     <a class="mobile-nav-link" href="./test-sat-selection#sat">SAT</a>
  223.     <a class="mobile-nav-link" href="./test-sat-selection#act">ACT</a>
  224.     <a class="mobile-nav-link" href="./test-selection?type=readingPractice">Reading Practice</a>
  225.     <a class="mobile-nav-link" href="./support">Support</a>
  226.     <a class="mobile-nav-link" href="./reviews">Reviews</a>
  227.   </div>
  228. </div>
  229. <!-- HEADER -->
  230. <header class="inner">
  231.     <h1 class="h1">Student Reviews</h1>
  232.     <p class="lead">Real scores. Real experiences. Add yours — photos welcome.</p>
  233.     <div class="band">
  234.         <div class="toolbar" role="group" aria-label="Filters" style="display:flex; gap:8px; flex-wrap:wrap; align-items:center">
  235.             <select id="filterExam" aria-label="Filter by exam">
  236.                 <option value="">All exams</option>
  237.                 <option value="1">TOEFL</option>
  238.                 <option value="2">IELTS</option>
  239.                 <option value="3">SAT</option>
  240.                 <option value="4">ACT</option>
  241.             </select>
  242.             <select id="sortBy" aria-label="Sort reviews">
  243.                 <option value="new">Newest</option>
  244.                 <option value="top">Highest rating</option>
  245.                 <option value="score">Highest score</option>
  246.             </select>
  247.             <button class="btn ghost" id="clearAll">Clear filters</button>
  248.         </div>
  249.         <a class="cta" href="#write">Write a review</a>
  250.     </div>
  251. </header>
  252. <!-- MAIN STACK -->
  253. <main class="inner" id="mainStack">
  254.     <!-- FORM FIRST -->
  255.     <section class="card" id="write" aria-label="Write a review">
  256.         <h3>Share your experience</h3>
  257.         <p class="lead" style="margin:.2rem 0 10px 0">Scores, tips, and a photo from test day or your notes help others.</p>
  258.         <form id="reviewForm" autocomplete="on">
  259.             <div class="row">
  260.                 <div>
  261.                     <label for="name">Your name</label>
  262.                     <input id="name" name="name" type="text" placeholder="e.g., Ani G." required/>
  263.                 </div>
  264.                 <div>
  265.                     <label for="exam">Exam</label>
  266.                     <select id="exam" name="exam" required>
  267.                         <option value="">Select exam</option>
  268.                         <option value="1">TOEFL</option>
  269.                         <option value="2">IELTS</option>
  270.                         <option value="3">SAT</option>
  271.                         <option value="4">ACT</option>
  272.                     </select>
  273.                 </div>
  274.             </div>
  275.             <div class="row">
  276.                 <div>
  277.                     <label for="score">Score</label>
  278.                     <input id="score" name="score" type="text" placeholder="e.g., 115 / 8.5 / 1540 / 34"/>
  279.                     <div class="help">Write it in your exam’s format (optional).</div>
  280.                 </div>
  281.                 <div>
  282.                     <label>Rating</label>
  283.                     <div class="chips" role="radiogroup" aria-label="Rating out of 5">
  284.                         <label class="chip"><input type="radio" name="rating" value="5" required/> ⭐⭐⭐⭐⭐</label>
  285.                         <label class="chip"><input type="radio" name="rating" value="4"/> ⭐⭐⭐⭐</label>
  286.                         <label class="chip"><input type="radio" name="rating" value="3"/> ⭐⭐⭐</label>
  287.                         <label class="chip"><input type="radio" name="rating" value="2"/> ⭐⭐</label>
  288.                         <label class="chip"><input type="radio" name="rating" value="1"/> ⭐</label>
  289.                     </div>
  290.                 </div>
  291.             </div>
  292.             <label for="message">Your message</label>
  293.             <textarea id="message" name="message" placeholder="What helped you? Which tasks felt hardest? Would you recommend Vrshikyans? (min 10 chars)" minlength="10" required></textarea>
  294.             <label>Photos</label>
  295.             <div class="uploader" id="uploader">
  296.                 <p><strong>Drop images here</strong> or <label style="text-decoration:underline; cursor:pointer;"><input type="file" id="photos" accept="image/*" multiple>browse</label></p>
  297.                 <div class="hint">PNG/JPG up to ~1MB each recommended for now.</div>
  298.                 <div class="thumbs" id="thumbs"></div>
  299.             </div>
  300.             <div style="display:flex; gap:8px; margin-top:12px">
  301.                 <button type="submit" class="btn primary">Post review</button>
  302.                 <button type="button" id="resetForm" class="btn ghost">Reset</button>
  303.             </div>
  304.         </form>
  305.         <p class="help" style="margin-top:10px">
  306.             No account yet — this writes to your device only. When we add accounts, your reviews will sync.
  307.         </p>
  308.     </section>
  309.     <!-- PINNED PHOTOS (optional highlight band) -->
  310.     <section id="pinnedSection" style="display:none; padding:0; margin-top:18px">
  311.         <h2 style="margin:0 0 8px">Pinned photos</h2>
  312.         <p class="lead" style="margin:0 0 8px">Photos you pin will appear here for everyone.</p>
  313.         <div class="pinned" id="pinnedGrid" aria-live="polite"></div>
  314.     </section>
  315.     <!-- FEED BELOW -->
  316.     <section aria-label="Reviews feed" style="margin-top:18px">
  317.         <div id="feed" class="feed onecol" aria-live="polite"></div>
  318.         <div style="display:flex; justify-content:center; margin-top:12px; gap:8px">
  319.             <button class="btn ghost" id="loadMore">Load more</button>
  320.         </div>
  321.     </section>
  322. </main>
  323. <!-- FOOTER -->
  324. <footer>
  325.     <div class="foot">
  326.         <div>
  327.             <b>Contact</b>
  328.             <p class="lead" style="margin:.4rem 0 0 0">examHubYerevan@gmail.com</p>
  329.             <p class="lead" style="margin:.2rem 0 0 0">+374 96 361 236</p>
  330.         </div>
  331.         <div>
  332.             <b>Vrshikyans</b>
  333.             <p class="lead" style="margin:.4rem 0 0 0">Yerevan • Since 2025</p>
  334.             <small>© <span id="year"></span> Vrshikyans — All rights reserved.</small>
  335.         </div>
  336.     </div>
  337. </footer>
  338. <script src="{{ asset('js/navbar.js') }}"></script>
  339. <script>
  340.     document.getElementById('year').textContent = new Date().getFullYear();
  341.     // ---------- Minimal “Backend Adapter” (swap later) ----------
  342.     // Replace these with real fetch() calls when your API is ready.
  343.     const api = {
  344.         KEY: 'vrshikyans_reviews_v1',
  345.         async _request(url, method = 'GET', params = {}) {
  346.             const reqParams = {
  347.                 method: method,
  348.             }
  349.             if (Object.keys(params).length) {
  350.                 reqParams.body = JSON.stringify(params)
  351.             }
  352.             if (params instanceof FormData) {
  353.                 reqParams.body = params
  354.             }
  355.             const response = await fetch(url, reqParams);
  356.             return await response.json();
  357.         },
  358.         async list({offset=0, limit=10, exam='', sort='new'}={}){
  359.             // const all = JSON.parse(localStorage.getItem(this.KEY) || '[]');
  360.             // let data = [...all];
  361.             // if (exam) data = data.filter(r => r.exam === exam);
  362.             // if (sort === 'new') data.sort((a,b)=> b.createdAt - a.createdAt);
  363.             // if (sort === 'top') data.sort((a,b)=> b.rating - a.rating || b.createdAt - a.createdAt);
  364.             // if (sort === 'score'){
  365.             //     const num = x => parseInt(String(x.score||'').match(/\d+/)?.[0]||'0',10);
  366.             //     data.sort((a,b)=> num(b)-num(a));
  367.             // }
  368.             return await this._request(`/review/list?sort=${sort}&exam=${exam}`);
  369.             // return { items: data.slice(offset, offset+limit), total: data.length };
  370.         },
  371.         async create(review){
  372.             const files = photosInput.files;
  373.             const formData = new FormData();
  374.             delete review.photos
  375.             for (const key in review) {
  376.                 const value = review[key];
  377.                 formData.append(key, value);
  378.             }
  379.             for (let i = 0; i < files.length; i++) {
  380.                 formData.append('photos[]', files[i]);
  381.             }
  382.             return await this._request(`/review/create`, 'POST', formData);
  383.         },
  384.         async pinPhoto(reviewId, photoIdx, pin=true){
  385.             return await this._request(`/review/${reviewId}/photos/${photoIdx}/pin`, 'POST');
  386.         }
  387.         // Later:
  388.         // list()  -> fetch('/api/reviews?...').then(r=>r.json())
  389.         // create() -> fetch('/api/reviews',{method:'POST', body:FormData|JSON })
  390.         // pinPhoto() -> fetch(`/api/reviews/${id}/photos/${idx}/pin`,{method:'POST',body:{pin}})
  391.     };
  392.     // ---------- State ----------
  393.     const state = {
  394.         files: [],          // {file, dataUrl, pinAfter?:bool}
  395.         page: 0,
  396.         pageSize: 6,
  397.         currentExam: '',
  398.         sortBy: 'new',
  399.         loading: false
  400.     };
  401.     // ---------- Uploader ----------
  402.     const photosInput = document.getElementById('photos');
  403.     const thumbs = document.getElementById('thumbs');
  404.     const uploader = document.getElementById('uploader');
  405.     function addFiles(list){
  406.         const toArray = [...list];
  407.         toArray.forEach(file=>{
  408.             if(!file.type.startsWith('image/')) return;
  409.             const reader = new FileReader();
  410.             reader.onload = () => {
  411.                 state.files.push({ file, dataUrl: reader.result, pinAfter:false });
  412.                 renderThumbs();
  413.             };
  414.             reader.readAsDataURL(file);
  415.         });
  416.     }
  417.     photosInput.addEventListener('change', e=> addFiles(e.target.files));
  418.     uploader.addEventListener('dragover', e=>{ e.preventDefault(); uploader.style.borderColor = 'var(--accent)'; });
  419.     uploader.addEventListener('dragleave', ()=> uploader.style.borderColor = 'var(--border)');
  420.     uploader.addEventListener('drop', e=>{
  421.         e.preventDefault(); uploader.style.borderColor = 'var(--border)';
  422.         addFiles(e.dataTransfer.files);
  423.     });
  424.     function renderThumbs(){
  425.         thumbs.innerHTML = '';
  426.         state.files.forEach((f, idx)=>{
  427.             const el = document.createElement('div');
  428.             el.className = 'thumb';
  429.             el.innerHTML = `
  430.           <img alt="preview ${idx+1}"/>
  431.           <button class="pin" type="button" title="Remove this photo">✕</button>
  432.           <label style="
  433.             position:absolute; left:6px; bottom:6px; display:flex; gap:6px; align-items:center;
  434.             background:rgba(0,0,0,.45); padding:2px 6px; border-radius:999px; border:1px solid var(--border); color:#fff; font-size:12px;">
  435.             <input type="checkbox" ${f.pinAfter ? 'checked' : ''} style="margin:0"/>
  436.             Pin after posting
  437.           </label>
  438.         `;
  439.             el.querySelector('img').src = f.dataUrl;
  440.             el.querySelector('.pin').addEventListener('click', ()=>{
  441.                 state.files.splice(idx,1);
  442.                 renderThumbs();
  443.             });
  444.             el.querySelector('input[type="checkbox"]').addEventListener('change', (e)=>{
  445.                 state.files[idx].pinAfter = e.target.checked;
  446.             });
  447.             thumbs.appendChild(el);
  448.         });
  449.     }
  450.     // ---------- Form submit ----------
  451.     const form = document.getElementById('reviewForm');
  452.     const resetBtn = document.getElementById('resetForm');
  453.     function initials(name){
  454.         const parts = String(name||'').trim().split(/\s+/);
  455.         return (parts[0]?.[0]||'V').toUpperCase() + (parts[1]?.[0]||'R').toUpperCase();
  456.     }
  457.     form.addEventListener('submit', async (e)=>{
  458.         e.preventDefault();
  459.         if(state.loading) return;
  460.         const name = form.name.value.trim();
  461.         const exam = form.exam.value.trim();
  462.         const score = form.score.value.trim();
  463.         const rating = Number(new FormData(form).get('rating'));
  464.         const message = form.message.value.trim();
  465.         if(!name || !exam || !message || !rating) return;
  466.         state.loading = true;
  467.         try{
  468.             const id = 'r_' + Math.random().toString(36).slice(2);
  469.             const photos = state.files.map(f => ({ url: f.dataUrl, pinned: !!f.pinAfter }));
  470.             const review = { id, name, exam, score, rating, message, photos, createdAt: Date.now() };
  471.             await api.create(review);
  472.             state.files = []; renderThumbs();
  473.             form.reset();
  474.             state.page = 0;
  475.             document.getElementById('feed').innerHTML = '';
  476.             await loadNextPage(true);
  477.             location.hash = '#feed';
  478.         } finally {
  479.             state.loading = false;
  480.         }
  481.     });
  482.     resetBtn.addEventListener('click', ()=>{
  483.         form.reset();
  484.         state.files = [];
  485.         renderThumbs();
  486.     });
  487.     // ---------- Feed + Pinned ----------
  488.     const feed = document.getElementById('feed');
  489.     const pinnedGrid = document.getElementById('pinnedGrid');
  490.     const pinnedSection = document.getElementById('pinnedSection');
  491.     function starIcons(n){ return '⭐'.repeat(n); }
  492.     function reviewCard(r){
  493.         const el = document.createElement('article');
  494.         el.className = 'review';
  495.         el.innerHTML = `
  496.         <div class="head">
  497.           <div class="avatar">${initials(r.name)}</div>
  498.           <div>
  499.             <div style="font-weight:900">${escapeHtml(r.name)}</div>
  500.             <div class="meta-line">${new Date(r.createdAt).toLocaleString()}</div>
  501.           </div>
  502.           <div style="margin-left:auto; display:flex; gap:8px; align-items:center">
  503.             <span class="badge">${escapeHtml(r.exam)}</span>
  504.             ${r.score ? `<span class="badge score">${escapeHtml(r.score)}</span>`:''}
  505.             <span class="badge rating">${starIcons(r.rating)}</span>
  506.           </div>
  507.         </div>
  508.         <p style="margin:10px 0 10px 0">${escapeHtml(r.message)}</p>
  509.         ${r.photos?.length ? `<div class="photos">${r.photos.map((p,idx)=>`
  510.             <figure style="margin:0; position:relative">
  511.               <img src="${p.url}" alt="review photo ${idx+1}">
  512.               <button class="pinBtn" data-idx="${idx}" data-pinned="${p.pinned ? '1':'0'}" title="${p.pinned?'Unpin from top':'Pin to top'}" style="
  513.                 position:absolute; right:6px; top:6px; border:1px solid var(--border); border-radius:999px;
  514.                 background:rgba(0,0,0,.55); color:#fff; font-weight:900; font-size:12px; padding:3px 8px; cursor:pointer">
  515.                 ${p.pinned ? '📌 Unpin' : '📍 Pin'}
  516.               </button>
  517.             </figure>
  518.         `).join('')}</div>` : ``}
  519.       `;
  520.         // wire pin buttons
  521.         el.querySelectorAll('.pinBtn').forEach(btn=>{
  522.             btn.addEventListener('click', async ()=>{
  523.                 const idx = Number(btn.dataset.idx);
  524.                 const currentlyPinned = btn.dataset.pinned === '1';
  525.                 await api.pinPhoto(r.id, idx, !currentlyPinned);
  526.                 await refreshPinned();
  527.                 await refreshSingleCard(el, r.id);
  528.             });
  529.         });
  530.         return el;
  531.     }
  532.     async function refreshSingleCard(container, id){
  533.         const all = (await api.list()).items;
  534.         const r = all.find(x=>x.id===id);
  535.         if(!r) return;
  536.         const fresh = reviewCard(r);
  537.         container.replaceWith(fresh);
  538.     }
  539.     async function refreshPinned(){
  540.         const all = (await api.list()).items;
  541.         const pinned = all.flatMap(r => r.photos?.filter(p=>p.pinned) || []);
  542.         pinnedGrid.innerHTML = '';
  543.         if(pinned.length){
  544.             pinnedSection.style.display = '';
  545.             pinned.forEach(p=>{
  546.                 const fig = document.createElement('figure');
  547.                 fig.innerHTML = `<img src="${p.url}" alt="Pinned review photo">`;
  548.                 pinnedGrid.appendChild(fig);
  549.             });
  550.         }else{
  551.             pinnedSection.style.display = 'none';
  552.         }
  553.     }
  554.     async function loadNextPage(reset=false){
  555.         const offset = state.page * state.pageSize;
  556.         const { items, total } = await api.list({
  557.             offset,
  558.             limit: state.pageSize,
  559.             exam: state.currentExam,
  560.             sort: state.sortBy
  561.         });
  562.         if(reset) feed.innerHTML = '';
  563.         items.forEach(r => feed.appendChild(reviewCard(r)));
  564.         const more = (offset + items.length) < total;
  565.         document.getElementById('loadMore').style.display = more ? '' : 'none';
  566.         if(items.length) state.page += 1;
  567.         await refreshPinned();
  568.     }
  569.     // ---------- Filters & Sorting ----------
  570.     const filterExam = document.getElementById('filterExam');
  571.     const sortBy = document.getElementById('sortBy');
  572.     const clearAll = document.getElementById('clearAll');
  573.     const loadMore = document.getElementById('loadMore');
  574.     filterExam.addEventListener('change', async ()=>{
  575.         state.currentExam = filterExam.value;
  576.         state.page = 0;
  577.         await loadNextPage(true);
  578.     });
  579.     sortBy.addEventListener('change', async ()=>{
  580.         state.sortBy = sortBy.value;
  581.         state.page = 0;
  582.         await loadNextPage(true);
  583.     });
  584.     clearAll.addEventListener('click', async ()=>{
  585.         filterExam.value = '';
  586.         sortBy.value = 'new';
  587.         state.currentExam = '';
  588.         state.sortBy = 'new';
  589.         state.page = 0;
  590.         await loadNextPage(true);
  591.     });
  592.     loadMore.addEventListener('click', ()=> loadNextPage(false));
  593.     // ---------- Utils ----------
  594.     function escapeHtml(s){
  595.         return String(s)
  596.             .replaceAll('&','&amp;')
  597.             .replaceAll('<','&lt;')
  598.             .replaceAll('>','&gt;')
  599.             .replaceAll('"','&quot;')
  600.             .replaceAll("'","&#39;");
  601.     }
  602.     // ---------- Init ----------
  603.     (async ()=>{
  604.         // if(!localStorage.getItem(api.KEY)){
  605.         //     const seed = [
  606.         //         { id:'r_seed1', name:'Arman K.', exam:'TOEFL', score:'113', rating:5, message:'Clean UI and very close to the real thing. Listening sets felt realistic.', photos:[], createdAt: Date.now()-1000*60*60*24*2 },
  607.         //         { id:'r_seed2', name:'Mariam S.', exam:'SAT', score:'1540', rating:5, message:'Loved the math layout. Short sessions daily worked for me.', photos:[], createdAt: Date.now()-1000*60*60*6 }
  608.         //     ];
  609.         //     localStorage.setItem(api.KEY, JSON.stringify(seed));
  610.         // }
  611.         await loadNextPage(true);
  612.     })();
  613. </script>
  614. </body>
  615. </html>