FastAPI-Project / app /static /index.html
abdullah090809's picture
added basic front end
d28ead5
Raw
History Blame Contribute Delete
85.4 kB
<!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>
/* ============================================================
DESIGN TOKENS
============================================================ */
:root {
/* Spacing scale */
--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;
/* Typography */
--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 */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-xl: 20px;
--radius-full: 9999px;
/* Transitions */
--ease: cubic-bezier(0.16, 1, 0.3, 1);
--duration-fast: 120ms;
--duration-base: 200ms;
--duration-slow: 350ms;
/* Accent */
--accent: #7c3aed;
--accent-light: #8b5cf6;
--accent-dim: rgba(124, 58, 237, 0.15);
--accent-hover: #6d28d9;
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--info: #3b82f6;
}
/* ============================================================
DARK THEME (default)
============================================================ */
[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);
}
/* ============================================================
LIGHT THEME
============================================================ */
[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);
}
/* ============================================================
RESET & BASE
============================================================ */
*,
*::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;
}
/* ============================================================
LAYOUT
============================================================ */
#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 ITEMS
============================================================ */
.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;
}
/* ============================================================
BUTTONS
============================================================ */
.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;
}
/* ============================================================
INPUTS & FORMS
============================================================ */
.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 / SWITCH
============================================================ */
.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);
}
/* ============================================================
CARDS
============================================================ */
.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 BUTTON
============================================================ */
.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);
}
/* ============================================================
BADGES
============================================================ */
.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
============================================================ */
.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;
}
/* ============================================================
SEARCH & FILTERS
============================================================ */
.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
============================================================ */
.post-grid {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
/* ============================================================
PAGINATION
============================================================ */
.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;
}
/* ============================================================
SKELETON LOADER
============================================================ */
@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
============================================================ */
.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
============================================================ */
.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
============================================================ */
#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 PAGES
============================================================ */
.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
============================================================ */
.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
============================================================ */
.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 TOGGLE
============================================================ */
.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 DROPDOWN
============================================================ */
.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;
}
/* ============================================================
LOADING OVERLAY
============================================================ */
.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 TRANSITIONS
============================================================ */
.page-enter {
animation: fadeSlideIn var(--duration-slow) var(--ease) forwards;
}
@keyframes fadeSlideIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ============================================================
UTILITIES
============================================================ */
.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;
}
/* ============================================================
SCROLLBAR
============================================================ */
::-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 BOTTOM BAR
============================================================ */
.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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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>