Compare commits
36 Commits
f82bc223bb
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 00af2350f4 | |||
| df013731be | |||
| c3ccab5fc5 | |||
| d7345ba234 | |||
| df8bc97bd2 | |||
| 4c67f38fa3 | |||
| 88e84fcec5 | |||
| 7c34323ff4 | |||
| 0e4e46824c | |||
| 56a4ee6c77 | |||
| d194834110 | |||
| 2130d85be5 | |||
| f37d554399 | |||
| 2d5914c970 | |||
| 31a8f03ab2 | |||
| 90d7eab7d0 | |||
| 3c9910a723 | |||
| 76a8acc731 | |||
| a33ec14c5f | |||
| 109ef398ee | |||
| faa96d88f5 | |||
| 6927f6eb9b | |||
| cc6586587f | |||
| 315b108fa1 | |||
| 4ec41ad4b3 | |||
| 4b36131183 | |||
| 8077e570f4 | |||
| 33c83e188e | |||
| 193ff815a1 | |||
| 1ed136e637 | |||
| bd0f5d52c2 | |||
| 874fec835d | |||
| a926733f1b | |||
| a70ea18e56 | |||
| abe10922eb | |||
| 9c131b98a6 |
40
.env.example
Normal file
40
.env.example
Normal file
@@ -0,0 +1,40 @@
|
||||
# Phoenix / App
|
||||
PHX_HOST=mixer.example.com
|
||||
PHX_SERVER=true
|
||||
PORT=4000
|
||||
SECRET_KEY_BASE=REPLACE_WITH_64_CHAR_SECRET # generate with: mix phx.gen.secret
|
||||
|
||||
# Database
|
||||
DATABASE_URL=ecto://USER:PASSWORD@HOST/DATABASE
|
||||
ECTO_IPV6=false
|
||||
POOL_SIZE=10
|
||||
|
||||
# Clustering (leave blank if not using DNS-based clustering)
|
||||
DNS_CLUSTER_QUERY=
|
||||
|
||||
# Auth
|
||||
TOKEN_SIGNING_SECRET=REPLACE_WITH_SECRET
|
||||
|
||||
# S3 / Object Storage
|
||||
S3_ACCESS_KEY_ID=your-access-key-id
|
||||
S3_SECRET_ACCESS_KEY=your-secret-access-key
|
||||
S3_HOST=s3.amazonaws.com
|
||||
S3_BUCKET=your-bucket-name
|
||||
S3_ASSET_HOST=https://your-bucket.s3.amazonaws.com
|
||||
S3_SCHEME=https://
|
||||
S3_PORT=80
|
||||
S3_VIRTUAL_HOST=false
|
||||
|
||||
# Email (Brevo)
|
||||
BREVO_API_KEY=your-brevo-api-key
|
||||
|
||||
# ClickHouse (analytics / metrics)
|
||||
# single connection URL (overrides all individual vars below)
|
||||
CLICKHOUSE_URL=http://default:password@localhost:8123/mixer_metrics
|
||||
# individual vars (used when CLICKHOUSE_URL is not set)
|
||||
CLICKHOUSE_HOST=localhost
|
||||
CLICKHOUSE_PORT=8123
|
||||
CLICKHOUSE_DATABASE=mixer_metrics
|
||||
CLICKHOUSE_USERNAME=default
|
||||
CLICKHOUSE_PASSWORD=
|
||||
CLICKHOUSE_SCHEME=http
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,3 +1,6 @@
|
||||
# .env
|
||||
.env
|
||||
|
||||
# The directory Mix will write compiled artifacts to.
|
||||
/_build/
|
||||
|
||||
@@ -35,3 +38,6 @@ mixer-*.tar
|
||||
npm-debug.log
|
||||
/assets/node_modules/
|
||||
|
||||
# Ralph code claude files
|
||||
/.ralph/
|
||||
.ralphrc
|
||||
96
README.md
96
README.md
@@ -1,18 +1,92 @@
|
||||
# Mixer
|
||||
|
||||
To start your Phoenix server:
|
||||
A social posting platform built with Elixir/Phoenix, Ash Framework, and React. Users can post, reply, like, follow each other, and upload media/avatars. Metrics are tracked in ClickHouse.
|
||||
|
||||
* Run `mix setup` to install and setup dependencies
|
||||
* Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server`
|
||||
## Stack
|
||||
|
||||
Now you can visit [`localhost:4000`](http://localhost:4000) from your browser.
|
||||
- **Backend:** Elixir 1.15+, Phoenix, Ash Framework (resources, policies, state machine, authentication)
|
||||
- **Frontend:** React + TypeScript, bundled via esbuild, styled with Tailwind CSS + DaisyUI
|
||||
- **Databases:** PostgreSQL (primary data), ClickHouse (metrics/analytics)
|
||||
- **Storage:** S3-compatible object storage (MinIO locally, any S3-compatible service in prod)
|
||||
- **Email:** Swoosh (local mailbox in dev, Brevo in prod)
|
||||
- **API layer:** AshTypescript RPC (type-safe TS client auto-generated from Ash resources)
|
||||
|
||||
Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html).
|
||||
## Dev environment setup
|
||||
|
||||
## Learn more
|
||||
### Prerequisites
|
||||
|
||||
* Official website: https://www.phoenixframework.org/
|
||||
* Guides: https://hexdocs.pm/phoenix/overview.html
|
||||
* Docs: https://hexdocs.pm/phoenix
|
||||
* Forum: https://elixirforum.com/c/phoenix-forum
|
||||
* Source: https://github.com/phoenixframework/phoenix
|
||||
- Elixir 1.15+ and Erlang/OTP (via [asdf](https://asdf-vm.com) or system package manager)
|
||||
- PostgreSQL running locally (default: `postgres`/`postgres` on `localhost:5432`)
|
||||
- ClickHouse running locally (default: `default`/no password on `localhost:8123`, database `mixer_metrics`)
|
||||
- MinIO running locally on `localhost:9000` with credentials `minioadmin`/`minioadmin`
|
||||
|
||||
### MinIO setup
|
||||
|
||||
Start MinIO and create the bucket before running the app:
|
||||
|
||||
```bash
|
||||
# Start MinIO (adjust data dir as needed)
|
||||
minio server /data --console-address ":9001"
|
||||
|
||||
# Create the bucket (using the MinIO CLI or the web console at http://localhost:9001)
|
||||
mc alias set local http://localhost:9000 minioadmin minioadmin
|
||||
mc mb local/mixer-bucket
|
||||
mc anonymous set public local/mixer-bucket
|
||||
```
|
||||
|
||||
### First-time setup
|
||||
|
||||
```bash
|
||||
# Install Elixir dependencies and set up both databases
|
||||
mix setup
|
||||
```
|
||||
|
||||
`mix setup` runs `mix deps.get`, creates and migrates both the PostgreSQL and ClickHouse databases, and seeds initial data.
|
||||
|
||||
### Running the server
|
||||
|
||||
```bash
|
||||
mix phx.server
|
||||
```
|
||||
|
||||
Visit [http://localhost:4000](http://localhost:4000). The frontend assets (esbuild + Tailwind) are compiled and watched automatically.
|
||||
|
||||
### Email in development
|
||||
|
||||
Magic-link sign-in emails are delivered to the local Swoosh mailbox. View them at [http://localhost:4000/dev/mailbox](http://localhost:4000/dev/mailbox).
|
||||
|
||||
### Regenerating the TypeScript RPC client
|
||||
|
||||
After changing Ash resource actions or attributes, regenerate the typed TS client:
|
||||
|
||||
```bash
|
||||
mix ash_typescript.generate
|
||||
```
|
||||
|
||||
The output goes to `assets/js/ash_rpc.ts`.
|
||||
|
||||
## Production environment variables
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `DATABASE_URL` | PostgreSQL connection URL (`ecto://user:pass@host/db`) |
|
||||
| `SECRET_KEY_BASE` | Phoenix secret key (generate with `mix phx.gen.secret`) |
|
||||
| `TOKEN_SIGNING_SECRET` | Ash authentication token signing secret |
|
||||
| `CLICKHOUSE_URL` | ClickHouse connection URL (or use individual vars below) |
|
||||
| `CLICKHOUSE_HOST` | ClickHouse host |
|
||||
| `CLICKHOUSE_PORT` | ClickHouse port (default `8123`) |
|
||||
| `CLICKHOUSE_DATABASE` | ClickHouse database name (default `mixer_metrics`) |
|
||||
| `CLICKHOUSE_USERNAME` | ClickHouse username (default `default`) |
|
||||
| `CLICKHOUSE_PASSWORD` | ClickHouse password |
|
||||
| `S3_ACCESS_KEY_ID` | S3 access key |
|
||||
| `S3_SECRET_ACCESS_KEY` | S3 secret key |
|
||||
| `S3_HOST` | S3 host (e.g. `s3.amazonaws.com`) |
|
||||
| `S3_BUCKET` | S3 bucket name |
|
||||
| `S3_ASSET_HOST` | Public base URL for serving assets (e.g. `https://cdn.example.com`) |
|
||||
| `S3_SCHEME` | S3 scheme (default `https://`) |
|
||||
| `S3_PORT` | S3 port (default `80`) |
|
||||
| `S3_VIRTUAL_HOST` | Use virtual-hosted S3 URLs (default `false`) |
|
||||
| `BREVO_API_KEY` | Brevo (Sendinblue) API key for transactional email |
|
||||
| `PHX_HOST` | Public hostname (default `mixer.jimweaver.com`) |
|
||||
| `PORT` | HTTP port (default `4000`) |
|
||||
| `PHX_SERVER` | Set to `true` to start the HTTP server in a release |
|
||||
|
||||
@@ -152,15 +152,6 @@ html, body {
|
||||
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;
|
||||
@@ -312,6 +303,12 @@ html, body {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.mx-tweet-avatar--lg {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.mx-compose-body { flex: 1; }
|
||||
|
||||
.mx-compose-textarea, .mx-edit-textarea {
|
||||
@@ -466,6 +463,30 @@ html, body {
|
||||
.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-follow-btn {
|
||||
padding: 0.25rem 0.875rem;
|
||||
border-radius: 9999px;
|
||||
border: 1.5px solid var(--mx-border2);
|
||||
background: none;
|
||||
color: var(--mx-fg);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.mx-follow-btn:hover:not(:disabled) { background: var(--mx-surface2); }
|
||||
.mx-follow-btn--following {
|
||||
background: var(--mx-surface);
|
||||
color: var(--mx-muted);
|
||||
}
|
||||
.mx-follow-btn--following:hover:not(:disabled) {
|
||||
background: color-mix(in oklch, var(--mx-red) 10%, transparent);
|
||||
border-color: var(--mx-red);
|
||||
color: var(--mx-red);
|
||||
}
|
||||
.mx-follow-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.mx-tweet-text {
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
@@ -675,6 +696,104 @@ html, body {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* ── Comment button on tweet cards ── */
|
||||
.mx-comment-btn {
|
||||
text-decoration: none;
|
||||
margin-left: 0.5rem;
|
||||
color: var(--mx-fg2);
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s, transform 0.15s;
|
||||
}
|
||||
.mx-comment-btn:hover {
|
||||
color: var(--mx-accent);
|
||||
border-color: color-mix(in oklch, var(--mx-accent) 35%, transparent);
|
||||
background: color-mix(in oklch, var(--mx-accent) 10%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Non-interactive reply count badge in detail view */
|
||||
.mx-comment-count-badge {
|
||||
margin-left: 0.5rem;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
color: var(--mx-fg2);
|
||||
}
|
||||
|
||||
/* ── Comments section (below tweet detail) ── */
|
||||
.mx-comments-section {
|
||||
border-top: 1px solid var(--mx-border);
|
||||
margin-top: 0.5rem;
|
||||
padding: 0 1.5rem 1.5rem;
|
||||
}
|
||||
|
||||
.mx-comments-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 0 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--mx-fg2);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.mx-comments-divider::after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--mx-border);
|
||||
}
|
||||
|
||||
.mx-comments-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
/* Comment card — slightly indented, more compact */
|
||||
.mx-comment {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--mx-radius-sm);
|
||||
}
|
||||
|
||||
/* Small avatar variant for comments and compose-comment */
|
||||
.mx-tweet-avatar--sm {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
min-width: 28px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Compact compose box for replies */
|
||||
.mx-compose--comment {
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--mx-border);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.mx-compose--comment .mx-compose-avatar--sm { align-self: flex-start; }
|
||||
.mx-compose-textarea--sm {
|
||||
min-height: 2.5rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.mx-btn-post--sm {
|
||||
padding: 0.35rem 0.875rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
/* Small empty state */
|
||||
.mx-empty--sm {
|
||||
padding: 1.5rem 0.5rem;
|
||||
}
|
||||
|
||||
/* Small sign-in CTA */
|
||||
.mx-signin-cta--sm {
|
||||
padding: 0.75rem 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--mx-muted);
|
||||
}
|
||||
|
||||
/* ── Clickable media thumb (used in detail view) ── */
|
||||
.mx-media-thumb {
|
||||
background: none;
|
||||
@@ -778,3 +897,330 @@ html, body {
|
||||
background: var(--mx-border);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────────
|
||||
Mobile bottom navigation bar
|
||||
Only shown at ≤640 px (sidebar is hidden at that breakpoint).
|
||||
───────────────────────────────────────────────────────────────────────────── */
|
||||
.mx-mobile-nav {
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 50;
|
||||
height: 60px;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
background: color-mix(in oklch, var(--mx-bg) 92%, transparent);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border-top: 1px solid var(--mx-border);
|
||||
/* respect iPhone home indicator */
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.mx-mobile-nav { display: flex; }
|
||||
}
|
||||
|
||||
.mx-mobile-nav-item {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
color: var(--mx-muted);
|
||||
text-decoration: none;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
padding: 0.25rem 0;
|
||||
transition: color 0.15s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.mx-mobile-nav-item--active { color: var(--mx-accent); }
|
||||
|
||||
.mx-mobile-nav-item svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Centred compose button — raised pill */
|
||||
.mx-mobile-nav-compose {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: var(--mx-accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 16px color-mix(in oklch, var(--mx-accent) 55%, transparent);
|
||||
transition: background 0.15s, transform 0.12s, box-shadow 0.15s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.mx-mobile-nav-compose:hover {
|
||||
background: var(--mx-accent2);
|
||||
box-shadow: 0 6px 20px color-mix(in oklch, var(--mx-accent) 65%, transparent);
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
.mx-mobile-nav-compose:active { transform: scale(0.94); }
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────────
|
||||
Mobile compose overlay (full-screen drafting page)
|
||||
Hidden on desktop — only the mobile nav can trigger it.
|
||||
───────────────────────────────────────────────────────────────────────────── */
|
||||
@keyframes mx-overlay-in {
|
||||
from { opacity: 0; transform: translateY(28px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.mx-compose-overlay {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.mx-compose-overlay {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: var(--mx-bg);
|
||||
animation: mx-overlay-in 0.22s cubic-bezier(0.34, 1.1, 0.64, 1);
|
||||
}
|
||||
}
|
||||
|
||||
.mx-compose-overlay-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--mx-border);
|
||||
background: color-mix(in oklch, var(--mx-bg) 85%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.mx-compose-overlay-title {
|
||||
font-family: 'Instrument Serif', Georgia, serif;
|
||||
font-size: 1.125rem;
|
||||
font-style: italic;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--mx-fg);
|
||||
}
|
||||
|
||||
.mx-compose-overlay-cancel {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--mx-fg2);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0;
|
||||
min-width: 60px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.mx-compose-overlay-cancel:hover { color: var(--mx-fg); }
|
||||
|
||||
.mx-compose-overlay-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
/* ───────────────────────────────────────────────────────────────────────────────
|
||||
Responsive layout overrides
|
||||
IMPORTANT: these rules must live AFTER all component base rules so that
|
||||
the cascade works correctly (later rule of equal specificity wins).
|
||||
─────────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Tablet + mobile (≤ 960 px): single column, no side panels, bottom nav */
|
||||
@media (max-width: 960px) {
|
||||
.mx-root { grid-template-columns: 1fr; }
|
||||
.mx-sidebar { display: none; }
|
||||
.mx-rightbar { display: none; }
|
||||
/* room for fixed bottom nav */
|
||||
.mx-main { padding-bottom: 72px; }
|
||||
/* hide inline compose — the overlay FAB handles it */
|
||||
.mx-compose-wrapper { display: none; }
|
||||
}
|
||||
|
||||
/* ── Avatar image ── */
|
||||
.mx-avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: inherit;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ── Tweet sub-handle (@username) ── */
|
||||
.mx-tweet-subhandle {
|
||||
font-size: 0.78rem;
|
||||
color: var(--mx-muted);
|
||||
font-weight: 400;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
/* ── Profile editor ── */
|
||||
.mx-profile-editor {
|
||||
padding: 1.5rem 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.mx-profile-avatar-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.mx-profile-avatar-wrap {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.mx-profile-avatar-img {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
border: 2px solid var(--mx-border2);
|
||||
}
|
||||
|
||||
.mx-profile-avatar-placeholder {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
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: 2rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.mx-profile-avatar-edit-btn {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: var(--mx-accent);
|
||||
border: 2px solid var(--mx-bg);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.mx-profile-avatar-edit-btn:hover { background: var(--mx-accent2); }
|
||||
.mx-profile-avatar-edit-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
.mx-profile-stats {
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--mx-muted);
|
||||
}
|
||||
.mx-profile-stats strong { color: var(--mx-fg); }
|
||||
|
||||
.mx-profile-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.mx-profile-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--mx-fg2);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.mx-profile-input {
|
||||
background: var(--mx-surface);
|
||||
border: 1px solid var(--mx-border2);
|
||||
border-radius: var(--mx-radius-sm);
|
||||
padding: 0.5rem 0.75rem;
|
||||
color: var(--mx-fg);
|
||||
font-family: inherit;
|
||||
font-size: 0.9375rem;
|
||||
width: 100%;
|
||||
transition: border-color 0.15s;
|
||||
outline: none;
|
||||
}
|
||||
.mx-profile-input:focus { border-color: var(--mx-accent); }
|
||||
.mx-profile-input--readonly { color: var(--mx-muted); cursor: not-allowed; }
|
||||
|
||||
.mx-profile-input-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--mx-surface);
|
||||
border: 1px solid var(--mx-border2);
|
||||
border-radius: var(--mx-radius-sm);
|
||||
padding: 0 0.75rem;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.mx-profile-input-wrap:focus-within { border-color: var(--mx-accent); }
|
||||
|
||||
.mx-profile-at {
|
||||
color: var(--mx-muted);
|
||||
font-size: 0.9375rem;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.mx-profile-input--handle {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding-left: 0.25rem;
|
||||
background: transparent;
|
||||
}
|
||||
.mx-profile-input--handle:focus { border-color: transparent; }
|
||||
|
||||
.mx-profile-hint {
|
||||
font-size: 0.72rem;
|
||||
color: var(--mx-muted);
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
/* Narrow phones (≤ 640 px): tighten spacing */
|
||||
@media (max-width: 640px) {
|
||||
.mx-feed { padding: 0.625rem; gap: 0.5rem; }
|
||||
.mx-tweet { padding: 0.875rem; }
|
||||
.mx-tweet-handle {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 180px;
|
||||
}
|
||||
.mx-header { padding: 0.75rem 1rem; }
|
||||
.mx-detail { padding: 0.875rem 1rem; }
|
||||
/* 5-item nav: slightly smaller labels so nothing wraps */
|
||||
.mx-mobile-nav-item { font-size: 0.6rem; }
|
||||
}
|
||||
|
||||
200
assets/js/App.tsx
Normal file
200
assets/js/App.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import React, { useState } from "react";
|
||||
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
|
||||
import { AuthCtx } from "./context";
|
||||
import { useIsDesktop } from "./hooks";
|
||||
import { ComposeTweet } from "./components/compose";
|
||||
import { Feed, FollowingFeed, RefreshButton } from "./components/feed";
|
||||
import { TweetDetail } from "./components/tweet-detail";
|
||||
import { UserList, UserDetail } from "./components/users";
|
||||
import { MyProfile } from "./components/profile";
|
||||
import { MobileNav, MobileComposePage } from "./components/nav";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { staleTime: 10_000 } },
|
||||
});
|
||||
|
||||
export function App() {
|
||||
const appEl = document.getElementById("app")!;
|
||||
const email = appEl.dataset.currentUserEmail ?? "";
|
||||
const userId = appEl.dataset.currentUserId ?? "";
|
||||
const username = appEl.dataset.currentUserUsername ?? "";
|
||||
const displayName = appEl.dataset.currentUserDisplayName ?? "";
|
||||
const avatarUrl = appEl.dataset.currentUserAvatarUrl ?? "";
|
||||
const tweetId = appEl.dataset.tweetId || null;
|
||||
const page = appEl.dataset.page ?? "feed";
|
||||
const profileUserId = appEl.dataset.userId || null;
|
||||
|
||||
const [mobileCompose, setMobileCompose] = useState(false);
|
||||
const isDesktop = useIsDesktop();
|
||||
|
||||
const onFeedPage = page === "feed" || page === "tweet";
|
||||
const onFollowingPage = page === "following";
|
||||
const onUsersPage = page === "users" || page === "user-detail";
|
||||
const onProfilePage = page === "profile";
|
||||
|
||||
function renderMain() {
|
||||
switch (page) {
|
||||
case "tweet":
|
||||
return (
|
||||
<>
|
||||
<header className="mx-header">
|
||||
<h1 className="mx-header-title">Tweet</h1>
|
||||
</header>
|
||||
<TweetDetail tweetId={tweetId!} />
|
||||
</>
|
||||
);
|
||||
case "following":
|
||||
return (
|
||||
<>
|
||||
<header className="mx-header">
|
||||
<h1 className="mx-header-title">Following</h1>
|
||||
<RefreshButton queryKey={["following_tweets"]} />
|
||||
</header>
|
||||
<FollowingFeed />
|
||||
</>
|
||||
);
|
||||
case "users":
|
||||
return (
|
||||
<>
|
||||
<header className="mx-header">
|
||||
<h1 className="mx-header-title">Users</h1>
|
||||
</header>
|
||||
<UserList />
|
||||
</>
|
||||
);
|
||||
case "user-detail":
|
||||
return (
|
||||
<>
|
||||
<header className="mx-header">
|
||||
<h1 className="mx-header-title">Profile</h1>
|
||||
</header>
|
||||
<UserDetail userId={profileUserId!} />
|
||||
</>
|
||||
);
|
||||
case "profile":
|
||||
return (
|
||||
<>
|
||||
<header className="mx-header">
|
||||
<h1 className="mx-header-title">My Profile</h1>
|
||||
</header>
|
||||
<MyProfile />
|
||||
</>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<>
|
||||
<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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthCtx.Provider value={{ email, userId, username, displayName, avatarUrl }}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<div className="mx-root">
|
||||
{isDesktop && (
|
||||
<aside className="mx-sidebar">
|
||||
<div className="mx-logo">
|
||||
<span className="mx-logo-icon">⬡</span>
|
||||
<span className="mx-logo-text">Mixer</span>
|
||||
</div>
|
||||
<nav className="mx-nav">
|
||||
<a className={`mx-nav-item${onFeedPage ? " mx-nav-active" : ""}`} href="/feed">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" />
|
||||
</svg>
|
||||
Feed
|
||||
</a>
|
||||
<a className={`mx-nav-item${onFollowingPage ? " mx-nav-active" : ""}`} href="/following">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" />
|
||||
</svg>
|
||||
Following
|
||||
</a>
|
||||
<a className={`mx-nav-item${onUsersPage ? " mx-nav-active" : ""}`} href="/users">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z" />
|
||||
</svg>
|
||||
Users
|
||||
</a>
|
||||
<a className={`mx-nav-item${onProfilePage ? " mx-nav-active" : ""}`} href="/profile">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
||||
</svg>
|
||||
Profile
|
||||
</a>
|
||||
</nav>
|
||||
<div className="mx-sidebar-footer">
|
||||
{email ? (
|
||||
<>
|
||||
<span className="mx-version" style={{ color: "var(--mx-fg2)" }}>
|
||||
{displayName || username || email}
|
||||
</span>
|
||||
{username && (
|
||||
<span className="mx-version">@{username}</span>
|
||||
)}
|
||||
<a className="mx-auth-link" href="/sign-out">Sign out</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<a className="mx-auth-link" href="/register">Create account</a>
|
||||
<a className="mx-auth-link" href="/sign-in">Sign in</a>
|
||||
</>
|
||||
)}
|
||||
<span className="mx-version">v0.1.0</span>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<main className="mx-main">
|
||||
{renderMain()}
|
||||
</main>
|
||||
|
||||
{isDesktop && (
|
||||
<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>
|
||||
|
||||
<MobileNav page={page} onCompose={() => setMobileCompose(true)} />
|
||||
|
||||
{mobileCompose && (
|
||||
<MobileComposePage
|
||||
email={email}
|
||||
onClose={() => setMobileCompose(false)}
|
||||
/>
|
||||
)}
|
||||
</QueryClientProvider>
|
||||
</AuthCtx.Provider>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
// Do not edit this file manually
|
||||
|
||||
|
||||
import type { AshRpcError, ConditionalPaginatedResultMixed, InferResult, SortString, UUID, UnifiedFieldSelection, ValidationResult, mediaFilterInput, mediaResourceSchema, mediaSortField, tweetsFilterInput, tweetsResourceSchema, tweetsSortField, usersFilterInput, usersResourceSchema, usersSortField } from "./ash_types";
|
||||
import type { AshRpcError, ConditionalPaginatedResultMixed, InferResult, SortString, UUID, UnifiedFieldSelection, ValidationResult, followsFilterInput, followsResourceSchema, followsSortField, mediaFilterInput, mediaResourceSchema, mediaSortField, tweetsFilterInput, tweetsResourceSchema, tweetsSortField, usersFilterInput, usersResourceSchema, usersSortField } from "./ash_types";
|
||||
export type * from "./ash_types";
|
||||
|
||||
// Helper Functions
|
||||
@@ -201,6 +201,245 @@ export async function executeValidationRpcRequest<T>(
|
||||
|
||||
|
||||
|
||||
export type FollowUserInput = {
|
||||
followingId: UUID;
|
||||
};
|
||||
|
||||
export type FollowUserFields = UnifiedFieldSelection<followsResourceSchema>[];
|
||||
|
||||
export type InferFollowUserResult<
|
||||
Fields extends FollowUserFields | undefined,
|
||||
> = InferResult<followsResourceSchema, Fields>;
|
||||
|
||||
export type FollowUserResult<Fields extends FollowUserFields | undefined = undefined> = | { success: true; data: InferFollowUserResult<Fields>; }
|
||||
| { success: false; errors: AshRpcError[]; }
|
||||
|
||||
;
|
||||
|
||||
/**
|
||||
* Create a new Follow
|
||||
*
|
||||
* @ashActionType :create
|
||||
*/
|
||||
export async function followUser<Fields extends FollowUserFields | undefined = undefined>(
|
||||
config: {
|
||||
tenant?: string;
|
||||
input: FollowUserInput;
|
||||
fields?: Fields;
|
||||
headers?: Record<string, string>;
|
||||
fetchOptions?: RequestInit;
|
||||
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
}
|
||||
): Promise<FollowUserResult<Fields extends undefined ? [] : Fields>> {
|
||||
const payload = {
|
||||
action: "follow_user",
|
||||
...(config.tenant !== undefined && { tenant: config.tenant }),
|
||||
input: config.input,
|
||||
...(config.fields !== undefined && { fields: config.fields })
|
||||
};
|
||||
|
||||
return executeActionRpcRequest<FollowUserResult<Fields extends undefined ? [] : Fields>>(
|
||||
payload,
|
||||
config
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Validate: Create a new Follow
|
||||
*
|
||||
* @ashActionType :create
|
||||
* @validation true
|
||||
*/
|
||||
export async function validateFollowUser(
|
||||
config: {
|
||||
tenant?: string;
|
||||
input: FollowUserInput;
|
||||
headers?: Record<string, string>;
|
||||
fetchOptions?: RequestInit;
|
||||
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
}
|
||||
): Promise<ValidationResult> {
|
||||
const payload = {
|
||||
action: "follow_user",
|
||||
...(config.tenant !== undefined && { tenant: config.tenant }),
|
||||
input: config.input
|
||||
};
|
||||
|
||||
return executeValidationRpcRequest<ValidationResult>(
|
||||
payload,
|
||||
config
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export type ReadFollowFields = UnifiedFieldSelection<followsResourceSchema>[];
|
||||
|
||||
|
||||
export type InferReadFollowResult<
|
||||
Fields extends ReadFollowFields | undefined,
|
||||
Page extends ReadFollowConfig["page"] = undefined
|
||||
> = ConditionalPaginatedResultMixed<Page, Array<InferResult<followsResourceSchema, Fields>>, {
|
||||
results: Array<InferResult<followsResourceSchema, Fields>>;
|
||||
hasMore: boolean;
|
||||
limit: number;
|
||||
offset: number;
|
||||
count?: number | null;
|
||||
type: "offset";
|
||||
}, {
|
||||
results: Array<InferResult<followsResourceSchema, Fields>>;
|
||||
hasMore: boolean;
|
||||
limit: number;
|
||||
after: string | null;
|
||||
before: string | null;
|
||||
previousPage: string;
|
||||
nextPage: string;
|
||||
count?: number | null;
|
||||
type: "keyset";
|
||||
}>;
|
||||
|
||||
export type ReadFollowConfig = {
|
||||
tenant?: string;
|
||||
fields: ReadFollowFields;
|
||||
filter?: followsFilterInput;
|
||||
sort?: SortString<followsSortField> | SortString<followsSortField>[];
|
||||
page?: (
|
||||
{
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
count?: boolean;
|
||||
} | {
|
||||
limit?: number;
|
||||
after?: string;
|
||||
before?: string;
|
||||
}
|
||||
);
|
||||
headers?: Record<string, string>;
|
||||
fetchOptions?: RequestInit;
|
||||
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
};
|
||||
|
||||
export type ReadFollowResult<Fields extends ReadFollowFields, Page extends ReadFollowConfig["page"] = undefined> = | { success: true; data: InferReadFollowResult<Fields, Page>; }
|
||||
| { success: false; errors: AshRpcError[]; }
|
||||
|
||||
;
|
||||
|
||||
/**
|
||||
* Read Follow records
|
||||
*
|
||||
* @ashActionType :read
|
||||
*/
|
||||
export async function readFollow<Fields extends ReadFollowFields, Config extends ReadFollowConfig = ReadFollowConfig>(
|
||||
config: Config & { fields: Fields }
|
||||
): Promise<ReadFollowResult<Fields, Config["page"]>> {
|
||||
const payload = {
|
||||
action: "read_follow",
|
||||
...(config.tenant !== undefined && { tenant: config.tenant }),
|
||||
...(config.fields !== undefined && { fields: config.fields }),
|
||||
...(config.filter && { filter: config.filter }),
|
||||
...(config.sort && { sort: Array.isArray(config.sort) ? config.sort.join(",") : config.sort }),
|
||||
...(config.page && { page: config.page })
|
||||
};
|
||||
|
||||
return executeActionRpcRequest<ReadFollowResult<Fields, Config["page"]>>(
|
||||
payload,
|
||||
config
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Validate: Read Follow records
|
||||
*
|
||||
* @ashActionType :read
|
||||
* @validation true
|
||||
*/
|
||||
export async function validateReadFollow(
|
||||
config: {
|
||||
tenant?: string;
|
||||
headers?: Record<string, string>;
|
||||
fetchOptions?: RequestInit;
|
||||
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
}
|
||||
): Promise<ValidationResult> {
|
||||
const payload = {
|
||||
action: "read_follow",
|
||||
...(config.tenant !== undefined && { tenant: config.tenant })
|
||||
};
|
||||
|
||||
return executeValidationRpcRequest<ValidationResult>(
|
||||
payload,
|
||||
config
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export type UnfollowUserInput = {
|
||||
followingId: UUID;
|
||||
};
|
||||
|
||||
export type InferUnfollowUserResult = {};
|
||||
|
||||
export type UnfollowUserResult = | { success: true; data: InferUnfollowUserResult; }
|
||||
| { success: false; errors: AshRpcError[]; }
|
||||
|
||||
;
|
||||
|
||||
/**
|
||||
* Execute generic action on Follow
|
||||
*
|
||||
* @ashActionType :action
|
||||
*/
|
||||
export async function unfollowUser(
|
||||
config: {
|
||||
tenant?: string;
|
||||
input: UnfollowUserInput;
|
||||
headers?: Record<string, string>;
|
||||
fetchOptions?: RequestInit;
|
||||
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
}
|
||||
): Promise<UnfollowUserResult> {
|
||||
const payload = {
|
||||
action: "unfollow_user",
|
||||
...(config.tenant !== undefined && { tenant: config.tenant }),
|
||||
input: config.input
|
||||
};
|
||||
|
||||
return executeActionRpcRequest<UnfollowUserResult>(
|
||||
payload,
|
||||
config
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Validate: Execute generic action on Follow
|
||||
*
|
||||
* @ashActionType :action
|
||||
* @validation true
|
||||
*/
|
||||
export async function validateUnfollowUser(
|
||||
config: {
|
||||
tenant?: string;
|
||||
input: UnfollowUserInput;
|
||||
headers?: Record<string, string>;
|
||||
fetchOptions?: RequestInit;
|
||||
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
}
|
||||
): Promise<ValidationResult> {
|
||||
const payload = {
|
||||
action: "unfollow_user",
|
||||
...(config.tenant !== undefined && { tenant: config.tenant }),
|
||||
input: config.input
|
||||
};
|
||||
|
||||
return executeValidationRpcRequest<ValidationResult>(
|
||||
payload,
|
||||
config
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export type ReadUserFields = UnifiedFieldSelection<usersResourceSchema>[];
|
||||
|
||||
|
||||
@@ -302,6 +541,83 @@ export async function validateReadUser(
|
||||
}
|
||||
|
||||
|
||||
export type UpdateProfileInput = {
|
||||
username?: string | null;
|
||||
displayName?: string | null;
|
||||
};
|
||||
|
||||
export type UpdateProfileFields = UnifiedFieldSelection<usersResourceSchema>[];
|
||||
|
||||
export type InferUpdateProfileResult<
|
||||
Fields extends UpdateProfileFields | undefined,
|
||||
> = InferResult<usersResourceSchema, Fields>;
|
||||
|
||||
export type UpdateProfileResult<Fields extends UpdateProfileFields | undefined = undefined> = | { success: true; data: InferUpdateProfileResult<Fields>; }
|
||||
| { success: false; errors: AshRpcError[]; }
|
||||
|
||||
;
|
||||
|
||||
/**
|
||||
* Update an existing User
|
||||
*
|
||||
* @ashActionType :update
|
||||
*/
|
||||
export async function updateProfile<Fields extends UpdateProfileFields | undefined = undefined>(
|
||||
config: {
|
||||
tenant?: string;
|
||||
identity: UUID;
|
||||
input?: UpdateProfileInput;
|
||||
fields?: Fields;
|
||||
headers?: Record<string, string>;
|
||||
fetchOptions?: RequestInit;
|
||||
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
}
|
||||
): Promise<UpdateProfileResult<Fields extends undefined ? [] : Fields>> {
|
||||
const payload = {
|
||||
action: "update_profile",
|
||||
...(config.tenant !== undefined && { tenant: config.tenant }),
|
||||
identity: config.identity,
|
||||
input: config.input,
|
||||
...(config.fields !== undefined && { fields: config.fields })
|
||||
};
|
||||
|
||||
return executeActionRpcRequest<UpdateProfileResult<Fields extends undefined ? [] : Fields>>(
|
||||
payload,
|
||||
config
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Validate: Update an existing User
|
||||
*
|
||||
* @ashActionType :update
|
||||
* @validation true
|
||||
*/
|
||||
export async function validateUpdateProfile(
|
||||
config: {
|
||||
tenant?: string;
|
||||
identity: UUID | string;
|
||||
input?: UpdateProfileInput;
|
||||
headers?: Record<string, string>;
|
||||
fetchOptions?: RequestInit;
|
||||
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
}
|
||||
): Promise<ValidationResult> {
|
||||
const payload = {
|
||||
action: "update_profile",
|
||||
...(config.tenant !== undefined && { tenant: config.tenant }),
|
||||
identity: config.identity,
|
||||
input: config.input
|
||||
};
|
||||
|
||||
return executeValidationRpcRequest<ValidationResult>(
|
||||
payload,
|
||||
config
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export type ReadMediaFields = UnifiedFieldSelection<mediaResourceSchema>[];
|
||||
|
||||
|
||||
@@ -405,6 +721,7 @@ export async function validateReadMedia(
|
||||
|
||||
export type CreateTweetInput = {
|
||||
content: string;
|
||||
parentTweetId?: UUID | null;
|
||||
mediaId?: UUID;
|
||||
};
|
||||
|
||||
@@ -604,6 +921,73 @@ export async function validateLikeTweet(
|
||||
}
|
||||
|
||||
|
||||
export type ReadFollowingFeedFields = UnifiedFieldSelection<tweetsResourceSchema>[];
|
||||
export type InferReadFollowingFeedResult<
|
||||
Fields extends ReadFollowingFeedFields,
|
||||
> = Array<InferResult<tweetsResourceSchema, Fields>>;
|
||||
|
||||
export type ReadFollowingFeedResult<Fields extends ReadFollowingFeedFields> = | { success: true; data: InferReadFollowingFeedResult<Fields>; }
|
||||
| { success: false; errors: AshRpcError[]; }
|
||||
|
||||
;
|
||||
|
||||
/**
|
||||
* Read Tweet records
|
||||
*
|
||||
* @ashActionType :read
|
||||
*/
|
||||
export async function readFollowingFeed<Fields extends ReadFollowingFeedFields>(
|
||||
config: {
|
||||
tenant?: string;
|
||||
fields: Fields;
|
||||
filter?: tweetsFilterInput;
|
||||
sort?: SortString<tweetsSortField> | SortString<tweetsSortField>[];
|
||||
headers?: Record<string, string>;
|
||||
fetchOptions?: RequestInit;
|
||||
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
}
|
||||
): Promise<ReadFollowingFeedResult<Fields>> {
|
||||
const payload = {
|
||||
action: "read_following_feed",
|
||||
...(config.tenant !== undefined && { tenant: config.tenant }),
|
||||
...(config.fields !== undefined && { fields: config.fields }),
|
||||
...(config.filter && { filter: config.filter }),
|
||||
...(config.sort && { sort: Array.isArray(config.sort) ? config.sort.join(",") : config.sort })
|
||||
};
|
||||
|
||||
return executeActionRpcRequest<ReadFollowingFeedResult<Fields>>(
|
||||
payload,
|
||||
config
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Validate: Read Tweet records
|
||||
*
|
||||
* @ashActionType :read
|
||||
* @validation true
|
||||
*/
|
||||
export async function validateReadFollowingFeed(
|
||||
config: {
|
||||
tenant?: string;
|
||||
headers?: Record<string, string>;
|
||||
fetchOptions?: RequestInit;
|
||||
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
}
|
||||
): Promise<ValidationResult> {
|
||||
const payload = {
|
||||
action: "read_following_feed",
|
||||
...(config.tenant !== undefined && { tenant: config.tenant })
|
||||
};
|
||||
|
||||
return executeValidationRpcRequest<ValidationResult>(
|
||||
payload,
|
||||
config
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export type ReadTweetFields = UnifiedFieldSelection<tweetsResourceSchema>[];
|
||||
|
||||
|
||||
|
||||
@@ -6,21 +6,47 @@
|
||||
export type UUID = string;
|
||||
export type UtcDateTimeUsec = string;
|
||||
|
||||
// follows Schema
|
||||
export type followsResourceSchema = {
|
||||
__type: "Resource";
|
||||
__primitiveFields: "id";
|
||||
id: UUID;
|
||||
};
|
||||
|
||||
|
||||
|
||||
export type followsAttributesOnlySchema = {
|
||||
__type: "Resource";
|
||||
__primitiveFields: "id";
|
||||
id: UUID;
|
||||
};
|
||||
|
||||
|
||||
// users Schema
|
||||
export type usersResourceSchema = {
|
||||
__type: "Resource";
|
||||
__primitiveFields: "id" | "email";
|
||||
__primitiveFields: "id" | "email" | "username" | "displayName" | "avatarUrl" | "followerCount" | "followingCount" | "amIFollowing" | "myFollowId";
|
||||
id: UUID;
|
||||
email: string;
|
||||
username: string | null;
|
||||
displayName: string | null;
|
||||
avatarUrl: string | null;
|
||||
followerCount: number;
|
||||
followingCount: number;
|
||||
amIFollowing: boolean;
|
||||
myFollowId: UUID;
|
||||
};
|
||||
|
||||
|
||||
|
||||
export type usersAttributesOnlySchema = {
|
||||
__type: "Resource";
|
||||
__primitiveFields: "id" | "email";
|
||||
__primitiveFields: "id" | "email" | "username" | "displayName" | "avatarUrl";
|
||||
id: UUID;
|
||||
email: string;
|
||||
username: string | null;
|
||||
displayName: string | null;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
|
||||
|
||||
@@ -51,16 +77,23 @@ export type mediaAttributesOnlySchema = {
|
||||
// tweets Schema
|
||||
export type tweetsResourceSchema = {
|
||||
__type: "Resource";
|
||||
__primitiveFields: "id" | "content" | "likes" | "userId" | "insertedAt" | "state" | "likedByMe" | "userEmail";
|
||||
__primitiveFields: "id" | "content" | "likes" | "userId" | "insertedAt" | "state" | "parentTweetId" | "commentCount" | "likedByMe" | "userEmail" | "userUsername" | "userDisplayName" | "userAvatarUrl";
|
||||
id: UUID;
|
||||
content: string;
|
||||
likes: number;
|
||||
userId: UUID;
|
||||
insertedAt: UtcDateTimeUsec;
|
||||
state: "posted" | "drafted";
|
||||
parentTweetId: UUID | null;
|
||||
commentCount: number;
|
||||
likedByMe: boolean;
|
||||
userEmail: string | null;
|
||||
userUsername: string | null;
|
||||
userDisplayName: string | null;
|
||||
userAvatarUrl: string | null;
|
||||
user: { __type: "Relationship"; __resource: usersResourceSchema; };
|
||||
parentTweet: { __type: "Relationship"; __resource: tweetsResourceSchema | null; };
|
||||
comments: { __type: "Relationship"; __array: true; __resource: tweetsResourceSchema; };
|
||||
media: { __type: "Relationship"; __array: true; __resource: mediaResourceSchema; };
|
||||
};
|
||||
|
||||
@@ -68,16 +101,31 @@ export type tweetsResourceSchema = {
|
||||
|
||||
export type tweetsAttributesOnlySchema = {
|
||||
__type: "Resource";
|
||||
__primitiveFields: "id" | "content" | "likes" | "userId" | "insertedAt" | "state";
|
||||
__primitiveFields: "id" | "content" | "likes" | "userId" | "insertedAt" | "state" | "parentTweetId";
|
||||
id: UUID;
|
||||
content: string;
|
||||
likes: number;
|
||||
userId: UUID;
|
||||
insertedAt: UtcDateTimeUsec;
|
||||
state: "posted" | "drafted";
|
||||
parentTweetId: UUID | null;
|
||||
};
|
||||
|
||||
|
||||
export type followsFilterInput = {
|
||||
and?: Array<followsFilterInput>;
|
||||
or?: Array<followsFilterInput>;
|
||||
not?: Array<followsFilterInput>;
|
||||
|
||||
id?: {
|
||||
eq?: UUID;
|
||||
notEq?: UUID;
|
||||
in?: Array<UUID>;
|
||||
};
|
||||
|
||||
|
||||
|
||||
};
|
||||
export type usersFilterInput = {
|
||||
and?: Array<usersFilterInput>;
|
||||
or?: Array<usersFilterInput>;
|
||||
@@ -95,6 +143,61 @@ export type usersFilterInput = {
|
||||
in?: Array<string>;
|
||||
};
|
||||
|
||||
username?: {
|
||||
eq?: string;
|
||||
notEq?: string;
|
||||
in?: Array<string>;
|
||||
isNil?: boolean;
|
||||
};
|
||||
|
||||
displayName?: {
|
||||
eq?: string;
|
||||
notEq?: string;
|
||||
in?: Array<string>;
|
||||
isNil?: boolean;
|
||||
};
|
||||
|
||||
avatarUrl?: {
|
||||
eq?: string;
|
||||
notEq?: string;
|
||||
in?: Array<string>;
|
||||
isNil?: boolean;
|
||||
};
|
||||
|
||||
followerCount?: {
|
||||
eq?: number;
|
||||
notEq?: number;
|
||||
greaterThan?: number;
|
||||
greaterThanOrEqual?: number;
|
||||
lessThan?: number;
|
||||
lessThanOrEqual?: number;
|
||||
in?: Array<number>;
|
||||
isNil?: boolean;
|
||||
};
|
||||
|
||||
followingCount?: {
|
||||
eq?: number;
|
||||
notEq?: number;
|
||||
greaterThan?: number;
|
||||
greaterThanOrEqual?: number;
|
||||
lessThan?: number;
|
||||
lessThanOrEqual?: number;
|
||||
in?: Array<number>;
|
||||
isNil?: boolean;
|
||||
};
|
||||
|
||||
amIFollowing?: {
|
||||
eq?: boolean;
|
||||
notEq?: boolean;
|
||||
isNil?: boolean;
|
||||
};
|
||||
|
||||
myFollowId?: {
|
||||
eq?: UUID;
|
||||
notEq?: UUID;
|
||||
in?: Array<UUID>;
|
||||
isNil?: boolean;
|
||||
};
|
||||
|
||||
|
||||
};
|
||||
@@ -183,6 +286,13 @@ export type tweetsFilterInput = {
|
||||
in?: Array<"posted" | "drafted">;
|
||||
};
|
||||
|
||||
parentTweetId?: {
|
||||
eq?: UUID;
|
||||
notEq?: UUID;
|
||||
in?: Array<UUID>;
|
||||
isNil?: boolean;
|
||||
};
|
||||
|
||||
userEmail?: {
|
||||
eq?: string;
|
||||
notEq?: string;
|
||||
@@ -190,6 +300,38 @@ export type tweetsFilterInput = {
|
||||
isNil?: boolean;
|
||||
};
|
||||
|
||||
userUsername?: {
|
||||
eq?: string;
|
||||
notEq?: string;
|
||||
in?: Array<string>;
|
||||
isNil?: boolean;
|
||||
};
|
||||
|
||||
userDisplayName?: {
|
||||
eq?: string;
|
||||
notEq?: string;
|
||||
in?: Array<string>;
|
||||
isNil?: boolean;
|
||||
};
|
||||
|
||||
userAvatarUrl?: {
|
||||
eq?: string;
|
||||
notEq?: string;
|
||||
in?: Array<string>;
|
||||
isNil?: boolean;
|
||||
};
|
||||
|
||||
commentCount?: {
|
||||
eq?: number;
|
||||
notEq?: number;
|
||||
greaterThan?: number;
|
||||
greaterThanOrEqual?: number;
|
||||
lessThan?: number;
|
||||
lessThanOrEqual?: number;
|
||||
in?: Array<number>;
|
||||
isNil?: boolean;
|
||||
};
|
||||
|
||||
likedByMe?: {
|
||||
eq?: boolean;
|
||||
notEq?: boolean;
|
||||
@@ -198,28 +340,38 @@ export type tweetsFilterInput = {
|
||||
|
||||
user?: usersFilterInput;
|
||||
|
||||
parentTweet?: tweetsFilterInput;
|
||||
|
||||
comments?: tweetsFilterInput;
|
||||
|
||||
media?: mediaFilterInput;
|
||||
|
||||
};
|
||||
|
||||
|
||||
export const usersFilterFields = ["id", "email"] as const;
|
||||
export const followsFilterFields = ["id"] as const;
|
||||
export type followsFilterField = (typeof followsFilterFields)[number];
|
||||
|
||||
export const usersFilterFields = ["id", "email", "username", "displayName", "avatarUrl", "followerCount", "followingCount", "amIFollowing", "myFollowId"] as const;
|
||||
export type usersFilterField = (typeof usersFilterFields)[number];
|
||||
|
||||
export const mediaFilterFields = ["id", "s3Key", "userId", "tweetId", "user", "tweet"] as const;
|
||||
export type mediaFilterField = (typeof mediaFilterFields)[number];
|
||||
|
||||
export const tweetsFilterFields = ["id", "content", "likes", "userId", "insertedAt", "state", "userEmail", "likedByMe", "user", "media"] as const;
|
||||
export const tweetsFilterFields = ["id", "content", "likes", "userId", "insertedAt", "state", "parentTweetId", "userEmail", "userUsername", "userDisplayName", "userAvatarUrl", "commentCount", "likedByMe", "user", "parentTweet", "comments", "media"] as const;
|
||||
export type tweetsFilterField = (typeof tweetsFilterFields)[number];
|
||||
|
||||
|
||||
export const usersSortFields = ["id", "email"] as const;
|
||||
export const followsSortFields = ["id"] as const;
|
||||
export type followsSortField = (typeof followsSortFields)[number];
|
||||
|
||||
export const usersSortFields = ["id", "email", "username", "displayName", "avatarUrl", "followerCount", "followingCount", "amIFollowing", "myFollowId"] as const;
|
||||
export type usersSortField = (typeof usersSortFields)[number];
|
||||
|
||||
export const mediaSortFields = ["id", "s3Key", "userId", "tweetId"] as const;
|
||||
export type mediaSortField = (typeof mediaSortFields)[number];
|
||||
|
||||
export const tweetsSortFields = ["id", "content", "likes", "userId", "insertedAt", "state", "userEmail", "likedByMe"] as const;
|
||||
export const tweetsSortFields = ["id", "content", "likes", "userId", "insertedAt", "state", "parentTweetId", "userEmail", "userUsername", "userDisplayName", "userAvatarUrl", "commentCount", "likedByMe"] as const;
|
||||
export type tweetsSortField = (typeof tweetsSortFields)[number];
|
||||
|
||||
|
||||
|
||||
294
assets/js/components/compose.tsx
Normal file
294
assets/js/components/compose.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
import React, { useState, useRef, useEffect, useContext } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { createTweet, buildCSRFHeaders } from "../ash_rpc";
|
||||
import { uploadFile } from "../upload";
|
||||
import { AuthCtx } from "../context";
|
||||
import { Avatar, CharCount } from "./ui";
|
||||
|
||||
const MAX = 280;
|
||||
|
||||
export function ComposeTweet({ onSuccess }: { onSuccess?: () => void }) {
|
||||
const { username, displayName, email, avatarUrl } = useContext(AuthCtx);
|
||||
const [text, setText] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [mediaId, setMediaId] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const qc = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (content: string) => {
|
||||
const res = await createTweet({
|
||||
input: { content, mediaId: mediaId ?? undefined },
|
||||
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"] });
|
||||
qc.invalidateQueries({ queryKey: ["following_tweets"] });
|
||||
setText("");
|
||||
setError(null);
|
||||
setMediaId(null);
|
||||
setPendingFile(null);
|
||||
setUploadError(null);
|
||||
if (previewUrl) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
setPreviewUrl(null);
|
||||
}
|
||||
onSuccess?.();
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
e.target.value = "";
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||
const localUrl = URL.createObjectURL(file);
|
||||
setPendingFile(file);
|
||||
setPreviewUrl(localUrl);
|
||||
setMediaId(null);
|
||||
setUploadError(null);
|
||||
setUploading(true);
|
||||
const csrfToken = buildCSRFHeaders()["X-CSRF-Token"] as string;
|
||||
const result = await uploadFile(file, csrfToken);
|
||||
setUploading(false);
|
||||
if ("error" in result) {
|
||||
setUploadError(result.error);
|
||||
setPendingFile(null);
|
||||
URL.revokeObjectURL(localUrl);
|
||||
setPreviewUrl(null);
|
||||
} else {
|
||||
setMediaId(result.mediaId);
|
||||
}
|
||||
}
|
||||
|
||||
function removeAttachment() {
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||
setPendingFile(null);
|
||||
setPreviewUrl(null);
|
||||
setMediaId(null);
|
||||
setUploadError(null);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
}, [text]);
|
||||
|
||||
return (
|
||||
<div className="mx-compose">
|
||||
<Avatar avatarUrl={avatarUrl} name={displayName || username || email} />
|
||||
<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}
|
||||
/>
|
||||
{previewUrl && pendingFile && (
|
||||
<div style={{ position: "relative", marginTop: "0.5rem", display: "inline-block" }}>
|
||||
{/\.(mp4|mov)$/i.test(pendingFile.name) ? (
|
||||
<video
|
||||
src={previewUrl}
|
||||
controls
|
||||
style={{ maxWidth: "100%", maxHeight: "200px", borderRadius: "0.5rem", display: "block" }}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="attachment preview"
|
||||
style={{ maxWidth: "100%", maxHeight: "200px", borderRadius: "0.5rem", display: "block" }}
|
||||
/>
|
||||
)}
|
||||
{uploading && (
|
||||
<div style={{
|
||||
position: "absolute", inset: 0, background: "rgba(0,0,0,0.4)",
|
||||
borderRadius: "0.5rem", display: "flex", alignItems: "center", justifyContent: "center",
|
||||
color: "#fff", fontSize: "0.75rem"
|
||||
}}>
|
||||
Uploading…
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={removeAttachment}
|
||||
style={{
|
||||
position: "absolute", top: "4px", right: "4px",
|
||||
background: "rgba(0,0,0,0.6)", border: "none", borderRadius: "50%",
|
||||
width: "20px", height: "20px", cursor: "pointer",
|
||||
color: "#fff", fontSize: "12px", lineHeight: 1,
|
||||
display: "flex", alignItems: "center", justifyContent: "center"
|
||||
}}
|
||||
title="Remove attachment"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{uploadError && <p className="mx-compose-error">{uploadError}</p>}
|
||||
{error && <p className="mx-compose-error">{error}</p>}
|
||||
<div className="mx-compose-footer">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
<button
|
||||
type="button"
|
||||
className="mx-action-btn"
|
||||
title="Attach image or video"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading || mutation.isPending}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,video/mp4,video/quicktime"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
{uploading && (
|
||||
<span style={{ fontSize: "0.75rem", color: "var(--mx-muted)" }}>
|
||||
{pendingFile?.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mx-compose-actions">
|
||||
<CharCount current={text.length} max={MAX} />
|
||||
<button
|
||||
className="mx-btn-post"
|
||||
onClick={submit}
|
||||
disabled={!text.trim() || mutation.isPending || uploading}
|
||||
>
|
||||
{mutation.isPending ? "Posting…" : "Post"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ComposeComment({
|
||||
parentTweetId,
|
||||
onSuccess,
|
||||
}: {
|
||||
parentTweetId: string;
|
||||
onSuccess?: () => void;
|
||||
}) {
|
||||
const [text, setText] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { username, displayName, email, avatarUrl } = useContext(AuthCtx);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (content: string) => {
|
||||
const res = await createTweet({
|
||||
input: { content, parentTweetId },
|
||||
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: ["comments", parentTweetId] });
|
||||
qc.invalidateQueries({ queryKey: ["tweet", parentTweetId] });
|
||||
qc.invalidateQueries({ queryKey: ["tweets"] });
|
||||
qc.invalidateQueries({ queryKey: ["following_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);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
}, [text]);
|
||||
|
||||
return (
|
||||
<div className="mx-compose mx-compose--comment">
|
||||
<Avatar avatarUrl={avatarUrl} name={displayName || username || email} size="sm" />
|
||||
<div className="mx-compose-body">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="mx-compose-textarea mx-compose-textarea--sm"
|
||||
placeholder="Post your reply…"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={1}
|
||||
maxLength={MAX + 1}
|
||||
/>
|
||||
{error && <p className="mx-compose-error">{error}</p>}
|
||||
<div className="mx-compose-footer">
|
||||
<div />
|
||||
<div className="mx-compose-actions">
|
||||
<CharCount current={text.length} max={MAX} />
|
||||
<button
|
||||
className="mx-btn-post mx-btn-post--sm"
|
||||
onClick={submit}
|
||||
disabled={!text.trim() || mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? "Replying…" : "Reply"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
199
assets/js/components/feed.tsx
Normal file
199
assets/js/components/feed.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import React, { useRef, useEffect, useContext, useState } from "react";
|
||||
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { readTweet, readFollowingFeed, buildCSRFHeaders } from "../ash_rpc";
|
||||
import { AuthCtx } from "../context";
|
||||
import { FEED_PAGE_SIZE } from "../constants";
|
||||
import { Spinner, ErrorBanner } from "./ui";
|
||||
import { TweetCard } from "./tweet-card";
|
||||
import type { Tweet } from "../types";
|
||||
|
||||
export function Feed() {
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ["tweets"],
|
||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||
const res = await readTweet({
|
||||
fields: ["id", "content", "likes", "likedByMe", "commentCount", "userId", "state", "userEmail", "userUsername", "userDisplayName", "userAvatarUrl", "insertedAt", { media: ["id", "s3Key"] }],
|
||||
sort: "-insertedAt",
|
||||
page: { limit: FEED_PAGE_SIZE, offset: pageParam },
|
||||
filter: { parentTweetId: { isNil: true } },
|
||||
headers: buildCSRFHeaders(),
|
||||
});
|
||||
if (!res.success) throw new Error("Failed to load tweets");
|
||||
const pageData = res.data as any;
|
||||
const tweets: Tweet[] = Array.isArray(pageData) ? pageData : (pageData?.results ?? []);
|
||||
const hasMore: boolean = Array.isArray(pageData) ? false : (pageData?.hasMore ?? false);
|
||||
return { tweets, hasMore, nextOffset: pageParam + FEED_PAGE_SIZE };
|
||||
},
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage) => lastPage.hasMore ? lastPage.nextOffset : undefined,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const el = sentinelRef.current;
|
||||
if (!el) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
if (isError) return <ErrorBanner message={(error as Error)?.message ?? "Could not load tweets"} />;
|
||||
|
||||
const tweets = data?.pages.flatMap((p) => p.tweets) ?? [];
|
||||
|
||||
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 ref={sentinelRef} style={{ height: "1px" }} />
|
||||
{isFetchingNextPage && <Spinner />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FollowingFeed() {
|
||||
const { userId } = useContext(AuthCtx);
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ["following_tweets"],
|
||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||
const res = await readFollowingFeed({
|
||||
fields: ["id", "content", "likes", "likedByMe", "commentCount", "userId", "state", "userEmail", "userUsername", "userDisplayName", "userAvatarUrl", "insertedAt", { media: ["id", "s3Key"] }],
|
||||
sort: "-insertedAt",
|
||||
page: { limit: FEED_PAGE_SIZE, offset: pageParam },
|
||||
filter: { parentTweetId: { isNil: true } },
|
||||
headers: buildCSRFHeaders(),
|
||||
});
|
||||
if (!res.success) throw new Error("Failed to load following feed");
|
||||
const pageData = res.data as any;
|
||||
const tweets: Tweet[] = Array.isArray(pageData) ? pageData : (pageData?.results ?? []);
|
||||
const hasMore: boolean = Array.isArray(pageData) ? false : (pageData?.hasMore ?? false);
|
||||
return { tweets, hasMore, nextOffset: pageParam + FEED_PAGE_SIZE };
|
||||
},
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage) => lastPage.hasMore ? lastPage.nextOffset : undefined,
|
||||
enabled: !!userId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const el = sentinelRef.current;
|
||||
if (!el) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
if (!userId) {
|
||||
return (
|
||||
<div className="mx-empty">
|
||||
<div className="mx-empty-icon">★</div>
|
||||
<p className="mx-empty-title">Your personalised feed</p>
|
||||
<p className="mx-empty-sub">
|
||||
<a href="/sign-in" style={{ color: "var(--mx-accent)", textDecoration: "none" }}>Sign in</a>
|
||||
{" "}to see posts from people you follow.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
if (isError) return <ErrorBanner message={(error as Error)?.message ?? "Could not load following feed"} />;
|
||||
|
||||
const tweets = data?.pages.flatMap((p) => p.tweets) ?? [];
|
||||
|
||||
if (tweets.length === 0) {
|
||||
return (
|
||||
<div className="mx-empty">
|
||||
<div className="mx-empty-icon">★</div>
|
||||
<p className="mx-empty-title">Nothing here yet</p>
|
||||
<p className="mx-empty-sub">
|
||||
Follow some people from the{" "}
|
||||
<a href="/users" style={{ color: "var(--mx-accent)", textDecoration: "none" }}>Users</a>
|
||||
{" "}page to fill this feed.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-feed">
|
||||
{tweets.map((t) => (
|
||||
<TweetCard key={t.id} tweet={t} />
|
||||
))}
|
||||
<div ref={sentinelRef} style={{ height: "1px" }} />
|
||||
{isFetchingNextPage && <Spinner />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RefreshButton({ queryKey = ["tweets"] }: { queryKey?: string[] }) {
|
||||
const qc = useQueryClient();
|
||||
const [spinning, setSpinning] = useState(false);
|
||||
|
||||
async function refresh() {
|
||||
setSpinning(true);
|
||||
await qc.invalidateQueries({ queryKey });
|
||||
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>
|
||||
);
|
||||
}
|
||||
55
assets/js/components/media.tsx
Normal file
55
assets/js/components/media.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { getAssetHost } from "../utils";
|
||||
import type { MediaItem } from "../types";
|
||||
|
||||
export function TweetMedia({ media }: { media: MediaItem[] }) {
|
||||
const assetHost = getAssetHost();
|
||||
return (
|
||||
<div style={{ marginTop: "0.5rem", display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
||||
{media.map((m) =>
|
||||
/\.(mp4|mov)$/i.test(m.s3Key) ? (
|
||||
<video
|
||||
key={m.id}
|
||||
src={`${assetHost}/${m.s3Key}`}
|
||||
controls
|
||||
style={{ maxWidth: "100%", borderRadius: "0.5rem" }}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
key={m.id}
|
||||
src={`${assetHost}/${m.s3Key}`}
|
||||
alt=""
|
||||
style={{ maxWidth: "100%", borderRadius: "0.5rem", display: "block" }}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MediaLightbox({ item, onClose }: { item: MediaItem; onClose: () => void }) {
|
||||
const assetHost = getAssetHost();
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
return createPortal(
|
||||
<div className="mx-lightbox" onClick={onClose}>
|
||||
<button className="mx-lightbox-close" onClick={onClose}>✕</button>
|
||||
<div className="mx-lightbox-content" onClick={(e) => e.stopPropagation()}>
|
||||
{/\.(mp4|mov)$/i.test(item.s3Key) ? (
|
||||
<video src={`${assetHost}/${item.s3Key}`} controls autoPlay className="mx-lightbox-media" />
|
||||
) : (
|
||||
<img src={`${assetHost}/${item.s3Key}`} alt="" className="mx-lightbox-media" />
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
109
assets/js/components/nav.tsx
Normal file
109
assets/js/components/nav.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import React from "react";
|
||||
import { ComposeTweet } from "./compose";
|
||||
|
||||
export function MobileNav({
|
||||
page,
|
||||
onCompose,
|
||||
}: {
|
||||
page: string;
|
||||
onCompose: () => void;
|
||||
}) {
|
||||
const onFeedPage = page === "feed" || page === "tweet";
|
||||
const onFollowingPage = page === "following";
|
||||
const onUsersPage = page === "users" || page === "user-detail";
|
||||
const onProfilePage = page === "profile";
|
||||
|
||||
return (
|
||||
<nav className="mx-mobile-nav">
|
||||
<a
|
||||
href="/feed"
|
||||
className={`mx-mobile-nav-item${onFeedPage ? " mx-mobile-nav-item--active" : ""}`}
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" />
|
||||
</svg>
|
||||
<span>Feed</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/following"
|
||||
className={`mx-mobile-nav-item${onFollowingPage ? " mx-mobile-nav-item--active" : ""}`}
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" />
|
||||
</svg>
|
||||
<span>Following</span>
|
||||
</a>
|
||||
|
||||
<button
|
||||
className="mx-mobile-nav-compose"
|
||||
onClick={onCompose}
|
||||
aria-label="New post"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<a
|
||||
href="/users"
|
||||
className={`mx-mobile-nav-item${onUsersPage ? " mx-mobile-nav-item--active" : ""}`}
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z" />
|
||||
</svg>
|
||||
<span>Users</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/profile"
|
||||
className={`mx-mobile-nav-item${onProfilePage ? " mx-mobile-nav-item--active" : ""}`}
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
||||
</svg>
|
||||
<span>Profile</span>
|
||||
</a>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileComposePage({
|
||||
email,
|
||||
onClose,
|
||||
}: {
|
||||
email: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mx-compose-overlay">
|
||||
<div className="mx-compose-overlay-header">
|
||||
<button className="mx-compose-overlay-cancel" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<span className="mx-compose-overlay-title">New Post</span>
|
||||
<div style={{ minWidth: "60px" }} />
|
||||
</div>
|
||||
<div className="mx-compose-overlay-body">
|
||||
{email ? (
|
||||
<ComposeTweet onSuccess={onClose} />
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
208
assets/js/components/profile.tsx
Normal file
208
assets/js/components/profile.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import React, { useState, useRef, useEffect, useContext } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { readUser, updateProfile, buildCSRFHeaders } from "../ash_rpc";
|
||||
import { uploadAvatar } from "../upload";
|
||||
import { AuthCtx } from "../context";
|
||||
import { getAssetHost } from "../utils";
|
||||
import { Spinner } from "./ui";
|
||||
import type { User } from "../types";
|
||||
|
||||
export function ProfileEditor({ userId }: { userId: string }) {
|
||||
const assetHost = getAssetHost();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data: user, isLoading } = useQuery({
|
||||
queryKey: ["user", userId],
|
||||
queryFn: async () => {
|
||||
const res = await readUser({
|
||||
fields: ["id", "email", "username", "displayName", "avatarUrl", "followerCount", "followingCount", "amIFollowing"],
|
||||
filter: { id: { eq: userId } },
|
||||
headers: buildCSRFHeaders(),
|
||||
});
|
||||
if (!res.success) throw new Error("Failed to load user");
|
||||
const results = Array.isArray(res.data) ? res.data : (res.data as any)?.results ?? [];
|
||||
return (results[0] as User) ?? null;
|
||||
},
|
||||
});
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
const [avatarUploading, setAvatarUploading] = useState(false);
|
||||
const [avatarError, setAvatarError] = useState<string | null>(null);
|
||||
const [previewAvatarUrl, setPreviewAvatarUrl] = useState<string | null>(null);
|
||||
const avatarInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setUsername(user.username ?? "");
|
||||
setDisplayName(user.displayName ?? "");
|
||||
}
|
||||
}, [user?.id]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await updateProfile({
|
||||
identity: userId,
|
||||
input: {
|
||||
username: username.trim() || null,
|
||||
displayName: displayName.trim() || null,
|
||||
},
|
||||
fields: ["id", "username", "displayName", "avatarUrl"],
|
||||
headers: buildCSRFHeaders(),
|
||||
});
|
||||
if (!res.success) throw new Error((res.errors?.[0] as any)?.message ?? "Save failed");
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["user", userId] });
|
||||
setSaveSuccess(true);
|
||||
setSaveError(null);
|
||||
setTimeout(() => setSaveSuccess(false), 3000);
|
||||
},
|
||||
onError: (e: Error) => setSaveError(e.message),
|
||||
});
|
||||
|
||||
async function handleAvatarChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
e.target.value = "";
|
||||
if (previewAvatarUrl) URL.revokeObjectURL(previewAvatarUrl);
|
||||
setPreviewAvatarUrl(URL.createObjectURL(file));
|
||||
setAvatarError(null);
|
||||
setAvatarUploading(true);
|
||||
const csrfToken = buildCSRFHeaders()["X-CSRF-Token"] as string;
|
||||
const result = await uploadAvatar(file, csrfToken);
|
||||
setAvatarUploading(false);
|
||||
if ("error" in result) {
|
||||
setAvatarError(result.error);
|
||||
if (previewAvatarUrl) URL.revokeObjectURL(previewAvatarUrl);
|
||||
setPreviewAvatarUrl(null);
|
||||
} else {
|
||||
qc.invalidateQueries({ queryKey: ["user", userId] });
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading || !user) return <Spinner />;
|
||||
|
||||
const currentAvatarUrl = previewAvatarUrl
|
||||
? previewAvatarUrl
|
||||
: user.avatarUrl
|
||||
? `${assetHost}/${user.avatarUrl}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="mx-profile-editor">
|
||||
<div className="mx-profile-avatar-section">
|
||||
<div className="mx-profile-avatar-wrap">
|
||||
{currentAvatarUrl ? (
|
||||
<img src={currentAvatarUrl} alt="Your avatar" className="mx-profile-avatar-img" />
|
||||
) : (
|
||||
<div className="mx-profile-avatar-placeholder">
|
||||
<span>{(user.displayName || user.username || user.email || "M")[0].toUpperCase()}</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="mx-profile-avatar-edit-btn"
|
||||
onClick={() => avatarInputRef.current?.click()}
|
||||
disabled={avatarUploading}
|
||||
title="Change avatar"
|
||||
>
|
||||
{avatarUploading ? (
|
||||
<div className="mx-spinner" style={{ width: "14px", height: "14px", borderWidth: "2px" }} />
|
||||
) : (
|
||||
<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>
|
||||
<input
|
||||
ref={avatarInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleAvatarChange}
|
||||
/>
|
||||
</div>
|
||||
{avatarError && <p className="mx-compose-error" style={{ marginTop: "0.5rem" }}>{avatarError}</p>}
|
||||
</div>
|
||||
|
||||
<div className="mx-profile-stats">
|
||||
<span><strong>{user.followerCount ?? 0}</strong> followers</span>
|
||||
<span><strong>{user.followingCount ?? 0}</strong> following</span>
|
||||
</div>
|
||||
|
||||
<div className="mx-profile-field">
|
||||
<label className="mx-profile-label">Email</label>
|
||||
<input
|
||||
type="text"
|
||||
className="mx-profile-input mx-profile-input--readonly"
|
||||
value={String(user.email)}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mx-profile-field">
|
||||
<label className="mx-profile-label">Display name</label>
|
||||
<input
|
||||
type="text"
|
||||
className="mx-profile-input"
|
||||
placeholder="Your display name"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mx-profile-field">
|
||||
<label className="mx-profile-label">Username</label>
|
||||
<div className="mx-profile-input-wrap">
|
||||
<span className="mx-profile-at">@</span>
|
||||
<input
|
||||
type="text"
|
||||
className="mx-profile-input mx-profile-input--handle"
|
||||
placeholder="your_handle"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value.replace(/[^a-zA-Z0-9_]/g, ""))}
|
||||
maxLength={30}
|
||||
/>
|
||||
</div>
|
||||
<p className="mx-profile-hint">3–30 characters. Letters, numbers, underscores only.</p>
|
||||
</div>
|
||||
|
||||
{saveError && <p className="mx-compose-error">{saveError}</p>}
|
||||
{saveSuccess && <p style={{ fontSize: "0.8rem", color: "var(--mx-green)", marginBottom: "0.5rem" }}>✓ Saved!</p>}
|
||||
|
||||
<div style={{ display: "flex", gap: "0.75rem", alignItems: "center" }}>
|
||||
<button
|
||||
className="mx-btn-post"
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending ? "Saving…" : "Save changes"}
|
||||
</button>
|
||||
<a href="/sign-out" className="mx-btn-cancel" style={{ textDecoration: "none" }}>Sign out</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MyProfile() {
|
||||
const { userId } = useContext(AuthCtx);
|
||||
|
||||
if (!userId) {
|
||||
return (
|
||||
<div className="mx-empty">
|
||||
<div className="mx-empty-icon">◎</div>
|
||||
<p className="mx-empty-title">Your profile</p>
|
||||
<p className="mx-empty-sub">
|
||||
<a href="/sign-in" style={{ color: "var(--mx-accent)", textDecoration: "none" }}>Sign in</a>
|
||||
{" "}to view your profile.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <ProfileEditor userId={userId} />;
|
||||
}
|
||||
365
assets/js/components/tweet-card.tsx
Normal file
365
assets/js/components/tweet-card.tsx
Normal file
@@ -0,0 +1,365 @@
|
||||
import React, { useState, useContext } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { destroyTweet, updateTweet, likeTweet, unlikeTweet, buildCSRFHeaders } from "../ash_rpc";
|
||||
import { AuthCtx } from "../context";
|
||||
import { timeAgo, userDisplayLabel } from "../utils";
|
||||
import { Avatar, ContextMenu } from "./ui";
|
||||
import { TweetMedia } from "./media";
|
||||
import type { Tweet, ContextMenuItem } from "../types";
|
||||
|
||||
export function CommentIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H5.17L4 17.17V4h16v12z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TweetCard({ tweet }: { tweet: Tweet }) {
|
||||
const { userId: currentUserId } = useContext(AuthCtx);
|
||||
const canModify = !!currentUserId && tweet.userId === currentUserId;
|
||||
const canLike = !!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 [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const qc = useQueryClient();
|
||||
|
||||
const tweetUrl = `${window.location.origin}/feed/${tweet.id}`;
|
||||
|
||||
const ctxItems: ContextMenuItem[] = canModify
|
||||
? [
|
||||
{
|
||||
type: "item",
|
||||
label: "Edit",
|
||||
onClick: () => {
|
||||
setEditText(tweet.content);
|
||||
setEditing(true);
|
||||
setConfirmDelete(false);
|
||||
},
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
type: "item",
|
||||
label: "Share",
|
||||
onClick: () => navigator.clipboard.writeText(tweetUrl),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
type: "item",
|
||||
label: "View",
|
||||
onClick: () => { window.location.href = tweetUrl; },
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
type: "item",
|
||||
label: "Share",
|
||||
onClick: () => navigator.clipboard.writeText(tweetUrl),
|
||||
},
|
||||
];
|
||||
|
||||
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"] });
|
||||
qc.invalidateQueries({ queryKey: ["following_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"] });
|
||||
qc.invalidateQueries({ queryKey: ["following_tweets"] });
|
||||
setEditing(false);
|
||||
setError(null);
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
const likeMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const action = tweet.likedByMe ? unlikeTweet : likeTweet;
|
||||
const res = await action({
|
||||
identity: tweet.id,
|
||||
fields: ["id", "likes", "likedByMe"],
|
||||
headers: buildCSRFHeaders(),
|
||||
});
|
||||
if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed to update like");
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["tweets"] });
|
||||
qc.invalidateQueries({ queryKey: ["following_tweets"] });
|
||||
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"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => { window.location.href = `/feed/${tweet.id}`; }}
|
||||
onContextMenu={(e) => { e.preventDefault(); setCtxMenu({ x: e.clientX, y: e.clientY }); }}
|
||||
>
|
||||
<Avatar avatarUrl={tweet.userAvatarUrl} name={tweet.userDisplayName || tweet.userUsername || tweet.userEmail} />
|
||||
<div className="mx-tweet-body">
|
||||
<div className="mx-tweet-header">
|
||||
<span className="mx-tweet-handle">{userDisplayLabel({ displayName: tweet.userDisplayName, username: tweet.userUsername, email: tweet.userEmail })}</span>
|
||||
{tweet.userUsername && (
|
||||
<span className="mx-tweet-subhandle">@{tweet.userUsername}</span>
|
||||
)}
|
||||
<span className="mx-tweet-dot">·</span>
|
||||
<span className="mx-tweet-time" title={tweet.insertedAt ? new Date(tweet.insertedAt).toLocaleString() : undefined}>{timeAgo(tweet.insertedAt)}</span>
|
||||
{canModify && (
|
||||
<div className="mx-tweet-actions">
|
||||
<button
|
||||
className="mx-action-btn"
|
||||
title="Edit"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
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={(e) => {
|
||||
e.stopPropagation();
|
||||
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>
|
||||
)}
|
||||
|
||||
{tweet.media && tweet.media.length > 0 && (
|
||||
<TweetMedia media={tweet.media} />
|
||||
)}
|
||||
|
||||
<div className="mx-tweet-footer">
|
||||
<button
|
||||
className={`mx-like-btn${tweet.likedByMe ? " mx-like-btn-active" : ""}`}
|
||||
onClick={(e) => { e.stopPropagation(); likeMutation.mutate(); }}
|
||||
disabled={!canLike || likeMutation.isPending}
|
||||
title={
|
||||
canLike
|
||||
? tweet.likedByMe
|
||||
? "Remove like"
|
||||
: "Like post"
|
||||
: "Sign in to like posts"
|
||||
}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12.1 21.35 10.55 19.93C5.4 15.27 2 12.19 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.69-3.4 6.77-8.55 11.44z" />
|
||||
</svg>
|
||||
<span>{tweet.likes}</span>
|
||||
</button>
|
||||
<a
|
||||
href={`/feed/${tweet.id}`}
|
||||
className="mx-like-btn mx-comment-btn"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="View comments"
|
||||
>
|
||||
<CommentIcon />
|
||||
<span>{tweet.commentCount ?? 0}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{error && !editing && <p className="mx-compose-error">{error}</p>}
|
||||
</div>
|
||||
{ctxMenu && (
|
||||
<ContextMenu
|
||||
x={ctxMenu.x}
|
||||
y={ctxMenu.y}
|
||||
items={ctxItems}
|
||||
onClose={() => setCtxMenu(null)}
|
||||
/>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function CommentCard({
|
||||
comment,
|
||||
parentTweetOwnerId,
|
||||
}: {
|
||||
comment: Tweet;
|
||||
parentTweetOwnerId?: string;
|
||||
}) {
|
||||
const { userId: currentUserId } = useContext(AuthCtx);
|
||||
const canLike = !!currentUserId;
|
||||
const canModify =
|
||||
!!currentUserId &&
|
||||
(comment.userId === currentUserId || parentTweetOwnerId === currentUserId);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const qc = useQueryClient();
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await destroyTweet({ identity: comment.id, headers: buildCSRFHeaders() });
|
||||
if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed to delete");
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["comments", comment.parentTweetId] });
|
||||
qc.invalidateQueries({ queryKey: ["tweet", comment.parentTweetId] });
|
||||
qc.invalidateQueries({ queryKey: ["tweets"] });
|
||||
qc.invalidateQueries({ queryKey: ["following_tweets"] });
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
const likeMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const action = comment.likedByMe ? unlikeTweet : likeTweet;
|
||||
const res = await action({
|
||||
identity: comment.id,
|
||||
fields: ["id", "likes", "likedByMe"],
|
||||
headers: buildCSRFHeaders(),
|
||||
});
|
||||
if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed");
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["comments", comment.parentTweetId] }),
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<article className="mx-tweet mx-comment">
|
||||
<Avatar
|
||||
avatarUrl={comment.userAvatarUrl}
|
||||
name={comment.userDisplayName || comment.userUsername || comment.userEmail}
|
||||
size="sm"
|
||||
/>
|
||||
<div className="mx-tweet-body">
|
||||
<div className="mx-tweet-header">
|
||||
<span className="mx-tweet-handle">{userDisplayLabel({ displayName: comment.userDisplayName, username: comment.userUsername, email: comment.userEmail })}</span>
|
||||
{comment.userUsername && (
|
||||
<span className="mx-tweet-subhandle">@{comment.userUsername}</span>
|
||||
)}
|
||||
<span className="mx-tweet-dot">·</span>
|
||||
<span className="mx-tweet-time" title={comment.insertedAt ? new Date(comment.insertedAt).toLocaleString() : undefined}>{timeAgo(comment.insertedAt)}</span>
|
||||
{canModify && (
|
||||
<div className="mx-tweet-actions">
|
||||
<button
|
||||
className={`mx-action-btn mx-action-delete${confirmDelete ? " mx-action-confirm" : ""}`}
|
||||
title={confirmDelete ? "Confirm delete" : "Delete"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
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>
|
||||
<p className="mx-tweet-text">{comment.content}</p>
|
||||
{comment.media && comment.media.length > 0 && <TweetMedia media={comment.media} />}
|
||||
<div className="mx-tweet-footer">
|
||||
<button
|
||||
className={`mx-like-btn${comment.likedByMe ? " mx-like-btn-active" : ""}`}
|
||||
onClick={() => likeMutation.mutate()}
|
||||
disabled={!canLike || likeMutation.isPending}
|
||||
title={canLike ? (comment.likedByMe ? "Remove like" : "Like reply") : "Sign in to like replies"}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12.1 21.35 10.55 19.93C5.4 15.27 2 12.19 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.69-3.4 6.77-8.55 11.44z" />
|
||||
</svg>
|
||||
<span>{comment.likes}</span>
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="mx-compose-error">{error}</p>}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
279
assets/js/components/tweet-detail.tsx
Normal file
279
assets/js/components/tweet-detail.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import React, { useState, useRef, useEffect, useContext } from "react";
|
||||
import { useQuery, useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { readTweet, destroyTweet, updateTweet, likeTweet, unlikeTweet, buildCSRFHeaders } from "../ash_rpc";
|
||||
import { AuthCtx } from "../context";
|
||||
import { getAssetHost, userDisplayLabel } from "../utils";
|
||||
import { COMMENTS_PAGE_SIZE } from "../constants";
|
||||
import { Spinner, ErrorBanner, Avatar } from "./ui";
|
||||
import { MediaLightbox } from "./media";
|
||||
import { CommentIcon, CommentCard } from "./tweet-card";
|
||||
import { ComposeComment } from "./compose";
|
||||
import type { Tweet, MediaItem } from "../types";
|
||||
|
||||
export function TweetDetail({ tweetId }: { tweetId: string }) {
|
||||
const { userId: currentUserId, email } = useContext(AuthCtx);
|
||||
const [lightboxItem, setLightboxItem] = useState<MediaItem | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editText, setEditText] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const qc = useQueryClient();
|
||||
const assetHost = getAssetHost();
|
||||
|
||||
const { data: tweet, isLoading, isError } = useQuery({
|
||||
queryKey: ["tweet", tweetId],
|
||||
queryFn: async () => {
|
||||
const res = await readTweet({
|
||||
fields: ["id", "content", "likes", "likedByMe", "commentCount", "userId", "state", "userEmail", "userUsername", "userDisplayName", "userAvatarUrl", "insertedAt", { media: ["id", "s3Key"] }],
|
||||
filter: { id: { eq: tweetId } },
|
||||
headers: buildCSRFHeaders(),
|
||||
});
|
||||
if (!res.success) throw new Error("Failed to load tweet");
|
||||
const results = Array.isArray(res.data) ? res.data : (res.data as any)?.results ?? [];
|
||||
return (results[0] as Tweet) ?? null;
|
||||
},
|
||||
});
|
||||
|
||||
const commentsSentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const {
|
||||
data: commentsData,
|
||||
isLoading: commentsLoading,
|
||||
fetchNextPage: fetchNextComments,
|
||||
hasNextPage: hasMoreComments,
|
||||
isFetchingNextPage: isFetchingMoreComments,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ["comments", tweetId],
|
||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||
const res = await readTweet({
|
||||
fields: ["id", "content", "likes", "likedByMe", "parentTweetId", "userId", "state", "userEmail", "userUsername", "userDisplayName", "userAvatarUrl", "insertedAt", { media: ["id", "s3Key"] }],
|
||||
filter: { parentTweetId: { eq: tweetId } },
|
||||
sort: "insertedAt",
|
||||
page: { limit: COMMENTS_PAGE_SIZE, offset: pageParam },
|
||||
headers: buildCSRFHeaders(),
|
||||
});
|
||||
if (!res.success) throw new Error("Failed to load comments");
|
||||
const pageData = res.data as any;
|
||||
const comments: Tweet[] = Array.isArray(pageData) ? pageData : (pageData?.results ?? []);
|
||||
const hasMore: boolean = Array.isArray(pageData) ? false : (pageData?.hasMore ?? false);
|
||||
return { comments, hasMore, nextOffset: pageParam + COMMENTS_PAGE_SIZE };
|
||||
},
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage) => lastPage.hasMore ? lastPage.nextOffset : undefined,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const el = commentsSentinelRef.current;
|
||||
if (!el) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasMoreComments && !isFetchingMoreComments) {
|
||||
fetchNextComments();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMoreComments, isFetchingMoreComments, fetchNextComments]);
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await destroyTweet({ identity: tweetId, headers: buildCSRFHeaders() });
|
||||
if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed to delete");
|
||||
},
|
||||
onSuccess: () => { window.location.href = "/feed"; },
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async (content: string) => {
|
||||
const res = await updateTweet({
|
||||
identity: tweetId,
|
||||
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: ["tweet", tweetId] });
|
||||
setEditing(false);
|
||||
setError(null);
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
const likeMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!tweet) return;
|
||||
const action = tweet.likedByMe ? unlikeTweet : likeTweet;
|
||||
const res = await action({ identity: tweetId, fields: ["id", "likes", "likedByMe"], headers: buildCSRFHeaders() });
|
||||
if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed to update like");
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["tweet", tweetId] }),
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
if (isError || !tweet) return <ErrorBanner message="Could not load tweet" />;
|
||||
|
||||
const canModify = !!currentUserId && tweet.userId === currentUserId;
|
||||
const canLike = !!currentUserId;
|
||||
|
||||
return (
|
||||
<div className="mx-detail">
|
||||
<div className="mx-detail-header">
|
||||
<a href="/feed" className="mx-back-btn">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
|
||||
</svg>
|
||||
Back
|
||||
</a>
|
||||
{canModify && (
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
<button
|
||||
className="mx-action-btn"
|
||||
title="Edit"
|
||||
onClick={() => { setEditText(tweet.content); setEditing(true); }}
|
||||
>
|
||||
<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>
|
||||
|
||||
<div className="mx-detail-body">
|
||||
<div className="mx-detail-author">
|
||||
<Avatar avatarUrl={tweet.userAvatarUrl} name={tweet.userDisplayName || tweet.userUsername || tweet.userEmail} />
|
||||
<div>
|
||||
<span className="mx-tweet-handle">{userDisplayLabel({ displayName: tweet.userDisplayName, username: tweet.userUsername, email: tweet.userEmail })}</span>
|
||||
{tweet.userUsername && (
|
||||
<div style={{ fontSize: "0.8rem", color: "var(--mx-muted)" }}>@{tweet.userUsername}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editing ? (
|
||||
<div className="mx-edit-area">
|
||||
<textarea
|
||||
className="mx-edit-textarea"
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
autoFocus
|
||||
rows={4}
|
||||
/>
|
||||
{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={() => { const t = editText.trim(); if (t) updateMutation.mutate(t); }}
|
||||
disabled={!editText.trim() || updateMutation.isPending}
|
||||
>
|
||||
{updateMutation.isPending ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mx-detail-content">{tweet.content}</p>
|
||||
)}
|
||||
|
||||
{tweet.media && tweet.media.length > 0 && (
|
||||
<div className="mx-detail-media">
|
||||
{tweet.media.map((m) => (
|
||||
<button key={m.id} className="mx-media-thumb" onClick={() => setLightboxItem(m)}>
|
||||
{/\.(mp4|mov)$/i.test(m.s3Key) ? (
|
||||
<video src={`${assetHost}/${m.s3Key}`} />
|
||||
) : (
|
||||
<img src={`${assetHost}/${m.s3Key}`} alt="" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mx-tweet-footer" style={{ marginTop: "1rem" }}>
|
||||
<button
|
||||
className={`mx-like-btn${tweet.likedByMe ? " mx-like-btn-active" : ""}`}
|
||||
onClick={() => likeMutation.mutate()}
|
||||
disabled={!canLike || likeMutation.isPending}
|
||||
title={canLike ? (tweet.likedByMe ? "Remove like" : "Like post") : "Sign in to like posts"}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12.1 21.35 10.55 19.93C5.4 15.27 2 12.19 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.69-3.4 6.77-8.55 11.44z" />
|
||||
</svg>
|
||||
<span>{tweet.likes}</span>
|
||||
</button>
|
||||
<span className="mx-like-btn mx-comment-count-badge" style={{ cursor: "default" }}>
|
||||
<CommentIcon />
|
||||
<span>{tweet.commentCount ?? 0} {(tweet.commentCount ?? 0) === 1 ? "reply" : "replies"}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && !editing && <p className="mx-compose-error">{error}</p>}
|
||||
</div>
|
||||
|
||||
{lightboxItem && <MediaLightbox item={lightboxItem} onClose={() => setLightboxItem(null)} />}
|
||||
|
||||
<div className="mx-comments-section">
|
||||
<div className="mx-comments-divider">
|
||||
<span>Replies</span>
|
||||
</div>
|
||||
|
||||
{email ? (
|
||||
<ComposeComment parentTweetId={tweetId} />
|
||||
) : (
|
||||
<div className="mx-signin-cta mx-signin-cta--sm">
|
||||
<p><a href="/sign-in" style={{ color: "var(--mx-accent)", textDecoration: "none" }}>Sign in</a> to reply.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{commentsLoading ? (
|
||||
<Spinner />
|
||||
) : (() => {
|
||||
const comments = commentsData?.pages.flatMap((p) => p.comments) ?? [];
|
||||
return comments.length > 0 ? (
|
||||
<div className="mx-comments-list">
|
||||
{comments.map((c) => (
|
||||
<CommentCard key={c.id} comment={c} parentTweetOwnerId={tweet?.userId} />
|
||||
))}
|
||||
<div ref={commentsSentinelRef} style={{ height: "1px" }} />
|
||||
{isFetchingMoreComments && <Spinner />}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-empty mx-empty--sm">
|
||||
<p className="mx-empty-sub">No replies yet. Be the first!</p>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
129
assets/js/components/ui.tsx
Normal file
129
assets/js/components/ui.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import React, { useRef, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { getAssetHost } from "../utils";
|
||||
import type { ContextMenuItem } from "../types";
|
||||
|
||||
export function Spinner() {
|
||||
return (
|
||||
<div style={{ display: "flex", justifyContent: "center", padding: "2rem" }}>
|
||||
<div className="mx-spinner" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBanner({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="mx-error-banner">
|
||||
<span className="mx-error-icon">⚠</span>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export 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>
|
||||
);
|
||||
}
|
||||
|
||||
export function Avatar({
|
||||
avatarUrl,
|
||||
name,
|
||||
size = "md",
|
||||
}: {
|
||||
avatarUrl?: string | null;
|
||||
name?: string | null;
|
||||
size?: "sm" | "md" | "lg";
|
||||
}) {
|
||||
const assetHost = getAssetHost();
|
||||
const initial = ((name ?? "")[0] || "M").toUpperCase();
|
||||
const cls =
|
||||
size === "sm"
|
||||
? "mx-tweet-avatar mx-tweet-avatar--sm"
|
||||
: size === "lg"
|
||||
? "mx-tweet-avatar mx-tweet-avatar--lg"
|
||||
: "mx-tweet-avatar";
|
||||
|
||||
return (
|
||||
<div className={cls}>
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={`${assetHost}/${avatarUrl}`}
|
||||
alt={name ?? "avatar"}
|
||||
className="mx-avatar-img"
|
||||
/>
|
||||
) : (
|
||||
<span>{initial}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContextMenu({
|
||||
x,
|
||||
y,
|
||||
items,
|
||||
onClose,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
items: ContextMenuItem[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
||||
}
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("mousedown", handleMouseDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleMouseDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const itemCount = items.filter((i) => i.type === "item").length;
|
||||
const sepCount = items.filter((i) => i.type === "separator").length;
|
||||
const menuH = itemCount * 34 + sepCount * 9 + 8;
|
||||
const menuW = 180;
|
||||
const left = Math.min(x, window.innerWidth - menuW - 8);
|
||||
const top = Math.min(y, window.innerHeight - menuH - 8);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
className="mx-context-menu"
|
||||
style={{ left, top }}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{items.map((item, i) =>
|
||||
item.type === "separator" ? (
|
||||
<div key={i} className="mx-context-menu-separator" />
|
||||
) : (
|
||||
<button
|
||||
key={i}
|
||||
className="mx-context-menu-item"
|
||||
onClick={() => {
|
||||
item.onClick();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
288
assets/js/components/users.tsx
Normal file
288
assets/js/components/users.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
import React, { useState, useRef, useEffect, useContext } from "react";
|
||||
import { useQuery, useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { readUser, readTweet, buildCSRFHeaders } from "../ash_rpc";
|
||||
import { AuthCtx } from "../context";
|
||||
import { FEED_PAGE_SIZE, USERS_PAGE_SIZE } from "../constants";
|
||||
import { userDisplayLabel } from "../utils";
|
||||
import { useFollowUser } from "../hooks";
|
||||
import { Spinner, ErrorBanner, Avatar, ContextMenu } from "./ui";
|
||||
import { TweetCard } from "./tweet-card";
|
||||
import type { User, Tweet, ContextMenuItem } from "../types";
|
||||
|
||||
export function FollowButton({
|
||||
amIFollowing,
|
||||
isPending,
|
||||
onToggle,
|
||||
}: {
|
||||
amIFollowing: boolean;
|
||||
isPending: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={`mx-follow-btn${amIFollowing ? " mx-follow-btn--following" : ""}`}
|
||||
disabled={isPending}
|
||||
onClick={(e) => { e.stopPropagation(); onToggle(); }}
|
||||
>
|
||||
{isPending ? "…" : amIFollowing ? "Unfollow" : "Follow"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserCard({ user }: { user: User }) {
|
||||
const { userId: currentUserId } = useContext(AuthCtx);
|
||||
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const { follow, unfollow, isPending } = useFollowUser(user.id);
|
||||
|
||||
const userUrl = `${window.location.origin}/users/${user.id}`;
|
||||
const canFollow = !!currentUserId && currentUserId !== user.id;
|
||||
const amIFollowing = user.amIFollowing ?? false;
|
||||
|
||||
const ctxItems: ContextMenuItem[] = [
|
||||
{ type: "item", label: "Share", onClick: () => navigator.clipboard.writeText(userUrl) },
|
||||
...(canFollow ? [
|
||||
{ type: "separator" as const },
|
||||
amIFollowing
|
||||
? { type: "item" as const, label: "Unfollow", onClick: unfollow }
|
||||
: { type: "item" as const, label: "Follow", onClick: follow },
|
||||
] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<article
|
||||
className="mx-tweet"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => { window.location.href = `/users/${user.id}`; }}
|
||||
onContextMenu={(e) => { e.preventDefault(); setCtxMenu({ x: e.clientX, y: e.clientY }); }}
|
||||
>
|
||||
<Avatar avatarUrl={user.avatarUrl} name={user.displayName || user.username || user.email} />
|
||||
<div className="mx-tweet-body">
|
||||
<div className="mx-tweet-header">
|
||||
<span className="mx-tweet-handle">{userDisplayLabel(user)}</span>
|
||||
{user.username && (
|
||||
<span className="mx-tweet-subhandle">@{user.username}</span>
|
||||
)}
|
||||
</div>
|
||||
{(user.followerCount !== undefined || user.followingCount !== undefined) && (
|
||||
<div className="mx-tweet-meta" style={{ fontSize: "0.8rem", color: "var(--mx-muted)", marginTop: "4px" }}>
|
||||
<span>{user.followerCount ?? 0} followers</span>
|
||||
<span style={{ marginLeft: "12px" }}>{user.followingCount ?? 0} following</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canFollow && (
|
||||
<div style={{ display: "flex", alignItems: "center", flexShrink: 0 }}>
|
||||
<FollowButton amIFollowing={amIFollowing} isPending={isPending} onToggle={amIFollowing ? unfollow : follow} />
|
||||
</div>
|
||||
)}
|
||||
{ctxMenu && (
|
||||
<ContextMenu x={ctxMenu.x} y={ctxMenu.y} items={ctxItems} onClose={() => setCtxMenu(null)} />
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserList() {
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ["users"],
|
||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||
const res = await readUser({
|
||||
fields: ["id", "email", "username", "displayName", "avatarUrl", "followerCount", "followingCount", "amIFollowing"],
|
||||
sort: "username",
|
||||
page: { limit: USERS_PAGE_SIZE, offset: pageParam },
|
||||
headers: buildCSRFHeaders(),
|
||||
});
|
||||
if (!res.success) throw new Error("Failed to load users");
|
||||
const pageData = res.data as any;
|
||||
const users: User[] = Array.isArray(pageData) ? pageData : (pageData?.results ?? []);
|
||||
const hasMore: boolean = Array.isArray(pageData) ? false : (pageData?.hasMore ?? false);
|
||||
return { users, hasMore, nextOffset: pageParam + USERS_PAGE_SIZE };
|
||||
},
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage) => lastPage.hasMore ? lastPage.nextOffset : undefined,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const el = sentinelRef.current;
|
||||
if (!el) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
if (isError) return <ErrorBanner message={(error as Error)?.message ?? "Could not load users"} />;
|
||||
|
||||
const users = data?.pages.flatMap((p) => p.users) ?? [];
|
||||
|
||||
if (users.length === 0) {
|
||||
return (
|
||||
<div className="mx-empty">
|
||||
<div className="mx-empty-icon">◎</div>
|
||||
<p className="mx-empty-title">No users yet</p>
|
||||
<p className="mx-empty-sub">Be the first to sign up.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-feed">
|
||||
{users.map((u) => (
|
||||
<UserCard key={u.id} user={u} />
|
||||
))}
|
||||
<div ref={sentinelRef} style={{ height: "1px" }} />
|
||||
{isFetchingNextPage && <Spinner />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserFeed({ userId }: { userId: string }) {
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ["user-tweets", userId],
|
||||
queryFn: async ({ pageParam }: { pageParam: number }) => {
|
||||
const res = await readTweet({
|
||||
fields: ["id", "content", "likes", "likedByMe", "commentCount", "userId", "state", "userEmail", "userUsername", "userDisplayName", "userAvatarUrl", "insertedAt", { media: ["id", "s3Key"] }],
|
||||
sort: "-insertedAt",
|
||||
page: { limit: FEED_PAGE_SIZE, offset: pageParam },
|
||||
filter: { userId: { eq: userId }, parentTweetId: { isNil: true } },
|
||||
headers: buildCSRFHeaders(),
|
||||
});
|
||||
if (!res.success) throw new Error("Failed to load tweets");
|
||||
const pageData = res.data as any;
|
||||
const tweets: Tweet[] = Array.isArray(pageData) ? pageData : (pageData?.results ?? []);
|
||||
const hasMore: boolean = Array.isArray(pageData) ? false : (pageData?.hasMore ?? false);
|
||||
return { tweets, hasMore, nextOffset: pageParam + FEED_PAGE_SIZE };
|
||||
},
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage) => lastPage.hasMore ? lastPage.nextOffset : undefined,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const el = sentinelRef.current;
|
||||
if (!el) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
if (isError) return <ErrorBanner message={(error as Error)?.message ?? "Could not load posts"} />;
|
||||
|
||||
const tweets = data?.pages.flatMap((p) => p.tweets) ?? [];
|
||||
|
||||
if (tweets.length === 0) {
|
||||
return (
|
||||
<div className="mx-empty">
|
||||
<div className="mx-empty-icon">◎</div>
|
||||
<p className="mx-empty-title">No posts yet</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-feed">
|
||||
{tweets.map((t) => (
|
||||
<TweetCard key={t.id} tweet={t} />
|
||||
))}
|
||||
<div ref={sentinelRef} style={{ height: "1px" }} />
|
||||
{isFetchingNextPage && <Spinner />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserDetail({ userId, isStandalone = false }: { userId: string; isStandalone?: boolean }) {
|
||||
const { userId: currentUserId } = useContext(AuthCtx);
|
||||
const { follow, unfollow, isPending } = useFollowUser(userId);
|
||||
const { data: user, isLoading, isError } = useQuery({
|
||||
queryKey: ["user", userId],
|
||||
queryFn: async () => {
|
||||
const res = await readUser({
|
||||
fields: ["id", "email", "username", "displayName", "avatarUrl", "followerCount", "followingCount", "amIFollowing"],
|
||||
filter: { id: { eq: userId } },
|
||||
headers: buildCSRFHeaders(),
|
||||
});
|
||||
if (!res.success) throw new Error("Failed to load user");
|
||||
const results = Array.isArray(res.data) ? res.data : (res.data as any)?.results ?? [];
|
||||
return (results[0] as User) ?? null;
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
if (isError || !user) return <ErrorBanner message="Could not load user" />;
|
||||
|
||||
const isOwnProfile = currentUserId === userId;
|
||||
const canFollow = !!currentUserId && !isOwnProfile;
|
||||
const amIFollowing = user.amIFollowing ?? false;
|
||||
|
||||
return (
|
||||
<div className="mx-detail">
|
||||
{!isStandalone && (
|
||||
<div className="mx-detail-header">
|
||||
<a href="/users" className="mx-back-btn">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
|
||||
</svg>
|
||||
Back
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<div className="mx-detail-body">
|
||||
<div className="mx-detail-author">
|
||||
<Avatar avatarUrl={user.avatarUrl} name={user.displayName || user.username || user.email} size="lg" />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "12px", flexWrap: "wrap" }}>
|
||||
<div>
|
||||
<div className="mx-tweet-handle" style={{ fontSize: "1.1rem" }}>{userDisplayLabel(user)}</div>
|
||||
{user.username && (
|
||||
<div style={{ fontSize: "0.85rem", color: "var(--mx-muted)" }}>@{user.username}</div>
|
||||
)}
|
||||
</div>
|
||||
{canFollow && (
|
||||
<FollowButton amIFollowing={amIFollowing} isPending={isPending} onToggle={amIFollowing ? unfollow : follow} />
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: "0.85rem", color: "var(--mx-muted)", marginTop: "8px", display: "flex", gap: "16px" }}>
|
||||
<span><strong style={{ color: "var(--mx-fg)" }}>{user.followerCount ?? 0}</strong> followers</span>
|
||||
<span><strong style={{ color: "var(--mx-fg)" }}>{user.followingCount ?? 0}</strong> following</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<UserFeed userId={userId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
assets/js/constants.ts
Normal file
3
assets/js/constants.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const FEED_PAGE_SIZE = 10;
|
||||
export const COMMENTS_PAGE_SIZE = 10;
|
||||
export const USERS_PAGE_SIZE = 20;
|
||||
9
assets/js/context.ts
Normal file
9
assets/js/context.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
export const AuthCtx = createContext({
|
||||
email: "",
|
||||
userId: "",
|
||||
username: "",
|
||||
displayName: "",
|
||||
avatarUrl: "",
|
||||
});
|
||||
79
assets/js/hooks.ts
Normal file
79
assets/js/hooks.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { followUser, unfollowUser, buildCSRFHeaders } from "./ash_rpc";
|
||||
|
||||
// ── useIsDesktop ──────────────────────────────────────────────────────────────
|
||||
// Returns true when viewport is wider than 960px. Reacts to resize.
|
||||
|
||||
const DESKTOP_MQ =
|
||||
typeof window !== "undefined"
|
||||
? window.matchMedia("(min-width: 961px)")
|
||||
: null;
|
||||
|
||||
function subscribe(cb: () => void) {
|
||||
DESKTOP_MQ?.addEventListener("change", cb);
|
||||
return () => DESKTOP_MQ?.removeEventListener("change", cb);
|
||||
}
|
||||
|
||||
export function useIsDesktop(): boolean {
|
||||
return useSyncExternalStore(
|
||||
subscribe,
|
||||
() => DESKTOP_MQ?.matches ?? true,
|
||||
() => true,
|
||||
);
|
||||
}
|
||||
|
||||
// ── useFollowUser ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function useFollowUser(targetUserId: string) {
|
||||
const qc = useQueryClient();
|
||||
|
||||
const followMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await followUser({
|
||||
input: { followingId: targetUserId },
|
||||
headers: buildCSRFHeaders(),
|
||||
});
|
||||
if (!res.success) {
|
||||
const message =
|
||||
"errors" in res && Array.isArray(res.errors)
|
||||
? (res.errors[0] as any)?.message
|
||||
: "Follow failed";
|
||||
throw new Error(message);
|
||||
}
|
||||
return res;
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["users"] });
|
||||
qc.invalidateQueries({ queryKey: ["user", targetUserId] });
|
||||
},
|
||||
});
|
||||
|
||||
const unfollowMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await unfollowUser({
|
||||
input: { followingId: targetUserId },
|
||||
headers: buildCSRFHeaders(),
|
||||
});
|
||||
if (!res.success) {
|
||||
const message =
|
||||
"errors" in res && Array.isArray(res.errors)
|
||||
? (res.errors[0] as any)?.message
|
||||
: "Unfollow failed";
|
||||
throw new Error(message);
|
||||
}
|
||||
return res;
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["users"] });
|
||||
qc.invalidateQueries({ queryKey: ["user", targetUserId] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
follow: () => followMutation.mutate(),
|
||||
unfollow: () => unfollowMutation.mutate(),
|
||||
isPending: followMutation.isPending || unfollowMutation.isPending,
|
||||
error: followMutation.error || unfollowMutation.error,
|
||||
};
|
||||
}
|
||||
1138
assets/js/index.tsx
1138
assets/js/index.tsx
File diff suppressed because it is too large
Load Diff
34
assets/js/types.ts
Normal file
34
assets/js/types.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export type User = {
|
||||
id: string;
|
||||
email: string;
|
||||
username?: string | null;
|
||||
displayName?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
followerCount?: number;
|
||||
followingCount?: number;
|
||||
amIFollowing?: boolean;
|
||||
myFollowId?: string | null;
|
||||
};
|
||||
|
||||
export type MediaItem = { id: string; s3Key: string };
|
||||
|
||||
export type Tweet = {
|
||||
id: string;
|
||||
content: string;
|
||||
likes: number;
|
||||
likedByMe?: boolean;
|
||||
commentCount?: number;
|
||||
parentTweetId?: string | null;
|
||||
userId: string;
|
||||
state: string;
|
||||
media?: MediaItem[];
|
||||
userEmail?: string | null;
|
||||
userUsername?: string | null;
|
||||
userDisplayName?: string | null;
|
||||
userAvatarUrl?: string | null;
|
||||
insertedAt?: string | null;
|
||||
};
|
||||
|
||||
export type ContextMenuItem =
|
||||
| { type: "item"; label: string; onClick: () => void }
|
||||
| { type: "separator" };
|
||||
@@ -9,6 +9,29 @@ export interface UploadError {
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface AvatarUploadResult {
|
||||
success: true;
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
export async function uploadAvatar(
|
||||
file: File,
|
||||
csrfToken: string
|
||||
): Promise<AvatarUploadResult | UploadError> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const res = await fetch("/upload/avatar", {
|
||||
method: "POST",
|
||||
headers: { "X-CSRF-Token": csrfToken },
|
||||
body: formData,
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok || !json.success) {
|
||||
return { error: json.error ?? "Upload failed" };
|
||||
}
|
||||
return json as AvatarUploadResult;
|
||||
}
|
||||
|
||||
export async function uploadFile(
|
||||
file: File,
|
||||
csrfToken: string
|
||||
|
||||
32
assets/js/utils.ts
Normal file
32
assets/js/utils.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export function timeAgo(insertedAt?: string | null): string {
|
||||
if (!insertedAt) return "just now";
|
||||
const now = Date.now();
|
||||
const then = new Date(insertedAt).getTime();
|
||||
const diffSec = Math.floor((now - then) / 1000);
|
||||
if (diffSec < 5) return "just now";
|
||||
if (diffSec < 60) return `${diffSec}s`;
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
if (diffMin < 60) return `${diffMin}m`;
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
if (diffHr < 24) return `${diffHr}h`;
|
||||
const diffDay = Math.floor(diffHr / 24);
|
||||
if (diffDay < 7) return `${diffDay}d`;
|
||||
return new Date(insertedAt).toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
export function getAssetHost(): string {
|
||||
const appEl = document.getElementById("app");
|
||||
return appEl?.dataset.assetHost ?? "http://localhost:9000";
|
||||
}
|
||||
|
||||
export function userDisplayLabel(u: {
|
||||
displayName?: string | null;
|
||||
username?: string | null;
|
||||
email?: string | null;
|
||||
}): string {
|
||||
return u.displayName || u.username || u.email || "@mixer";
|
||||
}
|
||||
|
||||
export function userHandle(u: { username?: string | null; email?: string | null }): string {
|
||||
return u.username ? `@${u.username}` : u.email ?? "@mixer";
|
||||
}
|
||||
@@ -28,7 +28,9 @@
|
||||
"*": ["../deps/*"]
|
||||
},
|
||||
"allowJs": true,
|
||||
"noEmit": true
|
||||
"noEmit": true,
|
||||
"target": "es5",
|
||||
"lib": ["ES2015", "DOM"]
|
||||
},
|
||||
"include": ["js/**/*"]
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ config :spark,
|
||||
]
|
||||
|
||||
config :mixer,
|
||||
ecto_repos: [Mixer.Repo],
|
||||
ecto_repos: [Mixer.Repo, Mixer.ClickhouseRepo],
|
||||
generators: [timestamp_type: :utc_datetime],
|
||||
ash_domains: [Mixer.Accounts, Mixer.Posts],
|
||||
ash_authentication: [return_error_on_invalid_magic_link_token?: true]
|
||||
@@ -126,7 +126,17 @@ config :esbuild,
|
||||
args:
|
||||
~w(js/index.tsx js/app.js --bundle --target=es2022 --outdir=../priv/static/assets --external:/fonts/* --external:/images/* --alias:@=. --splitting --format=esm),
|
||||
cd: Path.expand("../assets", __DIR__),
|
||||
env: %{"NODE_PATH" => Enum.join([Path.expand("../deps", __DIR__), Path.expand(Mix.Project.build_path()), Path.expand("../_build/dev", __DIR__)], ":")}
|
||||
env: %{
|
||||
"NODE_PATH" =>
|
||||
Enum.join(
|
||||
[
|
||||
Path.expand("../deps", __DIR__),
|
||||
Path.expand(Mix.Project.build_path()),
|
||||
Path.expand("../_build/dev", __DIR__)
|
||||
],
|
||||
":"
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
# Configure tailwind (the version is required)
|
||||
@@ -148,6 +158,11 @@ config :logger, :default_formatter,
|
||||
# Use Jason for JSON parsing in Phoenix
|
||||
config :phoenix, :json_library, Jason
|
||||
|
||||
# ClickHouse repo — migrations live in priv/clickhouse/migrations
|
||||
config :mixer, Mixer.ClickhouseRepo,
|
||||
priv: "priv/clickhouse",
|
||||
migration_source: "ch_schema_migrations"
|
||||
|
||||
# Import environment specific config. This must remain at the bottom
|
||||
# of this file so it overrides the configuration defined above.
|
||||
import_config "#{config_env()}.exs"
|
||||
|
||||
@@ -106,3 +106,12 @@ config :ex_aws, :s3,
|
||||
config :waffle,
|
||||
bucket: "mixer-bucket",
|
||||
asset_host: "http://localhost:9000"
|
||||
|
||||
# ClickHouse (default local install)
|
||||
config :mixer, Mixer.ClickhouseRepo,
|
||||
scheme: "http",
|
||||
hostname: "localhost",
|
||||
port: 8123,
|
||||
database: "mixer_metrics",
|
||||
username: "default",
|
||||
password: ""
|
||||
|
||||
@@ -21,7 +21,7 @@ config :mixer, MixerWeb.Endpoint,
|
||||
]
|
||||
|
||||
# Configure Swoosh API Client
|
||||
config :swoosh, api_client: Swoosh.ApiClient.Req
|
||||
config :swoosh, api_client: Swoosh.ApiClient.Hackney
|
||||
|
||||
# Disable Swoosh Local Memory Storage
|
||||
config :swoosh, local: false
|
||||
|
||||
@@ -22,6 +22,11 @@ end
|
||||
|
||||
config :mixer, MixerWeb.Endpoint, http: [port: String.to_integer(System.get_env("PORT", "4000"))]
|
||||
|
||||
# ClickHouse is available in all environments via env vars when set
|
||||
if clickhouse_url = System.get_env("CLICKHOUSE_URL") do
|
||||
config :mixer, Mixer.ClickhouseRepo, url: clickhouse_url
|
||||
end
|
||||
|
||||
if config_env() == :prod do
|
||||
database_url =
|
||||
System.get_env("DATABASE_URL") ||
|
||||
@@ -40,6 +45,19 @@ if config_env() == :prod do
|
||||
# pool_count: 4,
|
||||
socket_options: maybe_ipv6
|
||||
|
||||
# ClickHouse — configure via CLICKHOUSE_URL or individual vars
|
||||
unless System.get_env("CLICKHOUSE_URL") do
|
||||
config :mixer, Mixer.ClickhouseRepo,
|
||||
scheme: System.get_env("CLICKHOUSE_SCHEME", "http"),
|
||||
hostname:
|
||||
System.get_env("CLICKHOUSE_HOST") ||
|
||||
raise("Missing environment variable `CLICKHOUSE_HOST`!"),
|
||||
port: String.to_integer(System.get_env("CLICKHOUSE_PORT", "8123")),
|
||||
database: System.get_env("CLICKHOUSE_DATABASE", "mixer_metrics"),
|
||||
username: System.get_env("CLICKHOUSE_USERNAME", "default"),
|
||||
password: System.get_env("CLICKHOUSE_PASSWORD", "")
|
||||
end
|
||||
|
||||
# The secret key base is used to sign/encrypt cookies and other secrets.
|
||||
# A default value is used in config/dev.exs and config/test.exs but you
|
||||
# want to use a different value for prod and you most likely don't want
|
||||
@@ -52,7 +70,7 @@ if config_env() == :prod do
|
||||
You can generate one by calling: mix phx.gen.secret
|
||||
"""
|
||||
|
||||
host = System.get_env("PHX_HOST") || "example.com"
|
||||
host = System.get_env("PHX_HOST") || "mixer.jimweaver.com"
|
||||
|
||||
config :mixer, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
|
||||
|
||||
@@ -97,6 +115,12 @@ if config_env() == :prod do
|
||||
System.get_env("S3_ASSET_HOST") ||
|
||||
raise("Missing environment variable `S3_ASSET_HOST`!")
|
||||
|
||||
config :mixer, Mixer.Mailer,
|
||||
adapter: Swoosh.Adapters.Brevo,
|
||||
api_key:
|
||||
System.get_env("BREVO_API_KEY") ||
|
||||
raise("Missing environment variable `BREVO_API_KEY`!")
|
||||
|
||||
# ## SSL Support
|
||||
#
|
||||
# To get SSL working, you will need to add the `https` key
|
||||
|
||||
@@ -42,3 +42,12 @@ config :phoenix_live_view,
|
||||
# Sort query params output of verified routes for robust url comparisons
|
||||
config :phoenix,
|
||||
sort_verified_routes_query_params: true
|
||||
|
||||
# ClickHouse — point at a dedicated test database
|
||||
config :mixer, Mixer.ClickhouseRepo,
|
||||
scheme: "http",
|
||||
hostname: "localhost",
|
||||
port: 8123,
|
||||
database: "mixer_metrics_test",
|
||||
username: "default",
|
||||
password: ""
|
||||
|
||||
28
fix_plan.md
Normal file
28
fix_plan.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Fix Plan
|
||||
|
||||
## Completed
|
||||
|
||||
- [x] `tweet_like` tests: `user_fixture` missing `authorize?: false`, `Ash.Query.filter` needed `require Ash.Query`, `Ash.ForbiddenField.forbidden?/1` doesn't exist (use `match?`), `like` noop returned stale tweet struct → fixed all
|
||||
|
||||
## In Progress / Next
|
||||
|
||||
- [x] `unlike` noop returns stale tweet struct — same issue as `like` noop; reload from DB
|
||||
- [x] `decrement_likes` can go below 0 — use `GREATEST(likes - 1, 0)` via SQL fragment
|
||||
|
||||
## Backlog
|
||||
|
||||
- [x] Self-follow validation used `get_attribute(:follower_id)` which is nil at validation time (relate_actor runs after) — fixed to use `context.actor.id`
|
||||
- [x] Follow/unfollow test coverage (9 tests)
|
||||
- [x] User list pagination — useInfiniteQuery + scroll sentinel, USERS_PAGE_SIZE=20, sorted by username
|
||||
- [ ] No CHECK constraint on `likes >= 0` at DB level (low priority, app logic prevents it)
|
||||
- [ ] `read :following_feed` — nil actor returns empty list (not a bug)
|
||||
- [ ] No search for users or tweets
|
||||
- [x] Tweet creation, update, delete, comment tests (13 tests)
|
||||
- [ ] Missing test coverage: auth flows
|
||||
|
||||
## Notes
|
||||
|
||||
- Stack: Elixir/Phoenix + Ash Framework + React/TypeScript
|
||||
- Tests: `mix test` — 10 tests, all should pass
|
||||
- Build: `mix precommit` alias runs compile + test + format checks
|
||||
- No ClickHouse in test env (expected, non-fatal errors in test output)
|
||||
@@ -1,6 +1,19 @@
|
||||
defmodule Mixer.Accounts do
|
||||
use Ash.Domain, otp_app: :mixer, extensions: [AshTypescript.Rpc, AshAdmin.Domain]
|
||||
|
||||
typescript_rpc do
|
||||
resource Mixer.Accounts.User do
|
||||
rpc_action :read_user, :read
|
||||
rpc_action :update_profile, :update_profile
|
||||
end
|
||||
|
||||
resource Mixer.Accounts.Follow do
|
||||
rpc_action :read_follow, :read
|
||||
rpc_action :follow_user, :follow
|
||||
rpc_action :unfollow_user, :unfollow
|
||||
end
|
||||
end
|
||||
|
||||
admin do
|
||||
show? true
|
||||
end
|
||||
@@ -9,11 +22,7 @@ defmodule Mixer.Accounts do
|
||||
resource Mixer.Accounts.Token
|
||||
resource Mixer.Accounts.User
|
||||
resource Mixer.Accounts.ApiKey
|
||||
end
|
||||
|
||||
typescript_rpc do
|
||||
resource Mixer.Accounts.User do
|
||||
rpc_action :read_user, :read
|
||||
end
|
||||
resource Mixer.Accounts.Follow
|
||||
end
|
||||
end
|
||||
|
||||
33
lib/mixer/accounts/avatar_uploader.ex
Normal file
33
lib/mixer/accounts/avatar_uploader.ex
Normal file
@@ -0,0 +1,33 @@
|
||||
defmodule Mixer.Accounts.AvatarUploader do
|
||||
use Waffle.Definition
|
||||
|
||||
@versions [:original, :thumb]
|
||||
@extensions ~w(.jpg .jpeg .png .gif .webp)
|
||||
|
||||
def validate({file, _scope}) do
|
||||
ext = file.file_name |> Path.extname() |> String.downcase()
|
||||
if ext in @extensions, do: :ok, else: {:error, "unsupported file type #{ext}"}
|
||||
end
|
||||
|
||||
# Resize to a 256×256 square (centre-crop) and convert to WebP for efficiency
|
||||
def transform(:thumb, _) do
|
||||
{:convert, "-strip -thumbnail 256x256^ -gravity center -extent 256x256 -format webp", :webp}
|
||||
end
|
||||
|
||||
# Store both versions under avatars/:user_id/
|
||||
def storage_dir(_version, {_file, scope}), do: "avatars/#{scope.user_id}"
|
||||
|
||||
def filename(:original, {file, _scope}) do
|
||||
Path.basename(file.file_name, Path.extname(file.file_name))
|
||||
end
|
||||
|
||||
def filename(:thumb, _), do: "thumb"
|
||||
|
||||
def s3_object_headers(:thumb, _), do: [content_type: "image/webp"]
|
||||
|
||||
def s3_object_headers(_version, {file, _scope}) do
|
||||
[content_type: MIME.from_path(file.file_name)]
|
||||
end
|
||||
|
||||
def acl(_version, _), do: :public_read
|
||||
end
|
||||
104
lib/mixer/accounts/follow.ex
Normal file
104
lib/mixer/accounts/follow.ex
Normal file
@@ -0,0 +1,104 @@
|
||||
defmodule Mixer.Accounts.Follow do
|
||||
require Ash.Query
|
||||
|
||||
use Ash.Resource,
|
||||
domain: Mixer.Accounts,
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
authorizers: [Ash.Policy.Authorizer],
|
||||
extensions: [AshTypescript.Resource]
|
||||
|
||||
postgres do
|
||||
table "follows"
|
||||
repo Mixer.Repo
|
||||
|
||||
references do
|
||||
reference :follower, on_delete: :delete
|
||||
reference :following, on_delete: :delete
|
||||
end
|
||||
end
|
||||
|
||||
typescript do
|
||||
type_name "follows"
|
||||
end
|
||||
|
||||
actions do
|
||||
defaults [:read, :destroy]
|
||||
|
||||
create :follow do
|
||||
primary? true
|
||||
upsert? true
|
||||
upsert_identity :unique_follow
|
||||
accept [:following_id]
|
||||
change relate_actor(:follower)
|
||||
|
||||
validate fn changeset, context ->
|
||||
actor_id = context.actor && context.actor.id
|
||||
following_id = Ash.Changeset.get_attribute(changeset, :following_id)
|
||||
|
||||
if actor_id && actor_id == following_id do
|
||||
{:error, field: :following_id, message: "You cannot follow yourself"}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
action :unfollow do
|
||||
argument :following_id, :uuid, allow_nil?: false
|
||||
|
||||
run fn input, context ->
|
||||
actor = context.actor
|
||||
|
||||
Mixer.Accounts.Follow
|
||||
|> Ash.Query.filter(
|
||||
Ash.Expr.expr(
|
||||
follower_id == ^actor.id and following_id == ^input.arguments.following_id
|
||||
)
|
||||
)
|
||||
|> Ash.read_one(authorize?: false)
|
||||
|> case do
|
||||
{:ok, nil} -> :ok
|
||||
{:ok, follow} -> Ash.destroy(follow, authorize?: false)
|
||||
{:error, error} -> {:error, error}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
policies do
|
||||
policy action_type(:read) do
|
||||
authorize_if always()
|
||||
end
|
||||
|
||||
policy action(:follow) do
|
||||
authorize_if actor_present()
|
||||
end
|
||||
|
||||
policy action(:unfollow) do
|
||||
authorize_if actor_present()
|
||||
end
|
||||
end
|
||||
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
create_timestamp :created_at
|
||||
end
|
||||
|
||||
relationships do
|
||||
belongs_to :follower, Mixer.Accounts.User do
|
||||
primary_key? true
|
||||
allow_nil? false
|
||||
attribute_writable? true
|
||||
end
|
||||
|
||||
belongs_to :following, Mixer.Accounts.User do
|
||||
primary_key? true
|
||||
allow_nil? false
|
||||
attribute_writable? true
|
||||
end
|
||||
end
|
||||
|
||||
identities do
|
||||
identity :unique_follow, [:follower_id, :following_id]
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,6 @@
|
||||
defmodule Mixer.Accounts.User do
|
||||
import Ash.Expr
|
||||
|
||||
use Ash.Resource,
|
||||
otp_app: :mixer,
|
||||
domain: Mixer.Accounts,
|
||||
@@ -35,6 +37,7 @@ defmodule Mixer.Accounts.User do
|
||||
password :password do
|
||||
identity_field :email
|
||||
hash_provider AshAuthentication.BcryptProvider
|
||||
require_confirmed_with :confirmed_at
|
||||
|
||||
resettable do
|
||||
sender Mixer.Accounts.User.Senders.SendPasswordResetEmail
|
||||
@@ -174,9 +177,21 @@ defmodule Mixer.Accounts.User do
|
||||
sensitive? true
|
||||
end
|
||||
|
||||
argument :username, :string do
|
||||
description "The desired username for the user (letters, numbers, underscores)."
|
||||
allow_nil? false
|
||||
|
||||
constraints match: ~r/^[a-zA-Z0-9_]+$/,
|
||||
min_length: 3,
|
||||
max_length: 30
|
||||
end
|
||||
|
||||
# Sets the email from the argument
|
||||
change set_attribute(:email, arg(:email))
|
||||
|
||||
# Sets the username from the argument
|
||||
change set_attribute(:username, arg(:username))
|
||||
|
||||
# Hashes the provided password
|
||||
change AshAuthentication.Strategy.Password.HashPasswordChange
|
||||
|
||||
@@ -208,6 +223,18 @@ defmodule Mixer.Accounts.User do
|
||||
get_by :email
|
||||
end
|
||||
|
||||
update :update_profile do
|
||||
description "Update the user's public profile (username, display name)."
|
||||
accept [:username, :display_name]
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
update :update_avatar do
|
||||
description "Store the S3 key of the user's processed avatar thumbnail."
|
||||
accept [:avatar_url]
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
update :reset_password_with_token do
|
||||
argument :reset_token, :string do
|
||||
allow_nil? false
|
||||
@@ -253,6 +280,15 @@ defmodule Mixer.Accounts.User do
|
||||
allow_nil? true
|
||||
end
|
||||
|
||||
argument :username, :string do
|
||||
description "Username chosen during first-time magic link registration."
|
||||
allow_nil? true
|
||||
|
||||
constraints match: ~r/^[a-zA-Z0-9_]+$/,
|
||||
min_length: 3,
|
||||
max_length: 30
|
||||
end
|
||||
|
||||
upsert? true
|
||||
upsert_identity :unique_email
|
||||
upsert_fields [:email]
|
||||
@@ -263,6 +299,37 @@ defmodule Mixer.Accounts.User do
|
||||
change {AshAuthentication.Strategy.RememberMe.MaybeGenerateTokenChange,
|
||||
strategy_name: :remember_me}
|
||||
|
||||
# Set username on new users (or existing users who haven't set one yet)
|
||||
change fn changeset, _ctx ->
|
||||
case Ash.Changeset.get_argument(changeset, :username) do
|
||||
nil ->
|
||||
changeset
|
||||
|
||||
username ->
|
||||
# Set the attribute directly so the unique_username identity's
|
||||
# eager_check_with fires during Form.validate, surfacing "already
|
||||
# taken" errors in the UI before the action is submitted.
|
||||
changeset = Ash.Changeset.change_attribute(changeset, :username, username)
|
||||
|
||||
# Also update via after_action to handle existing users who have no
|
||||
# username yet: for upserts, only upsert_fields are applied to the
|
||||
# conflicting row, so change_attribute above won't touch them.
|
||||
Ash.Changeset.after_action(changeset, fn _cs, user ->
|
||||
if is_nil(user.username) do
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update_profile, %{username: username},authorize?: false)
|
||||
|> Ash.update()
|
||||
|> case do
|
||||
{:ok, updated} -> {:ok, updated}
|
||||
{:error, error} -> {:error, error}
|
||||
end
|
||||
else
|
||||
{:ok, user}
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
metadata :token, :string do
|
||||
allow_nil? false
|
||||
end
|
||||
@@ -290,6 +357,14 @@ defmodule Mixer.Accounts.User do
|
||||
policy action_type(:read) do
|
||||
authorize_if always()
|
||||
end
|
||||
|
||||
policy action(:update_profile) do
|
||||
authorize_if expr(id == ^actor(:id))
|
||||
end
|
||||
|
||||
policy action(:update_avatar) do
|
||||
authorize_if expr(id == ^actor(:id))
|
||||
end
|
||||
end
|
||||
|
||||
attributes do
|
||||
@@ -305,6 +380,23 @@ defmodule Mixer.Accounts.User do
|
||||
end
|
||||
|
||||
attribute :confirmed_at, :utc_datetime_usec
|
||||
|
||||
attribute :username, :string do
|
||||
public? true
|
||||
|
||||
constraints match: ~r/^[a-zA-Z0-9_]+$/,
|
||||
min_length: 3,
|
||||
max_length: 30
|
||||
end
|
||||
|
||||
attribute :display_name, :string do
|
||||
public? true
|
||||
constraints max_length: 50
|
||||
end
|
||||
|
||||
attribute :avatar_url, :string do
|
||||
public? true
|
||||
end
|
||||
end
|
||||
|
||||
relationships do
|
||||
@@ -315,9 +407,42 @@ defmodule Mixer.Accounts.User do
|
||||
has_many :tweet_likes, Mixer.Posts.TweetLike
|
||||
|
||||
has_many :tweets, Mixer.Posts.Tweet
|
||||
|
||||
has_many :followers, Mixer.Accounts.Follow do
|
||||
destination_attribute :following_id
|
||||
end
|
||||
|
||||
has_many :following, Mixer.Accounts.Follow do
|
||||
destination_attribute :follower_id
|
||||
end
|
||||
end
|
||||
|
||||
aggregates do
|
||||
count :follower_count, :followers do
|
||||
public? true
|
||||
end
|
||||
|
||||
count :following_count, :following do
|
||||
public? true
|
||||
end
|
||||
|
||||
exists :am_i_following, :followers do
|
||||
public? true
|
||||
filter expr(follower_id == ^actor(:id))
|
||||
end
|
||||
|
||||
first :my_follow_id, :followers, :id do
|
||||
public? true
|
||||
filter expr(follower_id == ^actor(:id))
|
||||
end
|
||||
end
|
||||
|
||||
identities do
|
||||
identity :unique_email, [:email]
|
||||
identity :unique_username, [:username] do
|
||||
eager_check_with Mixer.Accounts
|
||||
message "is already taken"
|
||||
nils_distinct? true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -21,8 +21,7 @@ defmodule Mixer.Accounts.User.Senders.SendMagicLinkEmail do
|
||||
end
|
||||
|
||||
new()
|
||||
# TODO: Replace with your email
|
||||
|> from({"noreply", "noreply@example.com"})
|
||||
|> from({"noreply", "noreply@jimweaver.com"})
|
||||
|> to(to_string(email))
|
||||
|> subject("Your login link")
|
||||
|> html_body(body(token: token, email: email))
|
||||
@@ -31,10 +30,86 @@ defmodule Mixer.Accounts.User.Senders.SendMagicLinkEmail do
|
||||
|
||||
defp body(params) do
|
||||
# NOTE: You may have to change this to match your magic link acceptance URL.
|
||||
link = url(~p"/magic_link/#{params[:token]}")
|
||||
|
||||
email_template(
|
||||
"Your magic link",
|
||||
"Hello, #{params[:email]}!",
|
||||
"""
|
||||
<p style="margin:0 0 20px 0;color:#4B5563;font-size:16px;line-height:1.6;">
|
||||
Use the button below to sign in to Mixer. This link is valid for a short time and can only be used once.
|
||||
</p>
|
||||
<p style="margin:0 0 32px 0;color:#4B5563;font-size:16px;line-height:1.6;">
|
||||
If you didn't request this, you can safely ignore this email.
|
||||
</p>
|
||||
""",
|
||||
link,
|
||||
"Sign In to Mixer"
|
||||
)
|
||||
end
|
||||
|
||||
defp email_template(title, greeting, content, button_url, button_label) do
|
||||
"""
|
||||
<p>Hello, #{params[:email]}! Click this link to sign in:</p>
|
||||
<p><a href="#{url(~p"/magic_link/#{params[:token]}")}">#{url(~p"/magic_link/#{params[:token]}")}</a></p>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>#{title}</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background-color:#09090f;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#09090f;padding:48px 16px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table width="600" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;width:100%;">
|
||||
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="background-color:#0e0e18;border-radius:12px 12px 0 0;padding:32px 40px;text-align:center;border:1px solid #1e1e30;border-bottom:none;">
|
||||
<div style="font-size:28px;font-style:italic;font-weight:400;color:#e8e8f0;letter-spacing:-0.02em;font-family:Georgia,'Times New Roman',serif;">Mixer</div>
|
||||
<div style="font-size:11px;color:#4a4a6a;margin-top:6px;letter-spacing:0.1em;text-transform:uppercase;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;">Your social feed</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Accent bar -->
|
||||
<tr>
|
||||
<td style="background-color:#7c3aed;height:2px;font-size:0;line-height:0;border-left:1px solid #1e1e30;border-right:1px solid #1e1e30;"> </td>
|
||||
</tr>
|
||||
|
||||
<!-- Body -->
|
||||
<tr>
|
||||
<td style="background-color:#111120;padding:40px 40px 32px 40px;border:1px solid #1e1e30;border-top:none;border-bottom:none;">
|
||||
<h1 style="margin:0 0 20px 0;font-size:20px;font-weight:600;color:#e8e8f0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;letter-spacing:-0.01em;">#{greeting}</h1>
|
||||
#{content}
|
||||
<!-- CTA Button -->
|
||||
<table cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td style="border-radius:8px;background-color:#7c3aed;">
|
||||
<a href="#{button_url}" style="display:inline-block;padding:13px 28px;font-size:14px;font-weight:600;color:#ffffff;text-decoration:none;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;letter-spacing:0.01em;border-radius:8px;">#{button_label}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="background-color:#0e0e18;border-radius:0 0 12px 12px;padding:24px 40px;border:1px solid #1e1e30;border-top:1px solid #1e1e30;">
|
||||
<p style="margin:0 0 8px 0;font-size:12px;color:#4a4a6a;line-height:1.6;font-family:'Courier New',Courier,monospace;letter-spacing:0.02em;">
|
||||
This is an automated message — replies to this address are not monitored.
|
||||
</p>
|
||||
<p style="margin:0;font-size:12px;color:#35354a;line-height:1.6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;">
|
||||
You received this because you have an account on Mixer.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,8 +13,7 @@ defmodule Mixer.Accounts.User.Senders.SendNewUserConfirmationEmail do
|
||||
@impl true
|
||||
def send(user, token, _) do
|
||||
new()
|
||||
# TODO: Replace with your email
|
||||
|> from({"noreply", "noreply@example.com"})
|
||||
|> from({"noreply", "noreply@jimweaver.com"})
|
||||
|> to(to_string(user.email))
|
||||
|> subject("Confirm your email address")
|
||||
|> html_body(body(token: token))
|
||||
@@ -22,11 +21,86 @@ defmodule Mixer.Accounts.User.Senders.SendNewUserConfirmationEmail do
|
||||
end
|
||||
|
||||
defp body(params) do
|
||||
url = url(~p"/confirm_new_user/#{params[:token]}")
|
||||
link = url(~p"/confirm_new_user/#{params[:token]}")
|
||||
|
||||
email_template(
|
||||
"Confirm your email",
|
||||
"Welcome to Mixer!",
|
||||
"""
|
||||
<p style="margin:0 0 20px 0;color:#4B5563;font-size:16px;line-height:1.6;">
|
||||
Thanks for signing up. Just one more step — confirm your email address to activate your account.
|
||||
</p>
|
||||
<p style="margin:0 0 32px 0;color:#4B5563;font-size:16px;line-height:1.6;">
|
||||
If you didn't create an account on Mixer, you can safely ignore this email.
|
||||
</p>
|
||||
""",
|
||||
link,
|
||||
"Confirm Email Address"
|
||||
)
|
||||
end
|
||||
|
||||
defp email_template(title, greeting, content, button_url, button_label) do
|
||||
"""
|
||||
<p>Click this link to confirm your email:</p>
|
||||
<p><a href="#{url}">#{url}</a></p>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>#{title}</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background-color:#09090f;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#09090f;padding:48px 16px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table width="600" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;width:100%;">
|
||||
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="background-color:#0e0e18;border-radius:12px 12px 0 0;padding:32px 40px;text-align:center;border:1px solid #1e1e30;border-bottom:none;">
|
||||
<div style="font-size:28px;font-style:italic;font-weight:400;color:#e8e8f0;letter-spacing:-0.02em;font-family:Georgia,'Times New Roman',serif;">Mixer</div>
|
||||
<div style="font-size:11px;color:#4a4a6a;margin-top:6px;letter-spacing:0.1em;text-transform:uppercase;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;">Your social feed</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Accent bar -->
|
||||
<tr>
|
||||
<td style="background-color:#7c3aed;height:2px;font-size:0;line-height:0;border-left:1px solid #1e1e30;border-right:1px solid #1e1e30;"> </td>
|
||||
</tr>
|
||||
|
||||
<!-- Body -->
|
||||
<tr>
|
||||
<td style="background-color:#111120;padding:40px 40px 32px 40px;border:1px solid #1e1e30;border-top:none;border-bottom:none;">
|
||||
<h1 style="margin:0 0 20px 0;font-size:20px;font-weight:600;color:#e8e8f0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;letter-spacing:-0.01em;">#{greeting}</h1>
|
||||
#{content}
|
||||
<!-- CTA Button -->
|
||||
<table cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td style="border-radius:8px;background-color:#7c3aed;">
|
||||
<a href="#{button_url}" style="display:inline-block;padding:13px 28px;font-size:14px;font-weight:600;color:#ffffff;text-decoration:none;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;letter-spacing:0.01em;border-radius:8px;">#{button_label}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="background-color:#0e0e18;border-radius:0 0 12px 12px;padding:24px 40px;border:1px solid #1e1e30;border-top:1px solid #1e1e30;">
|
||||
<p style="margin:0 0 8px 0;font-size:12px;color:#4a4a6a;line-height:1.6;font-family:'Courier New',Courier,monospace;letter-spacing:0.02em;">
|
||||
This is an automated message — replies to this address are not monitored.
|
||||
</p>
|
||||
<p style="margin:0;font-size:12px;color:#35354a;line-height:1.6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;">
|
||||
You received this because you signed up for Mixer.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,8 +13,7 @@ defmodule Mixer.Accounts.User.Senders.SendPasswordResetEmail do
|
||||
@impl true
|
||||
def send(user, token, _) do
|
||||
new()
|
||||
# TODO: Replace with your email
|
||||
|> from({"noreply", "noreply@example.com"})
|
||||
|> from({"noreply", "noreply@jimweaver.com"})
|
||||
|> to(to_string(user.email))
|
||||
|> subject("Reset your password")
|
||||
|> html_body(body(token: token))
|
||||
@@ -22,11 +21,86 @@ defmodule Mixer.Accounts.User.Senders.SendPasswordResetEmail do
|
||||
end
|
||||
|
||||
defp body(params) do
|
||||
url = url(~p"/password-reset/#{params[:token]}")
|
||||
link = url(~p"/password-reset/#{params[:token]}")
|
||||
|
||||
email_template(
|
||||
"Reset your password",
|
||||
"Password reset request",
|
||||
"""
|
||||
<p style="margin:0 0 20px 0;color:#4B5563;font-size:16px;line-height:1.6;">
|
||||
We received a request to reset the password for your Mixer account. Click the button below to choose a new one.
|
||||
</p>
|
||||
<p style="margin:0 0 32px 0;color:#4B5563;font-size:16px;line-height:1.6;">
|
||||
If you didn't request a password reset, you can safely ignore this email — your password will not change.
|
||||
</p>
|
||||
""",
|
||||
link,
|
||||
"Reset My Password"
|
||||
)
|
||||
end
|
||||
|
||||
defp email_template(title, greeting, content, button_url, button_label) do
|
||||
"""
|
||||
<p>Click this link to reset your password:</p>
|
||||
<p><a href="#{url}">#{url}</a></p>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>#{title}</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background-color:#09090f;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#09090f;padding:48px 16px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table width="600" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;width:100%;">
|
||||
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="background-color:#0e0e18;border-radius:12px 12px 0 0;padding:32px 40px;text-align:center;border:1px solid #1e1e30;border-bottom:none;">
|
||||
<div style="font-size:28px;font-style:italic;font-weight:400;color:#e8e8f0;letter-spacing:-0.02em;font-family:Georgia,'Times New Roman',serif;">Mixer</div>
|
||||
<div style="font-size:11px;color:#4a4a6a;margin-top:6px;letter-spacing:0.1em;text-transform:uppercase;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;">Your social feed</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Accent bar -->
|
||||
<tr>
|
||||
<td style="background-color:#7c3aed;height:2px;font-size:0;line-height:0;border-left:1px solid #1e1e30;border-right:1px solid #1e1e30;"> </td>
|
||||
</tr>
|
||||
|
||||
<!-- Body -->
|
||||
<tr>
|
||||
<td style="background-color:#111120;padding:40px 40px 32px 40px;border:1px solid #1e1e30;border-top:none;border-bottom:none;">
|
||||
<h1 style="margin:0 0 20px 0;font-size:20px;font-weight:600;color:#e8e8f0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;letter-spacing:-0.01em;">#{greeting}</h1>
|
||||
#{content}
|
||||
<!-- CTA Button -->
|
||||
<table cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td style="border-radius:8px;background-color:#7c3aed;">
|
||||
<a href="#{button_url}" style="display:inline-block;padding:13px 28px;font-size:14px;font-weight:600;color:#ffffff;text-decoration:none;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;letter-spacing:0.01em;border-radius:8px;">#{button_label}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="background-color:#0e0e18;border-radius:0 0 12px 12px;padding:24px 40px;border:1px solid #1e1e30;border-top:1px solid #1e1e30;">
|
||||
<p style="margin:0 0 8px 0;font-size:12px;color:#4a4a6a;line-height:1.6;font-family:'Courier New',Courier,monospace;letter-spacing:0.02em;">
|
||||
This is an automated message — replies to this address are not monitored.
|
||||
</p>
|
||||
<p style="margin:0;font-size:12px;color:#35354a;line-height:1.6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;">
|
||||
You received this because a password reset was requested for your Mixer account.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,6 +10,10 @@ defmodule Mixer.Application do
|
||||
children = [
|
||||
MixerWeb.Telemetry,
|
||||
Mixer.Repo,
|
||||
# ClickHouse repo for analytics — started before the metrics buffer
|
||||
Mixer.ClickhouseRepo,
|
||||
# In-memory event buffer that batches writes to ClickHouse
|
||||
Mixer.Metrics.Buffer,
|
||||
{DNSCluster, query: Application.get_env(:mixer, :dns_cluster_query) || :ignore},
|
||||
{Phoenix.PubSub, name: Mixer.PubSub},
|
||||
# Start a worker by calling: Mixer.Worker.start_link(arg)
|
||||
|
||||
13
lib/mixer/clickhouse_repo.ex
Normal file
13
lib/mixer/clickhouse_repo.ex
Normal file
@@ -0,0 +1,13 @@
|
||||
defmodule Mixer.ClickhouseRepo do
|
||||
@moduledoc """
|
||||
Ecto repository for ClickHouse, backed by the `ecto_ch` / `Ch` adapter.
|
||||
|
||||
Used exclusively for analytics writes (via `Mixer.Metrics.Buffer`) and
|
||||
read queries (via `Mixer.Metrics`). It is **not** an Ash repo and must
|
||||
never be used for transactional application data.
|
||||
"""
|
||||
|
||||
use Ecto.Repo,
|
||||
otp_app: :mixer,
|
||||
adapter: Ecto.Adapters.ClickHouse
|
||||
end
|
||||
291
lib/mixer/metrics.ex
Normal file
291
lib/mixer/metrics.ex
Normal file
@@ -0,0 +1,291 @@
|
||||
defmodule Mixer.Metrics do
|
||||
@moduledoc """
|
||||
Public API for tracking and querying post (tweet) metrics via ClickHouse.
|
||||
|
||||
## Tracking events
|
||||
|
||||
Tracking calls are non-blocking — events are handed off to the in-memory
|
||||
`Mixer.Metrics.Buffer` GenServer and written to ClickHouse in batches.
|
||||
|
||||
# Record a tweet view (anonymous)
|
||||
Mixer.Metrics.track_view(tweet_id)
|
||||
|
||||
# Record a view with a logged-in user and their IP
|
||||
Mixer.Metrics.track_view(tweet_id, user_id: user.id, ip_address: conn.remote_ip)
|
||||
|
||||
## Querying metrics
|
||||
|
||||
Query functions execute synchronous ClickHouse SQL and return plain maps.
|
||||
|
||||
{:ok, summary} = Mixer.Metrics.get_summary(tweet_id)
|
||||
# => %{views: 42, likes: 7, unlikes: 1, comments: 3, shares: 0}
|
||||
|
||||
{:ok, rows} = Mixer.Metrics.get_top_posts(10)
|
||||
# => [%{tweet_id: "...", views: 99}, ...]
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Mixer.ClickhouseRepo
|
||||
alias Mixer.Metrics.Buffer
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Event types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@type event_type ::
|
||||
:view | :post | :comment | :like | :unlike | :share | :delete_post | :delete_comment
|
||||
|
||||
@type track_opt ::
|
||||
{:user_id, binary() | nil}
|
||||
| {:ip_address, binary() | :inet.ip_address() | nil}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tracking helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@doc """
|
||||
Track a tweet view event.
|
||||
|
||||
## Options
|
||||
|
||||
* `:user_id` — UUID of the viewing user (nil for anonymous)
|
||||
* `:ip_address` — originating IP; accepts a string or an `:inet` tuple
|
||||
"""
|
||||
@spec track_view(binary(), [track_opt()]) :: :ok
|
||||
def track_view(tweet_id, opts \\ []), do: enqueue("view", tweet_id, opts)
|
||||
|
||||
@doc "Track a tweet like event."
|
||||
@spec track_like(binary(), [track_opt()]) :: :ok
|
||||
def track_like(tweet_id, opts \\ []), do: enqueue("like", tweet_id, opts)
|
||||
|
||||
@doc "Track a tweet unlike event."
|
||||
@spec track_unlike(binary(), [track_opt()]) :: :ok
|
||||
def track_unlike(tweet_id, opts \\ []), do: enqueue("unlike", tweet_id, opts)
|
||||
|
||||
@doc "Track a comment (reply) event on a tweet."
|
||||
@spec track_comment(binary(), [track_opt()]) :: :ok
|
||||
def track_comment(tweet_id, opts \\ []), do: enqueue("comment", tweet_id, opts)
|
||||
|
||||
@doc "Track a tweet share / repost event."
|
||||
@spec track_share(binary(), [track_opt()]) :: :ok
|
||||
def track_share(tweet_id, opts \\ []), do: enqueue("share", tweet_id, opts)
|
||||
|
||||
@doc """
|
||||
Track a new top-level tweet being published.
|
||||
|
||||
The event is recorded against the new tweet's own ID.
|
||||
"""
|
||||
@spec track_post(binary(), [track_opt()]) :: :ok
|
||||
def track_post(tweet_id, opts \\ []), do: enqueue("post", tweet_id, opts)
|
||||
|
||||
@doc """
|
||||
Track a top-level tweet being deleted.
|
||||
|
||||
The event is recorded against the deleted tweet's ID.
|
||||
Note: cascade-deleted comments are not individually tracked — only the
|
||||
explicit user-initiated destroy action emits this event.
|
||||
"""
|
||||
@spec track_delete_post(binary(), [track_opt()]) :: :ok
|
||||
def track_delete_post(tweet_id, opts \\ []), do: enqueue("delete_post", tweet_id, opts)
|
||||
|
||||
@doc """
|
||||
Track a comment (reply) being deleted.
|
||||
|
||||
The event is recorded against the *parent* tweet's ID so that
|
||||
`get_summary/1` can reflect net comment activity on a tweet.
|
||||
"""
|
||||
@spec track_delete_comment(binary(), [track_opt()]) :: :ok
|
||||
def track_delete_comment(tweet_id, opts \\ []), do: enqueue("delete_comment", tweet_id, opts)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Query helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@doc """
|
||||
Return a summary of all event counts for a single tweet.
|
||||
|
||||
Returns `{:ok, map}` on success or `{:error, reason}` on failure.
|
||||
|
||||
## Example
|
||||
|
||||
{:ok, %{views: 12, likes: 3, unlikes: 0, comments: 5, shares: 1}} =
|
||||
Mixer.Metrics.get_summary(tweet_id)
|
||||
"""
|
||||
@spec get_summary(binary()) :: {:ok, map()} | {:error, term()}
|
||||
def get_summary(tweet_id) do
|
||||
sql = """
|
||||
SELECT
|
||||
countIf(event_type = 'view') AS views,
|
||||
countIf(event_type = 'like') AS likes,
|
||||
countIf(event_type = 'unlike') AS unlikes,
|
||||
countIf(event_type = 'comment') AS comments,
|
||||
countIf(event_type = 'share') AS shares
|
||||
FROM post_events
|
||||
WHERE tweet_id = {tweet_id:String}
|
||||
"""
|
||||
|
||||
case ClickhouseRepo.query(sql, %{"tweet_id" => tweet_id}) do
|
||||
{:ok, result} ->
|
||||
{:ok, row_to_summary(result)}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("[Mixer.Metrics] get_summary failed for #{tweet_id}: #{inspect(reason)}")
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Return view counts bucketed by UTC hour for the past `hours` hours.
|
||||
|
||||
Useful for rendering a sparkline on a tweet detail page.
|
||||
|
||||
## Example
|
||||
|
||||
{:ok, rows} = Mixer.Metrics.get_hourly_views(tweet_id, 24)
|
||||
# => [%{hour: ~N[2026-04-07 00:00:00], views: 5}, ...]
|
||||
"""
|
||||
@spec get_hourly_views(binary(), pos_integer()) :: {:ok, [map()]} | {:error, term()}
|
||||
def get_hourly_views(tweet_id, hours \\ 24) when is_integer(hours) and hours > 0 do
|
||||
sql = """
|
||||
SELECT
|
||||
toStartOfHour(occurred_at) AS hour,
|
||||
count() AS views
|
||||
FROM post_events
|
||||
WHERE
|
||||
tweet_id = {tweet_id:String}
|
||||
AND event_type = 'view'
|
||||
AND occurred_at >= now() - toIntervalHour({hours:UInt32})
|
||||
GROUP BY hour
|
||||
ORDER BY hour ASC
|
||||
"""
|
||||
|
||||
case ClickhouseRepo.query(sql, %{"tweet_id" => tweet_id, "hours" => hours}) do
|
||||
{:ok, %{rows: rows}} ->
|
||||
{:ok, Enum.map(rows, fn [hour, views] -> %{hour: hour, views: views} end)}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("[Mixer.Metrics] get_hourly_views failed: #{inspect(reason)}")
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Return the top `limit` tweets ordered by total view count across all time.
|
||||
|
||||
## Example
|
||||
|
||||
{:ok, rows} = Mixer.Metrics.get_top_posts(10)
|
||||
# => [%{tweet_id: "...", views: 99}, %{tweet_id: "...", views: 72}, ...]
|
||||
"""
|
||||
@spec get_top_posts(pos_integer()) :: {:ok, [map()]} | {:error, term()}
|
||||
def get_top_posts(limit \\ 10) when is_integer(limit) and limit > 0 do
|
||||
sql = """
|
||||
SELECT
|
||||
tweet_id,
|
||||
countIf(event_type = 'view') AS views
|
||||
FROM post_events
|
||||
GROUP BY tweet_id
|
||||
ORDER BY views DESC
|
||||
LIMIT {limit:UInt32}
|
||||
"""
|
||||
|
||||
case ClickhouseRepo.query(sql, %{"limit" => limit}) do
|
||||
{:ok, %{rows: rows}} ->
|
||||
{:ok, Enum.map(rows, fn [tweet_id, views] -> %{tweet_id: tweet_id, views: views} end)}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("[Mixer.Metrics] get_top_posts failed: #{inspect(reason)}")
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Return per-event-type counts for a list of tweet IDs in a single query.
|
||||
|
||||
Handy for batch-enriching a feed with metrics without N+1 queries.
|
||||
|
||||
## Example
|
||||
|
||||
{:ok, map} = Mixer.Metrics.get_bulk_summaries(tweet_ids)
|
||||
# => %{"<uuid>" => %{views: 5, likes: 2, ...}, ...}
|
||||
"""
|
||||
@spec get_bulk_summaries([binary()]) :: {:ok, %{binary() => map()}} | {:error, term()}
|
||||
def get_bulk_summaries([]), do: {:ok, %{}}
|
||||
|
||||
def get_bulk_summaries(tweet_ids) when is_list(tweet_ids) do
|
||||
# ecto_ch supports passing arrays as query parameters
|
||||
sql = """
|
||||
SELECT
|
||||
tweet_id,
|
||||
countIf(event_type = 'view') AS views,
|
||||
countIf(event_type = 'like') AS likes,
|
||||
countIf(event_type = 'unlike') AS unlikes,
|
||||
countIf(event_type = 'comment') AS comments,
|
||||
countIf(event_type = 'share') AS shares
|
||||
FROM post_events
|
||||
WHERE tweet_id IN {tweet_ids:Array(String)}
|
||||
GROUP BY tweet_id
|
||||
"""
|
||||
|
||||
case ClickhouseRepo.query(sql, %{"tweet_ids" => tweet_ids}) do
|
||||
{:ok, %{rows: rows}} ->
|
||||
summaries =
|
||||
Map.new(rows, fn [tweet_id, views, likes, unlikes, comments, shares] ->
|
||||
{tweet_id,
|
||||
%{
|
||||
views: views,
|
||||
likes: likes,
|
||||
unlikes: unlikes,
|
||||
comments: comments,
|
||||
shares: shares
|
||||
}}
|
||||
end)
|
||||
|
||||
{:ok, summaries}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("[Mixer.Metrics] get_bulk_summaries failed: #{inspect(reason)}")
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Private helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
defp enqueue(event_type, tweet_id, opts) do
|
||||
event = %{
|
||||
event_type: event_type,
|
||||
tweet_id: tweet_id,
|
||||
user_id: Keyword.get(opts, :user_id),
|
||||
occurred_at: DateTime.utc_now() |> DateTime.truncate(:second),
|
||||
ip_address: opts |> Keyword.get(:ip_address) |> format_ip()
|
||||
}
|
||||
|
||||
Buffer.track(event)
|
||||
end
|
||||
|
||||
defp format_ip(nil), do: nil
|
||||
defp format_ip(ip) when is_binary(ip), do: ip
|
||||
|
||||
defp format_ip({a, b, c, d}), do: "#{a}.#{b}.#{c}.#{d}"
|
||||
|
||||
defp format_ip({a, b, c, d, e, f, g, h}) do
|
||||
[a, b, c, d, e, f, g, h]
|
||||
|> Enum.map_join(":", &Integer.to_string(&1, 16))
|
||||
end
|
||||
|
||||
defp row_to_summary(%{rows: [[views, likes, unlikes, comments, shares] | _]}) do
|
||||
%{
|
||||
views: views,
|
||||
likes: likes,
|
||||
unlikes: unlikes,
|
||||
comments: comments,
|
||||
shares: shares
|
||||
}
|
||||
end
|
||||
|
||||
# ClickHouse returns no rows when the tweet has zero events — default to 0
|
||||
defp row_to_summary(_), do: %{views: 0, likes: 0, unlikes: 0, comments: 0, shares: 0}
|
||||
end
|
||||
151
lib/mixer/metrics/buffer.ex
Normal file
151
lib/mixer/metrics/buffer.ex
Normal file
@@ -0,0 +1,151 @@
|
||||
defmodule Mixer.Metrics.Buffer do
|
||||
@moduledoc """
|
||||
GenServer that accumulates post metric events in memory and flushes them
|
||||
to ClickHouse in batches.
|
||||
|
||||
Two conditions trigger a flush:
|
||||
|
||||
1. **Timer** — every `@flush_interval` milliseconds (default 10 s).
|
||||
2. **Threshold** — whenever the in-memory buffer reaches `@max_buffer_size`
|
||||
rows (default 500).
|
||||
|
||||
If ClickHouse is unavailable the error is logged and the buffered events
|
||||
are discarded rather than retried indefinitely, preventing unbounded memory
|
||||
growth. For production deployments that require durability, consider adding
|
||||
a persistent queue in front of this buffer.
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
|
||||
require Logger
|
||||
|
||||
alias Mixer.Metrics.PostEvent
|
||||
|
||||
@flush_interval :timer.seconds(10)
|
||||
@max_buffer_size 500
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@doc """
|
||||
Start the buffer process and link it to the calling process.
|
||||
|
||||
Accepts an optional keyword list of overrides:
|
||||
|
||||
* `:flush_interval` — milliseconds between scheduled flushes
|
||||
* `:max_buffer_size` — row count that triggers an immediate flush
|
||||
"""
|
||||
@spec start_link(keyword()) :: GenServer.on_start()
|
||||
def start_link(opts \\ []) do
|
||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Enqueue a single analytics event map for buffered insertion into ClickHouse.
|
||||
|
||||
The map must contain at minimum the fields required by `Mixer.Metrics.PostEvent`:
|
||||
`:event_type`, `:tweet_id`, `:occurred_at`. Other fields are optional.
|
||||
|
||||
This call is asynchronous (cast) and returns `:ok` immediately.
|
||||
"""
|
||||
@spec track(map()) :: :ok
|
||||
def track(event) when is_map(event) do
|
||||
GenServer.cast(__MODULE__, {:track, event})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Force an immediate flush of all buffered events to ClickHouse, regardless
|
||||
of the timer or threshold. Returns `:ok` after the flush completes.
|
||||
|
||||
Primarily useful in tests.
|
||||
"""
|
||||
@spec flush() :: :ok
|
||||
def flush do
|
||||
GenServer.call(__MODULE__, :flush)
|
||||
end
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GenServer callbacks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@impl GenServer
|
||||
def init(opts) do
|
||||
flush_interval = Keyword.get(opts, :flush_interval, @flush_interval)
|
||||
max_buffer_size = Keyword.get(opts, :max_buffer_size, @max_buffer_size)
|
||||
|
||||
schedule_flush(flush_interval)
|
||||
|
||||
state = %{
|
||||
events: [],
|
||||
count: 0,
|
||||
flush_interval: flush_interval,
|
||||
max_buffer_size: max_buffer_size
|
||||
}
|
||||
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
@impl GenServer
|
||||
def handle_cast({:track, event}, state) do
|
||||
new_count = state.count + 1
|
||||
new_events = [event | state.events]
|
||||
|
||||
if new_count >= state.max_buffer_size do
|
||||
do_flush(new_events)
|
||||
{:noreply, %{state | events: [], count: 0}}
|
||||
else
|
||||
{:noreply, %{state | events: new_events, count: new_count}}
|
||||
end
|
||||
end
|
||||
|
||||
@impl GenServer
|
||||
def handle_call(:flush, _from, state) do
|
||||
do_flush(state.events)
|
||||
{:reply, :ok, %{state | events: [], count: 0}}
|
||||
end
|
||||
|
||||
@impl GenServer
|
||||
def handle_info(:flush, state) do
|
||||
do_flush(state.events)
|
||||
schedule_flush(state.flush_interval)
|
||||
{:noreply, %{state | events: [], count: 0}}
|
||||
end
|
||||
|
||||
@impl GenServer
|
||||
def terminate(_reason, state) do
|
||||
# Best-effort flush on shutdown so we don't lose buffered events during
|
||||
# graceful stops (e.g., deploys).
|
||||
do_flush(state.events)
|
||||
:ok
|
||||
end
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Private helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
defp do_flush([]), do: :ok
|
||||
|
||||
defp do_flush(events) do
|
||||
rows = Enum.reverse(events)
|
||||
count = length(rows)
|
||||
|
||||
try do
|
||||
# ClickHouse async inserts acknowledge writes immediately and always
|
||||
# return num_rows: 0 — the data is queued for background commitment.
|
||||
# We use our own row count for the log so it is always accurate.
|
||||
Mixer.ClickhouseRepo.insert_all(PostEvent, rows)
|
||||
Logger.debug("[Mixer.Metrics.Buffer] Flushed #{count} event(s) to ClickHouse")
|
||||
rescue
|
||||
error ->
|
||||
Logger.error(
|
||||
"[Mixer.Metrics.Buffer] Failed to flush #{count} event(s) to ClickHouse: " <>
|
||||
Exception.message(error)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defp schedule_flush(interval) do
|
||||
Process.send_after(self(), :flush, interval)
|
||||
end
|
||||
end
|
||||
47
lib/mixer/metrics/post_event.ex
Normal file
47
lib/mixer/metrics/post_event.ex
Normal file
@@ -0,0 +1,47 @@
|
||||
defmodule Mixer.Metrics.PostEvent do
|
||||
@moduledoc """
|
||||
Ecto schema that maps to the `post_events` table in ClickHouse.
|
||||
|
||||
Each row represents a single analytics event tied to a tweet (post).
|
||||
The table uses a MergeTree engine ordered by `(occurred_at, event_type,
|
||||
tweet_id)` for efficient time-range scans and per-tweet aggregations.
|
||||
|
||||
## Event types
|
||||
|
||||
| event_type | `tweet_id` refers to | Description |
|
||||
|--------------------|-----------------------|-------------------------------------------------|
|
||||
| `"view"` | the viewed tweet | Tweet detail page was loaded |
|
||||
| `"post"` | the new tweet | A new top-level tweet was published |
|
||||
| `"comment"` | the parent tweet | A reply was posted; count against the parent |
|
||||
| `"like"` | the liked tweet | A user liked a tweet |
|
||||
| `"unlike"` | the unliked tweet | A user removed their like |
|
||||
| `"share"` | the shared tweet | A user shared / reposted a tweet |
|
||||
| `"delete_post"` | the deleted tweet | A top-level tweet was deleted by its author |
|
||||
| `"delete_comment"` | the parent tweet | A reply was deleted; count against the parent |
|
||||
"""
|
||||
|
||||
use Ecto.Schema
|
||||
|
||||
@primary_key false
|
||||
|
||||
schema "post_events" do
|
||||
# Must be Ch-typed so ecto_ch emits LowCardinality(String) in the RowBinary
|
||||
# header, matching the ClickHouse table DDL exactly.
|
||||
field :event_type, Ch, type: "LowCardinality(String)"
|
||||
|
||||
# The tweet that the event relates to
|
||||
field :tweet_id, Ecto.UUID
|
||||
|
||||
# The acting user; may be nil for anonymous views.
|
||||
# Must be Ch-typed so ecto_ch emits Nullable(UUID) in the RowBinary header,
|
||||
# matching the ClickHouse table DDL exactly.
|
||||
field :user_id, Ch, type: "Nullable(UUID)"
|
||||
|
||||
# Wall-clock time of the event (UTC, second precision)
|
||||
field :occurred_at, :utc_datetime
|
||||
|
||||
# Optional originating IP, useful for deduplicating anonymous views.
|
||||
# Nullable(String) for the same reason as user_id above.
|
||||
field :ip_address, Ch, type: "Nullable(String)"
|
||||
end
|
||||
end
|
||||
@@ -3,6 +3,22 @@ defmodule Mixer.Posts do
|
||||
otp_app: :mixer,
|
||||
extensions: [AshTypescript.Rpc, AshAdmin.Domain]
|
||||
|
||||
typescript_rpc do
|
||||
resource Mixer.Posts.Tweet do
|
||||
rpc_action :create_tweet, :create
|
||||
rpc_action :like_tweet, :like
|
||||
rpc_action :read_tweet, :read
|
||||
rpc_action :read_following_feed, :following_feed
|
||||
rpc_action :unlike_tweet, :unlike
|
||||
rpc_action :update_tweet, :update
|
||||
rpc_action :destroy_tweet, :destroy
|
||||
end
|
||||
|
||||
resource Mixer.Posts.Media do
|
||||
rpc_action :read_media, :read
|
||||
end
|
||||
end
|
||||
|
||||
admin do
|
||||
show? true
|
||||
end
|
||||
@@ -12,19 +28,4 @@ defmodule Mixer.Posts do
|
||||
resource Mixer.Posts.TweetLike
|
||||
resource Mixer.Posts.Media
|
||||
end
|
||||
|
||||
typescript_rpc do
|
||||
resource Mixer.Posts.Tweet do
|
||||
rpc_action :create_tweet, :create
|
||||
rpc_action :like_tweet, :like
|
||||
rpc_action :read_tweet, :read
|
||||
rpc_action :unlike_tweet, :unlike
|
||||
rpc_action :update_tweet, :update
|
||||
rpc_action :destroy_tweet, :destroy
|
||||
end
|
||||
|
||||
resource Mixer.Posts.Media do
|
||||
rpc_action :read_media, :read
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -38,6 +38,24 @@ defmodule Mixer.Posts.Media do
|
||||
end
|
||||
end
|
||||
|
||||
policies do
|
||||
policy action_type(:read) do
|
||||
authorize_if always()
|
||||
end
|
||||
|
||||
policy action(:upload) do
|
||||
authorize_if actor_present()
|
||||
end
|
||||
|
||||
policy action(:link_to_tweet) do
|
||||
authorize_if relates_to_actor_via(:user)
|
||||
end
|
||||
|
||||
policy action_type(:destroy) do
|
||||
authorize_if relates_to_actor_via(:user)
|
||||
end
|
||||
end
|
||||
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
|
||||
@@ -64,22 +82,4 @@ defmodule Mixer.Posts.Media do
|
||||
public? true
|
||||
end
|
||||
end
|
||||
|
||||
policies do
|
||||
policy action_type(:read) do
|
||||
authorize_if always()
|
||||
end
|
||||
|
||||
policy action(:upload) do
|
||||
authorize_if actor_present()
|
||||
end
|
||||
|
||||
policy action(:link_to_tweet) do
|
||||
authorize_if relates_to_actor_via(:user)
|
||||
end
|
||||
|
||||
policy action_type(:destroy) do
|
||||
authorize_if relates_to_actor_via(:user)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,7 +10,8 @@ defmodule Mixer.Posts.MediaUploader do
|
||||
if ext in @extensions, do: :ok, else: {:error, "unsupported file type #{ext}"}
|
||||
end
|
||||
|
||||
def storage_dir(_version, {_file, scope}), do: "uploads/media/#{scope.user_id}/#{scope.media_id}"
|
||||
def storage_dir(_version, {_file, scope}),
|
||||
do: "uploads/media/#{scope.user_id}/#{scope.media_id}"
|
||||
|
||||
def filename(_version, {file, _scope}) do
|
||||
Path.basename(file.file_name, Path.extname(file.file_name))
|
||||
|
||||
@@ -12,10 +12,10 @@ defmodule Mixer.Posts.Tweet do
|
||||
postgres do
|
||||
table "tweets"
|
||||
repo Mixer.Repo
|
||||
end
|
||||
|
||||
typescript do
|
||||
type_name "tweets"
|
||||
references do
|
||||
reference :parent_tweet, on_delete: :delete
|
||||
end
|
||||
end
|
||||
|
||||
state_machine do
|
||||
@@ -27,15 +27,27 @@ defmodule Mixer.Posts.Tweet do
|
||||
end
|
||||
end
|
||||
|
||||
typescript do
|
||||
type_name "tweets"
|
||||
end
|
||||
|
||||
actions do
|
||||
defaults [:read, :destroy]
|
||||
defaults [:read]
|
||||
|
||||
read :following_feed do
|
||||
filter expr(
|
||||
user_id == ^actor(:id) or
|
||||
exists(user.followers, follower_id == ^actor(:id))
|
||||
)
|
||||
end
|
||||
|
||||
create :create do
|
||||
upsert? true
|
||||
accept [:content]
|
||||
accept [:content, :parent_tweet_id]
|
||||
argument :media_id, :uuid, allow_nil?: true
|
||||
change relate_actor(:user)
|
||||
change transition_state(:posted)
|
||||
|
||||
change fn changeset, context ->
|
||||
case Ash.Changeset.get_argument(changeset, :media_id) do
|
||||
nil ->
|
||||
@@ -45,13 +57,58 @@ defmodule Mixer.Posts.Tweet do
|
||||
Ash.Changeset.after_action(changeset, fn _changeset, tweet ->
|
||||
Mixer.Posts.Media
|
||||
|> Ash.get!(media_id, authorize?: false)
|
||||
|> Ash.Changeset.for_update(:link_to_tweet, %{tweet_id: tweet.id}, actor: context.actor)
|
||||
|> Ash.Changeset.for_update(:link_to_tweet, %{tweet_id: tweet.id},
|
||||
actor: context.actor
|
||||
)
|
||||
|> Ash.update!()
|
||||
|
||||
{:ok, tweet}
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
# Track post / comment creation metrics.
|
||||
# Root tweets emit a "post" event recorded against their own ID.
|
||||
# Replies emit a "comment" event recorded against the parent tweet ID so
|
||||
# that `get_summary/1` can count how many replies a tweet has received.
|
||||
change fn changeset, context ->
|
||||
parent_tweet_id = Ash.Changeset.get_attribute(changeset, :parent_tweet_id)
|
||||
user_id = context.actor && context.actor.id
|
||||
|
||||
Ash.Changeset.after_action(changeset, fn _changeset, tweet ->
|
||||
if parent_tweet_id do
|
||||
Mixer.Metrics.track_comment(parent_tweet_id, user_id: user_id)
|
||||
else
|
||||
Mixer.Metrics.track_post(tweet.id, user_id: user_id)
|
||||
end
|
||||
|
||||
{:ok, tweet}
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
# Explicit destroy so we can attach a metrics hook. The policy and cascade
|
||||
# behaviour are identical to the previous default :destroy action.
|
||||
destroy :destroy do
|
||||
require_atomic? false
|
||||
|
||||
change fn changeset, context ->
|
||||
# Capture the record's identity *before* deletion — after the action
|
||||
# completes the row no longer exists.
|
||||
tweet_id = changeset.data.id
|
||||
parent_tweet_id = changeset.data.parent_tweet_id
|
||||
user_id = context.actor && context.actor.id
|
||||
|
||||
Ash.Changeset.after_action(changeset, fn _changeset, result ->
|
||||
if parent_tweet_id do
|
||||
Mixer.Metrics.track_delete_comment(parent_tweet_id, user_id: user_id)
|
||||
else
|
||||
Mixer.Metrics.track_delete_post(tweet_id, user_id: user_id)
|
||||
end
|
||||
|
||||
{:ok, result}
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
update :update do
|
||||
@@ -66,10 +123,11 @@ defmodule Mixer.Posts.Tweet do
|
||||
Ash.Changeset.after_action(changeset, fn _changeset, tweet ->
|
||||
case ensure_like(tweet, context.actor) do
|
||||
{:created, _like} ->
|
||||
Mixer.Metrics.track_like(tweet.id, user_id: context.actor && context.actor.id)
|
||||
increment_likes(tweet, context.actor)
|
||||
|
||||
{:noop, _like} ->
|
||||
{:ok, tweet}
|
||||
Ash.get(__MODULE__, tweet.id, authorize?: false)
|
||||
|
||||
{:error, error} ->
|
||||
{:error, error}
|
||||
@@ -86,10 +144,11 @@ defmodule Mixer.Posts.Tweet do
|
||||
Ash.Changeset.after_action(changeset, fn _changeset, tweet ->
|
||||
case remove_like(tweet, context.actor) do
|
||||
{:deleted, _like} ->
|
||||
Mixer.Metrics.track_unlike(tweet.id, user_id: context.actor && context.actor.id)
|
||||
decrement_likes(tweet, context.actor)
|
||||
|
||||
{:noop, _like} ->
|
||||
{:ok, tweet}
|
||||
Ash.get(__MODULE__, tweet.id, authorize?: false)
|
||||
|
||||
{:error, error} ->
|
||||
{:error, error}
|
||||
@@ -107,7 +166,34 @@ defmodule Mixer.Posts.Tweet do
|
||||
update :decrement_likes do
|
||||
accept []
|
||||
require_atomic? false
|
||||
change atomic_update(:likes, expr(likes - 1))
|
||||
change atomic_update(:likes, expr(fragment("GREATEST(? - 1, 0)", likes)))
|
||||
end
|
||||
end
|
||||
|
||||
policies do
|
||||
policy action_type(:read) do
|
||||
authorize_if always()
|
||||
end
|
||||
|
||||
policy action_type(:create) do
|
||||
authorize_if actor_present()
|
||||
end
|
||||
|
||||
policy action(:update) do
|
||||
authorize_if relates_to_actor_via(:user)
|
||||
end
|
||||
|
||||
policy action(:destroy) do
|
||||
authorize_if relates_to_actor_via(:user)
|
||||
authorize_if relates_to_actor_via([:parent_tweet, :user])
|
||||
end
|
||||
|
||||
policy action(:like) do
|
||||
authorize_if actor_present()
|
||||
end
|
||||
|
||||
policy action(:unlike) do
|
||||
authorize_if actor_present()
|
||||
end
|
||||
end
|
||||
|
||||
@@ -145,6 +231,18 @@ defmodule Mixer.Posts.Tweet do
|
||||
public? true
|
||||
end
|
||||
|
||||
belongs_to :parent_tweet, Mixer.Posts.Tweet do
|
||||
attribute_type :uuid
|
||||
attribute_writable? true
|
||||
allow_nil? true
|
||||
public? true
|
||||
end
|
||||
|
||||
has_many :comments, Mixer.Posts.Tweet do
|
||||
destination_attribute :parent_tweet_id
|
||||
public? true
|
||||
end
|
||||
|
||||
has_many :media, Mixer.Posts.Media do
|
||||
public? true
|
||||
end
|
||||
@@ -156,41 +254,31 @@ defmodule Mixer.Posts.Tweet do
|
||||
calculate :user_email, :string, expr(user.email) do
|
||||
public? true
|
||||
end
|
||||
|
||||
calculate :user_username, :string, expr(user.username) do
|
||||
public? true
|
||||
end
|
||||
|
||||
calculate :user_display_name, :string, expr(user.display_name) do
|
||||
public? true
|
||||
end
|
||||
|
||||
calculate :user_avatar_url, :string, expr(user.avatar_url) do
|
||||
public? true
|
||||
end
|
||||
end
|
||||
|
||||
aggregates do
|
||||
count :comment_count, :comments do
|
||||
public? true
|
||||
end
|
||||
|
||||
exists :liked_by_me, :tweet_likes do
|
||||
public? true
|
||||
filter expr(user_id == ^actor(:id))
|
||||
end
|
||||
end
|
||||
|
||||
policies do
|
||||
policy action_type(:read) do
|
||||
authorize_if always()
|
||||
end
|
||||
|
||||
policy action_type(:create) do
|
||||
authorize_if actor_present()
|
||||
end
|
||||
|
||||
policy action(:update) do
|
||||
authorize_if relates_to_actor_via(:user)
|
||||
end
|
||||
|
||||
policy action(:destroy) do
|
||||
authorize_if relates_to_actor_via(:user)
|
||||
end
|
||||
|
||||
policy action(:like) do
|
||||
authorize_if actor_present()
|
||||
end
|
||||
|
||||
policy action(:unlike) do
|
||||
authorize_if actor_present()
|
||||
end
|
||||
end
|
||||
|
||||
defp ensure_like(_tweet, nil), do: {:error, Ash.Error.Forbidden.exception([])}
|
||||
|
||||
defp ensure_like(tweet, actor) do
|
||||
|
||||
@@ -23,6 +23,20 @@ defmodule Mixer.Posts.TweetLike do
|
||||
end
|
||||
end
|
||||
|
||||
policies do
|
||||
policy action_type(:read) do
|
||||
authorize_if always()
|
||||
end
|
||||
|
||||
policy action(:create) do
|
||||
authorize_if actor_present()
|
||||
end
|
||||
|
||||
policy action_type(:destroy) do
|
||||
authorize_if relates_to_actor_via(:user)
|
||||
end
|
||||
end
|
||||
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
|
||||
@@ -52,18 +66,4 @@ defmodule Mixer.Posts.TweetLike do
|
||||
identities do
|
||||
identity :unique_user_tweet, [:tweet_id, :user_id]
|
||||
end
|
||||
|
||||
policies do
|
||||
policy action_type(:read) do
|
||||
authorize_if always()
|
||||
end
|
||||
|
||||
policy action(:create) do
|
||||
authorize_if actor_present()
|
||||
end
|
||||
|
||||
policy action_type(:destroy) do
|
||||
authorize_if relates_to_actor_via(:user)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -9,12 +9,15 @@ defmodule MixerWeb.AuthOverrides do
|
||||
|
||||
# For a complete reference, see https://hexdocs.pm/ash_authentication_phoenix/ui-overrides.html
|
||||
|
||||
# override AshAuthentication.Phoenix.Components.Banner do
|
||||
# set :image_url, "https://media.giphy.com/media/g7GKcSzwQfugw/giphy.gif"
|
||||
# set :text_class, "bg-red-500"
|
||||
# end
|
||||
override AshAuthentication.Phoenix.Components.Banner do
|
||||
set :image_url, nil
|
||||
set :dark_image_url, nil
|
||||
set :text, "⬡ Mixer"
|
||||
set :text_class, "text-3xl font-bold tracking-tight"
|
||||
end
|
||||
|
||||
# override AshAuthentication.Phoenix.Components.SignIn do
|
||||
# set :show_banner, false
|
||||
# end
|
||||
# Inject the username field into the password registration form
|
||||
override AshAuthentication.Phoenix.Components.Password do
|
||||
set :register_extra_component, &MixerWeb.AuthComponents.username_field/1
|
||||
end
|
||||
end
|
||||
|
||||
55
lib/mixer_web/components/auth_components.ex
Normal file
55
lib/mixer_web/components/auth_components.ex
Normal file
@@ -0,0 +1,55 @@
|
||||
defmodule MixerWeb.AuthComponents do
|
||||
@moduledoc """
|
||||
Extra components injected into AshAuthentication.Phoenix forms.
|
||||
"""
|
||||
|
||||
use Phoenix.Component
|
||||
|
||||
@doc """
|
||||
Renders a username input field inside the password registration form.
|
||||
|
||||
Receives `form` (an `AshPhoenix.Form`) as an assign via the
|
||||
`register_extra_component` override.
|
||||
"""
|
||||
def username_field(assigns) do
|
||||
field = assigns.form[:username]
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:field_id, field.id)
|
||||
|> assign(:field_name, field.name)
|
||||
|> assign(:field_value, field.value || "")
|
||||
|> assign(:field_errors, field.errors)
|
||||
|
||||
~H"""
|
||||
<div class="mt-2 mb-2">
|
||||
<label for={@field_id} class="block text-sm font-medium text-base-content mb-1">
|
||||
Username
|
||||
</label>
|
||||
<div class="flex">
|
||||
<span class="flex items-center justify-center px-4 bg-base-200 border border-base-300 border-r-0 rounded-l-lg text-base-content/50 select-none">@</span>
|
||||
<input
|
||||
type="text"
|
||||
id={@field_id}
|
||||
name={@field_name}
|
||||
value={@field_value}
|
||||
class={"input w-full rounded-l-none #{if @field_errors != [], do: "input-error", else: ""}"}
|
||||
placeholder="your_handle"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<p :for={error <- @field_errors} class="mt-1 text-xs text-error">
|
||||
{translate_error(error)}
|
||||
</p>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def translate_error({msg, opts}) do
|
||||
if count = opts[:count] do
|
||||
Gettext.dngettext(MixerWeb.Gettext, "errors", msg, msg, count, opts)
|
||||
else
|
||||
Gettext.dgettext(MixerWeb.Gettext, "errors", msg, opts)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4,8 +4,23 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="csrf-token" content={get_csrf_token()} />
|
||||
<.live_title default="Mixer" suffix=" · Phoenix Framework">
|
||||
{assigns[:page_title]}
|
||||
<link rel="icon" href={~p"/favicon.ico"} sizes="any" />
|
||||
<% meta_title = assigns[:page_title] || "Mixer"
|
||||
|
||||
meta_description =
|
||||
assigns[:page_description] ||
|
||||
"Mixer is a social feed for all. Come join the conversation — built with Elixir." %>
|
||||
<meta name="description" content={meta_description} />
|
||||
<meta name="robots" content={assigns[:robots] || "index, follow"} />
|
||||
<meta property="og:site_name" content="Mixer" />
|
||||
<meta property="og:title" content={meta_title} />
|
||||
<meta property="og:description" content={meta_description} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:title" content={meta_title} />
|
||||
<meta name="twitter:description" content={meta_description} />
|
||||
<.live_title suffix=" · Mixer">
|
||||
{assigns[:page_title] || "Mixer"}
|
||||
</.live_title>
|
||||
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
|
||||
<script defer phx-track-static type="module" src={~p"/assets/app.js"}>
|
||||
|
||||
@@ -8,7 +8,37 @@ SPDX-License-Identifier: MIT
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="csrf-token" content={get_csrf_token()} />
|
||||
<.live_title default="AshTypescript">Page</.live_title>
|
||||
<link rel="icon" href={~p"/favicon.ico"} sizes="any" />
|
||||
<% {spa_title, spa_description} =
|
||||
case @page do
|
||||
"feed" -> {"Mixer · Feed", "See the latest posts from everyone on Mixer."}
|
||||
"tweet" -> {"Mixer · Post", "Read this post and join the conversation on Mixer."}
|
||||
"following" -> {"Mixer · Following", "Posts from the people you follow on Mixer."}
|
||||
"profile" -> {"Mixer · My Profile", "View and manage your Mixer profile."}
|
||||
"users" -> {"Mixer · People", "Discover and follow people on Mixer."}
|
||||
"user-detail" -> {"Mixer · Profile", "View this user's profile and posts on Mixer."}
|
||||
_ -> {"Mixer", "A social feed built in Elixir."}
|
||||
end %>
|
||||
<meta name="description" content={spa_description} />
|
||||
<meta name="robots" content="index, follow" />
|
||||
<meta property="og:site_name" content="Mixer" />
|
||||
<meta property="og:title" content={spa_title} />
|
||||
<meta property="og:description" content={spa_description} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:title" content={spa_title} />
|
||||
<meta name="twitter:description" content={spa_description} />
|
||||
<.live_title default="Mixer">
|
||||
{case @page do
|
||||
"feed" -> "Mixer · Feed"
|
||||
"tweet" -> "Mixer · Post"
|
||||
"following" -> "Mixer · Following"
|
||||
"profile" -> "Mixer · My Profile"
|
||||
"users" -> "Mixer · People"
|
||||
"user-detail" -> "Mixer · Profile"
|
||||
_ -> "Mixer"
|
||||
end}
|
||||
</.live_title>
|
||||
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -35,8 +35,11 @@ defmodule MixerWeb.AuthController do
|
||||
You can confirm your account using the link we sent to you, or by resetting your password.
|
||||
"""
|
||||
|
||||
{_, %AshAuthentication.Errors.UnconfirmedUser{}} ->
|
||||
"You must confirm your email address before signing in. Please check your inbox for a confirmation email."
|
||||
|
||||
_ ->
|
||||
"Incorrect email or password"
|
||||
"Incorrect email or password or unconfirmed email"
|
||||
end
|
||||
|
||||
conn
|
||||
|
||||
@@ -2,7 +2,13 @@ defmodule MixerWeb.PageController do
|
||||
use MixerWeb, :controller
|
||||
|
||||
def home(conn, _params) do
|
||||
render(conn, :home)
|
||||
if conn.assigns[:current_user] do
|
||||
redirect(conn, to: ~p"/feed")
|
||||
else
|
||||
conn
|
||||
|> assign(:page_title, "Mixer")
|
||||
|> render(:home)
|
||||
end
|
||||
end
|
||||
|
||||
def index(conn, _params) do
|
||||
@@ -10,9 +16,19 @@ defmodule MixerWeb.PageController do
|
||||
end
|
||||
|
||||
def show(conn, %{"tweet_id" => tweet_id}) do
|
||||
user_id = conn.assigns[:current_user] && conn.assigns[:current_user].id
|
||||
Mixer.Metrics.track_view(tweet_id, user_id: user_id, ip_address: conn.remote_ip)
|
||||
render_spa(conn, %{page: "tweet", tweet_id: tweet_id, user_id: nil})
|
||||
end
|
||||
|
||||
def following(conn, _params) do
|
||||
render_spa(conn, %{page: "following", tweet_id: nil, user_id: nil})
|
||||
end
|
||||
|
||||
def profile(conn, _params) do
|
||||
render_spa(conn, %{page: "profile", tweet_id: nil, user_id: nil})
|
||||
end
|
||||
|
||||
def users_index(conn, _params) do
|
||||
render_spa(conn, %{page: "users", tweet_id: nil, user_id: nil})
|
||||
end
|
||||
@@ -28,11 +44,11 @@ defmodule MixerWeb.PageController do
|
||||
conn
|
||||
|> put_root_layout(html: {MixerWeb.Layouts, :spa_root})
|
||||
|> render(:index,
|
||||
current_user: conn.assigns[:current_user],
|
||||
media_host: "#{asset_host}/#{bucket}",
|
||||
page: page,
|
||||
tweet_id: tweet_id,
|
||||
user_id: user_id
|
||||
)
|
||||
current_user: conn.assigns[:current_user],
|
||||
media_host: "#{asset_host}/#{bucket}",
|
||||
page: page,
|
||||
tweet_id: tweet_id,
|
||||
user_id: user_id
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<p class="text-base-content/60 text-lg mb-10">A social feed built with Ash & 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>
|
||||
<a href="/sign-in" class="btn btn-ghost btn-lg rounded-full px-8">Sign in</a>
|
||||
</div>
|
||||
<p class="mt-8 text-sm text-base-content/40">
|
||||
Already have an account?
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
<div id="app"
|
||||
data-current-user-id={if @current_user, do: @current_user.id, else: ""}
|
||||
data-current-user-email={if @current_user, do: @current_user.email, else: ""}
|
||||
data-asset-host={@media_host}
|
||||
data-page={@page}
|
||||
data-tweet-id={@tweet_id || ""}
|
||||
data-user-id={@user_id || ""}>
|
||||
<div
|
||||
id="app"
|
||||
data-current-user-id={if @current_user, do: @current_user.id, else: ""}
|
||||
data-current-user-email={if @current_user, do: @current_user.email, else: ""}
|
||||
data-current-user-username={if @current_user, do: @current_user.username || "", else: ""}
|
||||
data-current-user-display-name={
|
||||
if @current_user, do: @current_user.display_name || "", else: ""
|
||||
}
|
||||
data-current-user-avatar-url={if @current_user, do: @current_user.avatar_url || "", else: ""}
|
||||
data-asset-host={@media_host}
|
||||
data-page={@page}
|
||||
data-tweet-id={@tweet_id || ""}
|
||||
data-user-id={@user_id || ""}
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ defmodule MixerWeb.UploadController do
|
||||
use MixerWeb, :controller
|
||||
|
||||
alias Mixer.Posts.MediaUploader
|
||||
alias Mixer.Accounts.AvatarUploader
|
||||
|
||||
def create(conn, %{"file" => %Plug.Upload{} = upload}) do
|
||||
actor = conn.assigns[:current_user]
|
||||
@@ -46,4 +47,50 @@ defmodule MixerWeb.UploadController do
|
||||
|> put_status(:bad_request)
|
||||
|> json(%{error: "no file provided"})
|
||||
end
|
||||
|
||||
# ── Avatar upload ──────────────────────────────────────────────────────────
|
||||
|
||||
def upload_avatar(conn, %{"file" => %Plug.Upload{} = upload}) do
|
||||
actor = conn.assigns[:current_user]
|
||||
|
||||
unless actor do
|
||||
conn
|
||||
|> put_status(:unauthorized)
|
||||
|> json(%{error: "authentication required"})
|
||||
else
|
||||
scope = %{user_id: actor.id}
|
||||
|
||||
case AvatarUploader.store({upload, scope}) do
|
||||
{:ok, _file_name} ->
|
||||
# The thumb is always stored as avatars/:user_id/thumb.webp.
|
||||
# Append a timestamp so the browser doesn't serve a stale cached image
|
||||
# when the user updates their avatar (the URL changes, S3 ignores the param).
|
||||
thumb_key = "avatars/#{actor.id}/thumb.webp?v=#{System.system_time(:millisecond)}"
|
||||
|
||||
actor
|
||||
|> Ash.Changeset.for_update(:update_avatar, %{avatar_url: thumb_key}, actor: actor)
|
||||
|> Ash.update()
|
||||
|> case do
|
||||
{:ok, _user} ->
|
||||
json(conn, %{success: true, avatarUrl: thumb_key})
|
||||
|
||||
{:error, error} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{success: false, error: inspect(error)})
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
conn
|
||||
|> put_status(:unprocessable_entity)
|
||||
|> json(%{success: false, error: reason})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def upload_avatar(conn, _params) do
|
||||
conn
|
||||
|> put_status(:bad_request)
|
||||
|> json(%{error: "no file provided"})
|
||||
end
|
||||
end
|
||||
|
||||
188
lib/mixer_web/live/magic_sign_in_live.ex
Normal file
188
lib/mixer_web/live/magic_sign_in_live.ex
Normal file
@@ -0,0 +1,188 @@
|
||||
defmodule MixerWeb.MagicSignInLive do
|
||||
@moduledoc """
|
||||
Custom magic-link sign-in LiveView that collects a username for new users.
|
||||
|
||||
When a user clicks their magic link, this page is shown instead of the
|
||||
default auto-submit. If the user is brand new (no account) or has no
|
||||
username set yet, we ask them to choose one before completing sign-in.
|
||||
"""
|
||||
|
||||
use AshAuthentication.Phoenix.Overrides.Overridable,
|
||||
root_class: "CSS class for the root `div` element.",
|
||||
magic_sign_in_id: "Element ID for the `MagicSignIn` LiveComponent."
|
||||
|
||||
use AshAuthentication.Phoenix.Web, :live_view
|
||||
|
||||
alias AshAuthentication.Info
|
||||
alias AshPhoenix.Form
|
||||
alias Phoenix.LiveView.{Rendered, Socket}
|
||||
|
||||
import AshAuthentication.Phoenix.Components.Helpers, only: [auth_path: 5]
|
||||
import PhoenixHTMLHelpers.Form, only: [hidden_input: 3, submit: 2]
|
||||
import Slug
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
def mount(params, session, socket) do
|
||||
overrides =
|
||||
session
|
||||
|> Map.get("overrides", [AshAuthentication.Phoenix.Overrides.Default])
|
||||
|
||||
resource = session["resource"]
|
||||
strategy_name = session["strategy"]
|
||||
token = params["token"] || params["magic_link"]
|
||||
|
||||
strategy = Info.strategy!(resource, strategy_name)
|
||||
subject_name = Info.authentication_subject_name!(resource)
|
||||
domain = Info.authentication_domain!(resource)
|
||||
|
||||
# Determine whether this user needs to pick a username
|
||||
needs_username? = needs_username?(token, resource, domain)
|
||||
|
||||
form =
|
||||
resource
|
||||
|> Form.for_action(strategy.sign_in_action_name,
|
||||
params: %{"token" => token},
|
||||
domain: domain,
|
||||
as: subject_name |> to_string(),
|
||||
id: "#{subject_name}-#{strategy_name}-sign-in-form" |> slugify(),
|
||||
context: %{strategy: strategy, private: %{ash_authentication?: true}}
|
||||
)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(overrides: overrides)
|
||||
|> assign(:token, token)
|
||||
|> assign(:strategy, strategy)
|
||||
|> assign(:subject_name, subject_name)
|
||||
|> assign(:resource, resource)
|
||||
|> assign(:needs_username?, needs_username?)
|
||||
|> assign(:form, form)
|
||||
|> assign(:trigger_action, false)
|
||||
|> assign(:current_tenant, session["tenant"])
|
||||
|> assign(:auth_routes_prefix, session["auth_routes_prefix"])
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec handle_params(map, String.t(), Socket.t()) :: {:noreply, Socket.t()}
|
||||
def handle_params(_params, _uri, socket), do: {:noreply, socket}
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec render(Socket.assigns()) :: Rendered.t()
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="min-h-screen flex flex-col items-center justify-center bg-base-100 p-4">
|
||||
<div class="w-full max-w-sm mb-8 text-center">
|
||||
<.live_component
|
||||
module={AshAuthentication.Phoenix.Components.Banner}
|
||||
id="magic-sign-in-banner"
|
||||
overrides={@overrides}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="w-full max-w-sm p-6 bg-base-100 border border-base-200 rounded-xl shadow-sm">
|
||||
<.form :let={form} for={@form} phx-change="validate" phx-submit="submit" phx-trigger-action={@trigger_action}
|
||||
action={auth_path(@socket, @subject_name, @auth_routes_prefix, @strategy, :sign_in)}
|
||||
method="POST">
|
||||
|
||||
{hidden_input(form, :token, [])}
|
||||
|
||||
<%!-- Using the unified component --%>
|
||||
<MixerWeb.AuthComponents.username_field :if={@needs_username?} form={form} />
|
||||
|
||||
{submit("Sign in", class: "btn btn-primary w-full", phx_disable_with: "Signing in...")}
|
||||
</.form>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec handle_event(String.t(), map(), Socket.t()) :: {:noreply, Socket.t()}
|
||||
def handle_event("submit", params, socket) do
|
||||
subject_name =
|
||||
socket.assigns.subject_name
|
||||
|> to_string()
|
||||
|> slugify()
|
||||
|
||||
form_params = Map.get(params, subject_name, %{})
|
||||
|
||||
# Use Form.validate with :all_errors to surface uniqueness constraints
|
||||
form =
|
||||
socket.assigns.form
|
||||
|> Form.validate(form_params, errors: true)
|
||||
|
||||
if form.valid? do
|
||||
# Only trigger the POST redirect if the data is truly valid
|
||||
{:noreply, assign(socket, form: form, trigger_action: true)}
|
||||
else
|
||||
socket =
|
||||
socket
|
||||
|> assign(form: form, trigger_action: false)
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", params, socket) do
|
||||
subject_name = socket.assigns.subject_name |> to_string() |> slugify()
|
||||
form_params = Map.get(params, subject_name, %{})
|
||||
|
||||
form = Form.validate(socket.assigns.form, form_params, errors: true)
|
||||
{:noreply, assign(socket, form: form)}
|
||||
end
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
# Returns true if the user is new or has no username set yet.
|
||||
defp needs_username?(nil, _resource, _domain), do: true
|
||||
|
||||
defp needs_username?(token, resource, domain) do
|
||||
with {:ok, claims} <- AshAuthentication.Jwt.peek(token),
|
||||
# 1. Try to find an existing user from the claims
|
||||
user <- find_user(claims, resource, domain),
|
||||
# 2. If a user exists, check if they already have a username
|
||||
false <- is_nil(user) do
|
||||
is_nil(user.username) or user.username == ""
|
||||
else
|
||||
_ ->
|
||||
# Unknown / new user — ask for username to be safe
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
defp find_user(claims, resource, domain) do
|
||||
# Try 'sub' first if it looks like a user subject (e.g. "User:123")
|
||||
sub = Map.get(claims, "sub")
|
||||
|
||||
user =
|
||||
if is_binary(sub) and String.contains?(sub, ":") do
|
||||
case AshAuthentication.subject_to_user(sub, resource) do
|
||||
{:ok, user} -> user
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
# If not found via subject, try 'identity' (common in magic link tokens)
|
||||
user ||
|
||||
case Map.get(claims, "identity") || Map.get(claims, "email") do
|
||||
email when is_binary(email) ->
|
||||
# Use for_read with the explicit action and arguments
|
||||
resource
|
||||
|> Ash.Query.for_read(:get_by_email, %{email: email})
|
||||
|> Ash.read_one(domain: domain, authorize?: false)
|
||||
|> case do
|
||||
{:ok, user} -> user
|
||||
_ -> nil
|
||||
end
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -40,11 +40,14 @@ defmodule MixerWeb.Router do
|
||||
get "/", PageController, :home
|
||||
get "/feed", PageController, :index
|
||||
get "/feed/:tweet_id", PageController, :show
|
||||
get "/following", PageController, :following
|
||||
get "/profile", PageController, :profile
|
||||
get "/users", PageController, :users_index
|
||||
get "/users/:user_id", PageController, :user_show
|
||||
post "/rpc/run", AshTypescriptRpcController, :run
|
||||
post "/rpc/validate", AshTypescriptRpcController, :validate
|
||||
post "/upload", UploadController, :create
|
||||
post "/upload/avatar", UploadController, :upload_avatar
|
||||
auth_routes AuthController, Mixer.Accounts.User, path: "/auth"
|
||||
sign_out_route AuthController
|
||||
|
||||
@@ -72,6 +75,7 @@ defmodule MixerWeb.Router do
|
||||
|
||||
# Remove this if you do not use the magic link strategy.
|
||||
magic_sign_in_route(Mixer.Accounts.User, :magic_link,
|
||||
live_view: MixerWeb.MagicSignInLive,
|
||||
auth_routes_prefix: "/auth",
|
||||
overrides: [MixerWeb.AuthOverrides, Elixir.AshAuthentication.Phoenix.Overrides.DaisyUI]
|
||||
)
|
||||
|
||||
16
mix.exs
16
mix.exs
@@ -91,7 +91,8 @@ defmodule Mixer.MixProject do
|
||||
{:ex_aws, "~> 2.1.2"},
|
||||
{:ex_aws_s3, "~> 2.0"},
|
||||
{:hackney, "~> 1.9"},
|
||||
{:sweet_xml, "~> 0.6"}
|
||||
{:sweet_xml, "~> 0.6"},
|
||||
{:ecto_ch, "~> 0.3"}
|
||||
]
|
||||
end
|
||||
|
||||
@@ -133,18 +134,21 @@ defmodule Mixer.MixProject do
|
||||
build: [
|
||||
"ash-framework": [
|
||||
# The description tells people how to use this skill.
|
||||
description: "Use this skill working with Ash Framework or any of its extensions. Always consult this when making any domain changes, features or fixes.",
|
||||
description:
|
||||
"Use this skill working with Ash Framework or any of its extensions. Always consult this when making any domain changes, features or fixes.",
|
||||
# Include all Ash dependencies
|
||||
usage_rules: [:ash, ~r/^ash_/]
|
||||
],
|
||||
"phoenix-framework": [
|
||||
description: "Use this skill working with Phoenix Framework. Consult this when working with the web layer, controllers, views, liveviews etc.",
|
||||
description:
|
||||
"Use this skill working with Phoenix Framework. Consult this when working with the web layer, controllers, views, liveviews etc.",
|
||||
# Include all Phoenix dependencies
|
||||
usage_rules: [:phoenix, ~r/^phoenix_/]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
[
|
||||
file: "AGENTS.md",
|
||||
usage_rules: ["usage_rules:all"],
|
||||
@@ -152,11 +156,13 @@ defmodule Mixer.MixProject do
|
||||
location: ".agents/skills",
|
||||
build: [
|
||||
"ash-framework": [
|
||||
description: "Use this skill working with Ash Framework or any of its extensions. Always consult this when making any domain changes, features or fixes.",
|
||||
description:
|
||||
"Use this skill working with Ash Framework or any of its extensions. Always consult this when making any domain changes, features or fixes.",
|
||||
usage_rules: [:ash, ~r/^ash_/]
|
||||
],
|
||||
"phoenix-framework": [
|
||||
description: "Use this skill working with Phoenix Framework. Consult this when working with the web layer, controllers, views, liveviews etc.",
|
||||
description:
|
||||
"Use this skill working with Phoenix Framework. Consult this when working with the web layer, controllers, views, liveviews etc.",
|
||||
usage_rules: [:phoenix, ~r/^phoenix_/]
|
||||
]
|
||||
]
|
||||
|
||||
2
mix.lock
2
mix.lock
@@ -20,6 +20,7 @@
|
||||
"castore": {:hex, :castore, "1.0.18", "5e43ef0ec7d31195dfa5a65a86e6131db999d074179d2ba5a8de11fe14570f55", [:mix], [], "hexpm", "f393e4fe6317829b158fb74d86eb681f737d2fe326aa61ccf6293c4104957e34"},
|
||||
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
|
||||
"certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"},
|
||||
"ch": {:hex, :ch, "0.7.1", "116c08094b30d095c3bd6a8fe4ebe19fdaaf3dce84e2413cfdd6af157baf6303", [:mix], [{:db_connection, "~> 2.9.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "3c1c900291ff9c4c077cd1dc0c265051a3f1d26320d58b37ed9e91b33d41a868"},
|
||||
"cinder": {:hex, :cinder, "0.12.1", "02ae4988e025fb32c37e4e7f2e491586b952918c0dd99d856da13271cd680e16", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 2.3", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 1.0.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "a48b5677c1f57619d9d7564fb2bd7928f93750a2e8c0b1b145852a30ecf2aa20"},
|
||||
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
|
||||
"conv_case": {:hex, :conv_case, "0.2.3", "c1455c27d3c1ffcdd5f17f1e91f40b8a0bc0a337805a6e8302f441af17118ed8", [:mix], [], "hexpm", "88f29a3d97d1742f9865f7e394ed3da011abb7c5e8cc104e676fdef6270d4b4a"},
|
||||
@@ -29,6 +30,7 @@
|
||||
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
|
||||
"dotenvy": {:hex, :dotenvy, "1.1.1", "00e318f3c51de9fafc4b48598447e386f19204dc18ca69886905bb8f8b08b667", [:mix], [], "hexpm", "c8269471b5701e9e56dc86509c1199ded2b33dce088c3471afcfef7839766d8e"},
|
||||
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
|
||||
"ecto_ch": {:hex, :ecto_ch, "0.8.6", "f31b507e86690c003f46e75d6e742e6b5d8ce34b6b10a86604b1c3aa785e0b56", [:mix], [{:ch, "~> 0.5.0 or ~> 0.6.0 or ~> 0.7.0", [hex: :ch, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "6ca9f1cf9680452b1925c6a3a7b5e3d8b12e38ee134b03c6a45a8b26434fad97"},
|
||||
"ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"},
|
||||
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
|
||||
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
|
||||
|
||||
4
priv/clickhouse/migrations/.formatter.exs
Normal file
4
priv/clickhouse/migrations/.formatter.exs
Normal file
@@ -0,0 +1,4 @@
|
||||
[
|
||||
import_deps: [:ecto_ch],
|
||||
inputs: ["*.exs"]
|
||||
]
|
||||
@@ -0,0 +1,49 @@
|
||||
defmodule Mixer.ClickhouseRepo.Migrations.CreatePostEvents do
|
||||
use Ecto.Migration
|
||||
|
||||
@doc """
|
||||
Creates the `post_events` table using a MergeTree engine.
|
||||
|
||||
Key design decisions:
|
||||
|
||||
* `LowCardinality(String)` for `event_type` — the cardinality is tiny
|
||||
(5–10 values), so ClickHouse can store it as a dictionary, giving both
|
||||
compression and faster filtering.
|
||||
|
||||
* `Nullable(UUID)` / `Nullable(String)` for optional columns — ClickHouse
|
||||
handles NULLs differently from PostgreSQL; we make the nullable fields
|
||||
explicit so the schema is unambiguous.
|
||||
|
||||
* `ORDER BY (occurred_at, event_type, tweet_id)` — optimises the two most
|
||||
common query patterns:
|
||||
1. Time-range scans (`WHERE occurred_at >= now() - interval 24 HOUR`)
|
||||
2. Per-tweet aggregations (`WHERE tweet_id = ?`)
|
||||
|
||||
* `PARTITION BY toYYYYMM(occurred_at)` — monthly partitions make it cheap
|
||||
to drop old data with `ALTER TABLE … DROP PARTITION`.
|
||||
|
||||
* `TTL occurred_at + INTERVAL 1 YEAR DELETE` — automatically reclaim disk
|
||||
space after two years. Adjust as required.
|
||||
"""
|
||||
def up do
|
||||
execute("""
|
||||
CREATE TABLE IF NOT EXISTS post_events
|
||||
(
|
||||
event_type LowCardinality(String),
|
||||
tweet_id UUID,
|
||||
user_id Nullable(UUID),
|
||||
occurred_at DateTime,
|
||||
ip_address Nullable(String)
|
||||
)
|
||||
ENGINE = MergeTree()
|
||||
PARTITION BY toYYYYMM(occurred_at)
|
||||
ORDER BY (occurred_at, event_type, tweet_id)
|
||||
TTL occurred_at + INTERVAL 1 YEAR DELETE
|
||||
SETTINGS index_granularity = 8192
|
||||
""")
|
||||
end
|
||||
|
||||
def down do
|
||||
execute("DROP TABLE IF EXISTS post_events")
|
||||
end
|
||||
end
|
||||
11
priv/clickhouse/seeds.exs
Normal file
11
priv/clickhouse/seeds.exs
Normal file
@@ -0,0 +1,11 @@
|
||||
# Script for populating the database. You can run it as:
|
||||
#
|
||||
# mix run priv/clickhouse/seeds.exs
|
||||
#
|
||||
# Inside the script, you can read and write to any of your
|
||||
# repositories directly:
|
||||
#
|
||||
# Mixer.ClickhouseRepo.insert!(%Mixer.SomeSchema{})
|
||||
#
|
||||
# We recommend using the bang functions (`insert!`, `update!`
|
||||
# and so on) as they will fail if something goes wrong.
|
||||
@@ -18,7 +18,8 @@ defmodule Mixer.Repo.Migrations.SetupPostsAndTweets do
|
||||
name: "tweets_user_id_fkey",
|
||||
type: :uuid,
|
||||
prefix: "public"
|
||||
), null: false
|
||||
),
|
||||
null: false
|
||||
|
||||
add :state, :text, null: false, default: "drafted"
|
||||
end
|
||||
|
||||
@@ -22,7 +22,8 @@ defmodule Mixer.Repo.Migrations.AddPostsMediaS3 do
|
||||
name: "media_tweet_id_fkey",
|
||||
type: :uuid,
|
||||
prefix: "public"
|
||||
), null: false
|
||||
),
|
||||
null: false
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@ defmodule Mixer.Repo.Migrations.AddUserIdToMediaAndAllowNullTweetId do
|
||||
name: "media_user_id_fkey",
|
||||
type: :uuid,
|
||||
prefix: "public"
|
||||
), null: false
|
||||
),
|
||||
null: false
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@ defmodule Mixer.Repo.Migrations.AddTweetLikes do
|
||||
name: "tweet_likes_tweet_id_fkey",
|
||||
type: :uuid,
|
||||
prefix: "public"
|
||||
), null: false
|
||||
),
|
||||
null: false
|
||||
|
||||
add :user_id,
|
||||
references(:users,
|
||||
@@ -25,7 +26,8 @@ defmodule Mixer.Repo.Migrations.AddTweetLikes do
|
||||
name: "tweet_likes_user_id_fkey",
|
||||
type: :uuid,
|
||||
prefix: "public"
|
||||
), null: false
|
||||
),
|
||||
null: false
|
||||
end
|
||||
|
||||
create unique_index(:tweet_likes, [:tweet_id, :user_id],
|
||||
|
||||
53
priv/repo/migrations/20260403012654_follow_feature.exs
Normal file
53
priv/repo/migrations/20260403012654_follow_feature.exs
Normal file
@@ -0,0 +1,53 @@
|
||||
defmodule Mixer.Repo.Migrations.FollowFeature do
|
||||
@moduledoc """
|
||||
Updates resources based on their most recent snapshots.
|
||||
|
||||
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
create table(:follows, primary_key: false) do
|
||||
add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true
|
||||
|
||||
add :created_at, :utc_datetime_usec,
|
||||
null: false,
|
||||
default: fragment("(now() AT TIME ZONE 'utc')")
|
||||
|
||||
add :follower_id,
|
||||
references(:users,
|
||||
column: :id,
|
||||
name: "follows_follower_id_fkey",
|
||||
type: :uuid,
|
||||
prefix: "public",
|
||||
on_delete: :delete_all
|
||||
), primary_key: true, null: false
|
||||
|
||||
add :following_id,
|
||||
references(:users,
|
||||
column: :id,
|
||||
name: "follows_following_id_fkey",
|
||||
type: :uuid,
|
||||
prefix: "public",
|
||||
on_delete: :delete_all
|
||||
), primary_key: true, null: false
|
||||
end
|
||||
|
||||
create unique_index(:follows, [:follower_id, :following_id],
|
||||
name: "follows_unique_follow_index"
|
||||
)
|
||||
end
|
||||
|
||||
def down do
|
||||
drop_if_exists unique_index(:follows, [:follower_id, :following_id],
|
||||
name: "follows_unique_follow_index"
|
||||
)
|
||||
|
||||
drop constraint(:follows, "follows_follower_id_fkey")
|
||||
|
||||
drop constraint(:follows, "follows_following_id_fkey")
|
||||
|
||||
drop table(:follows)
|
||||
end
|
||||
end
|
||||
30
priv/repo/migrations/20260406180126_add_tweet_comments.exs
Normal file
30
priv/repo/migrations/20260406180126_add_tweet_comments.exs
Normal file
@@ -0,0 +1,30 @@
|
||||
defmodule Mixer.Repo.Migrations.AddTweetComments do
|
||||
@moduledoc """
|
||||
Updates resources based on their most recent snapshots.
|
||||
|
||||
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
alter table(:tweets) do
|
||||
add :parent_tweet_id,
|
||||
references(:tweets,
|
||||
column: :id,
|
||||
name: "tweets_parent_tweet_id_fkey",
|
||||
type: :uuid,
|
||||
prefix: "public",
|
||||
on_delete: :delete_all
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def down do
|
||||
drop constraint(:tweets, "tweets_parent_tweet_id_fkey")
|
||||
|
||||
alter table(:tweets) do
|
||||
remove :parent_tweet_id
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,29 @@
|
||||
defmodule Mixer.Repo.Migrations.AddUserProfileFields do
|
||||
@moduledoc """
|
||||
Updates resources based on their most recent snapshots.
|
||||
|
||||
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
alter table(:users) do
|
||||
add :username, :text
|
||||
add :display_name, :text
|
||||
add :avatar_url, :text
|
||||
end
|
||||
|
||||
create unique_index(:users, [:username], name: "users_unique_username_index")
|
||||
end
|
||||
|
||||
def down do
|
||||
drop_if_exists unique_index(:users, [:username], name: "users_unique_username_index")
|
||||
|
||||
alter table(:users) do
|
||||
remove :avatar_url
|
||||
remove :display_name
|
||||
remove :username
|
||||
end
|
||||
end
|
||||
end
|
||||
125
priv/resource_snapshots/repo/follows/20260403012655.json
Normal file
125
priv/resource_snapshots/repo/follows/20260403012655.json
Normal file
@@ -0,0 +1,125 @@
|
||||
{
|
||||
"attributes": [
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"gen_random_uuid()\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": true,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "created_at",
|
||||
"type": "utc_datetime_usec"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": true,
|
||||
"references": {
|
||||
"deferrable": false,
|
||||
"destination_attribute": "id",
|
||||
"destination_attribute_default": null,
|
||||
"destination_attribute_generated": null,
|
||||
"index?": false,
|
||||
"match_type": null,
|
||||
"match_with": null,
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"name": "follows_follower_id_fkey",
|
||||
"on_delete": "delete",
|
||||
"on_update": null,
|
||||
"primary_key?": true,
|
||||
"schema": "public",
|
||||
"table": "users"
|
||||
},
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "follower_id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": true,
|
||||
"references": {
|
||||
"deferrable": false,
|
||||
"destination_attribute": "id",
|
||||
"destination_attribute_default": null,
|
||||
"destination_attribute_generated": null,
|
||||
"index?": false,
|
||||
"match_type": null,
|
||||
"match_with": null,
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"name": "follows_following_id_fkey",
|
||||
"on_delete": "delete",
|
||||
"on_update": null,
|
||||
"primary_key?": true,
|
||||
"schema": "public",
|
||||
"table": "users"
|
||||
},
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "following_id",
|
||||
"type": "uuid"
|
||||
}
|
||||
],
|
||||
"base_filter": null,
|
||||
"check_constraints": [],
|
||||
"create_table_options": null,
|
||||
"custom_indexes": [],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "F54EC67C792D0DBDA389BB372D0403325AEAD4C232678C19BB951AA058813F0A",
|
||||
"identities": [
|
||||
{
|
||||
"all_tenants?": false,
|
||||
"base_filter": null,
|
||||
"index_name": "follows_unique_follow_index",
|
||||
"keys": [
|
||||
{
|
||||
"type": "atom",
|
||||
"value": "follower_id"
|
||||
},
|
||||
{
|
||||
"type": "atom",
|
||||
"value": "following_id"
|
||||
}
|
||||
],
|
||||
"name": "unique_follow",
|
||||
"nils_distinct?": true,
|
||||
"where": null
|
||||
}
|
||||
],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"repo": "Elixir.Mixer.Repo",
|
||||
"schema": null,
|
||||
"table": "follows"
|
||||
}
|
||||
154
priv/resource_snapshots/repo/tweets/20260406180127.json
Normal file
154
priv/resource_snapshots/repo/tweets/20260406180127.json
Normal file
@@ -0,0 +1,154 @@
|
||||
{
|
||||
"attributes": [
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"gen_random_uuid()\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": true,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "content",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "0",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "likes",
|
||||
"type": "bigint"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": {
|
||||
"deferrable": false,
|
||||
"destination_attribute": "id",
|
||||
"destination_attribute_default": null,
|
||||
"destination_attribute_generated": null,
|
||||
"index?": false,
|
||||
"match_type": null,
|
||||
"match_with": null,
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"name": "tweets_user_id_fkey",
|
||||
"on_delete": null,
|
||||
"on_update": null,
|
||||
"primary_key?": true,
|
||||
"schema": "public",
|
||||
"table": "users"
|
||||
},
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "user_id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "inserted_at",
|
||||
"type": "utc_datetime_usec"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "updated_at",
|
||||
"type": "utc_datetime_usec"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "\"drafted\"",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "state",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": {
|
||||
"deferrable": false,
|
||||
"destination_attribute": "id",
|
||||
"destination_attribute_default": null,
|
||||
"destination_attribute_generated": null,
|
||||
"index?": false,
|
||||
"match_type": null,
|
||||
"match_with": null,
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"name": "tweets_parent_tweet_id_fkey",
|
||||
"on_delete": "delete",
|
||||
"on_update": null,
|
||||
"primary_key?": true,
|
||||
"schema": "public",
|
||||
"table": "tweets"
|
||||
},
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "parent_tweet_id",
|
||||
"type": "uuid"
|
||||
}
|
||||
],
|
||||
"base_filter": null,
|
||||
"check_constraints": [],
|
||||
"create_table_options": null,
|
||||
"custom_indexes": [],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "090E928120B2CFAA2B8D5D2EB43AD6E782ABB552AFC211BB6173D6337F487218",
|
||||
"identities": [],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"repo": "Elixir.Mixer.Repo",
|
||||
"schema": null,
|
||||
"table": "tweets"
|
||||
}
|
||||
133
priv/resource_snapshots/repo/users/20260408035351.json
Normal file
133
priv/resource_snapshots/repo/users/20260408035351.json
Normal file
@@ -0,0 +1,133 @@
|
||||
{
|
||||
"attributes": [
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"gen_random_uuid()\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": true,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "email",
|
||||
"type": "citext"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "hashed_password",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "confirmed_at",
|
||||
"type": "utc_datetime_usec"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "username",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "display_name",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "avatar_url",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"base_filter": null,
|
||||
"check_constraints": [],
|
||||
"create_table_options": null,
|
||||
"custom_indexes": [],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "E57BFA1141A2F4D237E6B3C8FE4BAD93772015179B56AEC9FA1F762C4FF5B6B8",
|
||||
"identities": [
|
||||
{
|
||||
"all_tenants?": false,
|
||||
"base_filter": null,
|
||||
"index_name": "users_unique_email_index",
|
||||
"keys": [
|
||||
{
|
||||
"type": "atom",
|
||||
"value": "email"
|
||||
}
|
||||
],
|
||||
"name": "unique_email",
|
||||
"nils_distinct?": true,
|
||||
"where": null
|
||||
},
|
||||
{
|
||||
"all_tenants?": false,
|
||||
"base_filter": null,
|
||||
"index_name": "users_unique_username_index",
|
||||
"keys": [
|
||||
{
|
||||
"type": "atom",
|
||||
"value": "username"
|
||||
}
|
||||
],
|
||||
"name": "unique_username",
|
||||
"nils_distinct?": true,
|
||||
"where": null
|
||||
}
|
||||
],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"repo": "Elixir.Mixer.Repo",
|
||||
"schema": null,
|
||||
"table": "users"
|
||||
}
|
||||
BIN
priv/static/favicon-3133be378e68a7fd3d9cb261166b0629.ico
Normal file
BIN
priv/static/favicon-3133be378e68a7fd3d9cb261166b0629.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 152 B |
Binary file not shown.
|
Before Width: | Height: | Size: 152 B After Width: | Height: | Size: 15 KiB |
2
rel/env.sh.eex
Normal file
2
rel/env.sh.eex
Normal file
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
export RELEASE_DISTRIBUTION=none
|
||||
172
test/mixer/accounts/follow_test.exs
Normal file
172
test/mixer/accounts/follow_test.exs
Normal file
@@ -0,0 +1,172 @@
|
||||
defmodule Mixer.Accounts.FollowTest do
|
||||
use Mixer.DataCase, async: true
|
||||
|
||||
require Ash.Query
|
||||
|
||||
alias Mixer.Accounts.Follow
|
||||
alias Mixer.Accounts.User
|
||||
|
||||
describe "follow" do
|
||||
test "a user can follow another user" do
|
||||
alice = user_fixture("alice@example.com", "alice")
|
||||
bob = user_fixture("bob@example.com", "bob")
|
||||
|
||||
assert {:ok, follow} =
|
||||
Follow
|
||||
|> Ash.Changeset.for_create(:follow, %{following_id: bob.id}, actor: alice)
|
||||
|> Ash.create()
|
||||
|
||||
assert follow.follower_id == alice.id
|
||||
assert follow.following_id == bob.id
|
||||
end
|
||||
|
||||
test "following the same user twice is a noop (upsert)" do
|
||||
alice = user_fixture("alice2@example.com", "alice2")
|
||||
bob = user_fixture("bob2@example.com", "bob2")
|
||||
|
||||
assert {:ok, _} =
|
||||
Follow
|
||||
|> Ash.Changeset.for_create(:follow, %{following_id: bob.id}, actor: alice)
|
||||
|> Ash.create()
|
||||
|
||||
assert {:ok, _} =
|
||||
Follow
|
||||
|> Ash.Changeset.for_create(:follow, %{following_id: bob.id}, actor: alice)
|
||||
|> Ash.create()
|
||||
|
||||
assert count_follows(alice.id, bob.id) == 1
|
||||
end
|
||||
|
||||
test "a user cannot follow themselves" do
|
||||
alice = user_fixture("alice3@example.com", "alice3")
|
||||
|
||||
assert {:error, error} =
|
||||
Follow
|
||||
|> Ash.Changeset.for_create(:follow, %{following_id: alice.id}, actor: alice)
|
||||
|> Ash.create()
|
||||
|
||||
assert Exception.message(error) =~ "cannot follow yourself"
|
||||
end
|
||||
|
||||
test "guests cannot follow" do
|
||||
bob = user_fixture("bob3@example.com", "bob3")
|
||||
|
||||
assert {:error, _error} =
|
||||
Follow
|
||||
|> Ash.Changeset.for_create(:follow, %{following_id: bob.id})
|
||||
|> Ash.create()
|
||||
end
|
||||
end
|
||||
|
||||
describe "unfollow" do
|
||||
test "a user can unfollow someone they follow" do
|
||||
alice = user_fixture("alice4@example.com", "alice4")
|
||||
bob = user_fixture("bob4@example.com", "bob4")
|
||||
|
||||
Follow
|
||||
|> Ash.Changeset.for_create(:follow, %{following_id: bob.id}, actor: alice)
|
||||
|> Ash.create!()
|
||||
|
||||
assert count_follows(alice.id, bob.id) == 1
|
||||
|
||||
assert :ok =
|
||||
Follow
|
||||
|> Ash.ActionInput.for_action(:unfollow, %{following_id: bob.id}, actor: alice)
|
||||
|> Ash.run_action()
|
||||
|
||||
assert count_follows(alice.id, bob.id) == 0
|
||||
end
|
||||
|
||||
test "unfollowing when not following is a noop" do
|
||||
alice = user_fixture("alice5@example.com", "alice5")
|
||||
bob = user_fixture("bob5@example.com", "bob5")
|
||||
|
||||
assert :ok =
|
||||
Follow
|
||||
|> Ash.ActionInput.for_action(:unfollow, %{following_id: bob.id}, actor: alice)
|
||||
|> Ash.run_action()
|
||||
|
||||
assert count_follows(alice.id, bob.id) == 0
|
||||
end
|
||||
|
||||
test "guests cannot unfollow" do
|
||||
bob = user_fixture("bob6@example.com", "bob6")
|
||||
|
||||
assert {:error, error} =
|
||||
Follow
|
||||
|> Ash.ActionInput.for_action(:unfollow, %{following_id: bob.id})
|
||||
|> Ash.run_action()
|
||||
|
||||
assert Exception.message(error) =~ "forbidden"
|
||||
end
|
||||
end
|
||||
|
||||
describe "follower/following counts" do
|
||||
test "follower_count and following_count reflect current follows" do
|
||||
alice = user_fixture("alice6@example.com", "alice6")
|
||||
bob = user_fixture("bob7@example.com", "bob7")
|
||||
carol = user_fixture("carol@example.com", "carol")
|
||||
|
||||
Follow
|
||||
|> Ash.Changeset.for_create(:follow, %{following_id: bob.id}, actor: alice)
|
||||
|> Ash.create!()
|
||||
|
||||
Follow
|
||||
|> Ash.Changeset.for_create(:follow, %{following_id: carol.id}, actor: alice)
|
||||
|> Ash.create!()
|
||||
|
||||
Follow
|
||||
|> Ash.Changeset.for_create(:follow, %{following_id: bob.id}, actor: carol)
|
||||
|> Ash.create!()
|
||||
|
||||
alice_loaded = User |> Ash.get!(alice.id, load: [:follower_count, :following_count], authorize?: false)
|
||||
bob_loaded = User |> Ash.get!(bob.id, load: [:follower_count, :following_count], authorize?: false)
|
||||
|
||||
assert alice_loaded.following_count == 2
|
||||
assert alice_loaded.follower_count == 0
|
||||
assert bob_loaded.follower_count == 2
|
||||
assert bob_loaded.following_count == 0
|
||||
end
|
||||
|
||||
test "am_i_following reflects the actor's follow status" do
|
||||
alice = user_fixture("alice7@example.com", "alice7")
|
||||
bob = user_fixture("bob8@example.com", "bob8")
|
||||
|
||||
not_following =
|
||||
User |> Ash.get!(bob.id, actor: alice, load: [:am_i_following], authorize?: false)
|
||||
|
||||
refute not_following.am_i_following
|
||||
|
||||
Follow
|
||||
|> Ash.Changeset.for_create(:follow, %{following_id: bob.id}, actor: alice)
|
||||
|> Ash.create!()
|
||||
|
||||
following =
|
||||
User |> Ash.get!(bob.id, actor: alice, load: [:am_i_following], authorize?: false)
|
||||
|
||||
assert following.am_i_following
|
||||
end
|
||||
end
|
||||
|
||||
# ── fixtures ──────────────────────────────────────────────────────────────
|
||||
|
||||
defp user_fixture(email, username) do
|
||||
User
|
||||
|> Ash.Changeset.for_create(:register_with_password, %{
|
||||
email: email,
|
||||
password: "password1234",
|
||||
password_confirmation: "password1234",
|
||||
username: username
|
||||
})
|
||||
|> Ash.create!(authorize?: false)
|
||||
end
|
||||
|
||||
defp count_follows(follower_id, following_id) do
|
||||
Follow
|
||||
|> Ash.Query.filter(
|
||||
Ash.Expr.expr(follower_id == ^follower_id and following_id == ^following_id)
|
||||
)
|
||||
|> Ash.read!(authorize?: false)
|
||||
|> length()
|
||||
end
|
||||
end
|
||||
@@ -2,6 +2,7 @@ defmodule Mixer.Posts.TweetLikeTest do
|
||||
use Mixer.DataCase, async: true
|
||||
|
||||
import Ash.Expr
|
||||
require Ash.Query
|
||||
|
||||
alias Mixer.Accounts.User
|
||||
alias Mixer.Posts.Tweet
|
||||
@@ -24,14 +25,14 @@ defmodule Mixer.Posts.TweetLikeTest do
|
||||
Tweet
|
||||
|> Ash.get!(tweet.id, actor: user, load: [:liked_by_me], authorize?: false)
|
||||
|
||||
refute Ash.ForbiddenField.forbidden?(tweet_for_actor.liked_by_me)
|
||||
refute match?(%Ash.ForbiddenField{}, tweet_for_actor.liked_by_me)
|
||||
assert tweet_for_actor.liked_by_me
|
||||
|
||||
tweet_without_actor =
|
||||
Tweet
|
||||
|> Ash.get!(tweet.id, load: [:liked_by_me], authorize?: false)
|
||||
|
||||
refute Ash.ForbiddenField.forbidden?(tweet_without_actor.liked_by_me)
|
||||
refute match?(%Ash.ForbiddenField{}, tweet_without_actor.liked_by_me)
|
||||
refute tweet_without_actor.liked_by_me
|
||||
end
|
||||
|
||||
@@ -93,13 +94,17 @@ defmodule Mixer.Posts.TweetLikeTest do
|
||||
end
|
||||
|
||||
defp user_fixture(email) do
|
||||
username =
|
||||
email |> String.split("@") |> List.first() |> String.replace(~r/[^a-zA-Z0-9_]/, "_")
|
||||
|
||||
User
|
||||
|> Ash.Changeset.for_create(:register_with_password, %{
|
||||
email: email,
|
||||
password: "password1234",
|
||||
password_confirmation: "password1234"
|
||||
password_confirmation: "password1234",
|
||||
username: username
|
||||
})
|
||||
|> Ash.create!()
|
||||
|> Ash.create!(authorize?: false)
|
||||
end
|
||||
|
||||
defp tweet_fixture(user, content) do
|
||||
|
||||
220
test/mixer/posts/tweet_test.exs
Normal file
220
test/mixer/posts/tweet_test.exs
Normal file
@@ -0,0 +1,220 @@
|
||||
defmodule Mixer.Posts.TweetTest do
|
||||
use Mixer.DataCase, async: true
|
||||
|
||||
require Ash.Query
|
||||
|
||||
alias Mixer.Accounts.User
|
||||
alias Mixer.Posts.Tweet
|
||||
|
||||
describe "tweet creation" do
|
||||
test "a user can create a tweet" do
|
||||
user = user_fixture("poster@example.com", "poster")
|
||||
|
||||
assert {:ok, tweet} =
|
||||
Tweet
|
||||
|> Ash.Changeset.for_create(:create, %{content: "hello world"}, actor: user)
|
||||
|> Ash.create()
|
||||
|
||||
assert tweet.content == "hello world"
|
||||
assert tweet.user_id == user.id
|
||||
assert tweet.state == :posted
|
||||
assert tweet.likes == 0
|
||||
end
|
||||
|
||||
test "tweet content cannot be blank" do
|
||||
user = user_fixture("blank@example.com", "blankuser")
|
||||
|
||||
assert {:error, error} =
|
||||
Tweet
|
||||
|> Ash.Changeset.for_create(:create, %{content: nil}, actor: user)
|
||||
|> Ash.create()
|
||||
|
||||
assert Exception.message(error) =~ "content"
|
||||
end
|
||||
|
||||
test "guests cannot create tweets" do
|
||||
assert {:error, _error} =
|
||||
Tweet
|
||||
|> Ash.Changeset.for_create(:create, %{content: "spam"})
|
||||
|> Ash.create()
|
||||
end
|
||||
|
||||
test "all users can read tweets" do
|
||||
user = user_fixture("readable@example.com", "readable")
|
||||
|
||||
Tweet
|
||||
|> Ash.Changeset.for_create(:create, %{content: "public post"}, actor: user)
|
||||
|> Ash.create!()
|
||||
|
||||
tweets = Tweet |> Ash.read!(authorize?: false)
|
||||
assert length(tweets) >= 1
|
||||
end
|
||||
end
|
||||
|
||||
describe "tweet update" do
|
||||
test "owner can edit their tweet" do
|
||||
user = user_fixture("editor@example.com", "editor")
|
||||
tweet = tweet_fixture(user, "original content")
|
||||
|
||||
assert {:ok, updated} =
|
||||
tweet
|
||||
|> Ash.Changeset.for_update(:update, %{content: "edited content"}, actor: user)
|
||||
|> Ash.update()
|
||||
|
||||
assert updated.content == "edited content"
|
||||
end
|
||||
|
||||
test "non-owner cannot edit a tweet" do
|
||||
owner = user_fixture("owner@example.com", "tweetowner")
|
||||
other = user_fixture("other@example.com", "otheruser")
|
||||
tweet = tweet_fixture(owner, "owner's post")
|
||||
|
||||
assert {:error, error} =
|
||||
tweet
|
||||
|> Ash.Changeset.for_update(:update, %{content: "hacked"}, actor: other)
|
||||
|> Ash.update()
|
||||
|
||||
assert Exception.message(error) =~ "forbidden"
|
||||
end
|
||||
end
|
||||
|
||||
describe "tweet deletion" do
|
||||
test "owner can delete their tweet" do
|
||||
user = user_fixture("deleter@example.com", "deleter")
|
||||
tweet = tweet_fixture(user, "to be deleted")
|
||||
|
||||
assert :ok =
|
||||
tweet
|
||||
|> Ash.Changeset.for_destroy(:destroy, %{}, actor: user)
|
||||
|> Ash.destroy()
|
||||
|
||||
assert {:ok, nil} = Tweet |> Ash.get(tweet.id, authorize?: false, not_found_error?: false)
|
||||
end
|
||||
|
||||
test "non-owner cannot delete a tweet" do
|
||||
owner = user_fixture("owner2@example.com", "owner2")
|
||||
other = user_fixture("other2@example.com", "other2")
|
||||
tweet = tweet_fixture(owner, "protected post")
|
||||
|
||||
assert {:error, error} =
|
||||
tweet
|
||||
|> Ash.Changeset.for_destroy(:destroy, %{}, actor: other)
|
||||
|> Ash.destroy()
|
||||
|
||||
assert Exception.message(error) =~ "forbidden"
|
||||
end
|
||||
end
|
||||
|
||||
describe "comments (replies)" do
|
||||
test "a user can reply to a tweet" do
|
||||
author = user_fixture("author@example.com", "author")
|
||||
replier = user_fixture("replier@example.com", "replier")
|
||||
parent = tweet_fixture(author, "parent post")
|
||||
|
||||
assert {:ok, comment} =
|
||||
Tweet
|
||||
|> Ash.Changeset.for_create(
|
||||
:create,
|
||||
%{content: "great post!", parent_tweet_id: parent.id},
|
||||
actor: replier
|
||||
)
|
||||
|> Ash.create()
|
||||
|
||||
assert comment.parent_tweet_id == parent.id
|
||||
assert comment.user_id == replier.id
|
||||
end
|
||||
|
||||
test "comment_count reflects number of replies" do
|
||||
author = user_fixture("countauthor@example.com", "countauthor")
|
||||
replier = user_fixture("countreplier@example.com", "countreplier")
|
||||
parent = tweet_fixture(author, "tweet with replies")
|
||||
|
||||
Tweet
|
||||
|> Ash.Changeset.for_create(:create, %{content: "reply 1", parent_tweet_id: parent.id}, actor: replier)
|
||||
|> Ash.create!()
|
||||
|
||||
Tweet
|
||||
|> Ash.Changeset.for_create(:create, %{content: "reply 2", parent_tweet_id: parent.id}, actor: replier)
|
||||
|> Ash.create!()
|
||||
|
||||
loaded = Tweet |> Ash.get!(parent.id, load: [:comment_count], authorize?: false)
|
||||
assert loaded.comment_count == 2
|
||||
end
|
||||
|
||||
test "tweet owner can delete a comment on their tweet" do
|
||||
author = user_fixture("tweetowner3@example.com", "tweetowner3")
|
||||
replier = user_fixture("commenter@example.com", "commenter")
|
||||
parent = tweet_fixture(author, "parent tweet")
|
||||
|
||||
comment =
|
||||
Tweet
|
||||
|> Ash.Changeset.for_create(
|
||||
:create,
|
||||
%{content: "a comment", parent_tweet_id: parent.id},
|
||||
actor: replier
|
||||
)
|
||||
|> Ash.create!()
|
||||
|
||||
# Tweet owner (author) can delete someone else's comment on their post
|
||||
assert :ok =
|
||||
comment
|
||||
|> Ash.Changeset.for_destroy(:destroy, %{}, actor: author)
|
||||
|> Ash.destroy()
|
||||
end
|
||||
|
||||
test "a third party cannot delete a comment they don't own" do
|
||||
author = user_fixture("tweetowner4@example.com", "tweetowner4")
|
||||
replier = user_fixture("commenter2@example.com", "commenter2")
|
||||
bystander = user_fixture("bystander@example.com", "bystander")
|
||||
parent = tweet_fixture(author, "parent tweet 2")
|
||||
|
||||
comment =
|
||||
Tweet
|
||||
|> Ash.Changeset.for_create(
|
||||
:create,
|
||||
%{content: "a comment", parent_tweet_id: parent.id},
|
||||
actor: replier
|
||||
)
|
||||
|> Ash.create!()
|
||||
|
||||
assert {:error, error} =
|
||||
comment
|
||||
|> Ash.Changeset.for_destroy(:destroy, %{}, actor: bystander)
|
||||
|> Ash.destroy()
|
||||
|
||||
assert Exception.message(error) =~ "forbidden"
|
||||
end
|
||||
|
||||
test "guests cannot post comments" do
|
||||
author = user_fixture("tweetowner5@example.com", "tweetowner5")
|
||||
parent = tweet_fixture(author, "parent post 3")
|
||||
|
||||
assert {:error, _error} =
|
||||
Tweet
|
||||
|> Ash.Changeset.for_create(
|
||||
:create,
|
||||
%{content: "spam comment", parent_tweet_id: parent.id}
|
||||
)
|
||||
|> Ash.create()
|
||||
end
|
||||
end
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
defp user_fixture(email, username) do
|
||||
User
|
||||
|> Ash.Changeset.for_create(:register_with_password, %{
|
||||
email: email,
|
||||
password: "password1234",
|
||||
password_confirmation: "password1234",
|
||||
username: username
|
||||
})
|
||||
|> Ash.create!(authorize?: false)
|
||||
end
|
||||
|
||||
defp tweet_fixture(user, content) do
|
||||
Tweet
|
||||
|> Ash.Changeset.for_create(:create, %{content: content}, actor: user)
|
||||
|> Ash.create!()
|
||||
end
|
||||
end
|
||||
@@ -1,8 +1,32 @@
|
||||
defmodule MixerWeb.PageControllerTest do
|
||||
use MixerWeb.ConnCase
|
||||
|
||||
test "GET /", %{conn: conn} do
|
||||
test "GET / redirects to /feed when logged in", %{conn: conn} do
|
||||
user =
|
||||
Mixer.Accounts.User
|
||||
|> Ash.Changeset.for_create(
|
||||
:register_with_password,
|
||||
%{
|
||||
email: "test@example.com",
|
||||
password: "Password1!",
|
||||
password_confirmation: "Password1!",
|
||||
username: "testuser"
|
||||
},
|
||||
authorize?: false
|
||||
)
|
||||
|> Ash.create!()
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> Plug.Test.init_test_session(%{})
|
||||
|> AshAuthentication.Plug.Helpers.store_in_session(user)
|
||||
|> get(~p"/")
|
||||
|
||||
assert redirected_to(conn) == ~p"/feed"
|
||||
end
|
||||
|
||||
test "GET / renders the home page for unauthenticated users", %{conn: conn} do
|
||||
conn = get(conn, ~p"/")
|
||||
assert html_response(conn, 200) =~ "Peace of mind from prototype to production"
|
||||
assert html_response(conn, 200) =~ "Mixer"
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user