abdullah090809 commited on
Commit
d28ead5
·
1 Parent(s): 3fd619a

added basic front end

Browse files
Files changed (2) hide show
  1. app/main.py +22 -3
  2. app/static/index.html +2452 -0
app/main.py CHANGED
@@ -1,7 +1,10 @@
1
  from fastapi import FastAPI
 
 
2
  from app.database import Base, engine
3
  from app.routers import post, user, auth, vote
4
  from fastapi.middleware.cors import CORSMiddleware
 
5
 
6
  app = FastAPI()
7
 
@@ -15,12 +18,28 @@ app.add_middleware(
15
  allow_headers=["*"],
16
  )
17
 
18
-
19
  app.include_router(post.router)
20
  app.include_router(user.router)
21
  app.include_router(auth.router)
22
  app.include_router(vote.router)
23
 
24
- @app.get("/")
25
  def home():
26
- return {"message": "FastAPI Project Finished"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from fastapi import FastAPI
2
+ from fastapi.staticfiles import StaticFiles
3
+ from fastapi.responses import FileResponse
4
  from app.database import Base, engine
5
  from app.routers import post, user, auth, vote
6
  from fastapi.middleware.cors import CORSMiddleware
7
+ import os
8
 
9
  app = FastAPI()
10
 
 
18
  allow_headers=["*"],
19
  )
20
 
 
21
  app.include_router(post.router)
22
  app.include_router(user.router)
23
  app.include_router(auth.router)
24
  app.include_router(vote.router)
25
 
26
+ @app.get("/api/health")
27
  def home():
