diff --git a/README.md b/README.md index 8ff1f00..a17d005 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ Add your production Vercel URL to the authorized redirect URIs in Google Cloud C - **File inputs** — attach text/code files; contents are inlined as XML-tagged blocks - **Web search** — per-request toggle; uses each provider's native search tool - **System prompt** — configurable, persisted server-side per user +- **Girl Mode** — optional pink, sparkly UI theme persisted per user - **Encrypted chat history** — saved chats are AES-GCM encrypted client-side; key is stored server-side so it's shared across devices - **Shared chats** — generate a shareable read-only URL with OG image preview; authenticated users can fork the chat to continue it - **Google OAuth** — restricted to a configurable allowlist of emails diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index f4c8aac..a21a0bb 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -16,7 +16,7 @@ export async function GET(req: Request) { } const result = await query( - 'SELECT system_prompt, save_history, key_jwk FROM user_settings WHERE email = $1', + 'SELECT system_prompt, save_history, key_jwk, girl_mode FROM user_settings WHERE email = $1', [session.user.email], ); const row = result.rows[0]; @@ -26,11 +26,13 @@ export async function GET(req: Request) { durationMs: Date.now() - start, hasKey: !!row?.key_jwk, saveHistory: row?.save_history ?? false, + girlMode: row?.girl_mode ?? false, newUser: !row, }, 'settings.get'); return jsonResponse({ systemPrompt: row?.system_prompt ?? '', saveHistory: row?.save_history ?? false, + girlMode: row?.girl_mode ?? false, keyJwk: row?.key_jwk ?? null, }, {}, { requestId, cacheControl: PRIVATE_NO_STORE }); } catch (error) { @@ -63,18 +65,20 @@ export async function PUT(req: Request) { } await query( - `INSERT INTO user_settings (email, system_prompt, save_history, key_jwk) VALUES ($1, $2, $3, $4) + `INSERT INTO user_settings (email, system_prompt, save_history, key_jwk, girl_mode) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (email) DO UPDATE SET - system_prompt = EXCLUDED.system_prompt, - save_history = EXCLUDED.save_history, - key_jwk = COALESCE(EXCLUDED.key_jwk, user_settings.key_jwk)`, - [session.user.email, parsed.value.systemPrompt, parsed.value.saveHistory, parsed.value.keyJwk], + system_prompt = EXCLUDED.system_prompt, + save_history = EXCLUDED.save_history, + key_jwk = COALESCE(EXCLUDED.key_jwk, user_settings.key_jwk), + girl_mode = EXCLUDED.girl_mode`, + [session.user.email, parsed.value.systemPrompt, parsed.value.saveHistory, parsed.value.keyJwk, parsed.value.girlMode], ); logger.info({ requestId, user: session.user.email, durationMs: Date.now() - start, saveHistory: parsed.value.saveHistory, + girlMode: parsed.value.girlMode, hasKey: !!parsed.value.keyJwk, }, 'settings.put'); return new Response(null, { diff --git a/app/error.tsx b/app/error.tsx index 9b17aab..50a8f4c 100644 --- a/app/error.tsx +++ b/app/error.tsx @@ -23,11 +23,11 @@ export default function Error({ error, reset }: { error: Error & { digest?: stri }, [error]); return ( -
- GIPPIDY +
+ GIPPIDY something went wrong - {error.digest && ref: {error.digest}} - + {error.digest && ref: {error.digest}} +
); } diff --git a/app/globals.css b/app/globals.css index f5468aa..d9f6ee7 100644 --- a/app/globals.css +++ b/app/globals.css @@ -2,11 +2,74 @@ :root { --bg: #0c0c0c; + --body-bg: var(--bg); --fg: #c8c8c8; + --assistant-fg: #e0e0e0; --green: #33ff33; --blue: #5599ff; --dim: #555; --border: #2a2a2a; + --surface: #080808; + --surface-alt: #0a0a0a; + --surface-muted: #111; + --surface-deep: #060606; + --surface-highlight: #091109; + --strong: #fff; + --em: #bbb; + --link: var(--blue); + --placeholder: #4b4b4b; + --message-user-border: rgba(85, 153, 255, 0.6); + --message-user-bg: rgba(85, 153, 255, 0.03); + --message-user-label: rgba(85, 153, 255, 0.85); + --message-assistant-border: rgba(51, 255, 51, 0.55); + --message-assistant-bg: rgba(51, 255, 51, 0.02); + --message-assistant-label: rgba(51, 255, 51, 0.85); + --divider-soft: rgba(255, 255, 255, 0.05); + --code-bg: #1a1a1a; + --code-border: #2a2a2a; + --share-border: #383838; + --panel-shadow: none; + --app-shadow: none; + --accent-glow-soft: rgba(51, 255, 51, 0.08); + --accent-glow-strong: rgba(51, 255, 51, 0.18); + --sparkle-opacity: 0; + --sparkle-aura-opacity: 0; +} + +:root[data-girl-mode='true'] { + --bg: #17081f; + --body-bg: radial-gradient(circle at top, #5c2075 0%, #2b0a3b 40%, #110415 100%); + --fg: #ffeaf6; + --assistant-fg: #fff6fc; + --green: #ff77c8; + --blue: #b69bff; + --dim: #d8a8ca; + --border: rgba(255, 193, 231, 0.32); + --surface: rgba(70, 25, 92, 0.72); + --surface-alt: rgba(88, 34, 114, 0.72); + --surface-muted: rgba(105, 40, 131, 0.76); + --surface-deep: rgba(45, 15, 58, 0.9); + --surface-highlight: rgba(255, 255, 255, 0.08); + --strong: #fffaff; + --em: #ffd7f0; + --link: #ffd1ec; + --placeholder: rgba(255, 234, 246, 0.45); + --message-user-border: rgba(182, 155, 255, 0.95); + --message-user-bg: rgba(182, 155, 255, 0.12); + --message-user-label: rgba(182, 155, 255, 0.98); + --message-assistant-border: rgba(255, 119, 200, 0.95); + --message-assistant-bg: rgba(255, 119, 200, 0.11); + --message-assistant-label: rgba(255, 119, 200, 0.98); + --divider-soft: rgba(255, 255, 255, 0.12); + --code-bg: rgba(255, 255, 255, 0.08); + --code-border: rgba(255, 255, 255, 0.12); + --share-border: rgba(255, 209, 236, 0.42); + --panel-shadow: 0 0 26px rgba(255, 119, 200, 0.12); + --app-shadow: 0 0 40px rgba(255, 119, 200, 0.14); + --accent-glow-soft: rgba(255, 119, 200, 0.18); + --accent-glow-strong: rgba(255, 119, 200, 0.28); + --sparkle-opacity: 0.9; + --sparkle-aura-opacity: 1; } html, body { @@ -16,13 +79,61 @@ html, body { } body { - background: var(--bg); + position: relative; + isolation: isolate; + background: var(--body-bg); color: var(--fg); font-family: 'Courier New', Courier, monospace; font-size: 14px; line-height: 1.6; } +body::before, +body::after { + content: ''; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 0; +} + +body::before { + opacity: var(--sparkle-opacity); + background: + radial-gradient(circle at 10% 18%, rgba(255, 255, 255, 0.95) 0 1px, transparent 1.8px), + radial-gradient(circle at 24% 72%, rgba(255, 255, 255, 0.9) 0 1px, transparent 1.9px), + radial-gradient(circle at 38% 32%, rgba(255, 210, 244, 0.85) 0 1.2px, transparent 2.1px), + radial-gradient(circle at 52% 12%, rgba(255, 255, 255, 0.92) 0 1px, transparent 1.8px), + radial-gradient(circle at 68% 58%, rgba(255, 255, 255, 0.9) 0 1px, transparent 1.9px), + radial-gradient(circle at 82% 24%, rgba(255, 215, 241, 0.88) 0 1.1px, transparent 2px), + radial-gradient(circle at 90% 80%, rgba(255, 255, 255, 0.95) 0 1px, transparent 1.9px), + radial-gradient(circle at 16% 90%, rgba(255, 220, 244, 0.85) 0 1.1px, transparent 2px); + mix-blend-mode: screen; + animation: sparkle-twinkle 8s ease-in-out infinite alternate; +} + +body::after { + opacity: var(--sparkle-aura-opacity); + background: + radial-gradient(circle at 20% 14%, rgba(255, 119, 200, 0.22), transparent 30%), + radial-gradient(circle at 82% 12%, rgba(182, 155, 255, 0.22), transparent 26%), + radial-gradient(circle at 28% 78%, rgba(255, 202, 232, 0.18), transparent 24%), + radial-gradient(circle at 74% 72%, rgba(255, 119, 200, 0.14), transparent 28%); + animation: sparkle-drift 18s ease-in-out infinite; +} + +@keyframes sparkle-twinkle { + 0% { transform: translateY(0); filter: brightness(0.95); } + 50% { filter: brightness(1.2); } + 100% { transform: translateY(-8px); filter: brightness(1.05); } +} + +@keyframes sparkle-drift { + 0% { transform: translate3d(0, 0, 0); } + 50% { transform: translate3d(0, -12px, 0); } + 100% { transform: translate3d(0, 0, 0); } +} + .app { display: flex; flex-direction: column; @@ -32,6 +143,9 @@ body { margin: 0 auto; border-left: 1px solid var(--border); border-right: 1px solid var(--border); + position: relative; + z-index: 1; + box-shadow: var(--app-shadow); } /* ── header ── */ @@ -41,7 +155,7 @@ header { gap: 10px; padding: 8px 16px; border-bottom: 1px solid var(--border); - background: #080808; + background: var(--surface); flex-shrink: 0; } @@ -52,6 +166,7 @@ header { letter-spacing: 3px; margin-right: 4px; text-decoration: none; + text-shadow: 0 0 12px var(--accent-glow-soft); } header select, @@ -78,7 +193,7 @@ header button:focus { } header select option { - background: #111; + background: var(--surface-muted); } .header-spacer { flex: 1; } @@ -143,13 +258,14 @@ header select option { /* ── settings panel ── */ .settings { - background: #080808; + background: var(--surface); border-bottom: 1px solid var(--border); padding: 12px 16px; display: flex; flex-direction: column; gap: 8px; flex-shrink: 0; + box-shadow: var(--panel-shadow); } .settings-row { @@ -178,6 +294,23 @@ header select option { padding: 4px 8px; } +.settings-row button { + background: transparent; + color: var(--fg); + border: 1px solid var(--border); + font-family: inherit; + font-size: 12px; + padding: 4px 8px; + cursor: pointer; +} + +.settings-row button:hover { + border-color: var(--dim); + color: var(--green); +} + +.settings-row select:focus, +.settings-row button:focus, .settings-row input:focus, .settings-row textarea:focus { outline: none; @@ -189,6 +322,26 @@ header select option { min-height: 60px; } +.settings-row label.settings-toggle { + min-width: 0; + padding-top: 0; + color: var(--fg); + flex: 1; + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; +} + +.settings-toggle input { + accent-color: var(--green); +} + +.settings-toggle-copy { + font-size: 12px; + color: var(--dim); +} + /* ── history list (rendered inside .messages) ── */ .history-search { padding: 8px 4px; @@ -221,7 +374,7 @@ header select option { font-size: 13px; } -.history-item:hover { background: #111; } +.history-item:hover { background: var(--surface-muted); } .history-date { color: var(--dim); @@ -270,7 +423,7 @@ header select option { bottom: 12px; left: 50%; transform: translateX(-50%); - background: #111; + background: var(--surface-muted); color: var(--green); border: 1px solid var(--green); font-family: inherit; @@ -301,18 +454,19 @@ header select option { .message-shell { width: 100%; border: 1px solid var(--border); - background: #0a0a0a; + background: var(--surface-alt); padding: 10px 12px 12px; + box-shadow: var(--panel-shadow); } .message.user .message-shell { - border-left: 2px solid rgba(85, 153, 255, 0.6); - background: rgba(85, 153, 255, 0.03); + border-left: 2px solid var(--message-user-border); + background: var(--message-user-bg); } .message.assistant .message-shell { - border-left: 2px solid rgba(51, 255, 51, 0.55); - background: rgba(51, 255, 51, 0.02); + border-left: 2px solid var(--message-assistant-border); + background: var(--message-assistant-bg); } .message-head { @@ -323,7 +477,7 @@ header select option { gap: 12px; margin-bottom: 10px; padding-bottom: 6px; - border-bottom: 1px solid rgba(255, 255, 255, 0.05); + border-bottom: 1px solid var(--divider-soft); } .message-label { @@ -332,8 +486,8 @@ header select option { letter-spacing: 0.08em; } -.message.user .message-label { color: rgba(85, 153, 255, 0.85); } -.message.assistant .message-label { color: rgba(51, 255, 51, 0.85); } +.message.user .message-label { color: var(--message-user-label); } +.message.assistant .message-label { color: var(--message-assistant-label); } .message-body { display: flex; @@ -452,7 +606,7 @@ header select option { } .message.user .content { color: var(--fg); } -.message.assistant .content { color: #e0e0e0; } +.message.assistant .content { color: var(--assistant-fg); } .cursor { animation: blink 1s step-end infinite; @@ -486,10 +640,10 @@ header select option { flex-direction: column; padding: 12px 16px 14px; border-top: 1px solid var(--green); - background: #060606; + background: var(--surface-deep); flex-shrink: 0; gap: 8px; - box-shadow: inset 0 1px 0 rgba(51, 255, 51, 0.08); + box-shadow: inset 0 1px 0 var(--accent-glow-soft); } .pending-attachments { @@ -503,7 +657,7 @@ header select option { display: inline-flex; align-items: center; gap: 4px; - background: #111; + background: var(--surface-muted); border: 1px solid var(--border); padding: 2px 8px; font-size: 12px; @@ -546,7 +700,7 @@ header select option { position: absolute; top: 0; right: 0; - background: #000; + background: var(--surface-deep); color: var(--fg); border: none; font-size: 14px; @@ -560,13 +714,14 @@ header select option { align-items: flex-start; gap: 8px; border: 1px solid var(--border); - background: #0a0a0a; + background: var(--surface-alt); padding: 10px 12px; + box-shadow: var(--panel-shadow); } .input-row:focus-within { border-color: var(--green); - box-shadow: inset 0 0 0 1px rgba(51, 255, 51, 0.1); + box-shadow: inset 0 0 0 1px var(--accent-glow-soft), var(--panel-shadow); } .input-prompt { @@ -574,7 +729,7 @@ header select option { font-weight: bold; padding-top: 5px; flex-shrink: 0; - text-shadow: 0 0 8px rgba(51, 255, 51, 0.18); + text-shadow: 0 0 8px var(--accent-glow-strong); } .input-row textarea { @@ -593,11 +748,11 @@ header select option { } .input-row textarea:focus { outline: none; } -.input-row textarea::placeholder { color: #4b4b4b; } +.input-row textarea::placeholder { color: var(--placeholder); } .input-row textarea:disabled { opacity: 0.4; } .input-row button { - background: #091109; + background: var(--surface-highlight); color: var(--green); border: 1px solid var(--green); font-family: inherit; @@ -624,7 +779,7 @@ header select option { } .input-row > button:last-child:not(:disabled) { - box-shadow: 0 0 10px rgba(51, 255, 51, 0.18); + box-shadow: 0 0 10px var(--accent-glow-strong); } /* On desktop: tools flow inline with the rest of the input row */ @@ -707,21 +862,21 @@ header select option { } .content a { - color: var(--blue); + color: var(--link); text-decoration: none; } .content a:hover { text-decoration: underline; } -.content strong { color: #fff; font-weight: bold; } -.content em { font-style: italic; color: #bbb; } +.content strong { color: var(--strong); font-weight: bold; } +.content em { font-style: italic; color: var(--em); } .content code { - background: #1a1a1a; - color: #e0e0e0; + background: var(--code-bg); + color: var(--assistant-fg); padding: 1px 5px; font-family: 'Courier New', monospace; font-size: 0.9em; - border: 1px solid #2a2a2a; + border: 1px solid var(--code-border); } .code-block { @@ -733,7 +888,7 @@ header select option { position: absolute; top: 4px; right: 4px; - background: #111; + background: var(--surface-muted); color: var(--dim); border: 1px solid var(--border); font-family: inherit; @@ -750,7 +905,7 @@ header select option { .content pre { margin: 0; - border: 1px solid #2a2a2a; + border: 1px solid var(--code-border); border-radius: 0; overflow-x: auto; max-width: 100%; @@ -775,11 +930,11 @@ header select option { padding: 4px 10px; text-align: left; } -.content th { color: var(--green); background: #111; } +.content th { color: var(--green); background: var(--surface-muted); } /* ── share view ── */ .share-view { - border-color: #383838; + border-color: var(--share-border); } .share-banner { @@ -789,7 +944,7 @@ header select option { letter-spacing: 2px; color: var(--dim); border-bottom: 1px solid var(--border); - background: #080808; + background: var(--surface); flex-shrink: 0; } @@ -800,12 +955,28 @@ header select option { justify-content: center; align-items: center; height: 100vh; + position: relative; + z-index: 1; + padding: 24px; } .login-box { border: 1px solid var(--border); + background: var(--surface); padding: 40px 48px; text-align: center; + box-shadow: var(--panel-shadow); +} + +.login-logo { + font-size: 20px; + margin-bottom: 24px; +} + +.login-note { + color: var(--dim); + font-size: 12px; + text-align: center; } .login-box button { @@ -822,3 +993,47 @@ header select option { background: var(--green); color: var(--bg); } + +/* ── status pages ── */ + +.status-page { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100vh; + gap: 16px; + padding: 24px; + color: var(--dim); + text-align: center; + position: relative; + z-index: 1; +} + +.status-logo { + color: var(--green); + font-weight: bold; + letter-spacing: 3px; + text-shadow: 0 0 12px var(--accent-glow-soft); +} + +.status-meta { + font-size: 12px; +} + +.status-action { + display: inline-block; + background: transparent; + color: var(--fg); + border: 1px solid var(--border); + font-family: inherit; + font-size: 13px; + padding: 4px 12px; + cursor: pointer; + text-decoration: none; +} + +.status-action:hover { + background: var(--green); + color: var(--bg); +} diff --git a/app/layout.tsx b/app/layout.tsx index 6e83526..740c804 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,6 +2,15 @@ import type { Metadata, Viewport } from 'next'; import './globals.css'; import 'highlight.js/styles/atom-one-dark.min.css'; +const GIRL_MODE_STORAGE_KEY = 'gippidy-girl-mode'; +const GIRL_MODE_BOOTSTRAP = ` +try { + if (localStorage.getItem('${GIRL_MODE_STORAGE_KEY}') === '1') { + document.documentElement.setAttribute('data-girl-mode', 'true'); + } +} catch {} +`; + export const metadata: Metadata = { metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL ?? 'https://www.gippidy.chat'), title: 'GIPPIDY', @@ -16,8 +25,11 @@ export const viewport: Viewport = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - - {children} + + +