Simple landing page and moved css to the app.css
This commit is contained in:
@@ -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
|
/* See the Tailwind configuration guide for advanced usage
|
||||||
https://tailwindcss.com/docs/configuration */
|
https://tailwindcss.com/docs/configuration */
|
||||||
|
|
||||||
@@ -24,21 +26,21 @@
|
|||||||
on Elixir colors. Build your own at: https://daisyui.com/theme-generator/ */
|
on Elixir colors. Build your own at: https://daisyui.com/theme-generator/ */
|
||||||
@plugin "../vendor/daisyui-theme" {
|
@plugin "../vendor/daisyui-theme" {
|
||||||
name: "dark";
|
name: "dark";
|
||||||
default: false;
|
default: true;
|
||||||
prefersdark: true;
|
prefersdark: true;
|
||||||
color-scheme: "dark";
|
color-scheme: "dark";
|
||||||
--color-base-100: oklch(30.33% 0.016 252.42);
|
--color-base-100: oklch(5% 0.005 270);
|
||||||
--color-base-200: oklch(25.26% 0.014 253.1);
|
--color-base-200: oklch(8% 0.005 270);
|
||||||
--color-base-300: oklch(20.15% 0.012 254.09);
|
--color-base-300: oklch(13% 0.008 270);
|
||||||
--color-base-content: oklch(97.807% 0.029 256.847);
|
--color-base-content: oklch(93% 0.006 270);
|
||||||
--color-primary: oklch(58% 0.233 277.117);
|
--color-primary: oklch(58% 0.21 278);
|
||||||
--color-primary-content: oklch(96% 0.018 272.314);
|
--color-primary-content: oklch(98% 0.01 278);
|
||||||
--color-secondary: oklch(58% 0.233 277.117);
|
--color-secondary: oklch(52% 0.18 278);
|
||||||
--color-secondary-content: oklch(96% 0.018 272.314);
|
--color-secondary-content: oklch(98% 0.01 278);
|
||||||
--color-accent: oklch(60% 0.25 292.717);
|
--color-accent: oklch(68% 0.17 278);
|
||||||
--color-accent-content: oklch(96% 0.016 293.756);
|
--color-accent-content: oklch(98% 0.01 278);
|
||||||
--color-neutral: oklch(37% 0.044 257.287);
|
--color-neutral: oklch(20% 0.012 270);
|
||||||
--color-neutral-content: oklch(98% 0.003 247.858);
|
--color-neutral-content: oklch(93% 0.006 270);
|
||||||
--color-info: oklch(58% 0.158 241.966);
|
--color-info: oklch(58% 0.158 241.966);
|
||||||
--color-info-content: oklch(97% 0.013 236.62);
|
--color-info-content: oklch(97% 0.013 236.62);
|
||||||
--color-success: oklch(60% 0.118 184.704);
|
--color-success: oklch(60% 0.118 184.704);
|
||||||
@@ -47,33 +49,33 @@
|
|||||||
--color-warning-content: oklch(98% 0.022 95.277);
|
--color-warning-content: oklch(98% 0.022 95.277);
|
||||||
--color-error: oklch(58% 0.253 17.585);
|
--color-error: oklch(58% 0.253 17.585);
|
||||||
--color-error-content: oklch(96% 0.015 12.422);
|
--color-error-content: oklch(96% 0.015 12.422);
|
||||||
--radius-selector: 0.25rem;
|
--radius-selector: 0.5rem;
|
||||||
--radius-field: 0.25rem;
|
--radius-field: 0.5rem;
|
||||||
--radius-box: 0.5rem;
|
--radius-box: 0.75rem;
|
||||||
--size-selector: 0.21875rem;
|
--size-selector: 0.21875rem;
|
||||||
--size-field: 0.21875rem;
|
--size-field: 0.21875rem;
|
||||||
--border: 1.5px;
|
--border: 1px;
|
||||||
--depth: 1;
|
--depth: 0;
|
||||||
--noise: 0;
|
--noise: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@plugin "../vendor/daisyui-theme" {
|
@plugin "../vendor/daisyui-theme" {
|
||||||
name: "light";
|
name: "light";
|
||||||
default: true;
|
default: false;
|
||||||
prefersdark: false;
|
prefersdark: false;
|
||||||
color-scheme: "light";
|
color-scheme: "light";
|
||||||
--color-base-100: oklch(98% 0 0);
|
--color-base-100: oklch(97% 0.003 270);
|
||||||
--color-base-200: oklch(96% 0.001 286.375);
|
--color-base-200: oklch(93% 0.005 270);
|
||||||
--color-base-300: oklch(92% 0.004 286.32);
|
--color-base-300: oklch(88% 0.007 270);
|
||||||
--color-base-content: oklch(21% 0.006 285.885);
|
--color-base-content: oklch(12% 0.008 270);
|
||||||
--color-primary: oklch(70% 0.213 47.604);
|
--color-primary: oklch(58% 0.21 278);
|
||||||
--color-primary-content: oklch(98% 0.016 73.684);
|
--color-primary-content: oklch(98% 0.01 278);
|
||||||
--color-secondary: oklch(55% 0.027 264.364);
|
--color-secondary: oklch(52% 0.18 278);
|
||||||
--color-secondary-content: oklch(98% 0.002 247.839);
|
--color-secondary-content: oklch(98% 0.01 278);
|
||||||
--color-accent: oklch(0% 0 0);
|
--color-accent: oklch(68% 0.17 278);
|
||||||
--color-accent-content: oklch(100% 0 0);
|
--color-accent-content: oklch(98% 0.01 278);
|
||||||
--color-neutral: oklch(44% 0.017 285.786);
|
--color-neutral: oklch(88% 0.007 270);
|
||||||
--color-neutral-content: oklch(98% 0 0);
|
--color-neutral-content: oklch(12% 0.008 270);
|
||||||
--color-info: oklch(62% 0.214 259.815);
|
--color-info: oklch(62% 0.214 259.815);
|
||||||
--color-info-content: oklch(97% 0.014 254.604);
|
--color-info-content: oklch(97% 0.014 254.604);
|
||||||
--color-success: oklch(70% 0.14 182.503);
|
--color-success: oklch(70% 0.14 182.503);
|
||||||
@@ -82,13 +84,13 @@
|
|||||||
--color-warning-content: oklch(98% 0.022 95.277);
|
--color-warning-content: oklch(98% 0.022 95.277);
|
||||||
--color-error: oklch(58% 0.253 17.585);
|
--color-error: oklch(58% 0.253 17.585);
|
||||||
--color-error-content: oklch(96% 0.015 12.422);
|
--color-error-content: oklch(96% 0.015 12.422);
|
||||||
--radius-selector: 0.25rem;
|
--radius-selector: 0.5rem;
|
||||||
--radius-field: 0.25rem;
|
--radius-field: 0.5rem;
|
||||||
--radius-box: 0.5rem;
|
--radius-box: 0.75rem;
|
||||||
--size-selector: 0.21875rem;
|
--size-selector: 0.21875rem;
|
||||||
--size-field: 0.21875rem;
|
--size-field: 0.21875rem;
|
||||||
--border: 1.5px;
|
--border: 1px;
|
||||||
--depth: 1;
|
--depth: 0;
|
||||||
--noise: 0;
|
--noise: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,4 +105,455 @@
|
|||||||
/* Make LiveView wrapper divs transparent for layout */
|
/* Make LiveView wrapper divs transparent for layout */
|
||||||
[data-phx-session], [data-phx-teleported-src] { display: contents }
|
[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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 { createRoot } from "react-dom/client";
|
||||||
import {
|
import {
|
||||||
readTweet,
|
QueryClient,
|
||||||
|
QueryClientProvider,
|
||||||
|
useQuery,
|
||||||
|
useMutation,
|
||||||
|
useQueryClient,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
createTweet,
|
createTweet,
|
||||||
|
readTweet,
|
||||||
destroyTweet,
|
destroyTweet,
|
||||||
|
updateTweet,
|
||||||
buildCSRFHeaders,
|
buildCSRFHeaders,
|
||||||
} from "./ash_rpc";
|
} from "./ash_rpc";
|
||||||
|
|
||||||
type Tweet = {
|
const queryClient = new QueryClient({
|
||||||
id: string;
|
defaultOptions: { queries: { staleTime: 10_000 } },
|
||||||
content: string;
|
});
|
||||||
userId: string;
|
|
||||||
state: "posted" | "drafted";
|
|
||||||
};
|
|
||||||
|
|
||||||
function TweetCompose({ onPosted }: { onPosted: (tweet: Tweet) => void }) {
|
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||||
const [content, setContent] = useState("");
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
type Tweet = { id: string; content: string; userId: string; state: string };
|
||||||
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(", "));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// ── Auth context ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const AuthCtx = createContext({ email: "", userId: "" });
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function timeAgo(): string {
|
||||||
|
return "just now";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Components ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Spinner() {
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="card bg-base-200 p-4 mb-6">
|
<div style={{ display: "flex", justifyContent: "center", padding: "2rem" }}>
|
||||||
<textarea
|
<div className="mx-spinner" />
|
||||||
className="textarea textarea-bordered w-full mb-3 resize-none"
|
</div>
|
||||||
rows={3}
|
|
||||||
placeholder="What's happening?"
|
|
||||||
value={content}
|
|
||||||
maxLength={280}
|
|
||||||
onChange={(e) => setContent(e.target.value)}
|
|
||||||
/>
|
|
||||||
{error && <p className="text-error text-sm mb-2">{error}</p>}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm opacity-50">{content.length}/280</span>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="btn btn-primary btn-sm"
|
|
||||||
disabled={submitting || !content.trim()}
|
|
||||||
>
|
|
||||||
{submitting ? "Posting..." : "Post"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TweetCard({
|
function ErrorBanner({ message }: { message: string }) {
|
||||||
tweet,
|
return (
|
||||||
currentUserEmail,
|
<div className="mx-error-banner">
|
||||||
onDeleted,
|
<span className="mx-error-icon">⚠</span>
|
||||||
}: {
|
{message}
|
||||||
tweet: Tweet;
|
</div>
|
||||||
currentUserEmail: string;
|
);
|
||||||
onDeleted: (id: string) => void;
|
}
|
||||||
}) {
|
|
||||||
const [deleting, setDeleting] = useState(false);
|
|
||||||
|
|
||||||
async function handleDelete() {
|
function CharCount({ current, max }: { current: number; max: number }) {
|
||||||
setDeleting(true);
|
const remaining = max - current;
|
||||||
const result = await destroyTweet({
|
const pct = current / max;
|
||||||
identity: tweet.id,
|
const color =
|
||||||
headers: buildCSRFHeaders(),
|
pct > 0.9 ? "#ef4444" : pct > 0.75 ? "#f59e0b" : "var(--mx-muted)";
|
||||||
});
|
return (
|
||||||
if (result.success) {
|
<span style={{ color, fontSize: "0.75rem", fontVariantNumeric: "tabular-nums" }}>
|
||||||
onDeleted(tweet.id);
|
{remaining}
|
||||||
} else {
|
</span>
|
||||||
setDeleting(false);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComposeTweet({ onSuccess }: { onSuccess?: () => void }) {
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const MAX = 280;
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async (content: string) => {
|
||||||
|
const res = await createTweet({
|
||||||
|
input: { content },
|
||||||
|
fields: ["id", "content", "userId", "state"],
|
||||||
|
headers: buildCSRFHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed");
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["tweets"] });
|
||||||
|
setText("");
|
||||||
|
setError(null);
|
||||||
|
onSuccess?.();
|
||||||
|
},
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
submit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
if (trimmed.length > MAX) {
|
||||||
|
setError(`Max ${MAX} characters`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
mutation.mutate(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-resize textarea
|
||||||
|
useEffect(() => {
|
||||||
|
const el = textareaRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.style.height = "auto";
|
||||||
|
el.style.height = `${el.scrollHeight}px`;
|
||||||
|
}, [text]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card bg-base-200 mb-3 p-4">
|
<div className="mx-compose">
|
||||||
<p className="text-base-content whitespace-pre-wrap break-words">
|
<div className="mx-compose-avatar">
|
||||||
{tweet.content}
|
<span>M</span>
|
||||||
</p>
|
</div>
|
||||||
<div className="flex items-center justify-between mt-3">
|
<div className="mx-compose-body">
|
||||||
<span className="text-xs opacity-40 font-mono">
|
<textarea
|
||||||
{tweet.userId.slice(0, 8)}…
|
ref={textareaRef}
|
||||||
</span>
|
className="mx-compose-textarea"
|
||||||
{currentUserEmail && (
|
placeholder="What's mixing?"
|
||||||
<button
|
value={text}
|
||||||
className="btn btn-ghost btn-xs text-error"
|
onChange={(e) => setText(e.target.value)}
|
||||||
onClick={handleDelete}
|
onKeyDown={handleKeyDown}
|
||||||
disabled={deleting}
|
rows={2}
|
||||||
>
|
maxLength={MAX + 1}
|
||||||
{deleting ? "…" : "Delete"}
|
/>
|
||||||
</button>
|
{error && <p className="mx-compose-error">{error}</p>}
|
||||||
)}
|
<div className="mx-compose-footer">
|
||||||
|
<span className="mx-compose-hint">⌘↵ to post</span>
|
||||||
|
<div className="mx-compose-actions">
|
||||||
|
<CharCount current={text.length} max={MAX} />
|
||||||
|
<button
|
||||||
|
className="mx-btn-post"
|
||||||
|
onClick={submit}
|
||||||
|
disabled={!text.trim() || mutation.isPending}
|
||||||
|
>
|
||||||
|
{mutation.isPending ? "Posting…" : "Post"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TweetFeed({
|
function TweetCard({ tweet }: { tweet: Tweet }) {
|
||||||
tweets,
|
const { userId: currentUserId } = useContext(AuthCtx);
|
||||||
currentUserEmail,
|
const canModify = !!currentUserId && tweet.userId === currentUserId;
|
||||||
onDeleted,
|
|
||||||
}: {
|
const [editing, setEditing] = useState(false);
|
||||||
tweets: Tweet[];
|
const [editText, setEditText] = useState(tweet.content);
|
||||||
currentUserEmail: string;
|
const [error, setError] = useState<string | null>(null);
|
||||||
onDeleted: (id: string) => void;
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
}) {
|
const qc = useQueryClient();
|
||||||
if (tweets.length === 0) {
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const res = await destroyTweet({
|
||||||
|
identity: tweet.id,
|
||||||
|
headers: buildCSRFHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed to delete");
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["tweets"] }),
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: async (content: string) => {
|
||||||
|
const res = await updateTweet({
|
||||||
|
identity: tweet.id,
|
||||||
|
input: { content },
|
||||||
|
fields: ["id", "content", "userId", "state"],
|
||||||
|
headers: buildCSRFHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed to update");
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["tweets"] });
|
||||||
|
setEditing(false);
|
||||||
|
setError(null);
|
||||||
|
},
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
function saveEdit() {
|
||||||
|
const trimmed = editText.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
updateMutation.mutate(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="mx-tweet">
|
||||||
|
<div className="mx-tweet-avatar">
|
||||||
|
<span>M</span>
|
||||||
|
</div>
|
||||||
|
<div className="mx-tweet-body">
|
||||||
|
<div className="mx-tweet-header">
|
||||||
|
<span className="mx-tweet-handle">@mixer</span>
|
||||||
|
<span className="mx-tweet-dot">·</span>
|
||||||
|
<span className="mx-tweet-time">{timeAgo()}</span>
|
||||||
|
{canModify && (
|
||||||
|
<div className="mx-tweet-actions">
|
||||||
|
<button
|
||||||
|
className="mx-action-btn"
|
||||||
|
title="Edit"
|
||||||
|
onClick={() => {
|
||||||
|
setEditText(tweet.content);
|
||||||
|
setEditing(true);
|
||||||
|
setConfirmDelete(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zm17.71-10.04a1 1 0 0 0 0-1.41l-2.31-2.31a1 1 0 0 0-1.41 0l-1.79 1.79 3.75 3.75 1.76-1.82z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`mx-action-btn mx-action-delete${confirmDelete ? " mx-action-confirm" : ""}`}
|
||||||
|
title={confirmDelete ? "Confirm delete" : "Delete"}
|
||||||
|
onClick={() => {
|
||||||
|
if (!confirmDelete) {
|
||||||
|
setConfirmDelete(true);
|
||||||
|
setTimeout(() => setConfirmDelete(false), 3000);
|
||||||
|
} else {
|
||||||
|
deleteMutation.mutate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending ? (
|
||||||
|
<span style={{ fontSize: "0.65rem" }}>…</span>
|
||||||
|
) : confirmDelete ? (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing ? (
|
||||||
|
<div className="mx-edit-area">
|
||||||
|
<textarea
|
||||||
|
className="mx-edit-textarea"
|
||||||
|
value={editText}
|
||||||
|
onChange={(e) => setEditText(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
{error && <p className="mx-compose-error">{error}</p>}
|
||||||
|
<div className="mx-edit-footer">
|
||||||
|
<button
|
||||||
|
className="mx-btn-cancel"
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(false);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="mx-btn-save"
|
||||||
|
onClick={saveEdit}
|
||||||
|
disabled={!editText.trim() || updateMutation.isPending}
|
||||||
|
>
|
||||||
|
{updateMutation.isPending ? "Saving…" : "Save"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mx-tweet-text">{tweet.content}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && !editing && <p className="mx-compose-error">{error}</p>}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Feed() {
|
||||||
|
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||||
|
queryKey: ["tweets"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await readTweet({
|
||||||
|
fields: ["id", "content", "userId", "state"],
|
||||||
|
sort: "-id",
|
||||||
|
headers: buildCSRFHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.success) throw new Error("Failed to load tweets");
|
||||||
|
const tweets = Array.isArray(res.data) ? res.data : (res.data as any)?.results ?? [];
|
||||||
|
return tweets as Tweet[];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <Spinner />;
|
||||||
|
if (isError) {
|
||||||
return (
|
return (
|
||||||
<p className="text-center opacity-40 py-12">
|
<ErrorBanner message={(error as Error)?.message ?? "Could not load tweets"} />
|
||||||
No tweets yet. Be the first!
|
|
||||||
</p>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tweets = data ?? [];
|
||||||
|
|
||||||
|
if (tweets.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="mx-empty">
|
||||||
|
<div className="mx-empty-icon">◎</div>
|
||||||
|
<p className="mx-empty-title">Nothing posted yet</p>
|
||||||
|
<p className="mx-empty-sub">Be the first to mix something in.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="mx-feed">
|
||||||
{tweets.map((tweet) => (
|
{tweets.map((t) => (
|
||||||
<TweetCard
|
<TweetCard key={t.id} tweet={t} />
|
||||||
key={tweet.id}
|
|
||||||
tweet={tweet}
|
|
||||||
currentUserEmail={currentUserEmail}
|
|
||||||
onDeleted={onDeleted}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RefreshButton() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [spinning, setSpinning] = useState(false);
|
||||||
|
async function refresh() {
|
||||||
|
setSpinning(true);
|
||||||
|
await qc.invalidateQueries({ queryKey: ["tweets"] });
|
||||||
|
setTimeout(() => setSpinning(false), 600);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button className="mx-refresh-btn" onClick={refresh} title="Refresh feed">
|
||||||
|
<svg
|
||||||
|
width="15"
|
||||||
|
height="15"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
style={{
|
||||||
|
transition: "transform 0.6s ease",
|
||||||
|
transform: spinning ? "rotate(360deg)" : "rotate(0deg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<path d="M17.65 6.35A7.958 7.958 0 0 0 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const appEl = document.getElementById("app")!;
|
const appEl = document.getElementById("app")!;
|
||||||
const currentUserEmail = appEl.dataset.currentUserEmail ?? "";
|
const email = appEl.dataset.currentUserEmail ?? "";
|
||||||
|
const userId = appEl.dataset.currentUserId ?? "";
|
||||||
const [tweets, setTweets] = useState<Tweet[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
readTweet({ fields: ["id", "content", "userId", "state"] }).then(
|
|
||||||
(result) => {
|
|
||||||
if (result.success) {
|
|
||||||
const data = result.data;
|
|
||||||
const list: Tweet[] = Array.isArray(data)
|
|
||||||
? (data as Tweet[]).slice().reverse()
|
|
||||||
: (data as any).results
|
|
||||||
? ((data as any).results as Tweet[]).slice().reverse()
|
|
||||||
: [];
|
|
||||||
setTweets(list);
|
|
||||||
}
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
function handlePosted(tweet: Tweet) {
|
|
||||||
setTweets((prev) => [tweet, ...prev]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDeleted(id: string) {
|
|
||||||
setTweets((prev) => prev.filter((t) => t.id !== id));
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-base-100 text-base-content">
|
<AuthCtx.Provider value={{ email, userId }}>
|
||||||
<div className="max-w-xl mx-auto px-4 py-8">
|
<QueryClientProvider client={queryClient}>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="mx-root">
|
||||||
<h1 className="text-2xl font-bold">Mixer Feed</h1>
|
<aside className="mx-sidebar">
|
||||||
{currentUserEmail ? (
|
<div className="mx-logo">
|
||||||
<div className="flex items-center gap-3">
|
<span className="mx-logo-icon">⬡</span>
|
||||||
<span className="text-sm opacity-60">{currentUserEmail}</span>
|
<span className="mx-logo-text">Mixer</span>
|
||||||
<a href="/auth/sign-out" className="btn btn-ghost btn-sm">
|
|
||||||
Sign out
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<nav className="mx-nav">
|
||||||
<a href="/register" className="btn btn-primary btn-sm">
|
<a className="mx-nav-item mx-nav-active" href="#">
|
||||||
Sign in
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||||
</a>
|
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" />
|
||||||
)}
|
</svg>
|
||||||
|
Feed
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
<div className="mx-sidebar-footer">
|
||||||
|
{email ? (
|
||||||
|
<>
|
||||||
|
<span className="mx-version" style={{ color: "var(--mx-fg2)" }}>{email}</span>
|
||||||
|
<a className="mx-auth-link" href="/auth/sign-out">Sign out</a>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<a className="mx-auth-link" href="/register">Create account</a>
|
||||||
|
<a className="mx-auth-link" href="/auth/sign-in">Sign in</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="mx-version">v0.1.0</span>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="mx-main">
|
||||||
|
<header className="mx-header">
|
||||||
|
<h1 className="mx-header-title">Feed</h1>
|
||||||
|
<RefreshButton />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="mx-compose-wrapper">
|
||||||
|
{email ? (
|
||||||
|
<ComposeTweet />
|
||||||
|
) : (
|
||||||
|
<div className="mx-signin-cta">
|
||||||
|
<p>Sign in to start mixing.</p>
|
||||||
|
<a className="mx-btn-post" href="/register">Sign in</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mx-divider" />
|
||||||
|
|
||||||
|
<Feed />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div className="mx-rightbar">
|
||||||
|
<div className="mx-info-card">
|
||||||
|
<h3 className="mx-info-title">About Mixer</h3>
|
||||||
|
<p className="mx-info-body">
|
||||||
|
A minimal social feed built with Ash Framework, Phoenix, and React.
|
||||||
|
</p>
|
||||||
|
<div className="mx-stack">
|
||||||
|
{["Ash 3", "Phoenix 1.8", "AshTypescript", "React 19"].map((s) => (
|
||||||
|
<span key={s} className="mx-tag">{s}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</QueryClientProvider>
|
||||||
{currentUserEmail && <TweetCompose onPosted={handlePosted} />}
|
</AuthCtx.Provider>
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<p className="text-center opacity-40 py-12">Loading…</p>
|
|
||||||
) : (
|
|
||||||
<TweetFeed
|
|
||||||
tweets={tweets}
|
|
||||||
currentUserEmail={currentUserEmail}
|
|
||||||
onDeleted={handleDeleted}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
createRoot(document.getElementById("app")!).render(
|
// ── Bootstrap ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const root = createRoot(document.getElementById("app")!);
|
||||||
|
root.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
|
|||||||
27
assets/package-lock.json
generated
27
assets/package-lock.json
generated
@@ -5,6 +5,7 @@
|
|||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.0.0",
|
||||||
"phoenix": "file:../deps/phoenix",
|
"phoenix": "file:../deps/phoenix",
|
||||||
"phoenix_html": "file:../deps/phoenix_html",
|
"phoenix_html": "file:../deps/phoenix_html",
|
||||||
"phoenix_live_view": "file:../deps/phoenix_live_view",
|
"phoenix_live_view": "file:../deps/phoenix_live_view",
|
||||||
@@ -69,6 +70,32 @@
|
|||||||
"typescript-eslint": "^8.34.0"
|
"typescript-eslint": "^8.34.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/query-core": {
|
||||||
|
"version": "5.95.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.95.2.tgz",
|
||||||
|
"integrity": "sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/react-query": {
|
||||||
|
"version": "5.95.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.95.2.tgz",
|
||||||
|
"integrity": "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/query-core": "5.95.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18 || ^19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.14",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.0.0",
|
||||||
"phoenix": "file:../deps/phoenix",
|
"phoenix": "file:../deps/phoenix",
|
||||||
"phoenix_html": "file:../deps/phoenix_html",
|
"phoenix_html": "file:../deps/phoenix_html",
|
||||||
"phoenix_live_view": "file:../deps/phoenix_live_view",
|
"phoenix_live_view": "file:../deps/phoenix_live_view",
|
||||||
|
|||||||
@@ -1,202 +1,15 @@
|
|||||||
<Layouts.flash_group flash={@flash} />
|
<Layouts.flash_group flash={@flash} />
|
||||||
<div class="left-[40rem] fixed inset-y-0 right-0 z-0 hidden lg:block xl:left-[50rem]">
|
<div class="min-h-screen flex flex-col items-center justify-center px-6">
|
||||||
<svg
|
<div class="text-center max-w-md">
|
||||||
viewBox="0 0 1480 957"
|
<h1 class="text-5xl font-bold mb-3 tracking-tight">Mixer</h1>
|
||||||
fill="none"
|
<p class="text-base-content/60 text-lg mb-10">A social feed built with Ash & Phoenix.</p>
|
||||||
aria-hidden="true"
|
<div class="flex flex-col sm:flex-row gap-3 justify-center">
|
||||||
class="absolute inset-0 h-full w-full"
|
<a href="/register" class="btn btn-primary btn-lg rounded-full px-8">Create account</a>
|
||||||
preserveAspectRatio="xMinYMid slice"
|
<a href="/auth/sign-in" class="btn btn-ghost btn-lg rounded-full px-8">Sign in</a>
|
||||||
>
|
|
||||||
<path fill="#EE7868" d="M0 0h1480v957H0z" />
|
|
||||||
<path
|
|
||||||
d="M137.542 466.27c-582.851-48.41-988.806-82.127-1608.412 658.2l67.39 810 3083.15-256.51L1535.94-49.622l-98.36 8.183C1269.29 281.468 734.115 515.799 146.47 467.012l-8.928-.742Z"
|
|
||||||
fill="#FF9F92"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M371.028 528.664C-169.369 304.988-545.754 149.198-1361.45 665.565l-182.58 792.025 3014.73 694.98 389.42-1689.25-96.18-22.171C1505.28 697.438 924.153 757.586 379.305 532.09l-8.277-3.426Z"
|
|
||||||
fill="#FA8372"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M359.326 571.714C-104.765 215.795-428.003-32.102-1349.55 255.554l-282.3 1224.596 3047.04 722.01 312.24-1354.467C1411.25 1028.3 834.355 935.995 366.435 577.166l-7.109-5.452Z"
|
|
||||||
fill="#E96856"
|
|
||||||
fill-opacity=".6"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M1593.87 1236.88c-352.15 92.63-885.498-145.85-1244.602-613.557l-5.455-7.105C-12.347 152.31-260.41-170.8-1225-131.458l-368.63 1599.048 3057.19 704.76 130.31-935.47Z"
|
|
||||||
fill="#C42652"
|
|
||||||
fill-opacity=".2"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M1411.91 1526.93c-363.79 15.71-834.312-330.6-1085.883-863.909l-3.822-8.102C72.704 125.95-101.074-242.476-1052.01-408.907l-699.85 1484.267 2837.75 1338.01 326.02-886.44Z"
|
|
||||||
fill="#A41C42"
|
|
||||||
fill-opacity=".2"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M1116.26 1863.69c-355.457-78.98-720.318-535.27-825.287-1115.521l-1.594-8.816C185.286 163.833 112.786-237.016-762.678-643.898L-1822.83 608.665 571.922 2635.55l544.338-771.86Z"
|
|
||||||
fill="#A41C42"
|
|
||||||
fill-opacity=".2"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="px-4 py-10 sm:px-6 sm:py-28 lg:px-8 xl:px-28 xl:py-32">
|
|
||||||
<div class="mx-auto max-w-xl lg:mx-0">
|
|
||||||
<svg viewBox="0 0 71 48" class="h-12" aria-hidden="true">
|
|
||||||
<path
|
|
||||||
d="m26.371 33.477-.552-.1c-3.92-.729-6.397-3.1-7.57-6.829-.733-2.324.597-4.035 3.035-4.148 1.995-.092 3.362 1.055 4.57 2.39 1.557 1.72 2.984 3.558 4.514 5.305 2.202 2.515 4.797 4.134 8.347 3.634 3.183-.448 5.958-1.725 8.371-3.828.363-.316.761-.592 1.144-.886l-.241-.284c-2.027.63-4.093.841-6.205.735-3.195-.16-6.24-.828-8.964-2.582-2.486-1.601-4.319-3.746-5.19-6.611-.704-2.315.736-3.934 3.135-3.6.948.133 1.746.56 2.463 1.165.583.493 1.143 1.015 1.738 1.493 2.8 2.25 6.712 2.375 10.265-.068-5.842-.026-9.817-3.24-13.308-7.313-1.366-1.594-2.7-3.216-4.095-4.785-2.698-3.036-5.692-5.71-9.79-6.623C12.8-.623 7.745.14 2.893 2.361 1.926 2.804.997 3.319 0 4.149c.494 0 .763.006 1.032 0 2.446-.064 4.28 1.023 5.602 3.024.962 1.457 1.415 3.104 1.761 4.798.513 2.515.247 5.078.544 7.605.761 6.494 4.08 11.026 10.26 13.346 2.267.852 4.591 1.135 7.172.555ZM10.751 3.852c-.976.246-1.756-.148-2.56-.962 1.377-.343 2.592-.476 3.897-.528-.107.848-.607 1.306-1.336 1.49Zm32.002 37.924c-.085-.626-.62-.901-1.04-1.228-1.857-1.446-4.03-1.958-6.333-2-1.375-.026-2.735-.128-4.031-.61-.595-.22-1.26-.505-1.244-1.272.015-.78.693-1 1.31-1.184.505-.15 1.026-.247 1.6-.382-1.46-.936-2.886-1.065-4.787-.3-2.993 1.202-5.943 1.06-8.926-.017-1.684-.608-3.179-1.563-4.735-2.408l-.043.03a2.96 2.96 0 0 0 .04-.029c-.038-.117-.107-.12-.197-.054l.122.107c1.29 2.115 3.034 3.817 5.004 5.271 3.793 2.8 7.936 4.471 12.784 3.73A66.714 66.714 0 0 1 37 40.877c1.98-.16 3.866.398 5.753.899Zm-9.14-30.345c-.105-.076-.206-.266-.42-.069 1.745 2.36 3.985 4.098 6.683 5.193 4.354 1.767 8.773 2.07 13.293.51 3.51-1.21 6.033-.028 7.343 3.38.19-3.955-2.137-6.837-5.843-7.401-2.084-.318-4.01.373-5.962.94-5.434 1.575-10.485.798-15.094-2.553Zm27.085 15.425c.708.059 1.416.123 2.124.185-1.6-1.405-3.55-1.517-5.523-1.404-3.003.17-5.167 1.903-7.14 3.972-1.739 1.824-3.31 3.87-5.903 4.604.043.078.054.117.066.117.35.005.699.021 1.047.005 3.768-.17 7.317-.965 10.14-3.7.89-.86 1.685-1.817 2.544-2.71.716-.746 1.584-1.159 2.645-1.07Zm-8.753-4.67c-2.812.246-5.254 1.409-7.548 2.943-1.766 1.18-3.654 1.738-5.776 1.37-.374-.066-.75-.114-1.124-.17l-.013.156c.135.07.265.151.405.207.354.14.702.308 1.07.395 4.083.971 7.992.474 11.516-1.803 2.221-1.435 4.521-1.707 7.013-1.336.252.038.503.083.756.107.234.022.479.255.795.003-2.179-1.574-4.526-2.096-7.094-1.872Zm-10.049-9.544c1.475.051 2.943-.142 4.486-1.059-.452.04-.643.04-.827.076-2.126.424-4.033-.04-5.733-1.383-.623-.493-1.257-.974-1.889-1.457-2.503-1.914-5.374-2.555-8.514-2.5.05.154.054.26.108.315 3.417 3.455 7.371 5.836 12.369 6.008Zm24.727 17.731c-2.114-2.097-4.952-2.367-7.578-.537 1.738.078 3.043.632 4.101 1.728.374.388.763.768 1.182 1.106 1.6 1.29 4.311 1.352 5.896.155-1.861-.726-1.861-.726-3.601-2.452Zm-21.058 16.06c-1.858-3.46-4.981-4.24-8.59-4.008a9.667 9.667 0 0 1 2.977 1.39c.84.586 1.547 1.311 2.243 2.055 1.38 1.473 3.534 2.376 4.962 2.07-.656-.412-1.238-.848-1.592-1.507Zm17.29-19.32c0-.023.001-.045.003-.068l-.006.006.006-.006-.036-.004.021.018.012.053Zm-20 14.744a7.61 7.61 0 0 0-.072-.041.127.127 0 0 0 .015.043c.005.008.038 0 .058-.002Zm-.072-.041-.008-.034-.008.01.008-.01-.022-.006.005.026.024.014Z"
|
|
||||||
fill="#FD4F00"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<div class="mt-10 flex justify-between items-center">
|
|
||||||
<h1 class="flex items-center text-sm font-semibold leading-6">
|
|
||||||
Phoenix Framework
|
|
||||||
<small class="badge badge-warning badge-sm ml-3">
|
|
||||||
v{Application.spec(:phoenix, :vsn)}
|
|
||||||
</small>
|
|
||||||
</h1>
|
|
||||||
<Layouts.theme_toggle />
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-8 text-sm text-base-content/40">
|
||||||
<p class="text-[2rem] mt-4 font-semibold leading-10 tracking-tighter text-balance">
|
Already have an account?
|
||||||
Peace of mind from prototype to production.
|
<a href="/feed" class="underline hover:text-base-content/70">Go to feed →</a>
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-4 leading-7 text-base-content/70">
|
|
||||||
Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale.
|
|
||||||
</p>
|
|
||||||
<div class="flex">
|
|
||||||
<div class="w-full sm:w-auto">
|
|
||||||
<div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-3">
|
|
||||||
<a
|
|
||||||
href="https://hexdocs.pm/phoenix/overview.html"
|
|
||||||
class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6"
|
|
||||||
>
|
|
||||||
<span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105">
|
|
||||||
</span>
|
|
||||||
<span class="relative flex items-center gap-4 sm:flex-col">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6">
|
|
||||||
<path d="m12 4 10-2v18l-10 2V4Z" fill="currentColor" fill-opacity=".15" />
|
|
||||||
<path
|
|
||||||
d="M12 4 2 2v18l10 2m0-18v18m0-18 10-2v18l-10 2"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Guides & Docs
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://github.com/phoenixframework/phoenix"
|
|
||||||
class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6"
|
|
||||||
>
|
|
||||||
<span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105">
|
|
||||||
</span>
|
|
||||||
<span class="relative flex items-center gap-4 sm:flex-col">
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true" class="h-6 w-6">
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
fill-rule="evenodd"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
d="M12 0C5.37 0 0 5.506 0 12.303c0 5.445 3.435 10.043 8.205 11.674.6.107.825-.262.825-.585 0-.292-.015-1.261-.015-2.291C6 21.67 5.22 20.346 4.98 19.654c-.135-.354-.72-1.446-1.23-1.738-.42-.23-1.02-.8-.015-.815.945-.015 1.62.892 1.845 1.261 1.08 1.86 2.805 1.338 3.495 1.015.105-.8.42-1.338.765-1.645-2.67-.308-5.46-1.37-5.46-6.075 0-1.338.465-2.446 1.23-3.307-.12-.308-.54-1.569.12-3.26 0 0 1.005-.323 3.3 1.26.96-.276 1.98-.415 3-.415s2.04.139 3 .416c2.295-1.6 3.3-1.261 3.3-1.261.66 1.691.24 2.952.12 3.26.765.861 1.23 1.953 1.23 3.307 0 4.721-2.805 5.767-5.475 6.075.435.384.81 1.122.81 2.276 0 1.645-.015 2.968-.015 3.383 0 .323.225.707.825.585a12.047 12.047 0 0 0 5.919-4.489A12.536 12.536 0 0 0 24 12.304C24 5.505 18.63 0 12 0Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Source Code
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href={"https://github.com/phoenixframework/phoenix/blob/v#{Application.spec(:phoenix, :vsn)}/CHANGELOG.md"}
|
|
||||||
class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6"
|
|
||||||
>
|
|
||||||
<span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105">
|
|
||||||
</span>
|
|
||||||
<span class="relative flex items-center gap-4 sm:flex-col">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6">
|
|
||||||
<path
|
|
||||||
d="M12 1v6M12 17v6"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
<circle
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="4"
|
|
||||||
fill="currentColor"
|
|
||||||
fill-opacity=".15"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Changelog
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="mt-10 grid grid-cols-1 gap-y-4 text-sm leading-6 text-base-content/80 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<a
|
|
||||||
href="https://elixirforum.com"
|
|
||||||
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
aria-hidden="true"
|
|
||||||
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
|
|
||||||
>
|
|
||||||
<path d="M8 13.833c3.866 0 7-2.873 7-6.416C15 3.873 11.866 1 8 1S1 3.873 1 7.417c0 1.081.292 2.1.808 2.995.606 1.05.806 2.399.086 3.375l-.208.283c-.285.386-.01.905.465.85.852-.098 2.048-.318 3.137-.81a3.717 3.717 0 0 1 1.91-.318c.263.027.53.041.802.041Z" />
|
|
||||||
</svg>
|
|
||||||
Discuss on the Elixir Forum
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<a
|
|
||||||
href="https://discord.gg/elixir"
|
|
||||||
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
aria-hidden="true"
|
|
||||||
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
|
|
||||||
>
|
|
||||||
<path d="M13.545 2.995c-1.02-.46-2.114-.8-3.257-.994a.05.05 0 0 0-.052.024c-.141.246-.297.567-.406.82a12.377 12.377 0 0 0-3.658 0 8.238 8.238 0 0 0-.412-.82.052.052 0 0 0-.052-.024 13.315 13.315 0 0 0-3.257.994.046.046 0 0 0-.021.018C.356 6.063-.213 9.036.066 11.973c.001.015.01.029.02.038a13.353 13.353 0 0 0 3.996 1.987.052.052 0 0 0 .056-.018c.308-.414.582-.85.818-1.309a.05.05 0 0 0-.028-.069 8.808 8.808 0 0 1-1.248-.585.05.05 0 0 1-.005-.084c.084-.062.168-.126.248-.191a.05.05 0 0 1 .051-.007c2.619 1.176 5.454 1.176 8.041 0a.05.05 0 0 1 .053.006c.08.065.164.13.248.192a.05.05 0 0 1-.004.084c-.399.23-.813.423-1.249.585a.05.05 0 0 0-.027.07c.24.457.514.893.817 1.307a.051.051 0 0 0 .056.019 13.31 13.31 0 0 0 4.001-1.987.05.05 0 0 0 .021-.037c.334-3.396-.559-6.345-2.365-8.96a.04.04 0 0 0-.021-.02Zm-8.198 7.19c-.789 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.637 1.587-1.438 1.587Zm5.316 0c-.788 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.63 1.587-1.438 1.587Z" />
|
|
||||||
</svg>
|
|
||||||
Join our Discord server
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<a
|
|
||||||
href="https://elixir-slack.community/"
|
|
||||||
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
aria-hidden="true"
|
|
||||||
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
|
|
||||||
>
|
|
||||||
<path d="M3.361 10.11a1.68 1.68 0 1 1-1.68-1.681h1.68v1.682ZM4.209 10.11a1.68 1.68 0 1 1 3.361 0v4.21a1.68 1.68 0 1 1-3.361 0v-4.21ZM5.89 3.361a1.68 1.68 0 1 1 1.681-1.68v1.68H5.89ZM5.89 4.209a1.68 1.68 0 1 1 0 3.361H1.68a1.68 1.68 0 1 1 0-3.361h4.21ZM12.639 5.89a1.68 1.68 0 1 1 1.68 1.681h-1.68V5.89ZM11.791 5.89a1.68 1.68 0 1 1-3.361 0V1.68a1.68 1.68 0 0 1 3.361 0v4.21ZM10.11 12.639a1.68 1.68 0 1 1-1.681 1.68v-1.68h1.682ZM10.11 11.791a1.68 1.68 0 1 1 0-3.361h4.21a1.68 1.68 0 1 1 0 3.361h-4.21Z" />
|
|
||||||
</svg>
|
|
||||||
Join us on Slack
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<a
|
|
||||||
href="https://fly.io/docs/elixir/getting-started/"
|
|
||||||
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
aria-hidden="true"
|
|
||||||
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
|
|
||||||
>
|
|
||||||
<path d="M1 12.5A4.5 4.5 0 005.5 17H15a4 4 0 001.866-7.539 3.504 3.504 0 00-4.504-4.272A4.5 4.5 0 004.06 8.235 4.502 4.502 0 001 12.5z" />
|
|
||||||
</svg>
|
|
||||||
Deploy your application
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,49 +37,10 @@ defmodule MixerWeb.Router do
|
|||||||
scope "/", MixerWeb do
|
scope "/", MixerWeb do
|
||||||
pipe_through :browser
|
pipe_through :browser
|
||||||
|
|
||||||
ash_authentication_live_session :authenticated_routes do
|
get "/", PageController, :home
|
||||||
# in each liveview, add one of the following at the top of the module:
|
get "/feed", PageController, :index
|
||||||
#
|
|
||||||
# If an authenticated user must be present:
|
|
||||||
# on_mount {MixerWeb.LiveUserAuth, :live_user_required}
|
|
||||||
#
|
|
||||||
# If an authenticated user *may* be present:
|
|
||||||
# on_mount {MixerWeb.LiveUserAuth, :live_user_optional}
|
|
||||||
#
|
|
||||||
# If an authenticated user must *not* be present:
|
|
||||||
# on_mount {MixerWeb.LiveUserAuth, :live_no_user}
|
|
||||||
end
|
|
||||||
|
|
||||||
post "/rpc/run", AshTypescriptRpcController, :run
|
post "/rpc/run", AshTypescriptRpcController, :run
|
||||||
post "/rpc/validate", AshTypescriptRpcController, :validate
|
post "/rpc/validate", AshTypescriptRpcController, :validate
|
||||||
get "/ash-typescript", PageController, :index
|
|
||||||
end
|
|
||||||
|
|
||||||
scope "/api/json" do
|
|
||||||
pipe_through [:api]
|
|
||||||
|
|
||||||
forward "/swaggerui", OpenApiSpex.Plug.SwaggerUI,
|
|
||||||
path: "/api/json/open_api",
|
|
||||||
default_model_expand_depth: 4
|
|
||||||
|
|
||||||
forward "/", MixerWeb.AshJsonApiRouter
|
|
||||||
end
|
|
||||||
|
|
||||||
scope "/gql" do
|
|
||||||
pipe_through [:graphql]
|
|
||||||
|
|
||||||
forward "/playground", Absinthe.Plug.GraphiQL,
|
|
||||||
schema: Module.concat(["MixerWeb.GraphqlSchema"]),
|
|
||||||
socket: Module.concat(["MixerWeb.GraphqlSocket"]),
|
|
||||||
interface: :simple
|
|
||||||
|
|
||||||
forward "/", Absinthe.Plug, schema: Module.concat(["MixerWeb.GraphqlSchema"])
|
|
||||||
end
|
|
||||||
|
|
||||||
scope "/", MixerWeb do
|
|
||||||
pipe_through :browser
|
|
||||||
|
|
||||||
get "/", PageController, :home
|
|
||||||
auth_routes AuthController, Mixer.Accounts.User, path: "/auth"
|
auth_routes AuthController, Mixer.Accounts.User, path: "/auth"
|
||||||
sign_out_route AuthController
|
sign_out_route AuthController
|
||||||
|
|
||||||
@@ -110,12 +71,41 @@ defmodule MixerWeb.Router do
|
|||||||
auth_routes_prefix: "/auth",
|
auth_routes_prefix: "/auth",
|
||||||
overrides: [MixerWeb.AuthOverrides, Elixir.AshAuthentication.Phoenix.Overrides.DaisyUI]
|
overrides: [MixerWeb.AuthOverrides, Elixir.AshAuthentication.Phoenix.Overrides.DaisyUI]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ash_authentication_live_session :authenticated_routes do
|
||||||
|
# in each liveview, add one of the following at the top of the module:
|
||||||
|
#
|
||||||
|
# If an authenticated user must be present:
|
||||||
|
# on_mount {MixerWeb.LiveUserAuth, :live_user_required}
|
||||||
|
#
|
||||||
|
# If an authenticated user *may* be present:
|
||||||
|
# on_mount {MixerWeb.LiveUserAuth, :live_user_optional}
|
||||||
|
#
|
||||||
|
# If an authenticated user must *not* be present:
|
||||||
|
# on_mount {MixerWeb.LiveUserAuth, :live_no_user}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Other scopes may use custom stacks.
|
scope "/api/json" do
|
||||||
# scope "/api", MixerWeb do
|
pipe_through [:api]
|
||||||
# pipe_through :api
|
|
||||||
# end
|
forward "/swaggerui", OpenApiSpex.Plug.SwaggerUI,
|
||||||
|
path: "/api/json/open_api",
|
||||||
|
default_model_expand_depth: 4
|
||||||
|
|
||||||
|
forward "/", MixerWeb.AshJsonApiRouter
|
||||||
|
end
|
||||||
|
|
||||||
|
scope "/gql" do
|
||||||
|
pipe_through [:graphql]
|
||||||
|
|
||||||
|
forward "/playground", Absinthe.Plug.GraphiQL,
|
||||||
|
schema: Module.concat(["MixerWeb.GraphqlSchema"]),
|
||||||
|
socket: Module.concat(["MixerWeb.GraphqlSocket"]),
|
||||||
|
interface: :simple
|
||||||
|
|
||||||
|
forward "/", Absinthe.Plug, schema: Module.concat(["MixerWeb.GraphqlSchema"])
|
||||||
|
end
|
||||||
|
|
||||||
# Enable LiveDashboard and Swoosh mailbox preview in development
|
# Enable LiveDashboard and Swoosh mailbox preview in development
|
||||||
if Application.compile_env(:mixer, :dev_routes) do
|
if Application.compile_env(:mixer, :dev_routes) do
|
||||||
|
|||||||
Reference in New Issue
Block a user