Simple landing page and moved css to the app.css

This commit is contained in:
2026-03-30 02:37:57 -04:00
parent a13f80cf07
commit 4378a6fb21
6 changed files with 965 additions and 448 deletions

View File

@@ -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);
}

View File

@@ -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);
// ── Types ──────────────────────────────────────────────────────────────────────
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 (
<div style={{ display: "flex", justifyContent: "center", padding: "2rem" }}>
<div className="mx-spinner" />
</div>
);
}
function ErrorBanner({ message }: { message: string }) {
return (
<div className="mx-error-banner">
<span className="mx-error-icon"></span>
{message}
</div>
);
}
function CharCount({ current, max }: { current: number; max: number }) {
const remaining = max - current;
const pct = current / max;
const color =
pct > 0.9 ? "#ef4444" : pct > 0.75 ? "#f59e0b" : "var(--mx-muted)";
return (
<span style={{ color, fontSize: "0.75rem", fontVariantNumeric: "tabular-nums" }}>
{remaining}
</span>
);
}
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;
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!content.trim()) return;
setSubmitting(true);
setError(null);
const result = await createTweet({
input: { content: content.trim() },
const mutation = useMutation({
mutationFn: async (content: string) => {
const res = await createTweet({
input: { content },
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(", "));
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 (
<form onSubmit={handleSubmit} className="card bg-base-200 p-4 mb-6">
<div className="mx-compose">
<div className="mx-compose-avatar">
<span>M</span>
</div>
<div className="mx-compose-body">
<textarea
className="textarea textarea-bordered w-full mb-3 resize-none"
rows={3}
placeholder="What's happening?"
value={content}
maxLength={280}
onChange={(e) => setContent(e.target.value)}
ref={textareaRef}
className="mx-compose-textarea"
placeholder="What's mixing?"
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
rows={2}
maxLength={MAX + 1}
/>
{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>
{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
type="submit"
className="btn btn-primary btn-sm"
disabled={submitting || !content.trim()}
className="mx-btn-post"
onClick={submit}
disabled={!text.trim() || mutation.isPending}
>
{submitting ? "Posting..." : "Post"}
{mutation.isPending ? "Posting" : "Post"}
</button>
</div>
</form>
</div>
</div>
</div>
);
}
function TweetCard({
tweet,
currentUserEmail,
onDeleted,
}: {
tweet: Tweet;
currentUserEmail: string;
onDeleted: (id: string) => void;
}) {
const [deleting, setDeleting] = useState(false);
function TweetCard({ tweet }: { tweet: Tweet }) {
const { userId: currentUserId } = useContext(AuthCtx);
const canModify = !!currentUserId && tweet.userId === currentUserId;
async function handleDelete() {
setDeleting(true);
const result = await destroyTweet({
const [editing, setEditing] = useState(false);
const [editText, setEditText] = useState(tweet.content);
const [error, setError] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const qc = useQueryClient();
const deleteMutation = useMutation({
mutationFn: async () => {
const res = await destroyTweet({
identity: tweet.id,
headers: buildCSRFHeaders(),
});
if (result.success) {
onDeleted(tweet.id);
} else {
setDeleting(false);
}
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 (
<div className="card bg-base-200 mb-3 p-4">
<p className="text-base-content whitespace-pre-wrap break-words">
{tweet.content}
</p>
<div className="flex items-center justify-between mt-3">
<span className="text-xs opacity-40 font-mono">
{tweet.userId.slice(0, 8)}
</span>
{currentUserEmail && (
<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="btn btn-ghost btn-xs text-error"
onClick={handleDelete}
disabled={deleting}
className="mx-action-btn"
title="Edit"
onClick={() => {
setEditText(tweet.content);
setEditing(true);
setConfirmDelete(false);
}}
>
{deleting ? "…" : "Delete"}
<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 (
<ErrorBanner message={(error as Error)?.message ?? "Could not load tweets"} />
);
}
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 (
<div className="mx-feed">
{tweets.map((t) => (
<TweetCard key={t.id} tweet={t} />
))}
</div>
);
}
function TweetFeed({
tweets,
currentUserEmail,
onDeleted,
}: {
tweets: Tweet[];
currentUserEmail: string;
onDeleted: (id: string) => void;
}) {
if (tweets.length === 0) {
return (
<p className="text-center opacity-40 py-12">
No tweets yet. Be the first!
</p>
);
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 (
<>
{tweets.map((tweet) => (
<TweetCard
key={tweet.id}
tweet={tweet}
currentUserEmail={currentUserEmail}
onDeleted={onDeleted}
/>
))}
</>
<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() {
const appEl = document.getElementById("app")!;
const currentUserEmail = appEl.dataset.currentUserEmail ?? "";
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));
}
const email = appEl.dataset.currentUserEmail ?? "";
const userId = appEl.dataset.currentUserId ?? "";
return (
<div className="min-h-screen bg-base-100 text-base-content">
<div className="max-w-xl mx-auto px-4 py-8">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Mixer Feed</h1>
{currentUserEmail ? (
<div className="flex items-center gap-3">
<span className="text-sm opacity-60">{currentUserEmail}</span>
<a href="/auth/sign-out" className="btn btn-ghost btn-sm">
Sign out
</a>
<AuthCtx.Provider value={{ email, userId }}>
<QueryClientProvider client={queryClient}>
<div className="mx-root">
<aside className="mx-sidebar">
<div className="mx-logo">
<span className="mx-logo-icon"></span>
<span className="mx-logo-text">Mixer</span>
</div>
) : (
<a href="/register" className="btn btn-primary btn-sm">
Sign in
<nav className="mx-nav">
<a className="mx-nav-item mx-nav-active" href="#">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<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>
{currentUserEmail && <TweetCompose onPosted={handlePosted} />}
<div className="mx-divider" />
{loading ? (
<p className="text-center opacity-40 py-12">Loading</p>
) : (
<TweetFeed
tweets={tweets}
currentUserEmail={currentUserEmail}
onDeleted={handleDeleted}
/>
)}
<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>
</QueryClientProvider>
</AuthCtx.Provider>
);
}
createRoot(document.getElementById("app")!).render(
// ── Bootstrap ──────────────────────────────────────────────────────────────────
const root = createRoot(document.getElementById("app")!);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>

View File

@@ -5,6 +5,7 @@
"packages": {
"": {
"dependencies": {
"@tanstack/react-query": "^5.0.0",
"phoenix": "file:../deps/phoenix",
"phoenix_html": "file:../deps/phoenix_html",
"phoenix_live_view": "file:../deps/phoenix_live_view",
@@ -69,6 +70,32 @@
"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": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",

View File

@@ -1,5 +1,6 @@
{
"dependencies": {
"@tanstack/react-query": "^5.0.0",
"phoenix": "file:../deps/phoenix",
"phoenix_html": "file:../deps/phoenix_html",
"phoenix_live_view": "file:../deps/phoenix_live_view",

View File

@@ -1,202 +1,15 @@
<Layouts.flash_group flash={@flash} />
<div class="left-[40rem] fixed inset-y-0 right-0 z-0 hidden lg:block xl:left-[50rem]">
<svg
viewBox="0 0 1480 957"
fill="none"
aria-hidden="true"
class="absolute inset-0 h-full w-full"
preserveAspectRatio="xMinYMid slice"
>
<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 class="min-h-screen flex flex-col items-center justify-center px-6">
<div class="text-center max-w-md">
<h1 class="text-5xl font-bold mb-3 tracking-tight">Mixer</h1>
<p class="text-base-content/60 text-lg mb-10">A social feed built with Ash &amp; Phoenix.</p>
<div class="flex flex-col sm:flex-row gap-3 justify-center">
<a href="/register" class="btn btn-primary btn-lg rounded-full px-8">Create account</a>
<a href="/auth/sign-in" class="btn btn-ghost btn-lg rounded-full px-8">Sign in</a>
</div>
<p class="text-[2rem] mt-4 font-semibold leading-10 tracking-tighter text-balance">
Peace of mind from prototype to production.
<p class="mt-8 text-sm text-base-content/40">
Already have an account?
<a href="/feed" class="underline hover:text-base-content/70">Go to feed →</a>
</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 &amp; 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>

View File

@@ -37,49 +37,10 @@ defmodule MixerWeb.Router do
scope "/", MixerWeb do
pipe_through :browser
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
get "/", PageController, :home
get "/feed", PageController, :index
post "/rpc/run", AshTypescriptRpcController, :run
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"
sign_out_route AuthController
@@ -110,12 +71,41 @@ defmodule MixerWeb.Router do
auth_routes_prefix: "/auth",
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
# Other scopes may use custom stacks.
# scope "/api", MixerWeb do
# pipe_through :api
# 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
# Enable LiveDashboard and Swoosh mailbox preview in development
if Application.compile_env(:mixer, :dev_routes) do