Spaces:
Running
Running
| <html lang="en" data-theme="light"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" /> | |
| <title>Oye — Share your voice</title> | |
| <meta name="description" content="Oye — A minimal, modern, responsive social micro-posting app built with HTML, CSS, and JavaScript." /> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet"> | |
| <style> | |
| /* -------------------------- | |
| Design tokens | |
| ---------------------------*/ | |
| :root { | |
| --brand: #6d5dfc; | |
| --brand-2: #ff6ea6; | |
| --ok: #22c55e; | |
| --warn: #f59e0b; | |
| --danger: #ef4444; | |
| --bg: hsl(0 0% 99%); | |
| --bg-elev: hsl(0 0% 100%); | |
| --text: hsl(220 15% 15%); | |
| --muted: hsl(220 10% 45%); | |
| --border: hsl(220 14% 90%); | |
| --card: hsl(0 0% 100%); | |
| --shadow: 0 10px 30px rgba(0,0,0,0.08); | |
| --radius: 14px; | |
| --radius-sm: 10px; | |
| --radius-lg: 22px; | |
| --container: 1150px; | |
| --sp-1: 4px; | |
| --sp-2: 8px; | |
| --sp-3: 12px; | |
| --sp-4: 16px; | |
| --sp-5: 20px; | |
| --sp-6: 24px; | |
| --sp-7: 28px; | |
| --sp-8: 32px; | |
| } | |
| html[data-theme="dark"] { | |
| --bg: hsl(222 22% 8%); | |
| --bg-elev: hsl(222 22% 10%); | |
| --text: hsl(0 0% 98%); | |
| --muted: hsl(220 10% 65%); | |
| --border: hsl(222 14% 18%); | |
| --card: hsl(222 22% 12%); | |
| --shadow: 0 10px 30px rgba(0,0,0,0.45); | |
| } | |
| /* -------------------------- | |
| Base | |
| ---------------------------*/ | |
| * { box-sizing: border-box; } | |
| html, body { height: 100%; } | |
| body { | |
| margin: 0; | |
| font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji","Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", sans-serif; | |
| color: var(--text); | |
| background: | |
| radial-gradient(1200px 600px at 20% -10%, rgba(109,93,252,.18), transparent 60%), | |
| radial-gradient(1200px 600px at 110% 0%, rgba(255,110,166,.14), transparent 60%), | |
| var(--bg); | |
| line-height: 1.45; | |
| } | |
| img { max-width: 100%; display: block; } | |
| a { color: inherit; text-decoration: none; } | |
| button { font: inherit; color: inherit; } | |
| input, textarea { font: inherit; color: inherit; } | |
| .container { | |
| width: min(100% - 32px, var(--container)); | |
| margin-inline: auto; | |
| } | |
| /* -------------------------- | |
| Header | |
| ---------------------------*/ | |
| header.app-header { | |
| position: sticky; | |
| top: 0; | |
| z-index: 40; | |
| backdrop-filter: saturate(180%) blur(12px); | |
| background: color-mix(in oklab, var(--bg), transparent 30%); | |
| border-bottom: 1px solid var(--border); | |
| } | |
| .header-inner { | |
| display: grid; | |
| grid-template-columns: 1fr auto; | |
| align-items: center; | |
| gap: var(--sp-4); | |
| padding: var(--sp-4) 0; | |
| } | |
| .brand { | |
| display: flex; | |
| align-items: center; | |
| gap: var(--sp-3); | |
| } | |
| .brand .logo { | |
| width: 38px; height: 38px; | |
| border-radius: 12px; | |
| background: conic-gradient(from 120deg, var(--brand), var(--brand-2), var(--brand), var(--brand-2)); | |
| position: relative; | |
| box-shadow: var(--shadow); | |
| isolation: isolate; | |
| } | |
| .brand .logo::after { | |
| content: "Oye"; | |
| position: absolute; | |
| inset: 0; | |
| display: grid; | |
| place-items: center; | |
| color: white; | |
| font-weight: 800; | |
| letter-spacing: .5px; | |
| text-shadow: 0 2px 12px rgba(0,0,0,.35); | |
| } | |
| .brand h1 { | |
| font-size: 1.25rem; | |
| line-height: 1.1; | |
| margin: 0; | |
| letter-spacing: .3px; | |
| } | |
| .brand small { | |
| display: block; | |
| color: var(--muted); | |
| font-size: .85rem; | |
| margin-top: 2px; | |
| } | |
| .header-actions { | |
| display: flex; | |
| align-items: center; | |
| gap: var(--sp-3); | |
| } | |
| .search { | |
| display: flex; | |
| align-items: center; | |
| gap: var(--sp-2); | |
| background: var(--card); | |
| border: 1px solid var(--border); | |
| border-radius: 999px; | |
| padding: 8px 14px; | |
| min-width: 280px; | |
| box-shadow: var(--shadow); | |
| } | |
| .search svg { width: 18px; height: 18px; opacity: .7; } | |
| .search input { | |
| border: 0; | |
| outline: 0; | |
| background: transparent; | |
| width: 100%; | |
| } | |
| .icon-btn { | |
| display: inline-grid; | |
| place-items: center; | |
| width: 40px; height: 40px; | |
| border-radius: 12px; | |
| border: 1px solid var(--border); | |
| background: var(--card); | |
| cursor: pointer; | |
| transition: transform .08s ease, background .2s ease; | |
| box-shadow: var(--shadow); | |
| } | |
| .icon-btn:hover { transform: translateY(-1px); } | |
| .icon-btn svg { width: 18px; height: 18px; } | |
| /* -------------------------- | |
| Layout | |
| ---------------------------*/ | |
| main { | |
| padding: var(--sp-6) 0 var(--sp-8); | |
| } | |
| .layout { | |
| display: grid; | |
| grid-template-columns: 260px 1fr; | |
| gap: var(--sp-6); | |
| align-items: start; | |
| } | |
| .sidebar { | |
| position: sticky; | |
| top: 82px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: var(--sp-4); | |
| } | |
| .side-card { | |
| background: var(--card); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| padding: var(--sp-5); | |
| box-shadow: var(--shadow); | |
| } | |
| .side-card h3 { | |
| margin: 0 0 var(--sp-3); | |
| font-size: 1rem; | |
| letter-spacing: .2px; | |
| } | |
| .chips { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 10px; | |
| } | |
| .chip { | |
| border: 1px dashed var(--border); | |
| background: color-mix(in oklab, var(--card), transparent 12%); | |
| padding: 8px 12px; | |
| border-radius: 999px; | |
| cursor: pointer; | |
| font-size: .9rem; | |
| } | |
| .chip:hover { border-style: solid; } | |
| .footer { | |
| color: var(--muted); | |
| font-size: .9rem; | |
| } | |
| .footer a { color: var(--brand); text-decoration: underline; } | |
| /* -------------------------- | |
| Compose | |
| ---------------------------*/ | |
| .composer { | |
| background: var(--card); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius-lg); | |
| padding: var(--sp-5); | |
| box-shadow: var(--shadow); | |
| display: grid; | |
| gap: var(--sp-4); | |
| } | |
| .composer .row { | |
| display: grid; | |
| grid-template-columns: 48px 1fr; | |
| gap: var(--sp-3); | |
| align-items: start; | |
| } | |
| .avatar { | |
| width: 48px; height: 48px; | |
| border-radius: 50%; | |
| display: grid; | |
| place-items: center; | |
| color: white; | |
| font-weight: 700; | |
| letter-spacing: .3px; | |
| box-shadow: 0 10px 20px rgba(0,0,0,.15); | |
| } | |
| .avatar.small { width: 36px; height: 36px; font-size: .85rem; } | |
| .composer textarea { | |
| width: 100%; | |
| min-height: 80px; | |
| resize: vertical; | |
| border: 1px solid var(--border); | |
| background: color-mix(in oklab, var(--card), transparent 10%); | |
| border-radius: var(--radius); | |
| padding: var(--sp-4); | |
| outline: none; | |
| transition: border .2s ease, box-shadow .2s ease, background .2s ease; | |
| } | |
| .composer textarea:focus { | |
| border-color: color-mix(in oklab, var(--brand), white 60%); | |
| box-shadow: 0 0 0 6px color-mix(in oklab, var(--brand), transparent 88%); | |
| } | |
| .composer .tools { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: var(--sp-3); | |
| flex-wrap: wrap; | |
| } | |
| .left-tools, .right-tools { | |
| display: flex; | |
| align-items: center; | |
| gap: var(--sp-3); | |
| flex-wrap: wrap; | |
| } | |
| .btn { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 10px; | |
| border-radius: 12px; | |
| border: 1px solid var(--border); | |
| background: var(--bg-elev); | |
| padding: 10px 14px; | |
| cursor: pointer; | |
| transition: transform .08s ease, background .2s ease, border .2s ease; | |
| } | |
| .btn:hover { transform: translateY(-1px); } | |
| .btn svg { width: 18px; height: 18px; } | |
| .btn.primary { | |
| background: linear-gradient(135deg, var(--brand), var(--brand-2)); | |
| color: white; | |
| border-color: transparent; | |
| box-shadow: 0 10px 20px color-mix(in oklab, var(--brand), black 70% / 40%); | |
| } | |
| .btn:disabled { | |
| opacity: .6; | |
| cursor: not-allowed; | |
| transform: none; | |
| } | |
| .counter { | |
| color: var(--muted); | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .emoji-picker { | |
| position: absolute; | |
| z-index: 50; | |
| background: var(--card); | |
| border: 1px solid var(--border); | |
| border-radius: 14px; | |
| box-shadow: var(--shadow); | |
| padding: 10px; | |
| width: 260px; | |
| display: none; | |
| } | |
| .emoji-picker.active { display: block; } | |
| .emoji-grid { | |
| display: grid; | |
| grid-template-columns: repeat(8, 1fr); | |
| gap: 6px; | |
| font-size: 20px; | |
| line-height: 1; | |
| } | |
| .emoji-grid button { | |
| background: transparent; | |
| border: 0; | |
| padding: 6px; | |
| border-radius: 8px; | |
| cursor: pointer; | |
| } | |
| .emoji-grid button:hover { | |
| background: color-mix(in oklab, var(--brand), transparent 90%); | |
| } | |
| /* -------------------------- | |
| Feed | |
| ---------------------------*/ | |
| .feed { | |
| display: grid; | |
| gap: var(--sp-4); | |
| } | |
| .sortbar { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: var(--sp-3); | |
| padding: 8px 10px; | |
| border-radius: 12px; | |
| background: color-mix(in oklab, var(--card), transparent 10%); | |
| border: 1px dashed var(--border); | |
| } | |
| .sortbar .tabs { | |
| display: inline-flex; | |
| background: var(--bg-elev); | |
| border: 1px solid var(--border); | |
| border-radius: 999px; | |
| overflow: hidden; | |
| } | |
| .sortbar .tabs button { | |
| padding: 8px 12px; | |
| border: 0; | |
| background: transparent; | |
| cursor: pointer; | |
| color: var(--muted); | |
| } | |
| .sortbar .tabs button.active { | |
| background: var(--brand); | |
| color: white; | |
| } | |
| .post { | |
| background: var(--card); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| box-shadow: var(--shadow); | |
| padding: var(--sp-5); | |
| display: grid; | |
| gap: var(--sp-4); | |
| } | |
| .post.hidden { display: none; } | |
| .post .header { | |
| display: grid; | |
| grid-template-columns: 48px 1fr auto; | |
| gap: var(--sp-3); | |
| align-items: center; | |
| } | |
| .post .meta { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 8px 12px; | |
| align-items: center; | |
| color: var(--muted); | |
| font-size: .92rem; | |
| } | |
| .post .meta .name { | |
| font-weight: 700; | |
| color: var(--text); | |
| } | |
| .post .content { | |
| white-space: pre-wrap; | |
| word-break: break-word; | |
| font-size: 1.02rem; | |
| } | |
| .post .tags { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 8px; | |
| } | |
| .tag { | |
| font-size: .85rem; | |
| color: var(--brand); | |
| background: color-mix(in oklab, var(--brand), transparent 88%); | |
| padding: 6px 10px; | |
| border-radius: 999px; | |
| border: 1px solid color-mix(in oklab, var(--brand), transparent 60%); | |
| cursor: pointer; | |
| } | |
| .actions { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| flex-wrap: wrap; | |
| } | |
| .action { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 8px 10px; | |
| border-radius: 10px; | |
| border: 1px solid var(--border); | |
| background: var(--bg-elev); | |
| cursor: pointer; | |
| color: var(--muted); | |
| } | |
| .action.liked { | |
| color: #e11d48; | |
| border-color: color-mix(in oklab, #e11d48, transparent 60%); | |
| background: color-mix(in oklab, #e11d48, transparent 90%); | |
| } | |
| .action svg { width: 18px; height: 18px; } | |
| .comments { | |
| border-top: 1px dashed var(--border); | |
| padding-top: var(--sp-4); | |
| display: none; | |
| } | |
| .comments.open { display: grid; gap: var(--sp-3); } | |
| .comment { | |
| display: grid; | |
| grid-template-columns: 36px 1fr; | |
| gap: var(--sp-3); | |
| align-items: start; | |
| } | |
| .comment .bubble { | |
| background: color-mix(in oklab, var(--card), transparent 8%); | |
| border: 1px solid var(--border); | |
| border-radius: 12px; | |
| padding: var(--sp-3) var(--sp-4); | |
| } | |
| .comment .bubble .name { | |
| font-weight: 600; | |
| margin-right: 8px; | |
| } | |
| .comment-form { | |
| display: grid; | |
| grid-template-columns: 36px 1fr auto; | |
| gap: var(--sp-3); | |
| align-items: center; | |
| } | |
| .comment-form input { | |
| border: 1px solid var(--border); | |
| border-radius: 999px; | |
| background: var(--bg-elev); | |
| padding: 10px 14px; | |
| outline: none; | |
| } | |
| /* -------------------------- | |
| Responsive | |
| ---------------------------*/ | |
| @media (max-width: 1000px) { | |
| .layout { grid-template-columns: 1fr; } | |
| .sidebar { position: static; order: 2; } | |
| .feed { order: 1; } | |
| } | |
| @media (max-width: 640px) { | |
| .search { display: none; } | |
| .header-inner { grid-template-columns: 1fr auto; } | |
| .composer .row { grid-template-columns: 40px 1fr; } | |
| } | |
| @media (prefers-reduced-motion: reduce) { | |
| * { transition: none ; animation: none ; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <header class="app-header"> | |
| <div class="container header-inner"> | |
| <div class="brand"> | |
| <div class="logo" aria-hidden="true"></div> | |
| <div> | |
| <h1>Oye</h1> | |
| <small>Share your voice</small> | |
| </div> | |
| </div> | |
| <div class="header-actions"> | |
| <div class="search" role="search"> | |
| <svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M21 21l-4.2-4.2M10.5 18a7.5 7.5 0 1 1 0-15 7.5 7.5 0 0 1 0 15Z" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg> | |
| <input id="searchInput" type="search" placeholder="Search posts, #tags, @people…" aria-label="Search" /> | |
| </div> | |
| <button id="themeToggle" class="icon-btn" aria-label="Toggle theme" title="Toggle theme"> | |
| <svg id="themeIcon" viewBox="0 0 24 24" fill="none"><path d="M12 3v2m0 14v2m9-9h-2M5 12H3m14.95 6.95-1.41-1.41M7.46 7.46 6.05 6.05m12.9 0-1.41 1.41M7.46 16.54 6.05 17.95" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="1.6"/></svg> | |
| </button> | |
| </div> | |
| </div> | |
| </header> | |
| <main> | |
| <div class="container layout"> | |
| <aside class="sidebar"> | |
| <div class="side-card"> | |
| <h3>Trends for you</h3> | |
| <div class="chips"> | |
| <button class="chip" data-tag="javascript">#javascript</button> | |
| <button class="chip" data-tag="css">#css</button> | |
| <button class="chip" data-tag="webdev">#webdev</button> | |
| <button class="chip" data-tag="design">#design</button> | |
| <button class="chip" data-tag="productivity">#productivity</button> | |
| </div> | |
| </div> | |
| <div class="side-card footer"> | |
| <div style="display:flex; align-items:center; justify-content:space-between; gap:10px;"> | |
| <span>Built with</span> | |
| <a href="https://huggingface.co/spaces/akhaliq/anycoder" target="_blank" rel="noopener">anycoder</a> | |
| </div> | |
| <div style="margin-top:8px; font-size:.9rem;"> | |
| Keyboard: n to compose, / to search, t to toggle theme | |
| </div> | |
| </div> | |
| </aside> | |
| <section> | |
| <div class="composer" aria-label="Create post"> | |
| <div class="row"> | |
| <div id="myAvatar" class="avatar" title="You">Y</div> | |
| <div> | |
| <textarea id="postInput" maxlength="500" placeholder="Oye… what's on your mind?" aria-label="Post text"></textarea> | |
| <div class="tools"> | |
| <div class="left-tools"> | |
| <button id="emojiBtn" class="btn" type="button" aria-haspopup="dialog" aria-expanded="false" aria-controls="emojiPicker"> | |
| <svg viewBox="0 0 24 24" fill="none"><path d="M15.5 9.5a6.5 6.5 0 1 1-13 0M9 13.5h.01M15 13.5h.01M8 8h.01M16 8h.01" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg> | |
| Emoji | |
| </button> | |
| <button id="clearBtn" class="btn" type="button" title="Clear"> | |
| <svg viewBox="0 0 24 24" fill="none"><path d="M3 6h18M8 6v12a3 3 0 0 0 3 3h2a3 3 0 0 0 3-3V6M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg> | |
| Clear | |
| </button> | |
| </div> | |
| <div class="right-tools"> | |
| <span class="counter"><span id="charCount">0</span>/500</span> | |
| <button id="postBtn" class="btn primary" type="button" disabled> | |
| <svg viewBox="0 0 24 24" fill="none"><path d="M4 12l16-8-6 16-2-6-8-2Z" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg> | |
| Post | |
| </button> | |
| </div> | |
| </div> | |
| <div id="emojiPicker" class="emoji-picker" role="dialog" aria-label="Emoji picker"> | |
| <div class="emoji-grid" id="emojiGrid" aria-hidden="false"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="feed" id="feed"> | |
| <div class="sortbar"> | |
| <div class="tabs" role="tablist" aria-label="Sort posts"> | |
| <button class="active" data-sort="latest" role="tab" aria-selected="true">Latest</button> | |
| <button data-sort="popular" role="tab" aria-selected="false">Popular</button> | |
| </div> | |
| <div style="color:var(--muted); font-size:.95rem;"> | |
| <span id="visibleCount">0</span> posts | |
| </div> | |
| </div> | |
| <div id="posts"></div> | |
| </div> | |
| </section> | |
| </div> | |
| </main> | |
| <template id="postTemplate"> | |
| <article class="post" data-id=""> | |
| <div class="header"> | |
| <div class="avatar small" data-avatar></div> | |
| <div class="meta"> | |
| <span class="name" data-name></span> | |
| <span aria-hidden="true">•</span> | |
| <time data-time></time> | |
| <span class="muted" data-handle></span> | |
| </div> | |
| <button class="icon-btn" data-more aria-label="More"> | |
| <svg viewBox="0 0 24 24" fill="none"><circle cx="5" cy="12" r="1.8" fill="currentColor"/><circle cx="12" cy="12" r="1.8" fill="currentColor"/><circle cx="19" cy="12" r="1.8" fill="currentColor"/></svg> | |
| </button> | |
| </div> | |
| <div class="content" data-content></div> | |
| <div class="tags" data-tags></div> | |
| <div class="actions"> | |
| <button class="action like-btn" data-like aria-pressed="false"> | |
| <svg viewBox="0 0 24 24" fill="none"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 1 0-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 0 0 0-7.78Z" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg> | |
| <span data-like-count>0</span> | |
| </button> | |
| <button class="action comment-btn" data-comment> | |
| <svg viewBox="0 0 24 24" fill="none"><path d="M21 15a4 4 0 0 1-4 4H7l-4 4V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4v8Z" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg> | |
| <span data-comment-count>0</span> | |
| </button> | |
| <button class="action share-btn" data-share> | |
| <svg viewBox="0 0 24 24" fill="none"><path d="M4 12v7a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-7M16 6l-4-4-4 4M12 2v14" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg> | |
| Share | |
| </button> | |
| </div> | |
| <div class="comments" data-comments></div> | |
| </article> | |
| </template> | |
| <template id="commentTemplate"> | |
| <div class="comment"> | |
| <div class="avatar small" data-cavatar></div> | |
| <div class="bubble"> | |
| <div class="line"> | |
| <span class="name" data-cname></span> | |
| <span class="muted" style="font-size:.85rem;" data-ctime></span> | |
| </div> | |
| <div class="text" data-ctext></div> | |
| </div> | |
| </div> | |
| </template> | |
| <script> | |
| // Utilities | |
| const $ = (sel, root = document) => root.querySelector(sel); | |
| const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel)); | |
| const clamp = (n, min, max) => Math.max(min, Math.min(max, n)); | |
| const timeAgo = (ts) => { | |
| const s = Math.floor((Date.now() - ts) / 1000); | |
| if (s < 60) return s + "s"; | |
| const m = Math.floor(s / 60); | |
| if (m < 60) return m + "m"; | |
| const h = Math.floor(m / 60); | |
| if (h < 24) return h + "h"; | |
| const d = Math.floor(h / 24); | |
| if (d < 7) return d + "d"; | |
| const w = Math.floor(d / 7); | |
| if (w < 5) return w + "w"; | |
| const mo = Math.floor(d / 30); | |
| if (mo < 12) return mo + "mo"; | |
| const y = Math.floor(d / 365); | |
| return y + "y"; | |
| }; | |
| const escapeHTML = (str) => str.replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); | |
| const linkify = (text) => { | |
| const safe = escapeHTML(text); | |
| // URLs | |
| let out = safe.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>'); | |
| // @mentions | |
| out = out.replace(/(^|\s)@([a-zA-Z0-9_]+)/g, '$1<a href="#" class="mention">@$2</a>'); | |
| // #hashtags | |
| out = out.replace(/(^|\s)#([a-zA-Z0-9_]+)/g, '$1<a href="#" class="hashtag" data-tag="$2">#$2</a>'); | |
| // Line breaks | |
| out = out.replace(/\n/g, '<br>'); | |
| return out; | |
| }; | |
| // Seed data | |
| const seedPosts = [ | |
| { | |
| id: uid(), author: "Arjun Mehta", handle: "@arjun", color: "#6d5dfc", | |
| text: "Oye! Just shipped a tiny CSS trick for sticky headers with scroll-driven animations. Feels magical. #css #webdev", | |
| tags: ["css", "webdev"], likes: 12, comments: [], createdAt: Date.now() - 1000 * 60 * 42 | |
| }, | |
| { | |
| id: uid(), author: "Lucia Fernández", handle: "@lucia", color: "#ff6ea6", | |
| text: "Tip of the day: use :has() for cleaner UI states. Example: .card:has(:focus-within) { box-shadow: ... } #javascript #design", | |
| tags: ["javascript", "design"], likes: 34, comments: [], createdAt: Date.now() - 1000 * 60 * 75 | |
| }, | |
| { | |
| id: uid(), author: "Samir Khan", handle: "@samir", color: "#22c55e", | |
| text: "Built a weekend project in pure HTML, CSS, and JS. No frameworks. So satisfying. #webdev #javascript", | |
| tags: ["webdev", "javascript"], likes: 19, comments: [], createdAt: Date.now() - 1000 * 60 * 60 * 5 | |
| }, | |
| { | |
| id: uid(), author: "Mina Park", handle: "@mina", color: "#f59e0b", | |
| text: "System fonts + variable fonts = tiny bundle, huge personality. Don't sleep on them. #design #productivity", | |
| tags: ["design", "productivity"], likes: 7, comments: [], createdAt: Date.now() - 1000 * 60 * 12 | |
| } | |
| ]; | |
| // State | |
| let posts = []; | |
| let likesMap = {}; // id -> bool | |
| let commentsMap = {}; // id -> array | |
| let sortMode = "latest"; | |
| // Elements | |
| const postsEl = $("#posts"); | |
| const feedEl = $("#feed"); | |
| const postTemplate = $("#postTemplate"); | |
| const commentTemplate = $("#commentTemplate"); | |
| const charCountEl = $("#charCount"); | |
| const postInput = $("#postInput"); | |
| const postBtn = $("#postBtn"); | |
| const emojiBtn = $("#emojiBtn"); | |
| const emojiPicker = $("#emojiPicker"); | |
| const emojiGrid = $("#emojiGrid"); | |
| const themeToggle = $("#themeToggle"); | |
| const themeIcon = $("#themeIcon"); | |
| const searchInput = $("#searchInput"); | |
| const visibleCountEl = $("#visibleCount"); | |
| const sortTabs = $$(".sortbar .tabs button"); | |
| // Init | |
| init(); | |
| function init() { | |
| // Theme | |
| applySavedTheme(); | |
| // Avatar | |
| const my = getMyProfile(); | |
| const myAvatar = $("#myAvatar"); | |
| myAvatar.style.background = my.color; | |
| myAvatar.textContent = initials(my.name); | |
| // Restore data | |
| const saved = loadState(); | |
| posts = saved.posts.length ? saved.posts : seedPosts; | |
| likesMap = saved.likesMap || {}; | |
| commentsMap = saved.commentsMap || {}; | |
| renderPosts(); | |
| // Events | |
| postInput.addEventListener("input", onInput); | |
| postBtn.addEventListener("click", onPost); | |
| $("#clearBtn").addEventListener("click", () => { postInput.value = ""; onInput(); postInput.focus(); }); | |
| document.addEventListener("keydown", onKeyShortcuts); | |
| emojiBtn.addEventListener("click", toggleEmojiPicker); | |
| document.addEventListener("click", onDocClick); | |
| emojiGrid.addEventListener("click", onEmojiPick); | |
| themeToggle.addEventListener("click", toggleTheme); | |
| searchInput.addEventListener("input", onSearch); | |
| sortTabs.forEach(btn => btn.addEventListener("click", onSortChange)); | |
| $$(".chip").forEach(chip => chip.addEventListener("click", () => { | |
| const tag = chip.dataset.tag; | |
| postInput.value = (postInput.value + " #" + tag).trim(); | |
| onInput(); | |
| postInput.focus(); | |
| })); | |
| $$(".sortbar .tabs button").forEach(btn => btn.addEventListener("click", () => { | |
| $$(".sortbar .tabs button").forEach(b => b.classList.remove("active")); | |
| btn.classList.add("active"); | |
| })); | |
| // Emojis | |
| buildEmojiGrid(); | |
| } | |
| function onInput() { | |
| const val = postInput.value; | |
| charCountEl.textContent = val.length; | |
| postBtn.disabled = val.trim().length === 0; | |
| } | |
| function onPost() { | |
| const text = postInput.value.trim(); | |
| if (!text) return; | |
| const me = getMyProfile(); | |
| const newPost = { | |
| id: uid(), | |
| author: me.name, | |
| handle: me.handle, | |
| color: me.color, | |
| text, | |
| tags: extractTags(text), | |
| likes: 0, | |
| comments: [], | |
| createdAt: Date.now() | |
| }; | |
| posts.unshift(newPost); | |
| postInput.value = ""; | |
| onInput(); | |
| renderPosts(); | |
| saveState(); | |
| // Smooth scroll to top of feed | |
| postsEl.firstElementChild?.scrollIntoView({ behavior: "smooth", block: "center" }); | |
| } | |
| function onKeyShortcuts(e) { | |
| if (e.key.toLowerCase() === "n" && !isTypingInInput(e)) { | |
| e.preventDefault(); postInput.focus(); | |
| } | |
| if (e.key === "/" && document.activeElement !== searchInput) { | |
| e.preventDefault(); searchInput.focus(); | |
| } | |
| if (e.key.toLowerCase() === "t" && !isTypingInInput(e)) { | |
| e.preventDefault(); toggleTheme(); | |
| } | |
| if (e.key === "Escape") { | |
| closeEmojiPicker(); | |
| } | |
| } | |
| function isTypingInInput(e) { | |
| const el = e.target; | |
| return el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable); | |
| } | |
| function renderPosts() { | |
| postsEl.innerHTML = ""; | |
| const sorted = sortPosts(posts, sortMode); | |
| const q = searchInput.value.trim().toLowerCase(); | |
| let visible = 0; | |
| for (const p of sorted) { | |
| if (q) { | |
| const hay = (p.text + " " + p.author + " " + p.handle + " " + p.tags.join(" ")).toLowerCase(); | |
| if (!hay.includes(q)) continue; | |
| } | |
| const node = renderPost(p); | |
| postsEl.appendChild(node); | |
| visible++; | |
| } | |
| visibleCountEl.textContent = String(visible); | |
| } | |
| function renderPost(p) { | |
| const tpl = postTemplate.content.cloneNode(true); | |
| const article = tpl.querySelector("article"); | |
| article.dataset.id = p.id; | |
| // Header | |
| const avatar = tpl.querySelector("[data-avatar]"); | |
| avatar.style.background = p.color; | |
| avatar.textContent = initials(p.author); | |
| tpl.querySelector("[data-name]").textContent = p.author; | |
| tpl.querySelector("[data-handle]").textContent = p.handle; | |
| tpl.querySelector("[data-time]").textContent = timeAgo(p.createdAt); | |
| // Content | |
| const content = tpl.querySelector("[data-content]"); | |
| content.innerHTML = linkify(p.text); | |
| // Tags | |
| const tagsEl = tpl.querySelector("[data-tags]"); | |
| tagsEl.innerHTML = ""; | |
| p.tags.forEach(tag => { | |
| const a = document.createElement("a"); | |
| a.href = "#"; | |
| a.className = "tag"; | |
| a.textContent = "#" + tag; | |
| a.dataset.tag = tag; | |
| a.addEventListener("click", (e) => { | |
| e.preventDefault(); | |
| searchInput.value = "#" + tag; | |
| onSearch(); | |
| }); | |
| tagsEl.appendChild(a); | |
| }); | |
| // Actions | |
| const likeBtn = tpl.querySelector("[data-like]"); | |
| const likeCount = tpl.querySelector("[data-like-count]"); | |
| likeCount.textContent = p.likes; | |
| likeBtn.setAttribute("aria-pressed", likesMap[p.id] ? "true" : "false"); | |
| if (likesMap[p.id]) likeBtn.classList.add("liked"); | |
| likeBtn.addEventListener("click", () => { | |
| const liked = !likesMap[p.id]; | |
| likesMap[p.id] = liked; | |
| p.likes = clamp(p.likes + (liked ? 1 : -1), 0, 1e9); | |
| likeBtn.setAttribute("aria-pressed", liked ? "true" : "false"); | |
| likeBtn.classList.toggle("liked", liked); | |
| likeCount.textContent = p.likes; | |
| saveState(); | |
| }); | |
| const commentBtn = tpl.querySelector("[data-comment]"); | |
| const commentCount = tpl.querySelector("[data-comment-count]"); | |
| const commentsEl = tpl.querySelector("[data-comments]"); | |
| commentCount.textContent = p.comments.length; | |
| commentBtn.addEventListener("click", () => { | |
| commentsEl.classList.toggle("open"); | |
| if (commentsEl.classList.contains("open") && !commentsEl.hasChildNodes()) { | |
| renderComments(p, commentsEl); | |
| } | |
| }); | |
| // Share | |
| tpl.querySelector("[data-share]").addEventListener("click", async () => { | |
| const shareText = `${p.author} (${p.handle}): ${p.text}`; | |
| if (navigator.share) { | |
| try { | |
| await navigator.share({ title: "Oye", text: shareText, url: location.href }); | |
| } catch {} | |
| } else { | |
| await navigator.clipboard.writeText(shareText + " " + location.href); | |
| toast("Copied post to clipboard"); | |
| } | |
| }); | |
| // Time updater | |
| const timeEl = tpl.querySelector("[data-time]"); | |
| const interval = setInterval(() => { | |
| if (!document.body.contains(article)) return clearInterval(interval); | |
| timeEl.textContent = timeAgo(p.createdAt); | |
| }, 30000); | |
| return tpl; | |
| } | |
| function renderComments(post, container) { | |
| const list = commentsMap[post.id] || []; | |
| list.forEach(c => { | |
| const tpl = commentTemplate.content.cloneNode(true); | |
| const avatar = tpl.querySelector("[data-cavatar]"); | |
| avatar.style.background = c.color; | |
| avatar.textContent = initials(c.name); | |
| tpl.querySelector("[data-cname]").textContent = c.name; | |
| tpl.querySelector("[data-ctime]").textContent = timeAgo(c.ts); | |
| tpl.querySelector("[data-ctext]").textContent = c.text; | |
| container.appendChild(tpl); | |
| }); | |
| // Comment form | |
| const form = document.createElement("form"); | |
| form.className = "comment-form"; | |
| form.innerHTML = ` | |
| <div class="avatar small" style="background:${getMyProfile().color}">${initials(getMyProfile().name)}</div> | |
| <input type="text" placeholder="Write a comment…" aria-label="Write a comment" required /> | |
| <button class="btn" type="submit">Send</button> | |
| `; | |
| form.addEventListener("submit", (e) => { | |
| e.preventDefault(); | |
| const input = form.querySelector("input"); | |
| const text = input.value.trim(); | |
| if (!text) return; | |
| const me = getMyProfile(); | |
| const c = { name: me.name, color: me.color, text, ts: Date.now() }; | |
| commentsMap[post.id] = (commentsMap[post.id] || []).concat([c]); | |
| post.comments.push(c); | |
| input.value = ""; | |
| container.innerHTML = ""; | |
| renderComments(post, container); | |
| const countEl = container.parentElement.querySelector("[data-comment-count]"); | |
| countEl.textContent = String(post.comments.length); | |
| saveState(); | |
| }); | |
| container.appendChild(form); | |
| } | |
| function onSortChange(e) { | |
| sortMode = e.currentTarget.dataset.sort; | |
| renderPosts(); | |
| } | |
| function onSearch() { | |
| renderPosts(); | |
| } |