| <!doctype html> |
| <html lang="en" data-theme="dark"> |
| <head> |
| <meta charset="UTF-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| <title>Postly — Share your thoughts</title> |
| <link rel="preconnect" href="https://fonts.googleapis.com" /> |
| <link |
| href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" |
| rel="stylesheet" |
| /> |
| <style> |
| |
| |
| |
| :root { |
| |
| --space-1: 4px; |
| --space-2: 8px; |
| --space-3: 12px; |
| --space-4: 16px; |
| --space-5: 20px; |
| --space-6: 24px; |
| --space-8: 32px; |
| --space-10: 40px; |
| --space-12: 48px; |
| --space-16: 64px; |
| |
| |
| --font-sans: "Inter", system-ui, sans-serif; |
| --font-mono: "JetBrains Mono", monospace; |
| --text-xs: 11px; |
| --text-sm: 13px; |
| --text-base: 14px; |
| --text-md: 15px; |
| --text-lg: 17px; |
| --text-xl: 20px; |
| --text-2xl: 24px; |
| --text-3xl: 30px; |
| --text-4xl: 38px; |
| |
| |
| --radius-sm: 6px; |
| --radius-md: 10px; |
| --radius-lg: 14px; |
| --radius-xl: 20px; |
| --radius-full: 9999px; |
| |
| |
| --ease: cubic-bezier(0.16, 1, 0.3, 1); |
| --duration-fast: 120ms; |
| --duration-base: 200ms; |
| --duration-slow: 350ms; |
| |
| |
| --accent: #7c3aed; |
| --accent-light: #8b5cf6; |
| --accent-dim: rgba(124, 58, 237, 0.15); |
| --accent-hover: #6d28d9; |
| --success: #10b981; |
| --warning: #f59e0b; |
| --danger: #ef4444; |
| --info: #3b82f6; |
| } |
| |
| |
| |
| |
| [data-theme="dark"] { |
| --bg-base: #0a0a0f; |
| --bg-surface: #111118; |
| --bg-raised: #18181f; |
| --bg-overlay: #222230; |
| --bg-hover: rgba(255, 255, 255, 0.04); |
| --bg-active: rgba(255, 255, 255, 0.07); |
| |
| --border: rgba(255, 255, 255, 0.08); |
| --border-strong: rgba(255, 255, 255, 0.14); |
| |
| --text-primary: #f2f2f7; |
| --text-secondary: #9898ab; |
| --text-tertiary: #5a5a6e; |
| --text-inverse: #0a0a0f; |
| |
| --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4); |
| --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.5); |
| --shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.6); |
| --glow: 0 0 0 1px var(--accent), 0 0 20px rgba(124, 58, 237, 0.2); |
| } |
| |
| |
| |
| |
| [data-theme="light"] { |
| --bg-base: #f8f8fc; |
| --bg-surface: #ffffff; |
| --bg-raised: #f0f0f8; |
| --bg-overlay: #e8e8f4; |
| --bg-hover: rgba(0, 0, 0, 0.03); |
| --bg-active: rgba(0, 0, 0, 0.06); |
| |
| --border: rgba(0, 0, 0, 0.08); |
| --border-strong: rgba(0, 0, 0, 0.14); |
| |
| --text-primary: #111118; |
| --text-secondary: #5a5a6e; |
| --text-tertiary: #9898ab; |
| --text-inverse: #f2f2f7; |
| |
| --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08); |
| --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.1); |
| --shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.12); |
| --glow: 0 0 0 1px var(--accent), 0 0 20px rgba(124, 58, 237, 0.12); |
| } |
| |
| |
| |
| |
| *, |
| *::before, |
| *::after { |
| box-sizing: border-box; |
| margin: 0; |
| padding: 0; |
| } |
| html { |
| font-size: 16px; |
| scroll-behavior: smooth; |
| } |
| body { |
| font-family: var(--font-sans); |
| font-size: var(--text-base); |
| line-height: 1.6; |
| background: var(--bg-base); |
| color: var(--text-primary); |
| min-height: 100vh; |
| -webkit-font-smoothing: antialiased; |
| transition: |
| background var(--duration-slow) var(--ease), |
| color var(--duration-slow) var(--ease); |
| } |
| a { |
| color: inherit; |
| text-decoration: none; |
| } |
| button { |
| font-family: inherit; |
| cursor: pointer; |
| border: none; |
| outline: none; |
| } |
| input, |
| textarea { |
| font-family: inherit; |
| outline: none; |
| } |
| img { |
| display: block; |
| max-width: 100%; |
| } |
| ul { |
| list-style: none; |
| } |
| |
| |
| |
| |
| #app { |
| display: flex; |
| flex-direction: column; |
| min-height: 100vh; |
| } |
| |
| .navbar { |
| position: sticky; |
| top: 0; |
| z-index: 100; |
| height: 56px; |
| background: var(--bg-surface); |
| border-bottom: 1px solid var(--border); |
| backdrop-filter: blur(20px); |
| -webkit-backdrop-filter: blur(20px); |
| display: flex; |
| align-items: center; |
| padding: 0 var(--space-6); |
| gap: var(--space-4); |
| } |
| .navbar-brand { |
| display: flex; |
| align-items: center; |
| gap: var(--space-2); |
| font-size: var(--text-lg); |
| font-weight: 700; |
| letter-spacing: -0.5px; |
| color: var(--text-primary); |
| } |
| .navbar-brand .brand-dot { |
| width: 8px; |
| height: 8px; |
| border-radius: 50%; |
| background: var(--accent); |
| box-shadow: 0 0 8px var(--accent); |
| } |
| .navbar-spacer { |
| flex: 1; |
| } |
| .navbar-actions { |
| display: flex; |
| align-items: center; |
| gap: var(--space-2); |
| } |
| |
| .main-layout { |
| display: flex; |
| flex: 1; |
| } |
| .sidebar { |
| width: 220px; |
| flex-shrink: 0; |
| padding: var(--space-6) var(--space-3); |
| border-right: 1px solid var(--border); |
| display: flex; |
| flex-direction: column; |
| gap: var(--space-1); |
| position: sticky; |
| top: 56px; |
| height: calc(100vh - 56px); |
| overflow-y: auto; |
| } |
| .content-area { |
| flex: 1; |
| min-width: 0; |
| padding: var(--space-8) var(--space-8); |
| max-width: 900px; |
| } |
| @media (max-width: 900px) { |
| .sidebar { |
| display: none; |
| } |
| .content-area { |
| padding: var(--space-6) var(--space-4); |
| } |
| } |
| |
| |
| |
| |
| .nav-section-label { |
| font-size: var(--text-xs); |
| font-weight: 600; |
| text-transform: uppercase; |
| letter-spacing: 0.08em; |
| color: var(--text-tertiary); |
| padding: var(--space-4) var(--space-3) var(--space-2); |
| } |
| .nav-item { |
| display: flex; |
| align-items: center; |
| gap: var(--space-3); |
| padding: var(--space-2) var(--space-3); |
| border-radius: var(--radius-sm); |
| font-size: var(--text-sm); |
| font-weight: 500; |
| color: var(--text-secondary); |
| cursor: pointer; |
| transition: all var(--duration-fast) var(--ease); |
| user-select: none; |
| } |
| .nav-item:hover { |
| background: var(--bg-hover); |
| color: var(--text-primary); |
| } |
| .nav-item.active { |
| background: var(--accent-dim); |
| color: var(--accent-light); |
| } |
| .nav-item svg { |
| flex-shrink: 0; |
| opacity: 0.8; |
| } |
| .nav-item.active svg { |
| opacity: 1; |
| } |
| |
| |
| |
| |
| .btn { |
| display: inline-flex; |
| align-items: center; |
| justify-content: center; |
| gap: var(--space-2); |
| padding: 0 var(--space-4); |
| height: 34px; |
| border-radius: var(--radius-sm); |
| font-size: var(--text-sm); |
| font-weight: 500; |
| transition: all var(--duration-fast) var(--ease); |
| white-space: nowrap; |
| flex-shrink: 0; |
| } |
| .btn-primary { |
| background: var(--accent); |
| color: #fff; |
| box-shadow: |
| 0 1px 2px rgba(0, 0, 0, 0.2), |
| inset 0 1px 0 rgba(255, 255, 255, 0.1); |
| } |
| .btn-primary:hover { |
| background: var(--accent-hover); |
| transform: translateY(-1px); |
| } |
| .btn-primary:active { |
| transform: translateY(0); |
| } |
| |
| .btn-ghost { |
| background: transparent; |
| color: var(--text-secondary); |
| border: 1px solid var(--border); |
| } |
| .btn-ghost:hover { |
| background: var(--bg-hover); |
| color: var(--text-primary); |
| border-color: var(--border-strong); |
| } |
| |
| .btn-danger { |
| background: var(--danger); |
| color: #fff; |
| } |
| .btn-danger:hover { |
| background: #dc2626; |
| } |
| |
| .btn-sm { |
| height: 28px; |
| padding: 0 var(--space-3); |
| font-size: var(--text-xs); |
| } |
| .btn-lg { |
| height: 42px; |
| padding: 0 var(--space-6); |
| font-size: var(--text-md); |
| } |
| .btn-icon { |
| width: 34px; |
| padding: 0; |
| } |
| .btn-icon-sm { |
| width: 28px; |
| height: 28px; |
| padding: 0; |
| } |
| |
| .btn:disabled { |
| opacity: 0.45; |
| cursor: not-allowed; |
| transform: none !important; |
| } |
| |
| |
| |
| |
| .form-group { |
| display: flex; |
| flex-direction: column; |
| gap: var(--space-2); |
| } |
| .form-label { |
| font-size: var(--text-sm); |
| font-weight: 500; |
| color: var(--text-secondary); |
| } |
| .form-input { |
| width: 100%; |
| height: 40px; |
| padding: 0 var(--space-3); |
| background: var(--bg-raised); |
| color: var(--text-primary); |
| border: 1px solid var(--border); |
| border-radius: var(--radius-sm); |
| font-size: var(--text-base); |
| transition: |
| border-color var(--duration-fast), |
| box-shadow var(--duration-fast); |
| } |
| .form-input:focus { |
| border-color: var(--accent); |
| box-shadow: 0 0 0 3px var(--accent-dim); |
| } |
| .form-input::placeholder { |
| color: var(--text-tertiary); |
| } |
| .form-textarea { |
| width: 100%; |
| min-height: 120px; |
| padding: var(--space-3); |
| resize: vertical; |
| background: var(--bg-raised); |
| color: var(--text-primary); |
| border: 1px solid var(--border); |
| border-radius: var(--radius-sm); |
| font-size: var(--text-base); |
| line-height: 1.6; |
| transition: |
| border-color var(--duration-fast), |
| box-shadow var(--duration-fast); |
| } |
| .form-textarea:focus { |
| border-color: var(--accent); |
| box-shadow: 0 0 0 3px var(--accent-dim); |
| } |
| .form-textarea::placeholder { |
| color: var(--text-tertiary); |
| } |
| .form-error { |
| font-size: var(--text-xs); |
| color: var(--danger); |
| } |
| .form-hint { |
| font-size: var(--text-xs); |
| color: var(--text-tertiary); |
| } |
| |
| .input-wrap { |
| position: relative; |
| } |
| .input-wrap .form-input { |
| padding-right: 44px; |
| } |
| .input-wrap .input-suffix { |
| position: absolute; |
| right: 0; |
| top: 0; |
| bottom: 0; |
| width: 40px; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| color: var(--text-tertiary); |
| cursor: pointer; |
| } |
| .input-wrap .input-suffix:hover { |
| color: var(--text-primary); |
| } |
| |
| |
| |
| |
| .toggle-wrap { |
| display: flex; |
| align-items: center; |
| gap: var(--space-3); |
| } |
| .toggle { |
| position: relative; |
| width: 40px; |
| height: 22px; |
| background: var(--bg-overlay); |
| border-radius: var(--radius-full); |
| cursor: pointer; |
| transition: background var(--duration-base); |
| border: 1px solid var(--border); |
| } |
| .toggle.on { |
| background: var(--accent); |
| border-color: var(--accent); |
| } |
| .toggle::after { |
| content: ""; |
| position: absolute; |
| width: 16px; |
| height: 16px; |
| border-radius: 50%; |
| background: #fff; |
| top: 2px; |
| left: 2px; |
| transition: transform var(--duration-base) var(--ease); |
| box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); |
| } |
| .toggle.on::after { |
| transform: translateX(18px); |
| } |
| .toggle-label { |
| font-size: var(--text-sm); |
| color: var(--text-secondary); |
| } |
| |
| |
| |
| |
| .card { |
| background: var(--bg-surface); |
| border: 1px solid var(--border); |
| border-radius: var(--radius-lg); |
| transition: |
| border-color var(--duration-base), |
| box-shadow var(--duration-base); |
| } |
| .card:hover { |
| border-color: var(--border-strong); |
| box-shadow: var(--shadow-md); |
| } |
| |
| .post-card { |
| padding: var(--space-6); |
| display: flex; |
| flex-direction: column; |
| gap: var(--space-4); |
| cursor: pointer; |
| } |
| .post-card:hover { |
| border-color: var(--accent); |
| box-shadow: |
| 0 0 0 1px var(--accent-dim), |
| var(--shadow-md); |
| } |
| |
| .post-meta { |
| display: flex; |
| align-items: center; |
| gap: var(--space-3); |
| } |
| .avatar { |
| width: 32px; |
| height: 32px; |
| border-radius: 50%; |
| background: var(--accent-dim); |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| font-size: var(--text-xs); |
| font-weight: 700; |
| color: var(--accent-light); |
| flex-shrink: 0; |
| } |
| .avatar-lg { |
| width: 48px; |
| height: 48px; |
| font-size: var(--text-base); |
| } |
| |
| .post-author { |
| font-size: var(--text-sm); |
| font-weight: 500; |
| color: var(--text-secondary); |
| } |
| .post-time { |
| font-size: var(--text-xs); |
| color: var(--text-tertiary); |
| } |
| .post-title { |
| font-size: var(--text-xl); |
| font-weight: 600; |
| line-height: 1.35; |
| color: var(--text-primary); |
| } |
| .post-content-preview { |
| font-size: var(--text-sm); |
| color: var(--text-secondary); |
| line-height: 1.6; |
| display: -webkit-box; |
| -webkit-line-clamp: 3; |
| -webkit-box-orient: vertical; |
| overflow: hidden; |
| } |
| .post-footer { |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| padding-top: var(--space-2); |
| border-top: 1px solid var(--border); |
| } |
| .post-actions { |
| display: flex; |
| align-items: center; |
| gap: var(--space-1); |
| } |
| |
| |
| |
| |
| .vote-btn { |
| display: inline-flex; |
| align-items: center; |
| gap: var(--space-2); |
| padding: var(--space-1) var(--space-3); |
| border-radius: var(--radius-full); |
| font-size: var(--text-sm); |
| font-weight: 500; |
| background: var(--bg-raised); |
| color: var(--text-secondary); |
| border: 1px solid var(--border); |
| cursor: pointer; |
| transition: all var(--duration-base) var(--ease); |
| user-select: none; |
| } |
| .vote-btn:hover { |
| border-color: var(--accent-light); |
| color: var(--accent-light); |
| background: var(--accent-dim); |
| } |
| .vote-btn.voted { |
| background: var(--accent-dim); |
| color: var(--accent-light); |
| border-color: var(--accent); |
| } |
| .vote-btn svg { |
| transition: transform var(--duration-base) var(--ease); |
| } |
| .vote-btn:hover svg, |
| .vote-btn.voted svg { |
| transform: translateY(-2px); |
| } |
| |
| |
| |
| |
| .badge { |
| display: inline-flex; |
| align-items: center; |
| gap: 4px; |
| padding: 2px 8px; |
| border-radius: var(--radius-full); |
| font-size: var(--text-xs); |
| font-weight: 600; |
| } |
| .badge-published { |
| background: rgba(16, 185, 129, 0.12); |
| color: #10b981; |
| } |
| .badge-draft { |
| background: rgba(245, 158, 11, 0.12); |
| color: #f59e0b; |
| } |
| |
| |
| |
| |
| .page-header { |
| margin-bottom: var(--space-8); |
| } |
| .page-title { |
| font-size: var(--text-3xl); |
| font-weight: 700; |
| letter-spacing: -0.8px; |
| color: var(--text-primary); |
| } |
| .page-sub { |
| font-size: var(--text-md); |
| color: var(--text-secondary); |
| margin-top: var(--space-2); |
| } |
| .page-header-row { |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| gap: var(--space-4); |
| flex-wrap: wrap; |
| } |
| |
| |
| |
| |
| .toolbar { |
| display: flex; |
| align-items: center; |
| gap: var(--space-3); |
| margin-bottom: var(--space-6); |
| flex-wrap: wrap; |
| } |
| .search-wrap { |
| position: relative; |
| flex: 1; |
| min-width: 200px; |
| } |
| .search-wrap svg { |
| position: absolute; |
| left: 12px; |
| top: 50%; |
| transform: translateY(-50%); |
| color: var(--text-tertiary); |
| pointer-events: none; |
| } |
| .search-input { |
| width: 100%; |
| height: 36px; |
| padding: 0 var(--space-3) 0 36px; |
| background: var(--bg-raised); |
| color: var(--text-primary); |
| border: 1px solid var(--border); |
| border-radius: var(--radius-sm); |
| font-size: var(--text-sm); |
| transition: |
| border-color var(--duration-fast), |
| box-shadow var(--duration-fast); |
| } |
| .search-input:focus { |
| border-color: var(--accent); |
| box-shadow: 0 0 0 3px var(--accent-dim); |
| } |
| .search-input::placeholder { |
| color: var(--text-tertiary); |
| } |
| |
| .select { |
| height: 36px; |
| padding: 0 var(--space-3); |
| background: var(--bg-raised); |
| color: var(--text-secondary); |
| border: 1px solid var(--border); |
| border-radius: var(--radius-sm); |
| font-size: var(--text-sm); |
| cursor: pointer; |
| appearance: none; |
| -webkit-appearance: none; |
| padding-right: var(--space-8); |
| background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239898AB' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); |
| background-repeat: no-repeat; |
| background-position: right 10px center; |
| } |
| .select:focus { |
| outline: none; |
| border-color: var(--accent); |
| } |
| |
| |
| |
| |
| .post-grid { |
| display: flex; |
| flex-direction: column; |
| gap: var(--space-4); |
| } |
| |
| |
| |
| |
| .pagination { |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| gap: var(--space-2); |
| margin-top: var(--space-8); |
| flex-wrap: wrap; |
| } |
| .page-btn { |
| min-width: 34px; |
| height: 34px; |
| padding: 0 var(--space-2); |
| border-radius: var(--radius-sm); |
| font-size: var(--text-sm); |
| font-weight: 500; |
| background: var(--bg-raised); |
| color: var(--text-secondary); |
| border: 1px solid var(--border); |
| cursor: pointer; |
| transition: all var(--duration-fast); |
| display: inline-flex; |
| align-items: center; |
| justify-content: center; |
| } |
| .page-btn:hover { |
| border-color: var(--accent-light); |
| color: var(--accent-light); |
| } |
| .page-btn.active { |
| background: var(--accent); |
| color: #fff; |
| border-color: var(--accent); |
| } |
| .page-btn:disabled { |
| opacity: 0.35; |
| cursor: not-allowed; |
| } |
| |
| |
| |
| |
| @keyframes shimmer { |
| 0% { |
| background-position: -400px 0; |
| } |
| 100% { |
| background-position: 400px 0; |
| } |
| } |
| .skeleton { |
| background: linear-gradient( |
| 90deg, |
| var(--bg-raised) 25%, |
| var(--bg-overlay) 50%, |
| var(--bg-raised) 75% |
| ); |
| background-size: 800px 100%; |
| animation: shimmer 1.4s infinite linear; |
| border-radius: var(--radius-sm); |
| } |
| .skeleton-text { |
| height: 14px; |
| margin-bottom: var(--space-2); |
| } |
| .skeleton-title { |
| height: 22px; |
| margin-bottom: var(--space-3); |
| } |
| .skeleton-circle { |
| border-radius: 50%; |
| } |
| |
| |
| |
| |
| .empty-state { |
| text-align: center; |
| padding: var(--space-16) var(--space-8); |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| gap: var(--space-4); |
| } |
| .empty-icon { |
| width: 64px; |
| height: 64px; |
| background: var(--bg-raised); |
| border-radius: var(--radius-xl); |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| color: var(--text-tertiary); |
| } |
| .empty-title { |
| font-size: var(--text-xl); |
| font-weight: 600; |
| } |
| .empty-sub { |
| font-size: var(--text-sm); |
| color: var(--text-secondary); |
| max-width: 300px; |
| } |
| |
| |
| |
| |
| .modal-backdrop { |
| position: fixed; |
| inset: 0; |
| z-index: 200; |
| background: rgba(0, 0, 0, 0.6); |
| backdrop-filter: blur(4px); |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| padding: var(--space-4); |
| opacity: 0; |
| pointer-events: none; |
| transition: opacity var(--duration-base); |
| } |
| .modal-backdrop.open { |
| opacity: 1; |
| pointer-events: all; |
| } |
| .modal { |
| background: var(--bg-surface); |
| border: 1px solid var(--border-strong); |
| border-radius: var(--radius-xl); |
| padding: var(--space-8); |
| max-width: 440px; |
| width: 100%; |
| box-shadow: var(--shadow-lg); |
| transform: scale(0.94) translateY(8px); |
| transition: transform var(--duration-slow) var(--ease); |
| } |
| .modal-backdrop.open .modal { |
| transform: scale(1) translateY(0); |
| } |
| .modal-title { |
| font-size: var(--text-xl); |
| font-weight: 700; |
| margin-bottom: var(--space-2); |
| } |
| .modal-sub { |
| font-size: var(--text-sm); |
| color: var(--text-secondary); |
| margin-bottom: var(--space-6); |
| } |
| .modal-actions { |
| display: flex; |
| gap: var(--space-3); |
| justify-content: flex-end; |
| margin-top: var(--space-6); |
| } |
| |
| |
| |
| |
| #toast-container { |
| position: fixed; |
| bottom: var(--space-6); |
| right: var(--space-6); |
| z-index: 300; |
| display: flex; |
| flex-direction: column; |
| gap: var(--space-3); |
| pointer-events: none; |
| } |
| .toast { |
| display: flex; |
| align-items: flex-start; |
| gap: var(--space-3); |
| padding: var(--space-4); |
| background: var(--bg-surface); |
| border: 1px solid var(--border-strong); |
| border-radius: var(--radius-md); |
| box-shadow: var(--shadow-lg); |
| max-width: 320px; |
| min-width: 240px; |
| pointer-events: all; |
| transform: translateX(calc(100% + 24px)); |
| opacity: 0; |
| transition: |
| transform var(--duration-slow) var(--ease), |
| opacity var(--duration-slow); |
| } |
| .toast.show { |
| transform: translateX(0); |
| opacity: 1; |
| } |
| .toast.hide { |
| transform: translateX(calc(100% + 24px)); |
| opacity: 0; |
| } |
| .toast-icon { |
| flex-shrink: 0; |
| margin-top: 1px; |
| } |
| .toast-body { |
| flex: 1; |
| } |
| .toast-title { |
| font-size: var(--text-sm); |
| font-weight: 600; |
| } |
| .toast-msg { |
| font-size: var(--text-xs); |
| color: var(--text-secondary); |
| margin-top: 2px; |
| } |
| .toast-success .toast-icon { |
| color: var(--success); |
| } |
| .toast-error .toast-icon { |
| color: var(--danger); |
| } |
| .toast-info .toast-icon { |
| color: var(--info); |
| } |
| |
| |
| |
| |
| .auth-page { |
| min-height: 100vh; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| padding: var(--space-6); |
| background: var(--bg-base); |
| } |
| .auth-card { |
| background: var(--bg-surface); |
| border: 1px solid var(--border); |
| border-radius: var(--radius-xl); |
| padding: var(--space-10); |
| width: 100%; |
| max-width: 400px; |
| box-shadow: var(--shadow-lg); |
| } |
| .auth-logo { |
| display: flex; |
| align-items: center; |
| gap: var(--space-2); |
| font-size: var(--text-xl); |
| font-weight: 700; |
| margin-bottom: var(--space-8); |
| } |
| .auth-title { |
| font-size: var(--text-2xl); |
| font-weight: 700; |
| letter-spacing: -0.5px; |
| } |
| .auth-sub { |
| font-size: var(--text-sm); |
| color: var(--text-secondary); |
| margin-top: var(--space-2); |
| margin-bottom: var(--space-8); |
| } |
| .auth-form { |
| display: flex; |
| flex-direction: column; |
| gap: var(--space-5); |
| } |
| .auth-divider { |
| display: flex; |
| align-items: center; |
| gap: var(--space-3); |
| margin: var(--space-2) 0; |
| font-size: var(--text-xs); |
| color: var(--text-tertiary); |
| } |
| .auth-divider::before, |
| .auth-divider::after { |
| content: ""; |
| flex: 1; |
| height: 1px; |
| background: var(--border); |
| } |
| .auth-link { |
| color: var(--accent-light); |
| font-weight: 500; |
| cursor: pointer; |
| } |
| .auth-link:hover { |
| text-decoration: underline; |
| } |
| |
| |
| |
| |
| .post-detail-header { |
| margin-bottom: var(--space-8); |
| } |
| .post-detail-title { |
| font-size: var(--text-4xl); |
| font-weight: 700; |
| letter-spacing: -1px; |
| line-height: 1.2; |
| margin-bottom: var(--space-6); |
| } |
| .post-detail-content { |
| font-size: var(--text-md); |
| line-height: 1.8; |
| color: var(--text-secondary); |
| white-space: pre-wrap; |
| word-break: break-word; |
| } |
| .post-detail-meta { |
| display: flex; |
| align-items: center; |
| gap: var(--space-4); |
| padding: var(--space-4) 0; |
| border-top: 1px solid var(--border); |
| border-bottom: 1px solid var(--border); |
| margin-bottom: var(--space-8); |
| flex-wrap: wrap; |
| } |
| .breadcrumb { |
| display: flex; |
| align-items: center; |
| gap: var(--space-2); |
| font-size: var(--text-sm); |
| color: var(--text-tertiary); |
| margin-bottom: var(--space-6); |
| } |
| .breadcrumb-link { |
| cursor: pointer; |
| color: var(--text-tertiary); |
| } |
| .breadcrumb-link:hover { |
| color: var(--accent-light); |
| } |
| .breadcrumb-sep { |
| color: var(--text-tertiary); |
| } |
| |
| |
| |
| |
| .profile-card { |
| background: var(--bg-surface); |
| border: 1px solid var(--border); |
| border-radius: var(--radius-xl); |
| padding: var(--space-8); |
| display: flex; |
| align-items: flex-start; |
| gap: var(--space-6); |
| margin-bottom: var(--space-8); |
| } |
| .profile-info { |
| flex: 1; |
| } |
| .profile-name { |
| font-size: var(--text-2xl); |
| font-weight: 700; |
| } |
| .profile-email { |
| font-size: var(--text-sm); |
| color: var(--text-secondary); |
| margin-top: 4px; |
| } |
| .profile-stats { |
| display: flex; |
| gap: var(--space-6); |
| margin-top: var(--space-4); |
| } |
| .stat-item { |
| text-align: center; |
| } |
| .stat-num { |
| font-size: var(--text-xl); |
| font-weight: 700; |
| } |
| .stat-label { |
| font-size: var(--text-xs); |
| color: var(--text-tertiary); |
| } |
| |
| |
| |
| |
| .theme-btn { |
| width: 34px; |
| height: 34px; |
| border-radius: var(--radius-sm); |
| background: var(--bg-raised); |
| color: var(--text-secondary); |
| border: 1px solid var(--border); |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| cursor: pointer; |
| transition: all var(--duration-fast); |
| } |
| .theme-btn:hover { |
| color: var(--text-primary); |
| border-color: var(--border-strong); |
| } |
| |
| |
| |
| |
| .user-menu-wrap { |
| position: relative; |
| } |
| .user-btn { |
| display: flex; |
| align-items: center; |
| gap: var(--space-2); |
| padding: 4px 8px 4px 4px; |
| border-radius: var(--radius-full); |
| background: var(--bg-raised); |
| border: 1px solid var(--border); |
| cursor: pointer; |
| transition: all var(--duration-fast); |
| font-size: var(--text-sm); |
| font-weight: 500; |
| color: var(--text-primary); |
| } |
| .user-btn:hover { |
| border-color: var(--border-strong); |
| } |
| .user-dropdown { |
| position: absolute; |
| top: calc(100% + 8px); |
| right: 0; |
| min-width: 180px; |
| background: var(--bg-surface); |
| border: 1px solid var(--border-strong); |
| border-radius: var(--radius-md); |
| box-shadow: var(--shadow-lg); |
| overflow: hidden; |
| z-index: 150; |
| opacity: 0; |
| pointer-events: none; |
| transform: translateY(-6px); |
| transition: |
| opacity var(--duration-base), |
| transform var(--duration-base) var(--ease); |
| } |
| .user-dropdown.open { |
| opacity: 1; |
| pointer-events: all; |
| transform: translateY(0); |
| } |
| .dropdown-header { |
| padding: var(--space-3) var(--space-4); |
| border-bottom: 1px solid var(--border); |
| } |
| .dropdown-email { |
| font-size: var(--text-xs); |
| color: var(--text-tertiary); |
| } |
| .dropdown-item { |
| display: flex; |
| align-items: center; |
| gap: var(--space-3); |
| padding: var(--space-3) var(--space-4); |
| font-size: var(--text-sm); |
| color: var(--text-secondary); |
| cursor: pointer; |
| transition: background var(--duration-fast); |
| } |
| .dropdown-item:hover { |
| background: var(--bg-hover); |
| color: var(--text-primary); |
| } |
| .dropdown-item.danger:hover { |
| color: var(--danger); |
| } |
| .dropdown-sep { |
| height: 1px; |
| background: var(--border); |
| margin: 4px 0; |
| } |
| |
| |
| |
| |
| .spinner { |
| width: 18px; |
| height: 18px; |
| border: 2px solid rgba(255, 255, 255, 0.2); |
| border-top-color: currentColor; |
| border-radius: 50%; |
| animation: spin 0.7s linear infinite; |
| display: inline-block; |
| } |
| @keyframes spin { |
| to { |
| transform: rotate(360deg); |
| } |
| } |
| |
| |
| |
| |
| .page-enter { |
| animation: fadeSlideIn var(--duration-slow) var(--ease) forwards; |
| } |
| @keyframes fadeSlideIn { |
| from { |
| opacity: 0; |
| transform: translateY(10px); |
| } |
| to { |
| opacity: 1; |
| transform: translateY(0); |
| } |
| } |
| |
| |
| |
| |
| .flex { |
| display: flex; |
| } |
| .items-center { |
| align-items: center; |
| } |
| .gap-2 { |
| gap: var(--space-2); |
| } |
| .gap-3 { |
| gap: var(--space-3); |
| } |
| .gap-4 { |
| gap: var(--space-4); |
| } |
| .mt-4 { |
| margin-top: var(--space-4); |
| } |
| .mt-6 { |
| margin-top: var(--space-6); |
| } |
| .mt-8 { |
| margin-top: var(--space-8); |
| } |
| .text-sm { |
| font-size: var(--text-sm); |
| } |
| .text-secondary { |
| color: var(--text-secondary); |
| } |
| .text-accent { |
| color: var(--accent-light); |
| } |
| .font-600 { |
| font-weight: 600; |
| } |
| .w-full { |
| width: 100%; |
| } |
| .text-center { |
| text-align: center; |
| } |
| .hidden { |
| display: none !important; |
| } |
| |
| |
| |
| |
| ::-webkit-scrollbar { |
| width: 6px; |
| height: 6px; |
| } |
| ::-webkit-scrollbar-track { |
| background: transparent; |
| } |
| ::-webkit-scrollbar-thumb { |
| background: var(--border-strong); |
| border-radius: 3px; |
| } |
| ::-webkit-scrollbar-thumb:hover { |
| background: var(--text-tertiary); |
| } |
| |
| |
| |
| |
| .mobile-nav { |
| display: none; |
| position: fixed; |
| bottom: 0; |
| left: 0; |
| right: 0; |
| z-index: 100; |
| background: var(--bg-surface); |
| border-top: 1px solid var(--border); |
| padding: var(--space-2) var(--space-4) |
| calc(var(--space-2) + env(safe-area-inset-bottom)); |
| justify-content: space-around; |
| } |
| @media (max-width: 900px) { |
| .mobile-nav { |
| display: flex; |
| } |
| } |
| .mobile-nav-item { |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| gap: 2px; |
| padding: var(--space-2) var(--space-4); |
| border-radius: var(--radius-sm); |
| color: var(--text-tertiary); |
| cursor: pointer; |
| transition: color var(--duration-fast); |
| font-size: 10px; |
| font-weight: 500; |
| } |
| .mobile-nav-item.active { |
| color: var(--accent-light); |
| } |
| .mobile-nav-item:hover { |
| color: var(--text-primary); |
| } |
| @media (max-width: 900px) { |
| .content-area { |
| padding-bottom: 80px; |
| } |
| } |
| </style> |
| </head> |
| <body> |
| <div id="app"></div> |
| <div id="toast-container"></div> |
| <div id="modal-backdrop" class="modal-backdrop"> |
| <div class="modal" id="modal-content"></div> |
| </div> |
|
|
| <script> |
| /* ============================================================ |
| ICONS — inline SVG helpers |
| ============================================================ */ |
| const Icon = { |
| home: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>`, |
| plus: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>`, |
| user: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`, |
| search: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>`, |
| arrow_up: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>`, |
| trash: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>`, |
| edit: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`, |
| arrow_left: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>`, |
| log_out: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>`, |
| sun: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>`, |
| moon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>`, |
| check: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`, |
| alert: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>`, |
| info: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>`, |
| eye: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>`, |
| eye_off: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/></svg>`, |
| file: `<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>`, |
| dots: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>`, |
| chevron_right: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>`, |
| }; |
| |
| /* ============================================================ |
| STATE |
| ============================================================ */ |
| const state = { |
| user: null, |
| token: null, |
| theme: |
| localStorage.getItem("theme") || |
| (window.matchMedia("(prefers-color-scheme: dark)").matches |
| ? "dark" |
| : "light"), |
| currentRoute: "", |
| routeParams: {}, |
| dropdownOpen: false, |
| }; |
| |
| function init() { |
| const saved = localStorage.getItem("auth"); |
| if (saved) { |
| try { |
| const p = JSON.parse(saved); |
| state.token = p.token; |
| state.user = p.user; |
| } catch (e) {} |
| } |
| applyTheme(state.theme); |
| route(location.hash.slice(1) || "/"); |
| window.addEventListener("hashchange", () => |
| route(location.hash.slice(1) || "/"), |
| ); |
| document.addEventListener("click", (e) => { |
| if (!e.target.closest(".user-menu-wrap")) closeDropdown(); |
| }); |
| } |
| |
| /* ============================================================ |
| THEME |
| ============================================================ */ |
| function applyTheme(t) { |
| state.theme = t; |
| document.documentElement.setAttribute("data-theme", t); |
| localStorage.setItem("theme", t); |
| } |
| function toggleTheme() { |
| applyTheme(state.theme === "dark" ? "light" : "dark"); |
| renderNavbar(); |
| } |
| |
| /* ============================================================ |
| API LAYER |
| ============================================================ */ |
| const API_BASE = window.location.origin; |
| |
| async function api(method, path, body = null, auth = true) { |
| const headers = { "Content-Type": "application/json" }; |
| if (auth && state.token) |
| headers["Authorization"] = `Bearer ${state.token}`; |
| const opts = { method, headers }; |
| if (body) opts.body = JSON.stringify(body); |
| const res = await fetch(`${API_BASE}${path}`, opts); |
| if (res.status === 401) { |
| logout(); |
| return null; |
| } |
| if (res.status === 204) return null; |
| const data = await res.json().catch(() => ({})); |
| if (!res.ok) throw data; |
| return data; |
| } |
| |
| async function apiForm(path, formData) { |
| const res = await fetch(`${API_BASE}${path}`, { |
| method: "POST", |
| body: formData, |
| }); |
| const data = await res.json().catch(() => ({})); |
| if (!res.ok) throw data; |
| return data; |
| } |
| |
| /* ============================================================ |
| AUTH HELPERS |
| ============================================================ */ |
| function saveAuth(token, user) { |
| state.token = token; |
| state.user = user; |
| localStorage.setItem("auth", JSON.stringify({ token, user })); |
| } |
| function logout() { |
| state.token = null; |
| state.user = null; |
| localStorage.removeItem("auth"); |
| navigate("/login"); |
| } |
| |
| /* ============================================================ |
| ROUTER |
| ============================================================ */ |
| function navigate(path) { |
| location.hash = path; |
| } |
| |
| function route(path) { |
| state.currentRoute = path; |
| if (!state.token && path !== "/login" && path !== "/register") { |
| renderLoginPage(); |
| return; |
| } |
| if (path === "/login") { |
| renderLoginPage(); |
| return; |
| } |
| if (path === "/register") { |
| renderRegisterPage(); |
| return; |
| } |
| |
| const postEditMatch = path.match(/^\/posts\/(\d+)\/edit$/); |
| const postDetailMatch = path.match(/^\/posts\/(\d+)$/); |
| const profileMatch = path.match(/^\/profile\/(\d+)$/); |
| |
| if (path === "/" || path === "/feed") { |
| renderShell(() => renderFeedPage()); |
| return; |
| } |
| if (path === "/new") { |
| renderShell(() => renderPostFormPage()); |
| return; |
| } |
| if (postEditMatch) { |
| renderShell(() => renderPostFormPage(parseInt(postEditMatch[1]))); |
| return; |
| } |
| if (postDetailMatch) { |
| renderShell(() => renderPostDetailPage(parseInt(postDetailMatch[1]))); |
| return; |
| } |
| if (profileMatch) { |
| renderShell(() => renderProfilePage(parseInt(profileMatch[1]))); |
| return; |
| } |
| if (path === "/profile") { |
| renderShell(() => renderProfilePage(state.user?.id)); |
| return; |
| } |
| |
| renderShell(() => render404()); |
| } |
| |
| /* ============================================================ |
| SHELL (NAVBAR + SIDEBAR + MOBILE NAV) |
| ============================================================ */ |
| function renderShell(pageRenderer) { |
| const app = document.getElementById("app"); |
| app.innerHTML = ` |
| <nav class="navbar" id="navbar"></nav> |
| <div class="main-layout"> |
| <aside class="sidebar" id="sidebar"></aside> |
| <main class="content-area" id="main"></main> |
| </div> |
| <nav class="mobile-nav" id="mobile-nav"></nav> |
| `; |
| renderNavbar(); |
| renderSidebar(); |
| renderMobileNav(); |
| pageRenderer(); |
| } |
| |
| function renderNavbar() { |
| const isDark = state.theme === "dark"; |
| document.getElementById("navbar").innerHTML = ` |
| <div class="navbar-brand" onclick="navigate('/')"> |
| <span class="brand-dot"></span>Postly |
| </div> |
| <div class="navbar-spacer"></div> |
| <div class="navbar-actions"> |
| <button class="btn btn-primary btn-sm" onclick="navigate('/new')">${Icon.plus} New Post</button> |
| <button class="theme-btn" onclick="toggleTheme()" title="Toggle theme"> |
| ${isDark ? Icon.sun : Icon.moon} |
| </button> |
| ${ |
| state.user |
| ? ` |
| <div class="user-menu-wrap"> |
| <div class="user-btn" id="user-btn" onclick="toggleDropdown()"> |
| <div class="avatar" style="width:26px;height:26px;font-size:10px">${initials(state.user.email)}</div> |
| ${state.user.email.split("@")[0]} |
| ${Icon.chevron_right} |
| </div> |
| <div class="user-dropdown" id="user-dropdown"> |
| <div class="dropdown-header"> |
| <div class="font-600">${state.user.email.split("@")[0]}</div> |
| <div class="dropdown-email">${state.user.email}</div> |
| </div> |
| <div class="dropdown-item" onclick="navigate('/profile')"> |
| ${Icon.user} Profile |
| </div> |
| <div class="dropdown-item" onclick="navigate('/new')"> |
| ${Icon.plus} New Post |
| </div> |
| <div class="dropdown-sep"></div> |
| <div class="dropdown-item danger" onclick="logout()"> |
| ${Icon.log_out} Sign out |
| </div> |
| </div> |
| </div> |
| ` |
| : `<button class="btn btn-ghost btn-sm" onclick="navigate('/login')">Sign in</button>` |
| } |
| </div> |
| `; |
| } |
| |
| function toggleDropdown() { |
| state.dropdownOpen = !state.dropdownOpen; |
| document |
| .getElementById("user-dropdown") |
| ?.classList.toggle("open", state.dropdownOpen); |
| } |
| function closeDropdown() { |
| state.dropdownOpen = false; |
| document.getElementById("user-dropdown")?.classList.remove("open"); |
| } |
| |
| const NAV_ITEMS = [ |
| { label: "Feed", icon: Icon.home, path: "/" }, |
| { label: "New Post", icon: Icon.plus, path: "/new" }, |
| { label: "Profile", icon: Icon.user, path: "/profile" }, |
| ]; |
| |
| function renderSidebar() { |
| const nav = document.getElementById("sidebar"); |
| if (!nav) return; |
| nav.innerHTML = ` |
| <div class="nav-section-label">Navigation</div> |
| ${NAV_ITEMS.map( |
| (item) => ` |
| <div class="nav-item ${isActive(item.path)}" onclick="navigate('${item.path}')"> |
| ${item.icon} ${item.label} |
| </div> |
| `, |
| ).join("")} |
| `; |
| } |
| |
| function renderMobileNav() { |
| const nav = document.getElementById("mobile-nav"); |
| if (!nav) return; |
| nav.innerHTML = NAV_ITEMS.map( |
| (item) => ` |
| <div class="mobile-nav-item ${isActive(item.path)}" onclick="navigate('${item.path}')"> |
| ${item.icon} ${item.label} |
| </div> |
| `, |
| ).join(""); |
| } |
| |
| function isActive(path) { |
| if ( |
| path === "/" && |
| (state.currentRoute === "/" || state.currentRoute === "/feed") |
| ) |
| return "active"; |
| if (path !== "/" && state.currentRoute.startsWith(path)) |
| return "active"; |
| return ""; |
| } |
| |
| /* ============================================================ |
| FEED PAGE |
| ============================================================ */ |
| let feedState = { |
| search: "", |
| sort: "newest", |
| page: 1, |
| limit: 10, |
| total: 0, |
| posts: [], |
| loading: false, |
| }; |
| |
| async function renderFeedPage() { |
| const main = document.getElementById("main"); |
| main.innerHTML = ` |
| <div class="page-enter"> |
| <div class="page-header-row page-header"> |
| <div> |
| <h1 class="page-title">Feed</h1> |
| <p class="page-sub">Discover posts from the community</p> |
| </div> |
| <button class="btn btn-primary" onclick="navigate('/new')">${Icon.plus} New Post</button> |
| </div> |
| <div class="toolbar"> |
| <div class="search-wrap"> |
| ${Icon.search} |
| <input class="search-input" id="search-input" placeholder="Search posts…" value="${feedState.search}"/> |
| </div> |
| <select class="select" id="sort-select"> |
| <option value="newest" ${feedState.sort === "newest" ? "selected" : ""}>Newest</option> |
| <option value="oldest" ${feedState.sort === "oldest" ? "selected" : ""}>Oldest</option> |
| <option value="most_voted" ${feedState.sort === "most_voted" ? "selected" : ""}>Most Voted</option> |
| </select> |
| </div> |
| <div id="posts-container"></div> |
| <div id="pagination-container"></div> |
| </div> |
| `; |
| |
| // Search debounce |
| let debounce; |
| document |
| .getElementById("search-input") |
| .addEventListener("input", (e) => { |
| clearTimeout(debounce); |
| debounce = setTimeout(() => { |
| feedState.search = e.target.value; |
| feedState.page = 1; |
| loadFeed(); |
| }, 350); |
| }); |
| document |
| .getElementById("sort-select") |
| .addEventListener("change", (e) => { |
| feedState.sort = e.target.value; |
| feedState.page = 1; |
| loadFeed(); |
| }); |
| |
| loadFeed(); |
| } |
| |
| async function loadFeed() { |
| const container = document.getElementById("posts-container"); |
| if (!container) return; |
| renderSkeletons(container, 5); |
| |
| try { |
| const skip = (feedState.page - 1) * feedState.limit; |
| const data = await api( |
| "GET", |
| `/posts/?limit=${feedState.limit}&skip=${skip}&search=${encodeURIComponent(feedState.search)}`, |
| ); |
| if (!data) return; |
| |
| let posts = data; |
| if (feedState.sort === "oldest") posts = [...posts].reverse(); |
| else if (feedState.sort === "most_voted") |
| posts = [...posts].sort((a, b) => b.votes - a.votes); |
| |
| feedState.posts = posts; |
| |
| if (posts.length === 0) { |
| container.innerHTML = ` |
| <div class="empty-state"> |
| <div class="empty-icon">${Icon.file}</div> |
| <div class="empty-title">${feedState.search ? "No results found" : "No posts yet"}</div> |
| <div class="empty-sub">${feedState.search ? "Try a different search term" : "Be the first to share something with the community."}</div> |
| ${!feedState.search ? `<button class="btn btn-primary mt-4" onclick="navigate('/new')">${Icon.plus} Create first post</button>` : ""} |
| </div>`; |
| return; |
| } |
| |
| container.innerHTML = `<div class="post-grid">${posts.map(renderPostCard).join("")}</div>`; |
| renderPagination(); |
| } catch (e) { |
| container.innerHTML = `<div class="empty-state"><div class="empty-icon">${Icon.alert}</div><div class="empty-title">Failed to load posts</div><div class="empty-sub">Check your connection and try again.</div><button class="btn btn-ghost mt-4" onclick="loadFeed()">Retry</button></div>`; |
| } |
| } |
| |
| function renderPostCard(post) { |
| const isOwner = state.user && post.owner_id === state.user.id; |
| const timeAgo = formatTimeAgo(post.created_at); |
| const av = initials(post.owner?.email || "?"); |
| return ` |
| <div class="card post-card" onclick="navigate('/posts/${post.id}')"> |
| <div class="post-meta"> |
| <div class="avatar">${av}</div> |
| <div> |
| <div class="post-author">${post.owner?.email?.split("@")[0] || "Unknown"}</div> |
| <div class="post-time">${timeAgo}</div> |
| </div> |
| <div style="margin-left:auto;display:flex;align-items:center;gap:8px"> |
| <span class="badge ${post.published ? "badge-published" : "badge-draft"}">${post.published ? "Published" : "Draft"}</span> |
| ${ |
| isOwner |
| ? ` |
| <button class="btn btn-ghost btn-icon-sm btn-sm" onclick="event.stopPropagation();navigate('/posts/${post.id}/edit')" title="Edit">${Icon.edit}</button> |
| <button class="btn btn-ghost btn-icon-sm btn-sm" style="color:var(--danger)" onclick="event.stopPropagation();confirmDelete(${post.id})" title="Delete">${Icon.trash}</button> |
| ` |
| : "" |
| } |
| </div> |
| </div> |
| <div class="post-title">${escHtml(post.title)}</div> |
| <div class="post-content-preview">${escHtml(post.content)}</div> |
| <div class="post-footer"> |
| <div class="post-actions"> |
| <button class="vote-btn" onclick="event.stopPropagation();handleVote(${post.id}, this)" data-post-id="${post.id}"> |
| ${Icon.arrow_up} <span>${post.votes || 0}</span> |
| </button> |
| </div> |
| <div class="text-sm text-secondary" onclick="event.stopPropagation();navigate('/profile/${post.owner_id}')"> |
| by <span class="text-accent" style="cursor:pointer">${post.owner?.email?.split("@")[0] || "?"}</span> |
| </div> |
| </div> |
| </div>`; |
| } |
| |
| function renderPagination() { |
| const c = document.getElementById("pagination-container"); |
| if (!c || feedState.posts.length < feedState.limit) { |
| if (c) c.innerHTML = ""; |
| return; |
| } |
| c.innerHTML = ` |
| <div class="pagination"> |
| <button class="page-btn" ${feedState.page === 1 ? "disabled" : ""} onclick="feedPage(${feedState.page - 1})">${Icon.arrow_left}</button> |
| <span class="page-btn active">${feedState.page}</span> |
| <button class="page-btn" onclick="feedPage(${feedState.page + 1})">›</button> |
| </div>`; |
| } |
| function feedPage(p) { |
| feedState.page = p; |
| loadFeed(); |
| } |
| |
| /* ============================================================ |
| VOTE |
| ============================================================ */ |
| async function handleVote(postId, btn) { |
| if (!state.token) { |
| navigate("/login"); |
| return; |
| } |
| const voted = btn.classList.contains("voted"); |
| const span = btn.querySelector("span"); |
| const cur = parseInt(span.textContent); |
| btn.disabled = true; |
| try { |
| await api("POST", "/vote/", { post_id: postId, dir: voted ? 0 : 1 }); |
| btn.classList.toggle("voted", !voted); |
| span.textContent = voted ? cur - 1 : cur + 1; |
| toast( |
| voted ? "Vote removed" : "Upvoted!", |
| "", |
| voted ? "info" : "success", |
| ); |
| } catch (e) { |
| const msg = e?.detail || "Could not vote"; |
| toast("Error", msg, "error"); |
| } finally { |
| btn.disabled = false; |
| } |
| } |
| |
| /* ============================================================ |
| POST DETAIL PAGE |
| ============================================================ */ |
| async function renderPostDetailPage(postId) { |
| const main = document.getElementById("main"); |
| main.innerHTML = `<div class="page-enter"><div class="breadcrumb"> |
| <span class="breadcrumb-link" onclick="navigate('/')">Feed</span> |
| <span class="breadcrumb-sep">${Icon.chevron_right}</span> |
| <span>Post</span> |
| </div><div id="post-detail-content"></div></div>`; |
| |
| const c = document.getElementById("post-detail-content"); |
| renderSkeletons(c, 3, true); |
| |
| try { |
| const post = await api("GET", `/posts/${postId}`); |
| if (!post) return; |
| const isOwner = state.user && post.owner_id === state.user.id; |
| |
| c.innerHTML = ` |
| <div class="post-detail-header"> |
| <h1 class="post-detail-title">${escHtml(post.title)}</h1> |
| <div class="post-detail-meta"> |
| <div class="avatar avatar-lg" onclick="navigate('/profile/${post.owner_id}')" style="cursor:pointer">${initials(post.owner?.email || "?")}</div> |
| <div> |
| <div class="font-600" onclick="navigate('/profile/${post.owner_id}')" style="cursor:pointer;color:var(--accent-light)">${post.owner?.email?.split("@")[0]}</div> |
| <div class="text-sm text-secondary">${formatDate(post.created_at)}</div> |
| </div> |
| <span class="badge ${post.published ? "badge-published" : "badge-draft"}">${post.published ? "Published" : "Draft"}</span> |
| <div style="margin-left:auto;display:flex;gap:8px"> |
| <button class="vote-btn ${post.votes > 0 ? "" : ""}" id="detail-vote-btn" onclick="handleDetailVote(${post.id})" data-post-id="${post.id}"> |
| ${Icon.arrow_up} <span id="detail-vote-count">${post.votes || 0}</span> |
| </button> |
| ${ |
| isOwner |
| ? ` |
| <button class="btn btn-ghost btn-sm" onclick="navigate('/posts/${post.id}/edit')">${Icon.edit} Edit</button> |
| <button class="btn btn-danger btn-sm" onclick="confirmDelete(${post.id})">${Icon.trash} Delete</button> |
| ` |
| : "" |
| } |
| </div> |
| </div> |
| </div> |
| <div class="post-detail-content">${escHtml(post.content)}</div> |
| `; |
| } catch (e) { |
| c.innerHTML = `<div class="empty-state"><div class="empty-icon">${Icon.alert}</div><div class="empty-title">Post not found</div><button class="btn btn-ghost mt-4" onclick="navigate('/')">Back to feed</button></div>`; |
| } |
| } |
| |
| async function handleDetailVote(postId) { |
| const btn = document.getElementById("detail-vote-btn"); |
| const span = document.getElementById("detail-vote-count"); |
| if (!btn || !span) return; |
| const voted = btn.classList.contains("voted"); |
| btn.disabled = true; |
| try { |
| await api("POST", "/vote/", { post_id: postId, dir: voted ? 0 : 1 }); |
| btn.classList.toggle("voted", !voted); |
| span.textContent = parseInt(span.textContent) + (voted ? -1 : 1); |
| toast( |
| voted ? "Vote removed" : "Upvoted!", |
| "", |
| voted ? "info" : "success", |
| ); |
| } catch (e) { |
| toast("Error", e?.detail || "Could not vote", "error"); |
| } finally { |
| btn.disabled = false; |
| } |
| } |
| |
| /* ============================================================ |
| POST FORM PAGE (CREATE & EDIT) |
| ============================================================ */ |
| async function renderPostFormPage(postId = null) { |
| const main = document.getElementById("main"); |
| const isEdit = postId !== null; |
| main.innerHTML = `<div class="page-enter"> |
| <div class="breadcrumb"> |
| <span class="breadcrumb-link" onclick="navigate('/')">Feed</span> |
| <span class="breadcrumb-sep">${Icon.chevron_right}</span> |
| <span>${isEdit ? "Edit Post" : "New Post"}</span> |
| </div> |
| <div class="page-header"> |
| <h1 class="page-title">${isEdit ? "Edit Post" : "Create a Post"}</h1> |
| <p class="page-sub">${isEdit ? "Update your post" : "Share your thoughts with the community"}</p> |
| </div> |
| <div class="card" style="padding:var(--space-8)"> |
| <div id="post-form-content"></div> |
| </div> |
| </div>`; |
| |
| const fc = document.getElementById("post-form-content"); |
| |
| let post = { title: "", content: "", published: true }; |
| if (isEdit) { |
| fc.innerHTML = renderSkeletonHTML(3); |
| try { |
| post = await api("GET", `/posts/${postId}`); |
| if (!post) return; |
| if (post.owner_id !== state.user?.id) { |
| toast( |
| "Unauthorized", |
| "You can only edit your own posts.", |
| "error", |
| ); |
| navigate("/"); |
| return; |
| } |
| } catch (e) { |
| toast("Error", "Could not load post.", "error"); |
| navigate("/"); |
| return; |
| } |
| } |
| |
| fc.innerHTML = ` |
| <form id="post-form" style="display:flex;flex-direction:column;gap:var(--space-6)"> |
| <div class="form-group"> |
| <label class="form-label">Title</label> |
| <input class="form-input" id="pf-title" placeholder="Give your post a clear title…" value="${escAttr(post.title)}" maxlength="200"/> |
| <div class="form-error hidden" id="pf-title-err"></div> |
| </div> |
| <div class="form-group"> |
| <label class="form-label">Content</label> |
| <textarea class="form-textarea" id="pf-content" placeholder="Write your thoughts here…" style="min-height:200px">${escHtml(post.content)}</textarea> |
| <div class="form-error hidden" id="pf-content-err"></div> |
| </div> |
| <div class="toggle-wrap"> |
| <div class="toggle ${post.published ? "on" : ""}" id="pf-toggle" onclick="this.classList.toggle('on')"></div> |
| <span class="toggle-label">Published (visible to everyone)</span> |
| </div> |
| <div style="display:flex;gap:var(--space-3);justify-content:flex-end"> |
| <button type="button" class="btn btn-ghost" onclick="navigate('${isEdit ? "/posts/" + postId : "/"}')">Cancel</button> |
| <button type="submit" class="btn btn-primary" id="pf-submit">${isEdit ? Icon.edit + " Save changes" : Icon.plus + " Publish post"}</button> |
| </div> |
| </form> |
| `; |
| |
| document |
| .getElementById("post-form") |
| .addEventListener("submit", async (e) => { |
| e.preventDefault(); |
| const title = document.getElementById("pf-title").value.trim(); |
| const content = document.getElementById("pf-content").value.trim(); |
| const published = document |
| .getElementById("pf-toggle") |
| .classList.contains("on"); |
| let valid = true; |
| |
| if (!title) { |
| showErr("pf-title-err", "Title is required"); |
| valid = false; |
| } else hideErr("pf-title-err"); |
| if (!content) { |
| showErr("pf-content-err", "Content is required"); |
| valid = false; |
| } else hideErr("pf-content-err"); |
| if (!valid) return; |
| |
| const btn = document.getElementById("pf-submit"); |
| btn.disabled = true; |
| btn.innerHTML = `<span class="spinner"></span> Saving…`; |
| |
| try { |
| if (isEdit) { |
| await api("PUT", `/posts/${postId}`, { |
| title, |
| content, |
| published, |
| }); |
| toast("Saved!", "Your post has been updated.", "success"); |
| navigate(`/posts/${postId}`); |
| } else { |
| const p = await api("POST", "/posts/", { |
| title, |
| content, |
| published, |
| }); |
| toast("Published!", "Your post is now live.", "success"); |
| navigate(`/posts/${p.id}`); |
| } |
| } catch (e) { |
| toast("Error", e?.detail || "Could not save post.", "error"); |
| btn.disabled = false; |
| btn.innerHTML = isEdit |
| ? Icon.edit + " Save changes" |
| : Icon.plus + " Publish post"; |
| } |
| }); |
| } |
| |
| /* ============================================================ |
| PROFILE PAGE |
| ============================================================ */ |
| async function renderProfilePage(userId) { |
| const main = document.getElementById("main"); |
| main.innerHTML = `<div class="page-enter"><div id="profile-content"></div></div>`; |
| const c = document.getElementById("profile-content"); |
| renderSkeletons(c, 4); |
| |
| try { |
| const [user, posts] = await Promise.all([ |
| api("GET", `/users/${userId}`, null, false), |
| api("GET", `/posts/?limit=50`, null, true), |
| ]); |
| if (!user) return; |
| |
| const userPosts = posts |
| ? posts.filter((p) => p.owner_id === userId) |
| : []; |
| const totalVotes = userPosts.reduce((s, p) => s + (p.votes || 0), 0); |
| const isSelf = state.user && state.user.id === userId; |
| |
| c.innerHTML = ` |
| <div class="profile-card"> |
| <div class="avatar avatar-lg" style="width:64px;height:64px;font-size:20px">${initials(user.email)}</div> |
| <div class="profile-info"> |
| <div class="profile-name">${user.email.split("@")[0]}</div> |
| <div class="profile-email">${user.email}</div> |
| <div class="profile-stats"> |
| <div class="stat-item"><div class="stat-num">${userPosts.length}</div><div class="stat-label">Posts</div></div> |
| <div class="stat-item"><div class="stat-num">${totalVotes}</div><div class="stat-label">Upvotes</div></div> |
| <div class="stat-item"><div class="stat-num">${formatDate(user.created_at, true)}</div><div class="stat-label">Joined</div></div> |
| </div> |
| </div> |
| ${isSelf ? `<button class="btn btn-ghost btn-sm" onclick="logout()">${Icon.log_out} Sign out</button>` : ""} |
| </div> |
| <h2 style="font-size:var(--text-xl);font-weight:600;margin-bottom:var(--space-5)">${isSelf ? "Your posts" : "Posts by " + user.email.split("@")[0]}</h2> |
| ${ |
| userPosts.length === 0 |
| ? `<div class="empty-state"><div class="empty-icon">${Icon.file}</div><div class="empty-title">No posts yet</div>${isSelf ? `<button class="btn btn-primary mt-4" onclick="navigate('/new')">${Icon.plus} Write first post</button>` : ""}</div>` |
| : `<div class="post-grid">${userPosts.map(renderPostCard).join("")}</div>` |
| } |
| `; |
| } catch (e) { |
| c.innerHTML = `<div class="empty-state"><div class="empty-icon">${Icon.alert}</div><div class="empty-title">User not found</div></div>`; |
| } |
| } |
| |
| /* ============================================================ |
| LOGIN PAGE |
| ============================================================ */ |
| function renderLoginPage() { |
| document.getElementById("app").innerHTML = ` |
| <div class="auth-page"> |
| <div class="auth-card"> |
| <div class="auth-logo"><span class="brand-dot" style="width:10px;height:10px;border-radius:50%;background:var(--accent);box-shadow:0 0 8px var(--accent);display:inline-block"></span> Postly</div> |
| <h1 class="auth-title">Welcome back</h1> |
| <p class="auth-sub">Sign in to your account to continue</p> |
| <form class="auth-form" id="login-form"> |
| <div class="form-group"> |
| <label class="form-label">Email</label> |
| <input class="form-input" id="l-email" type="email" placeholder="you@example.com" autocomplete="email"/> |
| <div class="form-error hidden" id="l-email-err"></div> |
| </div> |
| <div class="form-group"> |
| <label class="form-label">Password</label> |
| <div class="input-wrap"> |
| <input class="form-input" id="l-pass" type="password" placeholder="••••••••" autocomplete="current-password"/> |
| <div class="input-suffix" onclick="togglePass('l-pass', this)">${Icon.eye}</div> |
| </div> |
| <div class="form-error hidden" id="l-pass-err"></div> |
| </div> |
| <div class="form-error hidden text-center" id="l-global-err"></div> |
| <button type="submit" class="btn btn-primary btn-lg w-full" id="l-btn">Sign in</button> |
| </form> |
| <div style="margin-top:var(--space-6);text-align:center;font-size:var(--text-sm);color:var(--text-secondary)"> |
| Don't have an account? <span class="auth-link" onclick="navigate('/register')">Create one</span> |
| </div> |
| <div style="margin-top:var(--space-4);text-align:right"> |
| <button class="theme-btn" onclick="toggleThemeAuth()" title="Toggle theme" style="margin-left:auto">${state.theme === "dark" ? Icon.sun : Icon.moon}</button> |
| </div> |
| </div> |
| </div>`; |
| |
| document |
| .getElementById("login-form") |
| .addEventListener("submit", async (e) => { |
| e.preventDefault(); |
| const email = document.getElementById("l-email").value.trim(); |
| const pass = document.getElementById("l-pass").value; |
| let valid = true; |
| if (!email) { |
| showErr("l-email-err", "Email is required"); |
| valid = false; |
| } else hideErr("l-email-err"); |
| if (!pass) { |
| showErr("l-pass-err", "Password is required"); |
| valid = false; |
| } else hideErr("l-pass-err"); |
| if (!valid) return; |
| |
| const btn = document.getElementById("l-btn"); |
| btn.disabled = true; |
| btn.innerHTML = `<span class="spinner"></span> Signing in…`; |
| hideErr("l-global-err"); |
| |
| try { |
| const fd = new FormData(); |
| fd.append("username", email); |
| fd.append("password", pass); |
| const data = await apiForm("/login", fd); |
| // fetch user info |
| const tempToken = data.access_token; |
| state.token = tempToken; |
| const res = await fetch(`${API_BASE}/users/`, { |
| headers: { Authorization: `Bearer ${tempToken}` }, |
| }); |
| const users = await res.json(); |
| const me = users.find((u) => u.email === email); |
| saveAuth(tempToken, me || { email, id: null }); |
| toast("Welcome back!", `Signed in as ${email}`, "success"); |
| navigate("/"); |
| } catch (e) { |
| showErr("l-global-err", e?.detail || "Invalid email or password"); |
| btn.disabled = false; |
| btn.innerHTML = "Sign in"; |
| } |
| }); |
| } |
| |
| /* ============================================================ |
| REGISTER PAGE |
| ============================================================ */ |
| function renderRegisterPage() { |
| document.getElementById("app").innerHTML = ` |
| <div class="auth-page"> |
| <div class="auth-card"> |
| <div class="auth-logo"><span class="brand-dot" style="width:10px;height:10px;border-radius:50%;background:var(--accent);box-shadow:0 0 8px var(--accent);display:inline-block"></span> Postly</div> |
| <h1 class="auth-title">Create an account</h1> |
| <p class="auth-sub">Join the community and start sharing</p> |
| <form class="auth-form" id="reg-form"> |
| <div class="form-group"> |
| <label class="form-label">Email</label> |
| <input class="form-input" id="r-email" type="email" placeholder="you@example.com" autocomplete="email"/> |
| <div class="form-error hidden" id="r-email-err"></div> |
| </div> |
| <div class="form-group"> |
| <label class="form-label">Password</label> |
| <div class="input-wrap"> |
| <input class="form-input" id="r-pass" type="password" placeholder="At least 8 characters" autocomplete="new-password"/> |
| <div class="input-suffix" onclick="togglePass('r-pass', this)">${Icon.eye}</div> |
| </div> |
| <div class="form-error hidden" id="r-pass-err"></div> |
| </div> |
| <div class="form-group"> |
| <label class="form-label">Confirm Password</label> |
| <div class="input-wrap"> |
| <input class="form-input" id="r-pass2" type="password" placeholder="Repeat password" autocomplete="new-password"/> |
| <div class="input-suffix" onclick="togglePass('r-pass2', this)">${Icon.eye}</div> |
| </div> |
| <div class="form-error hidden" id="r-pass2-err"></div> |
| </div> |
| <div class="form-error hidden text-center" id="r-global-err"></div> |
| <button type="submit" class="btn btn-primary btn-lg w-full" id="r-btn">Create account</button> |
| </form> |
| <div style="margin-top:var(--space-6);text-align:center;font-size:var(--text-sm);color:var(--text-secondary)"> |
| Already have an account? <span class="auth-link" onclick="navigate('/login')">Sign in</span> |
| </div> |
| </div> |
| </div>`; |
| |
| document |
| .getElementById("reg-form") |
| .addEventListener("submit", async (e) => { |
| e.preventDefault(); |
| const email = document.getElementById("r-email").value.trim(); |
| const pass = document.getElementById("r-pass").value; |
| const pass2 = document.getElementById("r-pass2").value; |
| let valid = true; |
| |
| if (!email) { |
| showErr("r-email-err", "Email is required"); |
| valid = false; |
| } else hideErr("r-email-err"); |
| if (pass.length < 8) { |
| showErr("r-pass-err", "Password must be at least 8 characters"); |
| valid = false; |
| } else hideErr("r-pass-err"); |
| if (pass !== pass2) { |
| showErr("r-pass2-err", "Passwords do not match"); |
| valid = false; |
| } else hideErr("r-pass2-err"); |
| if (!valid) return; |
| |
| const btn = document.getElementById("r-btn"); |
| btn.disabled = true; |
| btn.innerHTML = `<span class="spinner"></span> Creating…`; |
| hideErr("r-global-err"); |
| |
| try { |
| await api("POST", "/users/", { email, password: pass }, false); |
| toast("Account created!", "You can now sign in.", "success"); |
| navigate("/login"); |
| } catch (e) { |
| showErr("r-global-err", e?.detail || "Could not create account"); |
| btn.disabled = false; |
| btn.innerHTML = "Create account"; |
| } |
| }); |
| } |
| |
| /* ============================================================ |
| DELETE CONFIRM MODAL |
| ============================================================ */ |
| function confirmDelete(postId) { |
| const backdrop = document.getElementById("modal-backdrop"); |
| const mc = document.getElementById("modal-content"); |
| mc.innerHTML = ` |
| <div class="modal-title">${Icon.trash} Delete post</div> |
| <div class="modal-sub">This action cannot be undone. The post and all its votes will be permanently deleted.</div> |
| <div class="modal-actions"> |
| <button class="btn btn-ghost" onclick="closeModal()">Cancel</button> |
| <button class="btn btn-danger" id="confirm-del-btn" onclick="doDelete(${postId})">${Icon.trash} Delete</button> |
| </div>`; |
| backdrop.classList.add("open"); |
| } |
| |
| function closeModal() { |
| document.getElementById("modal-backdrop").classList.remove("open"); |
| } |
| |
| async function doDelete(postId) { |
| const btn = document.getElementById("confirm-del-btn"); |
| btn.disabled = true; |
| btn.innerHTML = `<span class="spinner"></span> Deleting…`; |
| try { |
| await api("DELETE", `/posts/${postId}`); |
| closeModal(); |
| toast("Deleted", "Post has been removed.", "info"); |
| navigate("/"); |
| } catch (e) { |
| toast("Error", e?.detail || "Could not delete post.", "error"); |
| closeModal(); |
| } |
| } |
| |
| document |
| .getElementById("modal-backdrop") |
| .addEventListener("click", (e) => { |
| if (e.target === document.getElementById("modal-backdrop")) |
| closeModal(); |
| }); |
| |
| /* ============================================================ |
| 404 |
| ============================================================ */ |
| function render404() { |
| document.getElementById("main").innerHTML = ` |
| <div class="empty-state" style="min-height:60vh"> |
| <div class="empty-icon" style="font-size:48px;width:80px;height:80px">404</div> |
| <div class="empty-title">Page not found</div> |
| <div class="empty-sub">The page you're looking for doesn't exist or has been moved.</div> |
| <button class="btn btn-primary mt-4" onclick="navigate('/')">Back to feed</button> |
| </div>`; |
| } |
| |
| /* ============================================================ |
| TOAST SYSTEM |
| ============================================================ */ |
| function toast(title, msg = "", type = "info") { |
| const iconMap = { |
| success: Icon.check, |
| error: Icon.alert, |
| info: Icon.info, |
| }; |
| const el = document.createElement("div"); |
| el.className = `toast toast-${type}`; |
| el.innerHTML = ` |
| <div class="toast-icon">${iconMap[type] || Icon.info}</div> |
| <div class="toast-body"> |
| <div class="toast-title">${title}</div> |
| ${msg ? `<div class="toast-msg">${msg}</div>` : ""} |
| </div>`; |
| document.getElementById("toast-container").appendChild(el); |
| requestAnimationFrame(() => el.classList.add("show")); |
| setTimeout(() => { |
| el.classList.replace("show", "hide"); |
| setTimeout(() => el.remove(), 400); |
| }, 3500); |
| } |
| |
| /* ============================================================ |
| SKELETON LOADERS |
| ============================================================ */ |
| function renderSkeletons(container, count, tall = false) { |
| container.innerHTML = Array.from( |
| { length: count }, |
| () => ` |
| <div class="card" style="padding:var(--space-6);margin-bottom:var(--space-4)"> |
| <div style="display:flex;gap:12px;align-items:center;margin-bottom:16px"> |
| <div class="skeleton skeleton-circle" style="width:32px;height:32px;flex-shrink:0"></div> |
| <div style="flex:1"><div class="skeleton skeleton-text" style="width:120px"></div><div class="skeleton skeleton-text" style="width:80px;margin-bottom:0"></div></div> |
| </div> |
| <div class="skeleton skeleton-title" style="width:70%"></div> |
| ${tall ? '<div class="skeleton skeleton-text" style="width:90%"></div><div class="skeleton skeleton-text" style="width:85%"></div>' : ""} |
| <div class="skeleton skeleton-text" style="width:60%"></div> |
| </div>`, |
| ).join(""); |
| } |
| |
| function renderSkeletonHTML(count) { |
| return Array.from( |
| { length: count }, |
| () => ` |
| <div style="margin-bottom:var(--space-5)"> |
| <div class="skeleton skeleton-text" style="width:80px;margin-bottom:8px"></div> |
| <div class="skeleton" style="height:40px;border-radius:6px"></div> |
| </div>`, |
| ).join(""); |
| } |
| |
| /* ============================================================ |
| HELPERS |
| ============================================================ */ |
| function initials(email = "") { |
| const name = email.split("@")[0]; |
| return name.slice(0, 2).toUpperCase(); |
| } |
| |
| function formatTimeAgo(dateStr) { |
| const d = new Date(dateStr); |
| const now = new Date(); |
| const s = Math.floor((now - d) / 1000); |
| if (s < 60) return "just now"; |
| const m = Math.floor(s / 60); |
| if (m < 60) return `${m}m ago`; |
| const h = Math.floor(m / 60); |
| if (h < 24) return `${h}h ago`; |
| const days = Math.floor(h / 24); |
| if (days < 7) return `${days}d ago`; |
| return formatDate(dateStr, true); |
| } |
| |
| function formatDate(dateStr, short = false) { |
| const d = new Date(dateStr); |
| if (short) |
| return d.toLocaleDateString("en-US", { |
| month: "short", |
| year: "numeric", |
| }); |
| return d.toLocaleDateString("en-US", { |
| year: "numeric", |
| month: "long", |
| day: "numeric", |
| }); |
| } |
| |
| function escHtml(str = "") { |
| return String(str) |
| .replace(/&/g, "&") |
| .replace(/</g, "<") |
| .replace(/>/g, ">") |
| .replace(/"/g, """); |
| } |
| function escAttr(str = "") { |
| return escHtml(str); |
| } |
| |
| function showErr(id, msg) { |
| const el = document.getElementById(id); |
| if (el) { |
| el.textContent = msg; |
| el.classList.remove("hidden"); |
| } |
| } |
| function hideErr(id) { |
| const el = document.getElementById(id); |
| if (el) { |
| el.textContent = ""; |
| el.classList.add("hidden"); |
| } |
| } |
| |
| function togglePass(inputId, btn) { |
| const input = document.getElementById(inputId); |
| if (input.type === "password") { |
| input.type = "text"; |
| btn.innerHTML = Icon.eye_off; |
| } else { |
| input.type = "password"; |
| btn.innerHTML = Icon.eye; |
| } |
| } |
| |
| function toggleThemeAuth() { |
| toggleTheme(); |
| } |
| |
| /* ============================================================ |
| BOOT |
| ============================================================ */ |
| init(); |
| </script> |
| </body> |
| </html> |
|
|