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
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,213 +1,446 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { createContext, useContext, useState, useRef, useEffect } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import {
|
||||
readTweet,
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
useQuery,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
createTweet,
|
||||
readTweet,
|
||||
destroyTweet,
|
||||
updateTweet,
|
||||
buildCSRFHeaders,
|
||||
} from "./ash_rpc";
|
||||
|
||||
type Tweet = {
|
||||
id: string;
|
||||
content: string;
|
||||
userId: string;
|
||||
state: "posted" | "drafted";
|
||||
};
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { staleTime: 10_000 } },
|
||||
});
|
||||
|
||||
function TweetCompose({ onPosted }: { onPosted: (tweet: Tweet) => void }) {
|
||||
const [content, setContent] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!content.trim()) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
const result = await createTweet({
|
||||
input: { content: content.trim() },
|
||||
fields: ["id", "content", "userId", "state"],
|
||||
headers: buildCSRFHeaders(),
|
||||
});
|
||||
setSubmitting(false);
|
||||
if (result.success) {
|
||||
onPosted(result.data as Tweet);
|
||||
setContent("");
|
||||
} else {
|
||||
setError(result.errors.map((e) => e.message).join(", "));
|
||||
}
|
||||
}
|
||||
type Tweet = { id: string; content: string; userId: string; state: string };
|
||||
|
||||
// ── Auth context ───────────────────────────────────────────────────────────────
|
||||
|
||||
const AuthCtx = createContext({ email: "", userId: "" });
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function timeAgo(): string {
|
||||
return "just now";
|
||||
}
|
||||
|
||||
// ── Components ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function Spinner() {
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="card bg-base-200 p-4 mb-6">
|
||||
<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)}
|
||||
/>
|
||||
{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>
|
||||
<div style={{ display: "flex", justifyContent: "center", padding: "2rem" }}>
|
||||
<div className="mx-spinner" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TweetCard({
|
||||
tweet,
|
||||
currentUserEmail,
|
||||
onDeleted,
|
||||
}: {
|
||||
tweet: Tweet;
|
||||
currentUserEmail: string;
|
||||
onDeleted: (id: string) => void;
|
||||
}) {
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
function ErrorBanner({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="mx-error-banner">
|
||||
<span className="mx-error-icon">⚠</span>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
setDeleting(true);
|
||||
const result = await destroyTweet({
|
||||
identity: tweet.id,
|
||||
headers: buildCSRFHeaders(),
|
||||
});
|
||||
if (result.success) {
|
||||
onDeleted(tweet.id);
|
||||
} else {
|
||||
setDeleting(false);
|
||||
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;
|
||||
|
||||
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 (
|
||||
<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 && (
|
||||
<button
|
||||
className="btn btn-ghost btn-xs text-error"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? "…" : "Delete"}
|
||||
</button>
|
||||
)}
|
||||
<div className="mx-compose">
|
||||
<div className="mx-compose-avatar">
|
||||
<span>M</span>
|
||||
</div>
|
||||
<div className="mx-compose-body">
|
||||
<textarea
|
||||
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="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>
|
||||
);
|
||||
}
|
||||
|
||||
function TweetFeed({
|
||||
tweets,
|
||||
currentUserEmail,
|
||||
onDeleted,
|
||||
}: {
|
||||
tweets: Tweet[];
|
||||
currentUserEmail: string;
|
||||
onDeleted: (id: string) => void;
|
||||
}) {
|
||||
if (tweets.length === 0) {
|
||||
function TweetCard({ tweet }: { tweet: Tweet }) {
|
||||
const { userId: currentUserId } = useContext(AuthCtx);
|
||||
const canModify = !!currentUserId && tweet.userId === currentUserId;
|
||||
|
||||
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 (!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 (
|
||||
<p className="text-center opacity-40 py-12">
|
||||
No tweets yet. Be the first!
|
||||
</p>
|
||||
<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 (
|
||||
<>
|
||||
{tweets.map((tweet) => (
|
||||
<TweetCard
|
||||
key={tweet.id}
|
||||
tweet={tweet}
|
||||
currentUserEmail={currentUserEmail}
|
||||
onDeleted={onDeleted}
|
||||
/>
|
||||
<div className="mx-feed">
|
||||
{tweets.map((t) => (
|
||||
<TweetCard key={t.id} tweet={t} />
|
||||
))}
|
||||
</>
|
||||
</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() {
|
||||
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
|
||||
</a>
|
||||
)}
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
{currentUserEmail && <TweetCompose onPosted={handlePosted} />}
|
||||
|
||||
{loading ? (
|
||||
<p className="text-center opacity-40 py-12">Loading…</p>
|
||||
) : (
|
||||
<TweetFeed
|
||||
tweets={tweets}
|
||||
currentUserEmail={currentUserEmail}
|
||||
onDeleted={handleDeleted}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
</AuthCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("app")!).render(
|
||||
// ── Bootstrap ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const root = createRoot(document.getElementById("app")!);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
|
||||
27
assets/package-lock.json
generated
27
assets/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user