28
+ return {"message": "FastAPI Project Finished"}
29
+
30
+ # Serve static files
31
+ static_dir = os.path.join(os.path.dirname(__file__), "static")
32
+ if os.path.exists(static_dir):
33
+ app.mount("/static", StaticFiles(directory=static_dir), name="static")
34
+
35
+ @app.get("/")
36
+ def serve_frontend():
37
+ return FileResponse(os.path.join(static_dir, "index.html"))
38
+
39
+ @app.get("/{full_path:path}")
40
+ def serve_spa(full_path: str):
41
+ # Don't intercept API routes
42
+ if full_path.startswith(("posts", "users", "login", "vote", "api")):
43
+ from fastapi import HTTPException
44
+ raise HTTPException(status_code=404)
45
+ return FileResponse(os.path.join(static_dir, "index.html"))
app/static/index.html ADDED
@@ -0,0 +1,2452 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en" data-theme="dark">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Postly — Share your thoughts</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link
9
+ href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
10
+ rel="stylesheet"
11
+ />
12
+ <style>
13
+ /* ============================================================
14
+ DESIGN TOKENS
15
+ ============================================================ */
16
+ :root {
17
+ /* Spacing scale */
18
+ --space-1: 4px;
19
+ --space-2: 8px;
20
+ --space-3: 12px;
21
+ --space-4: 16px;
22
+ --space-5: 20px;
23
+ --space-6: 24px;
24
+ --space-8: 32px;
25
+ --space-10: 40px;
26
+ --space-12: 48px;
27
+ --space-16: 64px;
28
+
29
+ /* Typography */
30
+ --font-sans: "Inter", system-ui, sans-serif;
31
+ --font-mono: "JetBrains Mono", monospace;
32
+ --text-xs: 11px;
33
+ --text-sm: 13px;
34
+ --text-base: 14px;
35
+ --text-md: 15px;
36
+ --text-lg: 17px;
37
+ --text-xl: 20px;
38
+ --text-2xl: 24px;
39
+ --text-3xl: 30px;
40
+ --text-4xl: 38px;
41
+
42
+ /* Radius */
43
+ --radius-sm: 6px;
44
+ --radius-md: 10px;
45
+ --radius-lg: 14px;
46
+ --radius-xl: 20px;
47
+ --radius-full: 9999px;
48
+
49
+ /* Transitions */
50
+ --ease: cubic-bezier(0.16, 1, 0.3, 1);
51
+ --duration-fast: 120ms;
52
+ --duration-base: 200ms;
53
+ --duration-slow: 350ms;
54
+
55
+ /* Accent */
56
+ --accent: #7c3aed;
57
+ --accent-light: #8b5cf6;
58
+ --accent-dim: rgba(124, 58, 237, 0.15);
59
+ --accent-hover: #6d28d9;
60
+ --success: #10b981;
61
+ --warning: #f59e0b;
62
+ --danger: #ef4444;
63
+ --info: #3b82f6;
64
+ }
65
+
66
+ /* ============================================================
67
+ DARK THEME (default)
68
+ ============================================================ */
69
+ [data-theme="dark"] {
70
+ --bg-base: #0a0a0f;
71
+ --bg-surface: #111118;
72
+ --bg-raised: #18181f;
73
+ --bg-overlay: #222230;
74
+ --bg-hover: rgba(255, 255, 255, 0.04);
75
+ --bg-active: rgba(255, 255, 255, 0.07);
76
+
77
+ --border: rgba(255, 255, 255, 0.08);
78
+ --border-strong: rgba(255, 255, 255, 0.14);
79
+
80
+ --text-primary: #f2f2f7;
81
+ --text-secondary: #9898ab;
82
+ --text-tertiary: #5a5a6e;
83
+ --text-inverse: #0a0a0f;
84
+
85
+ --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4);
86
+ --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.5);
87
+ --shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.6);
88
+ --glow: 0 0 0 1px var(--accent), 0 0 20px rgba(124, 58, 237, 0.2);
89
+ }
90
+
91
+ /* ============================================================
92
+ LIGHT THEME
93
+ ============================================================ */
94
+ [data-theme="light"] {
95
+ --bg-base: #f8f8fc;
96
+ --bg-surface: #ffffff;
97
+ --bg-raised: #f0f0f8;
98
+ --bg-overlay: #e8e8f4;
99
+ --bg-hover: rgba(0, 0, 0, 0.03);
100
+ --bg-active: rgba(0, 0, 0, 0.06);
101
+
102
+ --border: rgba(0, 0, 0, 0.08);
103
+ --border-strong: rgba(0, 0, 0, 0.14);
104
+
105
+ --text-primary: #111118;
106
+ --text-secondary: #5a5a6e;
107
+ --text-tertiary: #9898ab;
108
+ --text-inverse: #f2f2f7;
109
+
110
+ --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08);
111
+ --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.1);
112
+ --shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.12);
113
+ --glow: 0 0 0 1px var(--accent), 0 0 20px rgba(124, 58, 237, 0.12);
114
+ }
115
+
116
+ /* ============================================================
117
+ RESET & BASE
118
+ ============================================================ */
119
+ *,
120
+ *::before,
121
+ *::after {
122
+ box-sizing: border-box;
123
+ margin: 0;
124
+ padding: 0;
125
+ }
126
+ html {
127
+ font-size: 16px;
128
+ scroll-behavior: smooth;
129
+ }
130
+ body {
131
+ font-family: var(--font-sans);
132
+ font-size: var(--text-base);
133
+ line-height: 1.6;
134
+ background: var(--bg-base);
135
+ color: var(--text-primary);
136
+ min-height: 100vh;
137
+ -webkit-font-smoothing: antialiased;
138
+ transition:
139
+ background var(--duration-slow) var(--ease),
140
+ color var(--duration-slow) var(--ease);
141
+ }
142
+ a {
143
+ color: inherit;
144
+ text-decoration: none;
145
+ }
146
+ button {
147
+ font-family: inherit;
148
+ cursor: pointer;
149
+ border: none;
150
+ outline: none;
151
+ }
152
+ input,
153
+ textarea {
154
+ font-family: inherit;
155
+ outline: none;
156
+ }
157
+ img {
158
+ display: block;
159
+ max-width: 100%;
160
+ }
161
+ ul {
162
+ list-style: none;
163
+ }
164
+
165
+ /* ============================================================
166
+ LAYOUT
167
+ ============================================================ */
168
+ #app {
169
+ display: flex;
170
+ flex-direction: column;
171
+ min-height: 100vh;
172
+ }
173
+
174
+ .navbar {
175
+ position: sticky;
176
+ top: 0;
177
+ z-index: 100;
178
+ height: 56px;
179
+ background: var(--bg-surface);
180
+ border-bottom: 1px solid var(--border);
181
+ backdrop-filter: blur(20px);
182
+ -webkit-backdrop-filter: blur(20px);
183
+ display: flex;
184
+ align-items: center;
185
+ padding: 0 var(--space-6);
186
+ gap: var(--space-4);
187
+ }
188
+ .navbar-brand {
189
+ display: flex;
190
+ align-items: center;
191
+ gap: var(--space-2);
192
+ font-size: var(--text-lg);
193
+ font-weight: 700;
194
+ letter-spacing: -0.5px;
195
+ color: var(--text-primary);
196
+ }
197
+ .navbar-brand .brand-dot {
198
+ width: 8px;
199
+ height: 8px;
200
+ border-radius: 50%;
201
+ background: var(--accent);
202
+ box-shadow: 0 0 8px var(--accent);
203
+ }
204
+ .navbar-spacer {
205
+ flex: 1;
206
+ }
207
+ .navbar-actions {
208
+ display: flex;
209
+ align-items: center;
210
+ gap: var(--space-2);
211
+ }
212
+
213
+ .main-layout {
214
+ display: flex;
215
+ flex: 1;
216
+ }
217
+ .sidebar {
218
+ width: 220px;
219
+ flex-shrink: 0;
220
+ padding: var(--space-6) var(--space-3);
221
+ border-right: 1px solid var(--border);
222
+ display: flex;
223
+ flex-direction: column;
224
+ gap: var(--space-1);
225
+ position: sticky;
226
+ top: 56px;
227
+ height: calc(100vh - 56px);
228
+ overflow-y: auto;
229
+ }
230
+ .content-area {
231
+ flex: 1;
232
+ min-width: 0;
233
+ padding: var(--space-8) var(--space-8);
234
+ max-width: 900px;
235
+ }
236
+ @media (max-width: 900px) {
237
+ .sidebar {
238
+ display: none;
239
+ }
240
+ .content-area {
241
+ padding: var(--space-6) var(--space-4);
242
+ }
243
+ }
244
+
245
+ /* ============================================================
246
+ NAV ITEMS
247
+ ============================================================ */
248
+ .nav-section-label {
249
+ font-size: var(--text-xs);
250
+ font-weight: 600;
251
+ text-transform: uppercase;
252
+ letter-spacing: 0.08em;
253
+ color: var(--text-tertiary);
254
+ padding: var(--space-4) var(--space-3) var(--space-2);
255
+ }
256
+ .nav-item {
257
+ display: flex;
258
+ align-items: center;
259
+ gap: var(--space-3);
260
+ padding: var(--space-2) var(--space-3);
261
+ border-radius: var(--radius-sm);
262
+ font-size: var(--text-sm);
263
+ font-weight: 500;
264
+ color: var(--text-secondary);
265
+ cursor: pointer;
266
+ transition: all var(--duration-fast) var(--ease);
267
+ user-select: none;
268
+ }
269
+ .nav-item:hover {
270
+ background: var(--bg-hover);
271
+ color: var(--text-primary);
272
+ }
273
+ .nav-item.active {
274
+ background: var(--accent-dim);
275
+ color: var(--accent-light);
276
+ }
277
+ .nav-item svg {
278
+ flex-shrink: 0;
279
+ opacity: 0.8;
280
+ }
281
+ .nav-item.active svg {
282
+ opacity: 1;
283
+ }
284
+
285
+ /* ============================================================
286
+ BUTTONS
287
+ ============================================================ */
288
+ .btn {
289
+ display: inline-flex;
290
+ align-items: center;
291
+ justify-content: center;
292
+ gap: var(--space-2);
293
+ padding: 0 var(--space-4);
294
+ height: 34px;
295
+ border-radius: var(--radius-sm);
296
+ font-size: var(--text-sm);
297
+ font-weight: 500;
298
+ transition: all var(--duration-fast) var(--ease);
299
+ white-space: nowrap;
300
+ flex-shrink: 0;
301
+ }
302
+ .btn-primary {
303
+ background: var(--accent);
304
+ color: #fff;
305
+ box-shadow:
306
+ 0 1px 2px rgba(0, 0, 0, 0.2),
307
+ inset 0 1px 0 rgba(255, 255, 255, 0.1);
308
+ }
309
+ .btn-primary:hover {
310
+ background: var(--accent-hover);
311
+ transform: translateY(-1px);
312
+ }
313
+ .btn-primary:active {
314
+ transform: translateY(0);
315
+ }
316
+
317
+ .btn-ghost {
318
+ background: transparent;
319
+ color: var(--text-secondary);
320
+ border: 1px solid var(--border);
321
+ }
322
+ .btn-ghost:hover {
323
+ background: var(--bg-hover);
324
+ color: var(--text-primary);
325
+ border-color: var(--border-strong);
326
+ }
327
+
328
+ .btn-danger {
329
+ background: var(--danger);
330
+ color: #fff;
331
+ }
332
+ .btn-danger:hover {
333
+ background: #dc2626;
334
+ }
335
+
336
+ .btn-sm {
337
+ height: 28px;
338
+ padding: 0 var(--space-3);
339
+ font-size: var(--text-xs);
340
+ }
341
+ .btn-lg {
342
+ height: 42px;
343
+ padding: 0 var(--space-6);
344
+ font-size: var(--text-md);
345
+ }
346
+ .btn-icon {
347
+ width: 34px;
348
+ padding: 0;
349
+ }
350
+ .btn-icon-sm {
351
+ width: 28px;
352
+ height: 28px;
353
+ padding: 0;
354
+ }
355
+
356
+ .btn:disabled {
357
+ opacity: 0.45;
358
+ cursor: not-allowed;
359
+ transform: none !important;
360
+ }
361
+
362
+ /* ============================================================
363
+ INPUTS & FORMS
364
+ ============================================================ */
365
+ .form-group {
366
+ display: flex;
367
+ flex-direction: column;
368
+ gap: var(--space-2);
369
+ }
370
+ .form-label {
371
+ font-size: var(--text-sm);
372
+ font-weight: 500;
373
+ color: var(--text-secondary);
374
+ }
375
+ .form-input {
376
+ width: 100%;
377
+ height: 40px;
378
+ padding: 0 var(--space-3);
379
+ background: var(--bg-raised);
380
+ color: var(--text-primary);
381
+ border: 1px solid var(--border);
382
+ border-radius: var(--radius-sm);
383
+ font-size: var(--text-base);
384
+ transition:
385
+ border-color var(--duration-fast),
386
+ box-shadow var(--duration-fast);
387
+ }
388
+ .form-input:focus {
389
+ border-color: var(--accent);
390
+ box-shadow: 0 0 0 3px var(--accent-dim);
391
+ }
392
+ .form-input::placeholder {
393
+ color: var(--text-tertiary);
394
+ }
395
+ .form-textarea {
396
+ width: 100%;
397
+ min-height: 120px;
398
+ padding: var(--space-3);
399
+ resize: vertical;
400
+ background: var(--bg-raised);
401
+ color: var(--text-primary);
402
+ border: 1px solid var(--border);
403
+ border-radius: var(--radius-sm);
404
+ font-size: var(--text-base);
405
+ line-height: 1.6;
406
+ transition:
407
+ border-color var(--duration-fast),
408
+ box-shadow var(--duration-fast);
409
+ }
410
+ .form-textarea:focus {
411
+ border-color: var(--accent);
412
+ box-shadow: 0 0 0 3px var(--accent-dim);
413
+ }
414
+ .form-textarea::placeholder {
415
+ color: var(--text-tertiary);
416
+ }
417
+ .form-error {
418
+ font-size: var(--text-xs);
419
+ color: var(--danger);
420
+ }
421
+ .form-hint {
422
+ font-size: var(--text-xs);
423
+ color: var(--text-tertiary);
424
+ }
425
+
426
+ .input-wrap {
427
+ position: relative;
428
+ }
429
+ .input-wrap .form-input {
430
+ padding-right: 44px;
431
+ }
432
+ .input-wrap .input-suffix {
433
+ position: absolute;
434
+ right: 0;
435
+ top: 0;
436
+ bottom: 0;
437
+ width: 40px;
438
+ display: flex;
439
+ align-items: center;
440
+ justify-content: center;
441
+ color: var(--text-tertiary);
442
+ cursor: pointer;
443
+ }
444
+ .input-wrap .input-suffix:hover {
445
+ color: var(--text-primary);
446
+ }
447
+
448
+ /* ============================================================
449
+ TOGGLE / SWITCH
450
+ ============================================================ */
451
+ .toggle-wrap {
452
+ display: flex;
453
+ align-items: center;
454
+ gap: var(--space-3);
455
+ }
456
+ .toggle {
457
+ position: relative;
458
+ width: 40px;
459
+ height: 22px;
460
+ background: var(--bg-overlay);
461
+ border-radius: var(--radius-full);
462
+ cursor: pointer;
463
+ transition: background var(--duration-base);
464
+ border: 1px solid var(--border);
465
+ }
466
+ .toggle.on {
467
+ background: var(--accent);
468
+ border-color: var(--accent);
469
+ }
470
+ .toggle::after {
471
+ content: "";
472
+ position: absolute;
473
+ width: 16px;
474
+ height: 16px;
475
+ border-radius: 50%;
476
+ background: #fff;
477
+ top: 2px;
478
+ left: 2px;
479
+ transition: transform var(--duration-base) var(--ease);
480
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
481
+ }
482
+ .toggle.on::after {
483
+ transform: translateX(18px);
484
+ }
485
+ .toggle-label {
486
+ font-size: var(--text-sm);
487
+ color: var(--text-secondary);
488
+ }
489
+
490
+ /* ============================================================
491
+ CARDS
492
+ ============================================================ */
493
+ .card {
494
+ background: var(--bg-surface);
495
+ border: 1px solid var(--border);
496
+ border-radius: var(--radius-lg);
497
+ transition:
498
+ border-color var(--duration-base),
499
+ box-shadow var(--duration-base);
500
+ }
501
+ .card:hover {
502
+ border-color: var(--border-strong);
503
+ box-shadow: var(--shadow-md);
504
+ }
505
+
506
+ .post-card {
507
+ padding: var(--space-6);
508
+ display: flex;
509
+ flex-direction: column;
510
+ gap: var(--space-4);
511
+ cursor: pointer;
512
+ }
513
+ .post-card:hover {
514
+ border-color: var(--accent);
515
+ box-shadow:
516
+ 0 0 0 1px var(--accent-dim),
517
+ var(--shadow-md);
518
+ }
519
+
520
+ .post-meta {
521
+ display: flex;
522
+ align-items: center;
523
+ gap: var(--space-3);
524
+ }
525
+ .avatar {
526
+ width: 32px;
527
+ height: 32px;
528
+ border-radius: 50%;
529
+ background: var(--accent-dim);
530
+ display: flex;
531
+ align-items: center;
532
+ justify-content: center;
533
+ font-size: var(--text-xs);
534
+ font-weight: 700;
535
+ color: var(--accent-light);
536
+ flex-shrink: 0;
537
+ }
538
+ .avatar-lg {
539
+ width: 48px;
540
+ height: 48px;
541
+ font-size: var(--text-base);
542
+ }
543
+
544
+ .post-author {
545
+ font-size: var(--text-sm);
546
+ font-weight: 500;
547
+ color: var(--text-secondary);
548
+ }
549
+ .post-time {
550
+ font-size: var(--text-xs);
551
+ color: var(--text-tertiary);
552
+ }
553
+ .post-title {
554
+ font-size: var(--text-xl);
555
+ font-weight: 600;
556
+ line-height: 1.35;
557
+ color: var(--text-primary);
558
+ }
559
+ .post-content-preview {
560
+ font-size: var(--text-sm);
561
+ color: var(--text-secondary);
562
+ line-height: 1.6;
563
+ display: -webkit-box;
564
+ -webkit-line-clamp: 3;
565
+ -webkit-box-orient: vertical;
566
+ overflow: hidden;
567
+ }
568
+ .post-footer {
569
+ display: flex;
570
+ align-items: center;
571
+ justify-content: space-between;
572
+ padding-top: var(--space-2);
573
+ border-top: 1px solid var(--border);
574
+ }
575
+ .post-actions {
576
+ display: flex;
577
+ align-items: center;
578
+ gap: var(--space-1);
579
+ }
580
+
581
+ /* ============================================================
582
+ VOTE BUTTON
583
+ ============================================================ */
584
+ .vote-btn {
585
+ display: inline-flex;
586
+ align-items: center;
587
+ gap: var(--space-2);
588
+ padding: var(--space-1) var(--space-3);
589
+ border-radius: var(--radius-full);
590
+ font-size: var(--text-sm);
591
+ font-weight: 500;
592
+ background: var(--bg-raised);
593
+ color: var(--text-secondary);
594
+ border: 1px solid var(--border);
595
+ cursor: pointer;
596
+ transition: all var(--duration-base) var(--ease);
597
+ user-select: none;
598
+ }
599
+ .vote-btn:hover {
600
+ border-color: var(--accent-light);
601
+ color: var(--accent-light);
602
+ background: var(--accent-dim);
603
+ }
604
+ .vote-btn.voted {
605
+ background: var(--accent-dim);
606
+ color: var(--accent-light);
607
+ border-color: var(--accent);
608
+ }
609
+ .vote-btn svg {
610
+ transition: transform var(--duration-base) var(--ease);
611
+ }
612
+ .vote-btn:hover svg,
613
+ .vote-btn.voted svg {
614
+ transform: translateY(-2px);
615
+ }
616
+
617
+ /* ============================================================
618
+ BADGES
619
+ ============================================================ */
620
+ .badge {
621
+ display: inline-flex;
622
+ align-items: center;
623
+ gap: 4px;
624
+ padding: 2px 8px;
625
+ border-radius: var(--radius-full);
626
+ font-size: var(--text-xs);
627
+ font-weight: 600;
628
+ }
629
+ .badge-published {
630
+ background: rgba(16, 185, 129, 0.12);
631
+ color: #10b981;
632
+ }
633
+ .badge-draft {
634
+ background: rgba(245, 158, 11, 0.12);
635
+ color: #f59e0b;
636
+ }
637
+
638
+ /* ============================================================
639
+ PAGE HEADER
640
+ ============================================================ */
641
+ .page-header {
642
+ margin-bottom: var(--space-8);
643
+ }
644
+ .page-title {
645
+ font-size: var(--text-3xl);
646
+ font-weight: 700;
647
+ letter-spacing: -0.8px;
648
+ color: var(--text-primary);
649
+ }
650
+ .page-sub {
651
+ font-size: var(--text-md);
652
+ color: var(--text-secondary);
653
+ margin-top: var(--space-2);
654
+ }
655
+ .page-header-row {
656
+ display: flex;
657
+ align-items: center;
658
+ justify-content: space-between;
659
+ gap: var(--space-4);
660
+ flex-wrap: wrap;
661
+ }
662
+
663
+ /* ============================================================
664
+ SEARCH & FILTERS
665
+ ============================================================ */
666
+ .toolbar {
667
+ display: flex;
668
+ align-items: center;
669
+ gap: var(--space-3);
670
+ margin-bottom: var(--space-6);
671
+ flex-wrap: wrap;
672
+ }
673
+ .search-wrap {
674
+ position: relative;
675
+ flex: 1;
676
+ min-width: 200px;
677
+ }
678
+ .search-wrap svg {
679
+ position: absolute;
680
+ left: 12px;
681
+ top: 50%;
682
+ transform: translateY(-50%);
683
+ color: var(--text-tertiary);
684
+ pointer-events: none;
685
+ }
686
+ .search-input {
687
+ width: 100%;
688
+ height: 36px;
689
+ padding: 0 var(--space-3) 0 36px;
690
+ background: var(--bg-raised);
691
+ color: var(--text-primary);
692
+ border: 1px solid var(--border);
693
+ border-radius: var(--radius-sm);
694
+ font-size: var(--text-sm);
695
+ transition:
696
+ border-color var(--duration-fast),
697
+ box-shadow var(--duration-fast);
698
+ }
699
+ .search-input:focus {
700
+ border-color: var(--accent);
701
+ box-shadow: 0 0 0 3px var(--accent-dim);
702
+ }
703
+ .search-input::placeholder {
704
+ color: var(--text-tertiary);
705
+ }
706
+
707
+ .select {
708
+ height: 36px;
709
+ padding: 0 var(--space-3);
710
+ background: var(--bg-raised);
711
+ color: var(--text-secondary);
712
+ border: 1px solid var(--border);
713
+ border-radius: var(--radius-sm);
714
+ font-size: var(--text-sm);
715
+ cursor: pointer;
716
+ appearance: none;
717
+ -webkit-appearance: none;
718
+ padding-right: var(--space-8);
719
+ 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");
720
+ background-repeat: no-repeat;
721
+ background-position: right 10px center;
722
+ }
723
+ .select:focus {
724
+ outline: none;
725
+ border-color: var(--accent);
726
+ }
727
+
728
+ /* ============================================================
729
+ POST GRID
730
+ ============================================================ */
731
+ .post-grid {
732
+ display: flex;
733
+ flex-direction: column;
734
+ gap: var(--space-4);
735
+ }
736
+
737
+ /* ============================================================
738
+ PAGINATION
739
+ ============================================================ */
740
+ .pagination {
741
+ display: flex;
742
+ align-items: center;
743
+ justify-content: center;
744
+ gap: var(--space-2);
745
+ margin-top: var(--space-8);
746
+ flex-wrap: wrap;
747
+ }
748
+ .page-btn {
749
+ min-width: 34px;
750
+ height: 34px;
751
+ padding: 0 var(--space-2);
752
+ border-radius: var(--radius-sm);
753
+ font-size: var(--text-sm);
754
+ font-weight: 500;
755
+ background: var(--bg-raised);
756
+ color: var(--text-secondary);
757
+ border: 1px solid var(--border);
758
+ cursor: pointer;
759
+ transition: all var(--duration-fast);
760
+ display: inline-flex;
761
+ align-items: center;
762
+ justify-content: center;
763
+ }
764
+ .page-btn:hover {
765
+ border-color: var(--accent-light);
766
+ color: var(--accent-light);
767
+ }
768
+ .page-btn.active {
769
+ background: var(--accent);
770
+ color: #fff;
771
+ border-color: var(--accent);
772
+ }
773
+ .page-btn:disabled {
774
+ opacity: 0.35;
775
+ cursor: not-allowed;
776
+ }
777
+
778
+ /* ============================================================
779
+ SKELETON LOADER
780
+ ============================================================ */
781
+ @keyframes shimmer {
782
+ 0% {
783
+ background-position: -400px 0;
784
+ }
785
+ 100% {
786
+ background-position: 400px 0;
787
+ }
788
+ }
789
+ .skeleton {
790
+ background: linear-gradient(
791
+ 90deg,
792
+ var(--bg-raised) 25%,
793
+ var(--bg-overlay) 50%,
794
+ var(--bg-raised) 75%
795
+ );
796
+ background-size: 800px 100%;
797
+ animation: shimmer 1.4s infinite linear;
798
+ border-radius: var(--radius-sm);
799
+ }
800
+ .skeleton-text {
801
+ height: 14px;
802
+ margin-bottom: var(--space-2);
803
+ }
804
+ .skeleton-title {
805
+ height: 22px;
806
+ margin-bottom: var(--space-3);
807
+ }
808
+ .skeleton-circle {
809
+ border-radius: 50%;
810
+ }
811
+
812
+ /* ============================================================
813
+ EMPTY STATE
814
+ ============================================================ */
815
+ .empty-state {
816
+ text-align: center;
817
+ padding: var(--space-16) var(--space-8);
818
+ display: flex;
819
+ flex-direction: column;
820
+ align-items: center;
821
+ gap: var(--space-4);
822
+ }
823
+ .empty-icon {
824
+ width: 64px;
825
+ height: 64px;
826
+ background: var(--bg-raised);
827
+ border-radius: var(--radius-xl);
828
+ display: flex;
829
+ align-items: center;
830
+ justify-content: center;
831
+ color: var(--text-tertiary);
832
+ }
833
+ .empty-title {
834
+ font-size: var(--text-xl);
835
+ font-weight: 600;
836
+ }
837
+ .empty-sub {
838
+ font-size: var(--text-sm);
839
+ color: var(--text-secondary);
840
+ max-width: 300px;
841
+ }
842
+
843
+ /* ============================================================
844
+ MODAL
845
+ ============================================================ */
846
+ .modal-backdrop {
847
+ position: fixed;
848
+ inset: 0;
849
+ z-index: 200;
850
+ background: rgba(0, 0, 0, 0.6);
851
+ backdrop-filter: blur(4px);
852
+ display: flex;
853
+ align-items: center;
854
+ justify-content: center;
855
+ padding: var(--space-4);
856
+ opacity: 0;
857
+ pointer-events: none;
858
+ transition: opacity var(--duration-base);
859
+ }
860
+ .modal-backdrop.open {
861
+ opacity: 1;
862
+ pointer-events: all;
863
+ }
864
+ .modal {
865
+ background: var(--bg-surface);
866
+ border: 1px solid var(--border-strong);
867
+ border-radius: var(--radius-xl);
868
+ padding: var(--space-8);
869
+ max-width: 440px;
870
+ width: 100%;
871
+ box-shadow: var(--shadow-lg);
872
+ transform: scale(0.94) translateY(8px);
873
+ transition: transform var(--duration-slow) var(--ease);
874
+ }
875
+ .modal-backdrop.open .modal {
876
+ transform: scale(1) translateY(0);
877
+ }
878
+ .modal-title {
879
+ font-size: var(--text-xl);
880
+ font-weight: 700;
881
+ margin-bottom: var(--space-2);
882
+ }
883
+ .modal-sub {
884
+ font-size: var(--text-sm);
885
+ color: var(--text-secondary);
886
+ margin-bottom: var(--space-6);
887
+ }
888
+ .modal-actions {
889
+ display: flex;
890
+ gap: var(--space-3);
891
+ justify-content: flex-end;
892
+ margin-top: var(--space-6);
893
+ }
894
+
895
+ /* ============================================================
896
+ TOAST
897
+ ============================================================ */
898
+ #toast-container {
899
+ position: fixed;
900
+ bottom: var(--space-6);
901
+ right: var(--space-6);
902
+ z-index: 300;
903
+ display: flex;
904
+ flex-direction: column;
905
+ gap: var(--space-3);
906
+ pointer-events: none;
907
+ }
908
+ .toast {
909
+ display: flex;
910
+ align-items: flex-start;
911
+ gap: var(--space-3);
912
+ padding: var(--space-4);
913
+ background: var(--bg-surface);
914
+ border: 1px solid var(--border-strong);
915
+ border-radius: var(--radius-md);
916
+ box-shadow: var(--shadow-lg);
917
+ max-width: 320px;
918
+ min-width: 240px;
919
+ pointer-events: all;
920
+ transform: translateX(calc(100% + 24px));
921
+ opacity: 0;
922
+ transition:
923
+ transform var(--duration-slow) var(--ease),
924
+ opacity var(--duration-slow);
925
+ }
926
+ .toast.show {
927
+ transform: translateX(0);
928
+ opacity: 1;
929
+ }
930
+ .toast.hide {
931
+ transform: translateX(calc(100% + 24px));
932
+ opacity: 0;
933
+ }
934
+ .toast-icon {
935
+ flex-shrink: 0;
936
+ margin-top: 1px;
937
+ }
938
+ .toast-body {
939
+ flex: 1;
940
+ }
941
+ .toast-title {
942
+ font-size: var(--text-sm);
943
+ font-weight: 600;
944
+ }
945
+ .toast-msg {
946
+ font-size: var(--text-xs);
947
+ color: var(--text-secondary);
948
+ margin-top: 2px;
949
+ }
950
+ .toast-success .toast-icon {
951
+ color: var(--success);
952
+ }
953
+ .toast-error .toast-icon {
954
+ color: var(--danger);
955
+ }
956
+ .toast-info .toast-icon {
957
+ color: var(--info);
958
+ }
959
+
960
+ /* ============================================================
961
+ AUTH PAGES
962
+ ============================================================ */
963
+ .auth-page {
964
+ min-height: 100vh;
965
+ display: flex;
966
+ align-items: center;
967
+ justify-content: center;
968
+ padding: var(--space-6);
969
+ background: var(--bg-base);
970
+ }
971
+ .auth-card {
972
+ background: var(--bg-surface);
973
+ border: 1px solid var(--border);
974
+ border-radius: var(--radius-xl);
975
+ padding: var(--space-10);
976
+ width: 100%;
977
+ max-width: 400px;
978
+ box-shadow: var(--shadow-lg);
979
+ }
980
+ .auth-logo {
981
+ display: flex;
982
+ align-items: center;
983
+ gap: var(--space-2);
984
+ font-size: var(--text-xl);
985
+ font-weight: 700;
986
+ margin-bottom: var(--space-8);
987
+ }
988
+ .auth-title {
989
+ font-size: var(--text-2xl);
990
+ font-weight: 700;
991
+ letter-spacing: -0.5px;
992
+ }
993
+ .auth-sub {
994
+ font-size: var(--text-sm);
995
+ color: var(--text-secondary);
996
+ margin-top: var(--space-2);
997
+ margin-bottom: var(--space-8);
998
+ }
999
+ .auth-form {
1000
+ display: flex;
1001
+ flex-direction: column;
1002
+ gap: var(--space-5);
1003
+ }
1004
+ .auth-divider {
1005
+ display: flex;
1006
+ align-items: center;
1007
+ gap: var(--space-3);
1008
+ margin: var(--space-2) 0;
1009
+ font-size: var(--text-xs);
1010
+ color: var(--text-tertiary);
1011
+ }
1012
+ .auth-divider::before,
1013
+ .auth-divider::after {
1014
+ content: "";
1015
+ flex: 1;
1016
+ height: 1px;
1017
+ background: var(--border);
1018
+ }
1019
+ .auth-link {
1020
+ color: var(--accent-light);
1021
+ font-weight: 500;
1022
+ cursor: pointer;
1023
+ }
1024
+ .auth-link:hover {
1025
+ text-decoration: underline;
1026
+ }
1027
+
1028
+ /* ============================================================
1029
+ POST DETAIL
1030
+ ============================================================ */
1031
+ .post-detail-header {
1032
+ margin-bottom: var(--space-8);
1033
+ }
1034
+ .post-detail-title {
1035
+ font-size: var(--text-4xl);
1036
+ font-weight: 700;
1037
+ letter-spacing: -1px;
1038
+ line-height: 1.2;
1039
+ margin-bottom: var(--space-6);
1040
+ }
1041
+ .post-detail-content {
1042
+ font-size: var(--text-md);
1043
+ line-height: 1.8;
1044
+ color: var(--text-secondary);
1045
+ white-space: pre-wrap;
1046
+ word-break: break-word;
1047
+ }
1048
+ .post-detail-meta {
1049
+ display: flex;
1050
+ align-items: center;
1051
+ gap: var(--space-4);
1052
+ padding: var(--space-4) 0;
1053
+ border-top: 1px solid var(--border);
1054
+ border-bottom: 1px solid var(--border);
1055
+ margin-bottom: var(--space-8);
1056
+ flex-wrap: wrap;
1057
+ }
1058
+ .breadcrumb {
1059
+ display: flex;
1060
+ align-items: center;
1061
+ gap: var(--space-2);
1062
+ font-size: var(--text-sm);
1063
+ color: var(--text-tertiary);
1064
+ margin-bottom: var(--space-6);
1065
+ }
1066
+ .breadcrumb-link {
1067
+ cursor: pointer;
1068
+ color: var(--text-tertiary);
1069
+ }
1070
+ .breadcrumb-link:hover {
1071
+ color: var(--accent-light);
1072
+ }
1073
+ .breadcrumb-sep {
1074
+ color: var(--text-tertiary);
1075
+ }
1076
+
1077
+ /* ============================================================
1078
+ PROFILE
1079
+ ============================================================ */
1080
+ .profile-card {
1081
+ background: var(--bg-surface);
1082
+ border: 1px solid var(--border);
1083
+ border-radius: var(--radius-xl);
1084
+ padding: var(--space-8);
1085
+ display: flex;
1086
+ align-items: flex-start;
1087
+ gap: var(--space-6);
1088
+ margin-bottom: var(--space-8);
1089
+ }
1090
+ .profile-info {
1091
+ flex: 1;
1092
+ }
1093
+ .profile-name {
1094
+ font-size: var(--text-2xl);
1095
+ font-weight: 700;
1096
+ }
1097
+ .profile-email {
1098
+ font-size: var(--text-sm);
1099
+ color: var(--text-secondary);
1100
+ margin-top: 4px;
1101
+ }
1102
+ .profile-stats {
1103
+ display: flex;
1104
+ gap: var(--space-6);
1105
+ margin-top: var(--space-4);
1106
+ }
1107
+ .stat-item {
1108
+ text-align: center;
1109
+ }
1110
+ .stat-num {
1111
+ font-size: var(--text-xl);
1112
+ font-weight: 700;
1113
+ }
1114
+ .stat-label {
1115
+ font-size: var(--text-xs);
1116
+ color: var(--text-tertiary);
1117
+ }
1118
+
1119
+ /* ============================================================
1120
+ THEME TOGGLE
1121
+ ============================================================ */
1122
+ .theme-btn {
1123
+ width: 34px;
1124
+ height: 34px;
1125
+ border-radius: var(--radius-sm);
1126
+ background: var(--bg-raised);
1127
+ color: var(--text-secondary);
1128
+ border: 1px solid var(--border);
1129
+ display: flex;
1130
+ align-items: center;
1131
+ justify-content: center;
1132
+ cursor: pointer;
1133
+ transition: all var(--duration-fast);
1134
+ }
1135
+ .theme-btn:hover {
1136
+ color: var(--text-primary);
1137
+ border-color: var(--border-strong);
1138
+ }
1139
+
1140
+ /* ============================================================
1141
+ USER DROPDOWN
1142
+ ============================================================ */
1143
+ .user-menu-wrap {
1144
+ position: relative;
1145
+ }
1146
+ .user-btn {
1147
+ display: flex;
1148
+ align-items: center;
1149
+ gap: var(--space-2);
1150
+ padding: 4px 8px 4px 4px;
1151
+ border-radius: var(--radius-full);
1152
+ background: var(--bg-raised);
1153
+ border: 1px solid var(--border);
1154
+ cursor: pointer;
1155
+ transition: all var(--duration-fast);
1156
+ font-size: var(--text-sm);
1157
+ font-weight: 500;
1158
+ color: var(--text-primary);
1159
+ }
1160
+ .user-btn:hover {
1161
+ border-color: var(--border-strong);
1162
+ }
1163
+ .user-dropdown {
1164
+ position: absolute;
1165
+ top: calc(100% + 8px);
1166
+ right: 0;
1167
+ min-width: 180px;
1168
+ background: var(--bg-surface);
1169
+ border: 1px solid var(--border-strong);
1170
+ border-radius: var(--radius-md);
1171
+ box-shadow: var(--shadow-lg);
1172
+ overflow: hidden;
1173
+ z-index: 150;
1174
+ opacity: 0;
1175
+ pointer-events: none;
1176
+ transform: translateY(-6px);
1177
+ transition:
1178
+ opacity var(--duration-base),
1179
+ transform var(--duration-base) var(--ease);
1180
+ }
1181
+ .user-dropdown.open {
1182
+ opacity: 1;
1183
+ pointer-events: all;
1184
+ transform: translateY(0);
1185
+ }
1186
+ .dropdown-header {
1187
+ padding: var(--space-3) var(--space-4);
1188
+ border-bottom: 1px solid var(--border);
1189
+ }
1190
+ .dropdown-email {
1191
+ font-size: var(--text-xs);
1192
+ color: var(--text-tertiary);
1193
+ }
1194
+ .dropdown-item {
1195
+ display: flex;
1196
+ align-items: center;
1197
+ gap: var(--space-3);
1198
+ padding: var(--space-3) var(--space-4);
1199
+ font-size: var(--text-sm);
1200
+ color: var(--text-secondary);
1201
+ cursor: pointer;
1202
+ transition: background var(--duration-fast);
1203
+ }
1204
+ .dropdown-item:hover {
1205
+ background: var(--bg-hover);
1206
+ color: var(--text-primary);
1207
+ }
1208
+ .dropdown-item.danger:hover {
1209
+ color: var(--danger);
1210
+ }
1211
+ .dropdown-sep {
1212
+ height: 1px;
1213
+ background: var(--border);
1214
+ margin: 4px 0;
1215
+ }
1216
+
1217
+ /* ============================================================
1218
+ LOADING OVERLAY
1219
+ ============================================================ */
1220
+ .spinner {
1221
+ width: 18px;
1222
+ height: 18px;
1223
+ border: 2px solid rgba(255, 255, 255, 0.2);
1224
+ border-top-color: currentColor;
1225
+ border-radius: 50%;
1226
+ animation: spin 0.7s linear infinite;
1227
+ display: inline-block;
1228
+ }
1229
+ @keyframes spin {
1230
+ to {
1231
+ transform: rotate(360deg);
1232
+ }
1233
+ }
1234
+
1235
+ /* ============================================================
1236
+ PAGE TRANSITIONS
1237
+ ============================================================ */
1238
+ .page-enter {
1239
+ animation: fadeSlideIn var(--duration-slow) var(--ease) forwards;
1240
+ }
1241
+ @keyframes fadeSlideIn {
1242
+ from {
1243
+ opacity: 0;
1244
+ transform: translateY(10px);
1245
+ }
1246
+ to {
1247
+ opacity: 1;
1248
+ transform: translateY(0);
1249
+ }
1250
+ }
1251
+
1252
+ /* ============================================================
1253
+ UTILITIES
1254
+ ============================================================ */
1255
+ .flex {
1256
+ display: flex;
1257
+ }
1258
+ .items-center {
1259
+ align-items: center;
1260
+ }
1261
+ .gap-2 {
1262
+ gap: var(--space-2);
1263
+ }
1264
+ .gap-3 {
1265
+ gap: var(--space-3);
1266
+ }
1267
+ .gap-4 {
1268
+ gap: var(--space-4);
1269
+ }
1270
+ .mt-4 {
1271
+ margin-top: var(--space-4);
1272
+ }
1273
+ .mt-6 {
1274
+ margin-top: var(--space-6);
1275
+ }
1276
+ .mt-8 {
1277
+ margin-top: var(--space-8);
1278
+ }
1279
+ .text-sm {
1280
+ font-size: var(--text-sm);
1281
+ }
1282
+ .text-secondary {
1283
+ color: var(--text-secondary);
1284
+ }
1285
+ .text-accent {
1286
+ color: var(--accent-light);
1287
+ }
1288
+ .font-600 {
1289
+ font-weight: 600;
1290
+ }
1291
+ .w-full {
1292
+ width: 100%;
1293
+ }
1294
+ .text-center {
1295
+ text-align: center;
1296
+ }
1297
+ .hidden {
1298
+ display: none !important;
1299
+ }
1300
+
1301
+ /* ============================================================
1302
+ SCROLLBAR
1303
+ ============================================================ */
1304
+ ::-webkit-scrollbar {
1305
+ width: 6px;
1306
+ height: 6px;
1307
+ }
1308
+ ::-webkit-scrollbar-track {
1309
+ background: transparent;
1310
+ }
1311
+ ::-webkit-scrollbar-thumb {
1312
+ background: var(--border-strong);
1313
+ border-radius: 3px;
1314
+ }
1315
+ ::-webkit-scrollbar-thumb:hover {
1316
+ background: var(--text-tertiary);
1317
+ }
1318
+
1319
+ /* ============================================================
1320
+ MOBILE NAV BOTTOM BAR
1321
+ ============================================================ */
1322
+ .mobile-nav {
1323
+ display: none;
1324
+ position: fixed;
1325
+ bottom: 0;
1326
+ left: 0;
1327
+ right: 0;
1328
+ z-index: 100;
1329
+ background: var(--bg-surface);
1330
+ border-top: 1px solid var(--border);
1331
+ padding: var(--space-2) var(--space-4)
1332
+ calc(var(--space-2) + env(safe-area-inset-bottom));
1333
+ justify-content: space-around;
1334
+ }
1335
+ @media (max-width: 900px) {
1336
+ .mobile-nav {
1337
+ display: flex;
1338
+ }
1339
+ }
1340
+ .mobile-nav-item {
1341
+ display: flex;
1342
+ flex-direction: column;
1343
+ align-items: center;
1344
+ gap: 2px;
1345
+ padding: var(--space-2) var(--space-4);
1346
+ border-radius: var(--radius-sm);
1347
+ color: var(--text-tertiary);
1348
+ cursor: pointer;
1349
+ transition: color var(--duration-fast);
1350
+ font-size: 10px;
1351
+ font-weight: 500;
1352
+ }
1353
+ .mobile-nav-item.active {
1354
+ color: var(--accent-light);
1355
+ }
1356
+ .mobile-nav-item:hover {
1357
+ color: var(--text-primary);
1358
+ }
1359
+ @media (max-width: 900px) {
1360
+ .content-area {
1361
+ padding-bottom: 80px;
1362
+ }
1363
+ }
1364
+ </style>
1365
+ </head>
1366
+ <body>
1367
+ <div id="app"></div>
1368
+ <div id="toast-container"></div>
1369
+ <div id="modal-backdrop" class="modal-backdrop">
1370
+ <div class="modal" id="modal-content"></div>
1371
+ </div>
1372
+
1373
+ <script>
1374
+ /* ============================================================
1375
+ ICONS — inline SVG helpers
1376
+ ============================================================ */
1377
+ const Icon = {
1378
+ 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>`,
1379
+ 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>`,
1380
+ 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>`,
1381
+ 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>`,
1382
+ 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>`,
1383
+ 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>`,
1384
+ 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>`,
1385
+ 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>`,
1386
+ 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>`,
1387
+ 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>`,
1388
+ 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>`,
1389
+ 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>`,
1390
+ 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>`,
1391
+ 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>`,
1392
+ 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>`,
1393
+ 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>`,
1394
+ 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>`,
1395
+ 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>`,
1396
+ 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>`,
1397
+ };
1398
+
1399
+ /* ============================================================
1400
+ STATE
1401
+ ============================================================ */
1402
+ const state = {
1403
+ user: null,
1404
+ token: null,
1405
+ theme:
1406
+ localStorage.getItem("theme") ||
1407
+ (window.matchMedia("(prefers-color-scheme: dark)").matches
1408
+ ? "dark"
1409
+ : "light"),
1410
+ currentRoute: "",
1411
+ routeParams: {},
1412
+ dropdownOpen: false,
1413
+ };
1414
+
1415
+ function init() {
1416
+ const saved = localStorage.getItem("auth");
1417
+ if (saved) {
1418
+ try {
1419
+ const p = JSON.parse(saved);
1420
+ state.token = p.token;
1421
+ state.user = p.user;
1422
+ } catch (e) {}
1423
+ }
1424
+ applyTheme(state.theme);
1425
+ route(location.hash.slice(1) || "/");
1426
+ window.addEventListener("hashchange", () =>
1427
+ route(location.hash.slice(1) || "/"),
1428
+ );
1429
+ document.addEventListener("click", (e) => {
1430
+ if (!e.target.closest(".user-menu-wrap")) closeDropdown();
1431
+ });
1432
+ }
1433
+
1434
+ /* ============================================================
1435
+ THEME
1436
+ ============================================================ */
1437
+ function applyTheme(t) {
1438
+ state.theme = t;
1439
+ document.documentElement.setAttribute("data-theme", t);
1440
+ localStorage.setItem("theme", t);
1441
+ }
1442
+ function toggleTheme() {
1443
+ applyTheme(state.theme === "dark" ? "light" : "dark");
1444
+ renderNavbar();
1445
+ }
1446
+
1447
+ /* ============================================================
1448
+ API LAYER
1449
+ ============================================================ */
1450
+ const API_BASE = window.location.origin;
1451
+
1452
+ async function api(method, path, body = null, auth = true) {
1453
+ const headers = { "Content-Type": "application/json" };
1454
+ if (auth && state.token)
1455
+ headers["Authorization"] = `Bearer ${state.token}`;
1456
+ const opts = { method, headers };
1457
+ if (body) opts.body = JSON.stringify(body);
1458
+ const res = await fetch(`${API_BASE}${path}`, opts);
1459
+ if (res.status === 401) {
1460
+ logout();
1461
+ return null;
1462
+ }
1463
+ if (res.status === 204) return null;
1464
+ const data = await res.json().catch(() => ({}));
1465
+ if (!res.ok) throw data;
1466
+ return data;
1467
+ }
1468
+
1469
+ async function apiForm(path, formData) {
1470
+ const res = await fetch(`${API_BASE}${path}`, {
1471
+ method: "POST",
1472
+ body: formData,
1473
+ });
1474
+ const data = await res.json().catch(() => ({}));
1475
+ if (!res.ok) throw data;
1476
+ return data;
1477
+ }
1478
+
1479
+ /* ============================================================
1480
+ AUTH HELPERS
1481
+ ============================================================ */
1482
+ function saveAuth(token, user) {
1483
+ state.token = token;
1484
+ state.user = user;
1485
+ localStorage.setItem("auth", JSON.stringify({ token, user }));
1486
+ }
1487
+ function logout() {
1488
+ state.token = null;
1489
+ state.user = null;
1490
+ localStorage.removeItem("auth");
1491
+ navigate("/login");
1492
+ }
1493
+
1494
+ /* ============================================================
1495
+ ROUTER
1496
+ ============================================================ */
1497
+ function navigate(path) {
1498
+ location.hash = path;
1499
+ }
1500
+
1501
+ function route(path) {
1502
+ state.currentRoute = path;
1503
+ if (!state.token && path !== "/login" && path !== "/register") {
1504
+ renderLoginPage();
1505
+ return;
1506
+ }
1507
+ if (path === "/login") {
1508
+ renderLoginPage();
1509
+ return;
1510
+ }
1511
+ if (path === "/register") {
1512
+ renderRegisterPage();
1513
+ return;
1514
+ }
1515
+
1516
+ const postEditMatch = path.match(/^\/posts\/(\d+)\/edit$/);
1517
+ const postDetailMatch = path.match(/^\/posts\/(\d+)$/);
1518
+ const profileMatch = path.match(/^\/profile\/(\d+)$/);
1519
+
1520
+ if (path === "/" || path === "/feed") {
1521
+ renderShell(() => renderFeedPage());
1522
+ return;
1523
+ }
1524
+ if (path === "/new") {
1525
+ renderShell(() => renderPostFormPage());
1526
+ return;
1527
+ }
1528
+ if (postEditMatch) {
1529
+ renderShell(() => renderPostFormPage(parseInt(postEditMatch[1])));
1530
+ return;
1531
+ }
1532
+ if (postDetailMatch) {
1533
+ renderShell(() => renderPostDetailPage(parseInt(postDetailMatch[1])));
1534
+ return;
1535
+ }
1536
+ if (profileMatch) {
1537
+ renderShell(() => renderProfilePage(parseInt(profileMatch[1])));
1538
+ return;
1539
+ }
1540
+ if (path === "/profile") {
1541
+ renderShell(() => renderProfilePage(state.user?.id));
1542
+ return;
1543
+ }
1544
+
1545
+ renderShell(() => render404());
1546
+ }
1547
+
1548
+ /* ============================================================
1549
+ SHELL (NAVBAR + SIDEBAR + MOBILE NAV)
1550
+ ============================================================ */
1551
+ function renderShell(pageRenderer) {
1552
+ const app = document.getElementById("app");
1553
+ app.innerHTML = `
1554
+ <nav class="navbar" id="navbar"></nav>
1555
+ <div class="main-layout">
1556
+ <aside class="sidebar" id="sidebar"></aside>
1557
+ <main class="content-area" id="main"></main>
1558
+ </div>
1559
+ <nav class="mobile-nav" id="mobile-nav"></nav>
1560
+ `;
1561
+ renderNavbar();
1562
+ renderSidebar();
1563
+ renderMobileNav();
1564
+ pageRenderer();
1565
+ }
1566
+
1567
+ function renderNavbar() {
1568
+ const isDark = state.theme === "dark";
1569
+ document.getElementById("navbar").innerHTML = `
1570
+ <div class="navbar-brand" onclick="navigate('/')">
1571
+ <span class="brand-dot"></span>Postly
1572
+ </div>
1573
+ <div class="navbar-spacer"></div>
1574
+ <div class="navbar-actions">
1575
+ <button class="btn btn-primary btn-sm" onclick="navigate('/new')">${Icon.plus} New Post</button>
1576
+ <button class="theme-btn" onclick="toggleTheme()" title="Toggle theme">
1577
+ ${isDark ? Icon.sun : Icon.moon}
1578
+ </button>
1579
+ ${
1580
+ state.user
1581
+ ? `
1582
+ <div class="user-menu-wrap">
1583
+ <div class="user-btn" id="user-btn" onclick="toggleDropdown()">
1584
+ <div class="avatar" style="width:26px;height:26px;font-size:10px">${initials(state.user.email)}</div>
1585
+ ${state.user.email.split("@")[0]}
1586
+ ${Icon.chevron_right}
1587
+ </div>
1588
+ <div class="user-dropdown" id="user-dropdown">
1589
+ <div class="dropdown-header">
1590
+ <div class="font-600">${state.user.email.split("@")[0]}</div>
1591
+ <div class="dropdown-email">${state.user.email}</div>
1592
+ </div>
1593
+ <div class="dropdown-item" onclick="navigate('/profile')">
1594
+ ${Icon.user} Profile
1595
+ </div>
1596
+ <div class="dropdown-item" onclick="navigate('/new')">
1597
+ ${Icon.plus} New Post
1598
+ </div>
1599
+ <div class="dropdown-sep"></div>
1600
+ <div class="dropdown-item danger" onclick="logout()">
1601
+ ${Icon.log_out} Sign out
1602
+ </div>
1603
+ </div>
1604
+ </div>
1605
+ `
1606
+ : `<button class="btn btn-ghost btn-sm" onclick="navigate('/login')">Sign in</button>`
1607
+ }
1608
+ </div>
1609
+ `;
1610
+ }
1611
+
1612
+ function toggleDropdown() {
1613
+ state.dropdownOpen = !state.dropdownOpen;
1614
+ document
1615
+ .getElementById("user-dropdown")
1616
+ ?.classList.toggle("open", state.dropdownOpen);
1617
+ }
1618
+ function closeDropdown() {
1619
+ state.dropdownOpen = false;
1620
+ document.getElementById("user-dropdown")?.classList.remove("open");
1621
+ }
1622
+
1623
+ const NAV_ITEMS = [
1624
+ { label: "Feed", icon: Icon.home, path: "/" },
1625
+ { label: "New Post", icon: Icon.plus, path: "/new" },
1626
+ { label: "Profile", icon: Icon.user, path: "/profile" },
1627
+ ];
1628
+
1629
+ function renderSidebar() {
1630
+ const nav = document.getElementById("sidebar");
1631
+ if (!nav) return;
1632
+ nav.innerHTML = `
1633
+ <div class="nav-section-label">Navigation</div>
1634
+ ${NAV_ITEMS.map(
1635
+ (item) => `
1636
+ <div class="nav-item ${isActive(item.path)}" onclick="navigate('${item.path}')">
1637
+ ${item.icon} ${item.label}
1638
+ </div>
1639
+ `,
1640
+ ).join("")}
1641
+ `;
1642
+ }
1643
+
1644
+ function renderMobileNav() {
1645
+ const nav = document.getElementById("mobile-nav");
1646
+ if (!nav) return;
1647
+ nav.innerHTML = NAV_ITEMS.map(
1648
+ (item) => `
1649
+ <div class="mobile-nav-item ${isActive(item.path)}" onclick="navigate('${item.path}')">
1650
+ ${item.icon} ${item.label}
1651
+ </div>
1652
+ `,
1653
+ ).join("");
1654
+ }
1655
+
1656
+ function isActive(path) {
1657
+ if (
1658
+ path === "/" &&
1659
+ (state.currentRoute === "/" || state.currentRoute === "/feed")
1660
+ )
1661
+ return "active";
1662
+ if (path !== "/" && state.currentRoute.startsWith(path))
1663
+ return "active";
1664
+ return "";
1665
+ }
1666
+
1667
+ /* ============================================================
1668
+ FEED PAGE
1669
+ ============================================================ */
1670
+ let feedState = {
1671
+ search: "",
1672
+ sort: "newest",
1673
+ page: 1,
1674
+ limit: 10,
1675
+ total: 0,
1676
+ posts: [],
1677
+ loading: false,
1678
+ };
1679
+
1680
+ async function renderFeedPage() {
1681
+ const main = document.getElementById("main");
1682
+ main.innerHTML = `
1683
+ <div class="page-enter">
1684
+ <div class="page-header-row page-header">
1685
+ <div>
1686
+ <h1 class="page-title">Feed</h1>
1687
+ <p class="page-sub">Discover posts from the community</p>
1688
+ </div>
1689
+ <button class="btn btn-primary" onclick="navigate('/new')">${Icon.plus} New Post</button>
1690
+ </div>
1691
+ <div class="toolbar">
1692
+ <div class="search-wrap">
1693
+ ${Icon.search}
1694
+ <input class="search-input" id="search-input" placeholder="Search posts…" value="${feedState.search}"/>
1695
+ </div>
1696
+ <select class="select" id="sort-select">
1697
+ <option value="newest" ${feedState.sort === "newest" ? "selected" : ""}>Newest</option>
1698
+ <option value="oldest" ${feedState.sort === "oldest" ? "selected" : ""}>Oldest</option>
1699
+ <option value="most_voted" ${feedState.sort === "most_voted" ? "selected" : ""}>Most Voted</option>
1700
+ </select>
1701
+ </div>
1702
+ <div id="posts-container"></div>
1703
+ <div id="pagination-container"></div>
1704
+ </div>
1705
+ `;
1706
+
1707
+ // Search debounce
1708
+ let debounce;
1709
+ document
1710
+ .getElementById("search-input")
1711
+ .addEventListener("input", (e) => {
1712
+ clearTimeout(debounce);
1713
+ debounce = setTimeout(() => {
1714
+ feedState.search = e.target.value;
1715
+ feedState.page = 1;
1716
+ loadFeed();
1717
+ }, 350);
1718
+ });
1719
+ document
1720
+ .getElementById("sort-select")
1721
+ .addEventListener("change", (e) => {
1722
+ feedState.sort = e.target.value;
1723
+ feedState.page = 1;
1724
+ loadFeed();
1725
+ });
1726
+
1727
+ loadFeed();
1728
+ }
1729
+
1730
+ async function loadFeed() {
1731
+ const container = document.getElementById("posts-container");
1732
+ if (!container) return;
1733
+ renderSkeletons(container, 5);
1734
+
1735
+ try {
1736
+ const skip = (feedState.page - 1) * feedState.limit;
1737
+ const data = await api(
1738
+ "GET",
1739
+ `/posts/?limit=${feedState.limit}&skip=${skip}&search=${encodeURIComponent(feedState.search)}`,
1740
+ );
1741
+ if (!data) return;
1742
+
1743
+ let posts = data;
1744
+ if (feedState.sort === "oldest") posts = [...posts].reverse();
1745
+ else if (feedState.sort === "most_voted")
1746
+ posts = [...posts].sort((a, b) => b.votes - a.votes);
1747
+
1748
+ feedState.posts = posts;
1749
+
1750
+ if (posts.length === 0) {
1751
+ container.innerHTML = `
1752
+ <div class="empty-state">
1753
+ <div class="empty-icon">${Icon.file}</div>
1754
+ <div class="empty-title">${feedState.search ? "No results found" : "No posts yet"}</div>
1755
+ <div class="empty-sub">${feedState.search ? "Try a different search term" : "Be the first to share something with the community."}</div>
1756
+ ${!feedState.search ? `<button class="btn btn-primary mt-4" onclick="navigate('/new')">${Icon.plus} Create first post</button>` : ""}
1757
+ </div>`;
1758
+ return;
1759
+ }
1760
+
1761
+ container.innerHTML = `<div class="post-grid">${posts.map(renderPostCard).join("")}</div>`;
1762
+ renderPagination();
1763
+ } catch (e) {
1764
+ 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>`;
1765
+ }
1766
+ }
1767
+
1768
+ function renderPostCard(post) {
1769
+ const isOwner = state.user && post.owner_id === state.user.id;
1770
+ const timeAgo = formatTimeAgo(post.created_at);
1771
+ const av = initials(post.owner?.email || "?");
1772
+ return `
1773
+ <div class="card post-card" onclick="navigate('/posts/${post.id}')">
1774
+ <div class="post-meta">
1775
+ <div class="avatar">${av}</div>
1776
+ <div>
1777
+ <div class="post-author">${post.owner?.email?.split("@")[0] || "Unknown"}</div>
1778
+ <div class="post-time">${timeAgo}</div>
1779
+ </div>
1780
+ <div style="margin-left:auto;display:flex;align-items:center;gap:8px">
1781
+ <span class="badge ${post.published ? "badge-published" : "badge-draft"}">${post.published ? "Published" : "Draft"}</span>
1782
+ ${
1783
+ isOwner
1784
+ ? `
1785
+ <button class="btn btn-ghost btn-icon-sm btn-sm" onclick="event.stopPropagation();navigate('/posts/${post.id}/edit')" title="Edit">${Icon.edit}</button>
1786
+ <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>
1787
+ `
1788
+ : ""
1789
+ }
1790
+ </div>
1791
+ </div>
1792
+ <div class="post-title">${escHtml(post.title)}</div>
1793
+ <div class="post-content-preview">${escHtml(post.content)}</div>
1794
+ <div class="post-footer">
1795
+ <div class="post-actions">
1796
+ <button class="vote-btn" onclick="event.stopPropagation();handleVote(${post.id}, this)" data-post-id="${post.id}">
1797
+ ${Icon.arrow_up} <span>${post.votes || 0}</span>
1798
+ </button>
1799
+ </div>
1800
+ <div class="text-sm text-secondary" onclick="event.stopPropagation();navigate('/profile/${post.owner_id}')">
1801
+ by <span class="text-accent" style="cursor:pointer">${post.owner?.email?.split("@")[0] || "?"}</span>
1802
+ </div>
1803
+ </div>
1804
+ </div>`;
1805
+ }
1806
+
1807
+ function renderPagination() {
1808
+ const c = document.getElementById("pagination-container");
1809
+ if (!c || feedState.posts.length < feedState.limit) {
1810
+ if (c) c.innerHTML = "";
1811
+ return;
1812
+ }
1813
+ c.innerHTML = `
1814
+ <div class="pagination">
1815
+ <button class="page-btn" ${feedState.page === 1 ? "disabled" : ""} onclick="feedPage(${feedState.page - 1})">${Icon.arrow_left}</button>
1816
+ <span class="page-btn active">${feedState.page}</span>
1817
+ <button class="page-btn" onclick="feedPage(${feedState.page + 1})">›</button>
1818
+ </div>`;
1819
+ }
1820
+ function feedPage(p) {
1821
+ feedState.page = p;
1822
+ loadFeed();
1823
+ }
1824
+
1825
+ /* ============================================================
1826
+ VOTE
1827
+ ============================================================ */
1828
+ async function handleVote(postId, btn) {
1829
+ if (!state.token) {
1830
+ navigate("/login");
1831
+ return;
1832
+ }
1833
+ const voted = btn.classList.contains("voted");
1834
+ const span = btn.querySelector("span");
1835
+ const cur = parseInt(span.textContent);
1836
+ btn.disabled = true;
1837
+ try {
1838
+ await api("POST", "/vote/", { post_id: postId, dir: voted ? 0 : 1 });
1839
+ btn.classList.toggle("voted", !voted);
1840
+ span.textContent = voted ? cur - 1 : cur + 1;
1841
+ toast(
1842
+ voted ? "Vote removed" : "Upvoted!",
1843
+ "",
1844
+ voted ? "info" : "success",
1845
+ );
1846
+ } catch (e) {
1847
+ const msg = e?.detail || "Could not vote";
1848
+ toast("Error", msg, "error");
1849
+ } finally {
1850
+ btn.disabled = false;
1851
+ }
1852
+ }
1853
+
1854
+ /* ============================================================
1855
+ POST DETAIL PAGE
1856
+ ============================================================ */
1857
+ async function renderPostDetailPage(postId) {
1858
+ const main = document.getElementById("main");
1859
+ main.innerHTML = `<div class="page-enter"><div class="breadcrumb">
1860
+ <span class="breadcrumb-link" onclick="navigate('/')">Feed</span>
1861
+ <span class="breadcrumb-sep">${Icon.chevron_right}</span>
1862
+ <span>Post</span>
1863
+ </div><div id="post-detail-content"></div></div>`;
1864
+
1865
+ const c = document.getElementById("post-detail-content");
1866
+ renderSkeletons(c, 3, true);
1867
+
1868
+ try {
1869
+ const post = await api("GET", `/posts/${postId}`);
1870
+ if (!post) return;
1871
+ const isOwner = state.user && post.owner_id === state.user.id;
1872
+
1873
+ c.innerHTML = `
1874
+ <div class="post-detail-header">
1875
+ <h1 class="post-detail-title">${escHtml(post.title)}</h1>
1876
+ <div class="post-detail-meta">
1877
+ <div class="avatar avatar-lg" onclick="navigate('/profile/${post.owner_id}')" style="cursor:pointer">${initials(post.owner?.email || "?")}</div>
1878
+ <div>
1879
+ <div class="font-600" onclick="navigate('/profile/${post.owner_id}')" style="cursor:pointer;color:var(--accent-light)">${post.owner?.email?.split("@")[0]}</div>
1880
+ <div class="text-sm text-secondary">${formatDate(post.created_at)}</div>
1881
+ </div>
1882
+ <span class="badge ${post.published ? "badge-published" : "badge-draft"}">${post.published ? "Published" : "Draft"}</span>
1883
+ <div style="margin-left:auto;display:flex;gap:8px">
1884
+ <button class="vote-btn ${post.votes > 0 ? "" : ""}" id="detail-vote-btn" onclick="handleDetailVote(${post.id})" data-post-id="${post.id}">
1885
+ ${Icon.arrow_up} <span id="detail-vote-count">${post.votes || 0}</span>
1886
+ </button>
1887
+ ${
1888
+ isOwner
1889
+ ? `
1890
+ <button class="btn btn-ghost btn-sm" onclick="navigate('/posts/${post.id}/edit')">${Icon.edit} Edit</button>
1891
+ <button class="btn btn-danger btn-sm" onclick="confirmDelete(${post.id})">${Icon.trash} Delete</button>
1892
+ `
1893
+ : ""
1894
+ }
1895
+ </div>
1896
+ </div>
1897
+ </div>
1898
+ <div class="post-detail-content">${escHtml(post.content)}</div>
1899
+ `;
1900
+ } catch (e) {
1901
+ 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>`;
1902
+ }
1903
+ }
1904
+
1905
+ async function handleDetailVote(postId) {
1906
+ const btn = document.getElementById("detail-vote-btn");
1907
+ const span = document.getElementById("detail-vote-count");
1908
+ if (!btn || !span) return;
1909
+ const voted = btn.classList.contains("voted");
1910
+ btn.disabled = true;
1911
+ try {
1912
+ await api("POST", "/vote/", { post_id: postId, dir: voted ? 0 : 1 });
1913
+ btn.classList.toggle("voted", !voted);
1914
+ span.textContent = parseInt(span.textContent) + (voted ? -1 : 1);
1915
+ toast(
1916
+ voted ? "Vote removed" : "Upvoted!",
1917
+ "",
1918
+ voted ? "info" : "success",
1919
+ );
1920
+ } catch (e) {
1921
+ toast("Error", e?.detail || "Could not vote", "error");
1922
+ } finally {
1923
+ btn.disabled = false;
1924
+ }
1925
+ }
1926
+
1927
+ /* ============================================================
1928
+ POST FORM PAGE (CREATE & EDIT)
1929
+ ============================================================ */
1930
+ async function renderPostFormPage(postId = null) {
1931
+ const main = document.getElementById("main");
1932
+ const isEdit = postId !== null;
1933
+ main.innerHTML = `<div class="page-enter">
1934
+ <div class="breadcrumb">
1935
+ <span class="breadcrumb-link" onclick="navigate('/')">Feed</span>
1936
+ <span class="breadcrumb-sep">${Icon.chevron_right}</span>
1937
+ <span>${isEdit ? "Edit Post" : "New Post"}</span>
1938
+ </div>
1939
+ <div class="page-header">
1940
+ <h1 class="page-title">${isEdit ? "Edit Post" : "Create a Post"}</h1>
1941
+ <p class="page-sub">${isEdit ? "Update your post" : "Share your thoughts with the community"}</p>
1942
+ </div>
1943
+ <div class="card" style="padding:var(--space-8)">
1944
+ <div id="post-form-content"></div>
1945
+ </div>
1946
+ </div>`;
1947
+
1948
+ const fc = document.getElementById("post-form-content");
1949
+
1950
+ let post = { title: "", content: "", published: true };
1951
+ if (isEdit) {
1952
+ fc.innerHTML = renderSkeletonHTML(3);
1953
+ try {
1954
+ post = await api("GET", `/posts/${postId}`);
1955
+ if (!post) return;
1956
+ if (post.owner_id !== state.user?.id) {
1957
+ toast(
1958
+ "Unauthorized",
1959
+ "You can only edit your own posts.",
1960
+ "error",
1961
+ );
1962
+ navigate("/");
1963
+ return;
1964
+ }
1965
+ } catch (e) {
1966
+ toast("Error", "Could not load post.", "error");
1967
+ navigate("/");
1968
+ return;
1969
+ }
1970
+ }
1971
+
1972
+ fc.innerHTML = `
1973
+ <form id="post-form" style="display:flex;flex-direction:column;gap:var(--space-6)">
1974
+ <div class="form-group">
1975
+ <label class="form-label">Title</label>
1976
+ <input class="form-input" id="pf-title" placeholder="Give your post a clear title…" value="${escAttr(post.title)}" maxlength="200"/>
1977
+ <div class="form-error hidden" id="pf-title-err"></div>
1978
+ </div>
1979
+ <div class="form-group">
1980
+ <label class="form-label">Content</label>
1981
+ <textarea class="form-textarea" id="pf-content" placeholder="Write your thoughts here…" style="min-height:200px">${escHtml(post.content)}</textarea>
1982
+ <div class="form-error hidden" id="pf-content-err"></div>
1983
+ </div>
1984
+ <div class="toggle-wrap">
1985
+ <div class="toggle ${post.published ? "on" : ""}" id="pf-toggle" onclick="this.classList.toggle('on')"></div>
1986
+ <span class="toggle-label">Published (visible to everyone)</span>
1987
+ </div>
1988
+ <div style="display:flex;gap:var(--space-3);justify-content:flex-end">
1989
+ <button type="button" class="btn btn-ghost" onclick="navigate('${isEdit ? "/posts/" + postId : "/"}')">Cancel</button>
1990
+ <button type="submit" class="btn btn-primary" id="pf-submit">${isEdit ? Icon.edit + " Save changes" : Icon.plus + " Publish post"}</button>
1991
+ </div>
1992
+ </form>
1993
+ `;
1994
+
1995
+ document
1996
+ .getElementById("post-form")
1997
+ .addEventListener("submit", async (e) => {
1998
+ e.preventDefault();
1999
+ const title = document.getElementById("pf-title").value.trim();
2000
+ const content = document.getElementById("pf-content").value.trim();
2001
+ const published = document
2002
+ .getElementById("pf-toggle")
2003
+ .classList.contains("on");
2004
+ let valid = true;
2005
+
2006
+ if (!title) {
2007
+ showErr("pf-title-err", "Title is required");
2008
+ valid = false;
2009
+ } else hideErr("pf-title-err");
2010
+ if (!content) {
2011
+ showErr("pf-content-err", "Content is required");
2012
+ valid = false;
2013
+ } else hideErr("pf-content-err");
2014
+ if (!valid) return;
2015
+
2016
+ const btn = document.getElementById("pf-submit");
2017
+ btn.disabled = true;
2018
+ btn.innerHTML = `<span class="spinner"></span> Saving…`;
2019
+
2020
+ try {
2021
+ if (isEdit) {
2022
+ await api("PUT", `/posts/${postId}`, {
2023
+ title,
2024
+ content,
2025
+ published,
2026
+ });
2027
+ toast("Saved!", "Your post has been updated.", "success");
2028
+ navigate(`/posts/${postId}`);
2029
+ } else {
2030
+ const p = await api("POST", "/posts/", {
2031
+ title,
2032
+ content,
2033
+ published,
2034
+ });
2035
+ toast("Published!", "Your post is now live.", "success");
2036
+ navigate(`/posts/${p.id}`);
2037
+ }
2038
+ } catch (e) {
2039
+ toast("Error", e?.detail || "Could not save post.", "error");
2040
+ btn.disabled = false;
2041
+ btn.innerHTML = isEdit
2042
+ ? Icon.edit + " Save changes"
2043
+ : Icon.plus + " Publish post";
2044
+ }
2045
+ });
2046
+ }
2047
+
2048
+ /* ============================================================
2049
+ PROFILE PAGE
2050
+ ============================================================ */
2051
+ async function renderProfilePage(userId) {
2052
+ const main = document.getElementById("main");
2053
+ main.innerHTML = `<div class="page-enter"><div id="profile-content"></div></div>`;
2054
+ const c = document.getElementById("profile-content");
2055
+ renderSkeletons(c, 4);
2056
+
2057
+ try {
2058
+ const [user, posts] = await Promise.all([
2059
+ api("GET", `/users/${userId}`, null, false),
2060
+ api("GET", `/posts/?limit=50`, null, true),
2061
+ ]);
2062
+ if (!user) return;
2063
+
2064
+ const userPosts = posts
2065
+ ? posts.filter((p) => p.owner_id === userId)
2066
+ : [];
2067
+ const totalVotes = userPosts.reduce((s, p) => s + (p.votes || 0), 0);
2068
+ const isSelf = state.user && state.user.id === userId;
2069
+
2070
+ c.innerHTML = `
2071
+ <div class="profile-card">
2072
+ <div class="avatar avatar-lg" style="width:64px;height:64px;font-size:20px">${initials(user.email)}</div>
2073
+ <div class="profile-info">
2074
+ <div class="profile-name">${user.email.split("@")[0]}</div>
2075
+ <div class="profile-email">${user.email}</div>
2076
+ <div class="profile-stats">
2077
+ <div class="stat-item"><div class="stat-num">${userPosts.length}</div><div class="stat-label">Posts</div></div>
2078
+ <div class="stat-item"><div class="stat-num">${totalVotes}</div><div class="stat-label">Upvotes</div></div>
2079
+ <div class="stat-item"><div class="stat-num">${formatDate(user.created_at, true)}</div><div class="stat-label">Joined</div></div>
2080
+ </div>
2081
+ </div>
2082
+ ${isSelf ? `<button class="btn btn-ghost btn-sm" onclick="logout()">${Icon.log_out} Sign out</button>` : ""}
2083
+ </div>
2084
+ <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>
2085
+ ${
2086
+ userPosts.length === 0
2087
+ ? `<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>`
2088
+ : `<div class="post-grid">${userPosts.map(renderPostCard).join("")}</div>`
2089
+ }
2090
+ `;
2091
+ } catch (e) {
2092
+ c.innerHTML = `<div class="empty-state"><div class="empty-icon">${Icon.alert}</div><div class="empty-title">User not found</div></div>`;
2093
+ }
2094
+ }
2095
+
2096
+ /* ============================================================
2097
+ LOGIN PAGE
2098
+ ============================================================ */
2099
+ function renderLoginPage() {
2100
+ document.getElementById("app").innerHTML = `
2101
+ <div class="auth-page">
2102
+ <div class="auth-card">
2103
+ <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>
2104
+ <h1 class="auth-title">Welcome back</h1>
2105
+ <p class="auth-sub">Sign in to your account to continue</p>
2106
+ <form class="auth-form" id="login-form">
2107
+ <div class="form-group">
2108
+ <label class="form-label">Email</label>
2109
+ <input class="form-input" id="l-email" type="email" placeholder="you@example.com" autocomplete="email"/>
2110
+ <div class="form-error hidden" id="l-email-err"></div>
2111
+ </div>
2112
+ <div class="form-group">
2113
+ <label class="form-label">Password</label>
2114
+ <div class="input-wrap">
2115
+ <input class="form-input" id="l-pass" type="password" placeholder="••••••••" autocomplete="current-password"/>
2116
+ <div class="input-suffix" onclick="togglePass('l-pass', this)">${Icon.eye}</div>
2117
+ </div>
2118
+ <div class="form-error hidden" id="l-pass-err"></div>
2119
+ </div>
2120
+ <div class="form-error hidden text-center" id="l-global-err"></div>
2121
+ <button type="submit" class="btn btn-primary btn-lg w-full" id="l-btn">Sign in</button>
2122
+ </form>
2123
+ <div style="margin-top:var(--space-6);text-align:center;font-size:var(--text-sm);color:var(--text-secondary)">
2124
+ Don't have an account? <span class="auth-link" onclick="navigate('/register')">Create one</span>
2125
+ </div>
2126
+ <div style="margin-top:var(--space-4);text-align:right">
2127
+ <button class="theme-btn" onclick="toggleThemeAuth()" title="Toggle theme" style="margin-left:auto">${state.theme === "dark" ? Icon.sun : Icon.moon}</button>
2128
+ </div>
2129
+ </div>
2130
+ </div>`;
2131
+
2132
+ document
2133
+ .getElementById("login-form")
2134
+ .addEventListener("submit", async (e) => {
2135
+ e.preventDefault();
2136
+ const email = document.getElementById("l-email").value.trim();
2137
+ const pass = document.getElementById("l-pass").value;
2138
+ let valid = true;
2139
+ if (!email) {
2140
+ showErr("l-email-err", "Email is required");
2141
+ valid = false;
2142
+ } else hideErr("l-email-err");
2143
+ if (!pass) {
2144
+ showErr("l-pass-err", "Password is required");
2145
+ valid = false;
2146
+ } else hideErr("l-pass-err");
2147
+ if (!valid) return;
2148
+
2149
+ const btn = document.getElementById("l-btn");
2150
+ btn.disabled = true;
2151
+ btn.innerHTML = `<span class="spinner"></span> Signing in…`;
2152
+ hideErr("l-global-err");
2153
+
2154
+ try {
2155
+ const fd = new FormData();
2156
+ fd.append("username", email);
2157
+ fd.append("password", pass);
2158
+ const data = await apiForm("/login", fd);
2159
+ // fetch user info
2160
+ const tempToken = data.access_token;
2161
+ state.token = tempToken;
2162
+ const res = await fetch(`${API_BASE}/users/`, {
2163
+ headers: { Authorization: `Bearer ${tempToken}` },
2164
+ });
2165
+ const users = await res.json();
2166
+ const me = users.find((u) => u.email === email);
2167
+ saveAuth(tempToken, me || { email, id: null });
2168
+ toast("Welcome back!", `Signed in as ${email}`, "success");
2169
+ navigate("/");
2170
+ } catch (e) {
2171
+ showErr("l-global-err", e?.detail || "Invalid email or password");
2172
+ btn.disabled = false;
2173
+ btn.innerHTML = "Sign in";
2174
+ }
2175
+ });
2176
+ }
2177
+
2178
+ /* ============================================================
2179
+ REGISTER PAGE
2180
+ ============================================================ */
2181
+ function renderRegisterPage() {
2182
+ document.getElementById("app").innerHTML = `
2183
+ <div class="auth-page">
2184
+ <div class="auth-card">
2185
+ <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>
2186
+ <h1 class="auth-title">Create an account</h1>
2187
+ <p class="auth-sub">Join the community and start sharing</p>
2188
+ <form class="auth-form" id="reg-form">
2189
+ <div class="form-group">
2190
+ <label class="form-label">Email</label>
2191
+ <input class="form-input" id="r-email" type="email" placeholder="you@example.com" autocomplete="email"/>
2192
+ <div class="form-error hidden" id="r-email-err"></div>
2193
+ </div>
2194
+ <div class="form-group">
2195
+ <label class="form-label">Password</label>
2196
+ <div class="input-wrap">
2197
+ <input class="form-input" id="r-pass" type="password" placeholder="At least 8 characters" autocomplete="new-password"/>
2198
+ <div class="input-suffix" onclick="togglePass('r-pass', this)">${Icon.eye}</div>
2199
+ </div>
2200
+ <div class="form-error hidden" id="r-pass-err"></div>
2201
+ </div>
2202
+ <div class="form-group">
2203
+ <label class="form-label">Confirm Password</label>
2204
+ <div class="input-wrap">
2205
+ <input class="form-input" id="r-pass2" type="password" placeholder="Repeat password" autocomplete="new-password"/>
2206
+ <div class="input-suffix" onclick="togglePass('r-pass2', this)">${Icon.eye}</div>
2207
+ </div>
2208
+ <div class="form-error hidden" id="r-pass2-err"></div>
2209
+ </div>
2210
+ <div class="form-error hidden text-center" id="r-global-err"></div>
2211
+ <button type="submit" class="btn btn-primary btn-lg w-full" id="r-btn">Create account</button>
2212
+ </form>
2213
+ <div style="margin-top:var(--space-6);text-align:center;font-size:var(--text-sm);color:var(--text-secondary)">
2214
+ Already have an account? <span class="auth-link" onclick="navigate('/login')">Sign in</span>
2215
+ </div>
2216
+ </div>
2217
+ </div>`;
2218
+
2219
+ document
2220
+ .getElementById("reg-form")
2221
+ .addEventListener("submit", async (e) => {
2222
+ e.preventDefault();
2223
+ const email = document.getElementById("r-email").value.trim();
2224
+ const pass = document.getElementById("r-pass").value;
2225
+ const pass2 = document.getElementById("r-pass2").value;
2226
+ let valid = true;
2227
+
2228
+ if (!email) {
2229
+ showErr("r-email-err", "Email is required");
2230
+ valid = false;
2231
+ } else hideErr("r-email-err");
2232
+ if (pass.length < 8) {
2233
+ showErr("r-pass-err", "Password must be at least 8 characters");
2234
+ valid = false;
2235
+ } else hideErr("r-pass-err");
2236
+ if (pass !== pass2) {
2237
+ showErr("r-pass2-err", "Passwords do not match");
2238
+ valid = false;
2239
+ } else hideErr("r-pass2-err");
2240
+ if (!valid) return;
2241
+
2242
+ const btn = document.getElementById("r-btn");
2243
+ btn.disabled = true;
2244
+ btn.innerHTML = `<span class="spinner"></span> Creating…`;
2245
+ hideErr("r-global-err");
2246
+
2247
+ try {
2248
+ await api("POST", "/users/", { email, password: pass }, false);
2249
+ toast("Account created!", "You can now sign in.", "success");
2250
+ navigate("/login");
2251
+ } catch (e) {
2252
+ showErr("r-global-err", e?.detail || "Could not create account");
2253
+ btn.disabled = false;
2254
+ btn.innerHTML = "Create account";
2255
+ }
2256
+ });
2257
+ }
2258
+
2259
+ /* ============================================================
2260
+ DELETE CONFIRM MODAL
2261
+ ============================================================ */
2262
+ function confirmDelete(postId) {
2263
+ const backdrop = document.getElementById("modal-backdrop");
2264
+ const mc = document.getElementById("modal-content");
2265
+ mc.innerHTML = `
2266
+ <div class="modal-title">${Icon.trash} Delete post</div>
2267
+ <div class="modal-sub">This action cannot be undone. The post and all its votes will be permanently deleted.</div>
2268
+ <div class="modal-actions">
2269
+ <button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
2270
+ <button class="btn btn-danger" id="confirm-del-btn" onclick="doDelete(${postId})">${Icon.trash} Delete</button>
2271
+ </div>`;
2272
+ backdrop.classList.add("open");
2273
+ }
2274
+
2275
+ function closeModal() {
2276
+ document.getElementById("modal-backdrop").classList.remove("open");
2277
+ }
2278
+
2279
+ async function doDelete(postId) {
2280
+ const btn = document.getElementById("confirm-del-btn");
2281
+ btn.disabled = true;
2282
+ btn.innerHTML = `<span class="spinner"></span> Deleting…`;
2283
+ try {
2284
+ await api("DELETE", `/posts/${postId}`);
2285
+ closeModal();
2286
+ toast("Deleted", "Post has been removed.", "info");
2287
+ navigate("/");
2288
+ } catch (e) {
2289
+ toast("Error", e?.detail || "Could not delete post.", "error");
2290
+ closeModal();
2291
+ }
2292
+ }
2293
+
2294
+ document
2295
+ .getElementById("modal-backdrop")
2296
+ .addEventListener("click", (e) => {
2297
+ if (e.target === document.getElementById("modal-backdrop"))
2298
+ closeModal();
2299
+ });
2300
+
2301
+ /* ============================================================
2302
+ 404
2303
+ ============================================================ */
2304
+ function render404() {
2305
+ document.getElementById("main").innerHTML = `
2306
+ <div class="empty-state" style="min-height:60vh">
2307
+ <div class="empty-icon" style="font-size:48px;width:80px;height:80px">404</div>
2308
+ <div class="empty-title">Page not found</div>
2309
+ <div class="empty-sub">The page you're looking for doesn't exist or has been moved.</div>
2310
+ <button class="btn btn-primary mt-4" onclick="navigate('/')">Back to feed</button>
2311
+ </div>`;
2312
+ }
2313
+
2314
+ /* ============================================================
2315
+ TOAST SYSTEM
2316
+ ============================================================ */
2317
+ function toast(title, msg = "", type = "info") {
2318
+ const iconMap = {
2319
+ success: Icon.check,
2320
+ error: Icon.alert,
2321
+ info: Icon.info,
2322
+ };
2323
+ const el = document.createElement("div");
2324
+ el.className = `toast toast-${type}`;
2325
+ el.innerHTML = `
2326
+ <div class="toast-icon">${iconMap[type] || Icon.info}</div>
2327
+ <div class="toast-body">
2328
+ <div class="toast-title">${title}</div>
2329
+ ${msg ? `<div class="toast-msg">${msg}</div>` : ""}
2330
+ </div>`;
2331
+ document.getElementById("toast-container").appendChild(el);
2332
+ requestAnimationFrame(() => el.classList.add("show"));
2333
+ setTimeout(() => {
2334
+ el.classList.replace("show", "hide");
2335
+ setTimeout(() => el.remove(), 400);
2336
+ }, 3500);
2337
+ }
2338
+
2339
+ /* ============================================================
2340
+ SKELETON LOADERS
2341
+ ============================================================ */
2342
+ function renderSkeletons(container, count, tall = false) {
2343
+ container.innerHTML = Array.from(
2344
+ { length: count },
2345
+ () => `
2346
+ <div class="card" style="padding:var(--space-6);margin-bottom:var(--space-4)">
2347
+ <div style="display:flex;gap:12px;align-items:center;margin-bottom:16px">
2348
+ <div class="skeleton skeleton-circle" style="width:32px;height:32px;flex-shrink:0"></div>
2349
+ <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>
2350
+ </div>
2351
+ <div class="skeleton skeleton-title" style="width:70%"></div>
2352
+ ${tall ? '<div class="skeleton skeleton-text" style="width:90%"></div><div class="skeleton skeleton-text" style="width:85%"></div>' : ""}
2353
+ <div class="skeleton skeleton-text" style="width:60%"></div>
2354
+ </div>`,
2355
+ ).join("");
2356
+ }
2357
+
2358
+ function renderSkeletonHTML(count) {
2359
+ return Array.from(
2360
+ { length: count },
2361
+ () => `
2362
+ <div style="margin-bottom:var(--space-5)">
2363
+ <div class="skeleton skeleton-text" style="width:80px;margin-bottom:8px"></div>
2364
+ <div class="skeleton" style="height:40px;border-radius:6px"></div>
2365
+ </div>`,
2366
+ ).join("");
2367
+ }
2368
+
2369
+ /* ============================================================
2370
+ HELPERS
2371
+ ============================================================ */
2372
+ function initials(email = "") {
2373
+ const name = email.split("@")[0];
2374
+ return name.slice(0, 2).toUpperCase();
2375
+ }
2376
+
2377
+ function formatTimeAgo(dateStr) {
2378
+ const d = new Date(dateStr);
2379
+ const now = new Date();
2380
+ const s = Math.floor((now - d) / 1000);
2381
+ if (s < 60) return "just now";
2382
+ const m = Math.floor(s / 60);
2383
+ if (m < 60) return `${m}m ago`;
2384
+ const h = Math.floor(m / 60);
2385
+ if (h < 24) return `${h}h ago`;
2386
+ const days = Math.floor(h / 24);
2387
+ if (days < 7) return `${days}d ago`;
2388
+ return formatDate(dateStr, true);
2389
+ }
2390
+
2391
+ function formatDate(dateStr, short = false) {
2392
+ const d = new Date(dateStr);
2393
+ if (short)
2394
+ return d.toLocaleDateString("en-US", {
2395
+ month: "short",
2396
+ year: "numeric",
2397
+ });
2398
+ return d.toLocaleDateString("en-US", {
2399
+ year: "numeric",
2400
+ month: "long",
2401
+ day: "numeric",
2402
+ });
2403
+ }
2404
+
2405
+ function escHtml(str = "") {
2406
+ return String(str)
2407
+ .replace(/&/g, "&amp;")
2408
+ .replace(/</g, "&lt;")
2409
+ .replace(/>/g, "&gt;")
2410
+ .replace(/"/g, "&quot;");
2411
+ }
2412
+ function escAttr(str = "") {
2413
+ return escHtml(str);
2414
+ }
2415
+
2416
+ function showErr(id, msg) {
2417
+ const el = document.getElementById(id);
2418
+ if (el) {
2419
+ el.textContent = msg;
2420
+ el.classList.remove("hidden");
2421
+ }
2422
+ }
2423
+ function hideErr(id) {
2424
+ const el = document.getElementById(id);
2425
+ if (el) {
2426
+ el.textContent = "";
2427
+ el.classList.add("hidden");
2428
+ }
2429
+ }
2430
+
2431
+ function togglePass(inputId, btn) {
2432
+ const input = document.getElementById(inputId);
2433
+ if (input.type === "password") {
2434
+ input.type = "text";
2435
+ btn.innerHTML = Icon.eye_off;
2436
+ } else {
2437
+ input.type = "password";
2438
+ btn.innerHTML = Icon.eye;
2439
+ }
2440
+ }
2441
+
2442
+ function toggleThemeAuth() {
2443
+ toggleTheme();
2444
+ }
2445
+
2446
+ /* ============================================================
2447
+ BOOT
2448
+ ============================================================ */
2449
+ init();
2450
+ </script>
2451
+ </body>
2452
+ </html>