From 4378a6fb21aaad98468d7347ae6abff96962790b Mon Sep 17 00:00:00 2001 From: qdust41 Date: Mon, 30 Mar 2026 02:37:57 -0400 Subject: [PATCH] Simple landing page and moved css to the app.css --- assets/css/app.css | 527 ++++++++++++++-- assets/js/index.tsx | 571 ++++++++++++------ assets/package-lock.json | 27 + assets/package.json | 1 + .../controllers/page_html/home.html.heex | 207 +------ lib/mixer_web/router.ex | 80 ++- 6 files changed, 965 insertions(+), 448 deletions(-) diff --git a/assets/css/app.css b/assets/css/app.css index 441beaa..61f7218 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -1,3 +1,5 @@ +@import url('https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=DM+Mono:wght@400;500&family=Geist:wght@300;400;500;600&display=swap'); + /* See the Tailwind configuration guide for advanced usage https://tailwindcss.com/docs/configuration */ @@ -24,21 +26,21 @@ on Elixir colors. Build your own at: https://daisyui.com/theme-generator/ */ @plugin "../vendor/daisyui-theme" { name: "dark"; - default: false; + default: true; prefersdark: true; color-scheme: "dark"; - --color-base-100: oklch(30.33% 0.016 252.42); - --color-base-200: oklch(25.26% 0.014 253.1); - --color-base-300: oklch(20.15% 0.012 254.09); - --color-base-content: oklch(97.807% 0.029 256.847); - --color-primary: oklch(58% 0.233 277.117); - --color-primary-content: oklch(96% 0.018 272.314); - --color-secondary: oklch(58% 0.233 277.117); - --color-secondary-content: oklch(96% 0.018 272.314); - --color-accent: oklch(60% 0.25 292.717); - --color-accent-content: oklch(96% 0.016 293.756); - --color-neutral: oklch(37% 0.044 257.287); - --color-neutral-content: oklch(98% 0.003 247.858); + --color-base-100: oklch(5% 0.005 270); + --color-base-200: oklch(8% 0.005 270); + --color-base-300: oklch(13% 0.008 270); + --color-base-content: oklch(93% 0.006 270); + --color-primary: oklch(58% 0.21 278); + --color-primary-content: oklch(98% 0.01 278); + --color-secondary: oklch(52% 0.18 278); + --color-secondary-content: oklch(98% 0.01 278); + --color-accent: oklch(68% 0.17 278); + --color-accent-content: oklch(98% 0.01 278); + --color-neutral: oklch(20% 0.012 270); + --color-neutral-content: oklch(93% 0.006 270); --color-info: oklch(58% 0.158 241.966); --color-info-content: oklch(97% 0.013 236.62); --color-success: oklch(60% 0.118 184.704); @@ -47,33 +49,33 @@ --color-warning-content: oklch(98% 0.022 95.277); --color-error: oklch(58% 0.253 17.585); --color-error-content: oklch(96% 0.015 12.422); - --radius-selector: 0.25rem; - --radius-field: 0.25rem; - --radius-box: 0.5rem; + --radius-selector: 0.5rem; + --radius-field: 0.5rem; + --radius-box: 0.75rem; --size-selector: 0.21875rem; --size-field: 0.21875rem; - --border: 1.5px; - --depth: 1; + --border: 1px; + --depth: 0; --noise: 0; } @plugin "../vendor/daisyui-theme" { name: "light"; - default: true; + default: false; prefersdark: false; color-scheme: "light"; - --color-base-100: oklch(98% 0 0); - --color-base-200: oklch(96% 0.001 286.375); - --color-base-300: oklch(92% 0.004 286.32); - --color-base-content: oklch(21% 0.006 285.885); - --color-primary: oklch(70% 0.213 47.604); - --color-primary-content: oklch(98% 0.016 73.684); - --color-secondary: oklch(55% 0.027 264.364); - --color-secondary-content: oklch(98% 0.002 247.839); - --color-accent: oklch(0% 0 0); - --color-accent-content: oklch(100% 0 0); - --color-neutral: oklch(44% 0.017 285.786); - --color-neutral-content: oklch(98% 0 0); + --color-base-100: oklch(97% 0.003 270); + --color-base-200: oklch(93% 0.005 270); + --color-base-300: oklch(88% 0.007 270); + --color-base-content: oklch(12% 0.008 270); + --color-primary: oklch(58% 0.21 278); + --color-primary-content: oklch(98% 0.01 278); + --color-secondary: oklch(52% 0.18 278); + --color-secondary-content: oklch(98% 0.01 278); + --color-accent: oklch(68% 0.17 278); + --color-accent-content: oklch(98% 0.01 278); + --color-neutral: oklch(88% 0.007 270); + --color-neutral-content: oklch(12% 0.008 270); --color-info: oklch(62% 0.214 259.815); --color-info-content: oklch(97% 0.014 254.604); --color-success: oklch(70% 0.14 182.503); @@ -82,13 +84,13 @@ --color-warning-content: oklch(98% 0.022 95.277); --color-error: oklch(58% 0.253 17.585); --color-error-content: oklch(96% 0.015 12.422); - --radius-selector: 0.25rem; - --radius-field: 0.25rem; - --radius-box: 0.5rem; + --radius-selector: 0.5rem; + --radius-field: 0.5rem; + --radius-box: 0.75rem; --size-selector: 0.21875rem; --size-field: 0.21875rem; - --border: 1.5px; - --depth: 1; + --border: 1px; + --depth: 0; --noise: 0; } @@ -103,4 +105,455 @@ /* Make LiveView wrapper divs transparent for layout */ [data-phx-session], [data-phx-teleported-src] { display: contents } -/* This file is for your main application CSS */ +/* ── Global base ── */ +html, body { + font-family: 'Geist', system-ui, sans-serif; +} + +/* ── Mixer design tokens — mapped to daisyUI so they track the active theme ── */ +:root { + --mx-bg: var(--color-base-100); + --mx-surface: var(--color-base-200); + --mx-surface2: var(--color-base-300); + --mx-fg: var(--color-base-content); + --mx-accent: var(--color-primary); + --mx-accent2: var(--color-accent); + --mx-red: #ef4444; + --mx-green: #22c55e; + --mx-radius: 12px; + --mx-radius-sm: 8px; +} + +[data-theme=dark] { + --mx-fg2: #9090a8; + --mx-muted: #5a5a72; + --mx-border: #1e1e26; + --mx-border2: #2a2a36; +} + +[data-theme=light] { + --mx-fg2: #6060a0; + --mx-muted: #9090b8; + --mx-border: #d8d8e8; + --mx-border2: #c0c0d8; +} + +/* ── Mixer app shell ── */ +#app { + min-height: 100vh; +} + +/* ── Layout ── */ +.mx-root { + display: grid; + grid-template-columns: 240px 1fr 280px; + min-height: 100vh; + max-width: 1200px; + margin: 0 auto; +} + +@media (max-width: 960px) { + .mx-root { grid-template-columns: 64px 1fr; } + .mx-rightbar { display: none; } +} +@media (max-width: 640px) { + .mx-root { grid-template-columns: 1fr; } + .mx-sidebar { display: none; } +} + +/* ── Sidebar ── */ +.mx-sidebar { + position: sticky; + top: 0; + height: 100vh; + padding: 1.5rem 1rem; + display: flex; + flex-direction: column; + border-right: 1px solid var(--mx-border); +} + +.mx-logo { + display: flex; + align-items: center; + gap: 0.625rem; + padding: 0.25rem 0.5rem; + margin-bottom: 2rem; +} + +.mx-logo-icon { + font-size: 1.4rem; + color: var(--mx-accent2); + line-height: 1; +} + +.mx-logo-text { + font-family: 'Instrument Serif', Georgia, serif; + font-size: 1.5rem; + font-style: italic; + color: var(--mx-fg); + letter-spacing: -0.02em; +} + +.mx-nav { display: flex; flex-direction: column; gap: 0.25rem; } + +.mx-nav-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.625rem 0.75rem; + border-radius: var(--mx-radius-sm); + text-decoration: none; + color: var(--mx-fg2); + font-size: 0.9375rem; + font-weight: 500; + transition: color 0.15s, background 0.15s; +} + +.mx-nav-item:hover { color: var(--mx-fg); background: var(--mx-surface2); } +.mx-nav-active { color: var(--mx-fg) !important; } + +.mx-sidebar-footer { + margin-top: auto; + padding: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.mx-version { + font-family: 'DM Mono', monospace; + font-size: 0.7rem; + color: var(--mx-muted); +} + +.mx-auth-link { + font-size: 0.75rem; + color: var(--mx-accent2); + text-decoration: none; + transition: color 0.15s; +} +.mx-auth-link:hover { color: var(--mx-fg); } + +/* ── Main ── */ +.mx-main { + border-right: 1px solid var(--mx-border); + min-height: 100vh; +} + +.mx-header { + position: sticky; + top: 0; + z-index: 10; + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.25rem; + background: color-mix(in oklch, var(--mx-bg) 85%, transparent); + backdrop-filter: blur(12px); + border-bottom: 1px solid var(--mx-border); +} + +.mx-header-title { + font-family: 'Instrument Serif', Georgia, serif; + font-size: 1.25rem; + font-style: italic; + font-weight: 400; + color: var(--mx-fg); + letter-spacing: -0.02em; +} + +.mx-refresh-btn { + background: none; + border: 1px solid var(--mx-border2); + color: var(--mx-fg2); + width: 32px; + height: 32px; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.15s, border-color 0.15s; +} +.mx-refresh-btn:hover { color: var(--mx-fg); border-color: var(--mx-accent); } + +.mx-divider { height: 1px; background: var(--mx-border); } + +/* ── Compose ── */ +.mx-compose-wrapper { padding: 1rem 1.25rem; border-bottom: 1px solid var(--mx-border); } + +.mx-compose { + display: flex; + gap: 0.75rem; +} + +.mx-compose-avatar, .mx-tweet-avatar { + width: 38px; + height: 38px; + border-radius: 50%; + background: linear-gradient(135deg, var(--mx-accent) 0%, var(--mx-accent2) 100%); + display: flex; + align-items: center; + justify-content: center; + font-size: 0.8rem; + font-weight: 600; + color: white; + flex-shrink: 0; + user-select: none; +} + +.mx-compose-body { flex: 1; } + +.mx-compose-textarea, .mx-edit-textarea { + width: 100%; + background: transparent; + border: none; + outline: none; + color: var(--mx-fg); + font-family: 'Geist', system-ui, sans-serif; + font-size: 1rem; + resize: none; + overflow: hidden; + line-height: 1.55; + padding: 0.375rem 0; +} + +.mx-compose-textarea::placeholder { color: var(--mx-muted); } + +.mx-compose-footer { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 0.5rem; + padding-top: 0.5rem; + border-top: 1px solid var(--mx-border); +} + +.mx-compose-hint { font-size: 0.7rem; color: var(--mx-muted); font-family: 'DM Mono', monospace; } + +.mx-compose-actions { display: flex; align-items: center; gap: 0.75rem; } + +.mx-compose-error { + font-size: 0.75rem; + color: var(--mx-red); + margin-top: 0.25rem; +} + +.mx-signin-cta { + padding: 1.25rem; + text-align: center; + border-bottom: 1px solid var(--mx-border); +} + +.mx-signin-cta p { + font-size: 0.875rem; + color: var(--mx-fg2); + margin-bottom: 0.75rem; +} + +.mx-btn-post, .mx-btn-save { + background: var(--mx-accent); + color: white; + border: none; + border-radius: 99px; + padding: 0.375rem 1rem; + font-size: 0.8125rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, opacity 0.15s; + font-family: inherit; + text-decoration: none; +} +.mx-btn-post:hover, .mx-btn-save:hover { background: var(--mx-accent2); } +.mx-btn-post:disabled, .mx-btn-save:disabled { opacity: 0.5; cursor: not-allowed; } + +.mx-btn-cancel { + background: none; + border: 1px solid var(--mx-border2); + color: var(--mx-fg2); + border-radius: 99px; + padding: 0.375rem 0.875rem; + font-size: 0.8125rem; + cursor: pointer; + transition: color 0.15s, border-color 0.15s; + font-family: inherit; +} +.mx-btn-cancel:hover { color: var(--mx-fg); border-color: var(--mx-fg2); } + +/* ── Tweet Card ── */ +.mx-tweet { + display: flex; + gap: 0.75rem; + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--mx-border); + transition: background 0.1s; + animation: mx-fade-in 0.2s ease; +} +.mx-tweet:hover { background: color-mix(in oklch, var(--mx-fg) 2%, transparent); } + +@keyframes mx-fade-in { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} + +.mx-tweet-body { flex: 1; min-width: 0; } + +.mx-tweet-header { + display: flex; + align-items: center; + gap: 0.375rem; + margin-bottom: 0.375rem; +} + +.mx-tweet-handle { + font-size: 0.875rem; + font-weight: 600; + color: var(--mx-fg); +} + +.mx-tweet-dot, .mx-tweet-time { + font-size: 0.8rem; + color: var(--mx-muted); +} + +.mx-tweet-actions { + margin-left: auto; + display: flex; + gap: 0.125rem; + opacity: 0; + transition: opacity 0.15s; +} +.mx-tweet:hover .mx-tweet-actions { opacity: 1; } + +.mx-action-btn { + background: none; + border: none; + color: var(--mx-muted); + cursor: pointer; + width: 28px; + height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.15s, background 0.15s; +} +.mx-action-btn:hover { color: var(--mx-fg); background: var(--mx-surface2); } +.mx-action-delete:hover { color: var(--mx-red); background: color-mix(in oklch, var(--mx-red) 10%, transparent); } +.mx-action-confirm { color: var(--mx-red) !important; background: color-mix(in oklch, var(--mx-red) 15%, transparent) !important; } + +.mx-tweet-text { + font-size: 0.9375rem; + line-height: 1.6; + color: var(--mx-fg); + white-space: pre-wrap; + word-break: break-word; +} + +/* ── Edit ── */ +.mx-edit-area { margin-top: 0.25rem; } + +.mx-edit-textarea { + border: 1px solid var(--mx-border2); + border-radius: var(--mx-radius-sm); + padding: 0.5rem 0.75rem; + background: var(--mx-surface2); + width: 100%; + overflow: auto; +} +.mx-edit-textarea:focus { border-color: var(--mx-accent); outline: none; } + +.mx-edit-footer { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 0.5rem; +} + +/* ── Empty state ── */ +.mx-empty { + display: flex; + flex-direction: column; + align-items: center; + padding: 4rem 2rem; + text-align: center; +} + +.mx-empty-icon { + font-size: 2.5rem; + color: var(--mx-muted); + margin-bottom: 1rem; + opacity: 0.5; +} + +.mx-empty-title { + font-family: 'Instrument Serif', serif; + font-style: italic; + font-size: 1.25rem; + color: var(--mx-fg2); + margin-bottom: 0.375rem; +} + +.mx-empty-sub { font-size: 0.875rem; color: var(--mx-muted); } + +/* ── Spinner ── */ +.mx-spinner { + width: 22px; + height: 22px; + border: 2px solid var(--mx-border2); + border-top-color: var(--mx-accent); + border-radius: 50%; + animation: mx-spin 0.7s linear infinite; +} +@keyframes mx-spin { to { transform: rotate(360deg); } } + +/* ── Error banner ── */ +.mx-error-banner { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 1rem 1.25rem; + padding: 0.75rem 1rem; + background: color-mix(in oklch, var(--mx-red) 8%, transparent); + border: 1px solid color-mix(in oklch, var(--mx-red) 25%, transparent); + border-radius: var(--mx-radius-sm); + color: color-mix(in oklch, var(--mx-red) 70%, white); + font-size: 0.875rem; +} +.mx-error-icon { font-size: 1rem; } + +/* ── Right bar ── */ +.mx-rightbar { padding: 1.25rem; } + +.mx-info-card { + background: var(--mx-surface); + border: 1px solid var(--mx-border); + border-radius: var(--mx-radius); + padding: 1rem; +} + +.mx-info-title { + font-size: 0.875rem; + font-weight: 600; + color: var(--mx-fg); + margin-bottom: 0.5rem; +} + +.mx-info-body { + font-size: 0.8125rem; + color: var(--mx-fg2); + line-height: 1.5; + margin-bottom: 0.875rem; +} + +.mx-stack { display: flex; flex-wrap: wrap; gap: 0.375rem; } + +.mx-tag { + font-family: 'DM Mono', monospace; + font-size: 0.65rem; + padding: 0.25rem 0.5rem; + border-radius: 4px; + background: var(--mx-surface2); + border: 1px solid var(--mx-border2); + color: var(--mx-accent2); +} diff --git a/assets/js/index.tsx b/assets/js/index.tsx index 6fff9b9..2b0382f 100644 --- a/assets/js/index.tsx +++ b/assets/js/index.tsx @@ -1,213 +1,446 @@ -import React, { useEffect, useState } from "react"; +import React, { createContext, useContext, useState, useRef, useEffect } from "react"; import { createRoot } from "react-dom/client"; import { - readTweet, + QueryClient, + QueryClientProvider, + useQuery, + useMutation, + useQueryClient, +} from "@tanstack/react-query"; +import { createTweet, + readTweet, destroyTweet, + updateTweet, buildCSRFHeaders, } from "./ash_rpc"; -type Tweet = { - id: string; - content: string; - userId: string; - state: "posted" | "drafted"; -}; +const queryClient = new QueryClient({ + defaultOptions: { queries: { staleTime: 10_000 } }, +}); -function TweetCompose({ onPosted }: { onPosted: (tweet: Tweet) => void }) { - const [content, setContent] = useState(""); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(null); +// ── Types ────────────────────────────────────────────────────────────────────── - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - if (!content.trim()) return; - setSubmitting(true); - setError(null); - const result = await createTweet({ - input: { content: content.trim() }, - fields: ["id", "content", "userId", "state"], - headers: buildCSRFHeaders(), - }); - setSubmitting(false); - if (result.success) { - onPosted(result.data as Tweet); - setContent(""); - } else { - setError(result.errors.map((e) => e.message).join(", ")); - } - } +type Tweet = { id: string; content: string; userId: string; state: string }; +// ── Auth context ─────────────────────────────────────────────────────────────── + +const AuthCtx = createContext({ email: "", userId: "" }); + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +function timeAgo(): string { + return "just now"; +} + +// ── Components ───────────────────────────────────────────────────────────────── + +function Spinner() { return ( -
-