anycoder-df4285ed / index.html
mav23's picture
Upload folder using huggingface_hub
bde971a verified
<!DOCTYPE html>
<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 !important; animation: none !important; }
}
</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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[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();
}