Compare commits

...

44 Commits

Author SHA1 Message Date
00af2350f4 some ralph changes that i tried i guess 2026-04-12 21:37:18 -04:00
df013731be feat: add user list pagination
UserList now uses useInfiniteQuery with offset pagination (20 per page)
and an IntersectionObserver scroll sentinel for infinite scroll.
Users sorted by username. Follows same pattern as Feed/UserFeed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 20:00:59 -04:00
c3ccab5fc5 test: add tweet creation + comment tests (13 tests)
Covers: create, blank content validation, guest restriction, read,
owner edit, non-owner edit forbidden, owner delete, non-owner delete
forbidden, reply creation, comment_count aggregate, tweet owner
deletes comment, third party forbidden, guest comment forbidden.

Key learnings:
- Tweet :destroy is not primary; use Ash.Changeset.for_destroy(:destroy)
- relate_actor fails with Invalid (not Forbidden) when no actor
- Ash.get returns NotFound error on miss; pass not_found_error?: false for nil

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 19:57:35 -04:00
d7345ba234 fix: self-follow validation + add follow/unfollow tests
Self-follow check used get_attribute(:follower_id) which is nil at
validation time because relate_actor runs after validations in Ash.
Fixed to use context.actor.id directly.

Added 9 tests covering: follow, follow idempotency, self-follow
prevention, guest restriction, unfollow, unfollow noop,
guest unfollow, follower/following counts, and am_i_following.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 19:54:43 -04:00
df8bc97bd2 fix: unlike noop stale struct + likes floor at 0
- unlike noop now reloads tweet from DB (same fix as like noop from prev loop)
- decrement_likes uses GREATEST(likes - 1, 0) to prevent negative counts
- add fix_plan.md to track remaining work

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 19:51:56 -04:00
4c67f38fa3 fix: make tweet_like tests pass
- Add authorize?: false to user_fixture so register_with_password
  bypasses policy check in test context
- Add require Ash.Query so Ash.Query.filter macro works in count_likes
- Replace nonexistent Ash.ForbiddenField.forbidden?/1 with match?/2
- Fix stale tweet struct in :like noop case by reloading from DB

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 19:48:23 -04:00
88e84fcec5 fixed ts compile warnings 2026-04-12 19:19:42 -04:00
7c34323ff4 Merge branch 'dev' 2026-04-10 19:34:35 -04:00
0e4e46824c claude refactor of index.tsx so its humanly editable 2026-04-10 19:33:56 -04:00
56a4ee6c77 Updated README 2026-04-09 18:15:46 -04:00
d194834110 claude fix for making the avatars properly get re-fetched if they are newer than the old avatars 2026-04-09 17:44:13 -04:00
2130d85be5 claude generated code for adding tweet viewership to users pages 2026-04-09 17:10:05 -04:00
f37d554399 fixed auth userflow 2026-04-09 15:24:26 -04:00
2d5914c970 some not working changes trying to fix user login to include a username 2026-04-08 04:34:16 -04:00
31a8f03ab2 gemini fixed it but its ux does not work and lowkey idc 2026-04-08 02:27:52 -04:00
90d7eab7d0 some ai generated code from claude that does not work 2026-04-08 02:03:43 -04:00
3c9910a723 Working metrics for all forms of interactions and updated .env.example 2026-04-07 00:09:10 -04:00
76a8acc731 Adjusted to properly type each of the database interactions 2026-04-06 23:31:17 -04:00
a33ec14c5f Integrating clickhouse for metrics. 2026-04-06 23:05:04 -04:00
109ef398ee paginating comments and letting tweet authors delete comments on their post 2026-04-06 14:15:36 -04:00
faa96d88f5 added comments to tweets 2026-04-06 14:11:10 -04:00
6927f6eb9b Login with password now requires email to be confirmed 2026-04-06 13:18:00 -04:00
cc6586587f Added some meta tags for "SEO" (fake ass concept for this) but hopefully works for embeds 2026-04-05 15:19:47 -04:00
315b108fa1 Added page titles and proper favicon 2026-04-05 15:15:07 -04:00
4ec41ad4b3 Added /profile to see your profile 2026-04-05 14:50:50 -04:00
4b36131183 Added following page to see posts from yourself and people you follow 2026-04-05 14:41:29 -04:00
8077e570f4 changed feed page to paginate requests 2026-04-05 14:32:51 -04:00
33c83e188e fixed mobile ui and ux 2026-04-04 13:02:10 -04:00
193ff815a1 Mobile nav and drafting setup 2026-04-04 12:38:40 -04:00
1ed136e637 fixed tweet time display to show when the tweet was actually posted 2026-04-04 12:29:54 -04:00
bd0f5d52c2 fixing auth login flow and making custom login flow 2026-04-03 19:54:18 -04:00
874fec835d some reformatting and adjusting so logged in users get moved directly to their feed 2026-04-03 19:40:17 -04:00
a926733f1b adding this to prevent a warning on startup of the app 2026-04-03 19:12:52 -04:00
a70ea18e56 added .env support for a systemd EnvironmentFile with an example and updated email sender 2026-04-03 17:00:38 -04:00
abe10922eb claude fixed up the ui 2026-04-02 21:46:15 -04:00
9c131b98a6 Working follow and unfollow interactions for users 2026-04-02 21:41:27 -04:00
f82bc223bb added right click context menu and did a static deployment test 2026-04-02 03:34:56 -04:00
580265bc51 Added users page and user viewing 2026-04-02 03:28:09 -04:00
0f41e86cf0 Individual tweets are now viewable in their own pages 2026-04-01 12:44:03 -04:00
0ac0b68029 Added timestamps to tweets and they organize by newest on top 2026-04-01 11:58:12 -04:00
ae35600822 Adding .agent support in addition to .claude 2026-04-01 11:29:13 -04:00
1ea0d232a4 moving some environment variables to be aquireable at runtime to generate a mix release 2026-04-01 00:32:16 -04:00
53467cd611 slightly changed how files are stored in the bucket and allows post deletion 2026-03-31 17:12:40 -04:00
1c1830b086 Adding likes to tweets 2026-03-31 15:18:46 -04:00
123 changed files with 11082 additions and 1027 deletions

View File

@@ -0,0 +1,107 @@
---
name: 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."
metadata:
managed-by: usage-rules
---
<!-- usage-rules-skill-start -->
## Additional References
- [actions](references/actions.md)
- [aggregates](references/aggregates.md)
- [authorization](references/authorization.md)
- [calculations](references/calculations.md)
- [code_interfaces](references/code_interfaces.md)
- [code_structure](references/code_structure.md)
- [data_layers](references/data_layers.md)
- [exist_expressions](references/exist_expressions.md)
- [generating_code](references/generating_code.md)
- [migrations](references/migrations.md)
- [query_filter](references/query_filter.md)
- [querying_data](references/querying_data.md)
- [relationships](references/relationships.md)
- [testing](references/testing.md)
- [ash](references/ash.md)
- [ash_admin](references/ash_admin.md)
- [ash_ai](references/ash_ai.md)
- [ash_authentication](references/ash_authentication.md)
- [ash_authentication_phoenix](references/ash_authentication_phoenix.md)
- [ash_graphql](references/ash_graphql.md)
- [ash_json_api](references/ash_json_api.md)
- [ash_phoenix](references/ash_phoenix.md)
- [ash_postgres](references/ash_postgres.md)
- [ash_state_machine](references/ash_state_machine.md)
- [ash_typescript](references/ash_typescript.md)
## Searching Documentation
```sh
mix usage_rules.search_docs "search term" -p ash -p ash_admin -p ash_ai -p ash_authentication -p ash_authentication_phoenix -p ash_graphql -p ash_json_api -p ash_phoenix -p ash_postgres -p ash_state_machine -p ash_typescript
```
## Available Mix Tasks
- `mix ash` - Prints Ash help information
- `mix ash.codegen` - Runs all codegen tasks for any extension on any resource/domain in your application.
- `mix ash.extend` - Adds an extension or extensions to the given domain/resource
- `mix ash.gen.base_resource` - Generates a base resource. This is a module that you can use instead of `Ash.Resource`, for consistency.
- `mix ash.gen.change` - Generates a custom change module.
- `mix ash.gen.custom_expression` - Generates a custom expression module.
- `mix ash.gen.domain` - Generates an Ash.Domain
- `mix ash.gen.enum` - Generates an Ash.Type.Enum
- `mix ash.gen.gettext` - Copies Ash's .pot file for error message translation
- `mix ash.gen.preparation` - Generates a custom preparation module.
- `mix ash.gen.resource` - Generate and configure an Ash.Resource.
- `mix ash.gen.validation` - Generates a custom validation module.
- `mix ash.generate_livebook` - Generates a Livebook for each Ash domain
- `mix ash.generate_policy_charts` - Generates a Mermaid Flow Chart for a given resource's policies.
- `mix ash.generate_resource_diagrams` - Generates Mermaid Resource Diagrams for each Ash domain
- `mix ash.gettext.extract` - Extracts Ash error messages into a .pot file
- `mix ash.install` - Installs Ash into a project. Should be called with `mix igniter.install ash`
- `mix ash.migrate` - Runs all migration tasks for any extension on any resource/domain in your application.
- `mix ash.patch.extend` - Adds an extension or extensions to the given domain/resource
- `mix ash.reset` - Runs all tear down & setup tasks for any extension on any resource/domain in your application.
- `mix ash.rollback` - Runs all rollback tasks for any extension on any resource/domain in your application.
- `mix ash.setup` - Runs all setup tasks for any extension on any resource/domain in your application.
- `mix ash.tear_down` - Runs all tear_down tasks for any extension on any resource/domain in your application.
- `mix ash_admin.install` - Installs AshAdmin
- `mix ash_admin.install.docs`
- `mix ash_ai.gen.chat` - Generates the resources and views for a conversational UI backed by `ash_postgres` and `ash_oban`
- `mix ash_ai.gen.chat.docs`
- `mix ash_ai.gen.mcp` - Sets up an MCP server for your application
- `mix ash_ai.gen.mcp.docs`
- `mix ash_ai.gen.usage_rules`
- `mix ash_ai.gen.usage_rules.docs`
- `mix ash_ai.install` - Installs `AshAi`. Call with `mix igniter.install ash_ai`. Requires igniter to run.
- `mix ash_ai.install.docs`
- `mix ash_authentication.add_add_on` - Adds the provided add-on to your user resource
- `mix ash_authentication.add_strategy` - Adds the provided strategy or strategies to your user resource
- `mix ash_authentication.install` - Installs AshAuthentication. Invoke with `mix igniter.install ash_authentication`
- `mix ash_authentication.upgrade`
- `mix ash_authentication.phoenix.routes` - Prints all routes generated by AshAuthentication Phoenix
- `mix ash_authentication_phoenix.install` - Installs AshAuthenticationPhoenix. Invoke with `mix igniter.install ash_authentication_phoenix`
- `mix ash_authentication_phoenix.upgrade`
- `mix ash_graphql.install` - Installs AshGraphql. Should be run with `mix igniter.install ash_graphql`
- `mix ash_json_api.install` - Installs AshJsonApi. Should be run with `mix igniter.install ash_json_api`
- `mix ash_json_api.routes` - Prints all routes by AshJsonApiRouter
- `mix ash_phoenix.gen.html` - Generates a controller and HTML views for an existing Ash resource.
- `mix ash_phoenix.gen.live` - Generates liveviews for a given domain and resource.
- `mix ash_phoenix.install` - Installs AshPhoenix into a project. Should be called with `mix igniter.install ash_phoenix`
- `mix ash_postgres.create` - Creates the repository storage
- `mix ash_postgres.drop` - Drops the repository storage for the repos in the specified (or configured) domains
- `mix ash_postgres.gen.resources` - Generates resources based on a database schema
- `mix ash_postgres.generate_migrations` - Generates migrations, and stores a snapshot of your resources
- `mix ash_postgres.install` - Installs AshPostgres. Should be run with `mix igniter.install ash_postgres`
- `mix ash_postgres.migrate` - Runs the repository migrations for all repositories in the provided (or configured) domains
- `mix ash_postgres.rollback` - Rolls back the repository migrations for all repositories in the provided (or configured) domains
- `mix ash_postgres.setup_vector` - Sets up pgvector for AshPostgres
- `mix ash_postgres.setup_vector.docs`
- `mix ash_postgres.squash_snapshots` - Cleans snapshots folder, leaving only one snapshot per resource
- `mix ash_state_machine.generate_flow_charts` - Generates Mermaid Flow Charts for each resource using `AshStateMachine`
- `mix ash_state_machine.install` - Installs AshStateMachine
- `mix ash_state_machine.install.docs`
- `mix ash_typescript.codegen` - Generates TypeScript types for Ash Rpc-calls
- `mix ash_typescript.install` - Installs AshTypescript into a project. Should be called with `mix igniter.install ash_typescript`
- `mix ash_typescript.npm_install`
<!-- usage-rules-skill-end -->

View File

@@ -0,0 +1,401 @@
# Actions
- Create specific, well-named actions rather than generic ones
- Put all business logic inside action definitions
- Use hooks like `Ash.Changeset.after_action/2`, `Ash.Changeset.before_action/2` to add additional logic
inside the same transaction.
- Use hooks like `Ash.Changeset.after_transaction/2`, `Ash.Changeset.before_transaction/2` to add additional logic
outside the transaction.
- Use action arguments for inputs that need validation
- Use preparations to modify queries before execution
- Preparations support `where` clauses for conditional execution
- Use `only_when_valid?` to skip preparations when the query is invalid
- Use changes to modify changesets before execution
- Use validations to validate changesets before execution
- Prefer domain code interfaces to call actions instead of directly building queries/changesets and calling functions in the `Ash` module
- A resource could be *only generic actions*. This can be useful when you are using a resource only to model behavior.
## Error Handling
Functions to call actions, like `Ash.create` and code interfaces like `MyApp.Accounts.register_user` all return ok/error tuples. All have `!` variations, like `Ash.create!` and `MyApp.Accounts.register_user!`. Use the `!` variations when you want to "let it crash", like if looking something up that should definitely exist, or calling an action that should always succeed. Always prefer the raising `!` variation over something like `{:ok, user} = MyApp.Accounts.register_user(...)`.
All Ash code returns errors in the form of `{:error, error_class}`. Ash categorizes errors into four main classes:
1. **Forbidden** (`Ash.Error.Forbidden`) - Occurs when a user attempts an action they don't have permission to perform
2. **Invalid** (`Ash.Error.Invalid`) - Occurs when input data doesn't meet validation requirements
3. **Framework** (`Ash.Error.Framework`) - Occurs when there's an issue with how Ash is being used
4. **Unknown** (`Ash.Error.Unknown`) - Occurs for unexpected errors that don't fit the other categories
These error classes help you catch and handle errors at an appropriate level of granularity. An error class will always be the "worst" (highest in the above list) error class from above. Each error class can contain multiple underlying errors, accessible via the `errors` field on the exception.
## Using Validations
Validations ensure that data meets your business requirements before it gets processed by an action. Unlike changes, validations cannot modify the changeset - they can only validate it or add errors.
Validations work on both changesets and queries. Built-in validations that support queries include:
- `action_is`, `argument_does_not_equal`, `argument_equals`, `argument_in`
- `compare`, `confirm`, `match`, `negate`, `one_of`, `present`, `string_length`
- Custom validations that implement the `supports/1` callback
Common validation patterns:
```elixir
# Built-in validations with custom messages
validate compare(:age, greater_than_or_equal_to: 18) do
message "You must be at least 18 years old"
end
validate match(:email, "@")
validate one_of(:status, [:active, :inactive, :pending])
# Conditional validations with where clauses
validate present(:phone_number) do
where present(:contact_method) and eq(:contact_method, "phone")
end
# only_when_valid? - skip validation if prior validations failed
validate expensive_validation() do
only_when_valid? true
end
# Action-specific vs global validations
actions do
create :sign_up do
validate present([:email, :password]) # Only for this action
end
read :search do
argument :email, :string
validate match(:email, ~r/^[^\s]+@[^\s]+\.[^\s]+$/) # Validates query arguments
end
end
validations do
validate present([:title, :body]), on: [:create, :update] # Multiple actions
end
```
- Create **custom validation modules** for complex validation logic:
```elixir
defmodule MyApp.Validations.UniqueUsername do
use Ash.Resource.Validation
@impl true
def init(opts), do: {:ok, opts}
@impl true
def validate(changeset, _opts, _context) do
# Validation logic here
# Return :ok or {:error, message}
end
end
# Usage in resource:
validate {MyApp.Validations.UniqueUsername, []}
```
- Make validations **atomic** when possible to ensure they work correctly with direct database operations by implementing the `atomic/3` callback in custom validation modules.
```elixir
defmodule MyApp.Validations.IsEven do
# transform and validate opts
use Ash.Resource.Validation
@impl true
def init(opts) do
if is_atom(opts[:attribute]) do
{:ok, opts}
else
{:error, "attribute must be an atom!"}
end
end
@impl true
# This is optional, but useful to have in addition to validation
# so you get early feedback for validations that can otherwise
# only run in the datalayer
def validate(changeset, opts, _context) do
value = Ash.Changeset.get_attribute(changeset, opts[:attribute])
if is_nil(value) || (is_number(value) && rem(value, 2) == 0) do
:ok
else
{:error, field: opts[:attribute], message: "must be an even number"}
end
end
@impl true
def atomic(changeset, opts, context) do
{:atomic,
# the list of attributes that are involved in the validation
[opts[:attribute]],
# the condition that should cause the error
# here we refer to the new value or the current value
expr(rem(^atomic_ref(opts[:attribute]), 2) != 0),
# the error expression
expr(
error(^InvalidAttribute, %{
field: ^opts[:attribute],
# the value that caused the error
value: ^atomic_ref(opts[:attribute]),
# the message to display
message: ^(context.message || "%{field} must be an even number"),
vars: %{field: ^opts[:attribute]}
})
)
}
end
end
```
- **Avoid redundant validations** - Don't add validations that duplicate attribute constraints:
```elixir
# WRONG - redundant validation
attribute :name, :string do
allow_nil? false
constraints min_length: 1
end
validate present(:name) do # Redundant! allow_nil? false already handles this
message "Name is required"
end
validate attribute_does_not_equal(:name, "") do # Redundant! min_length: 1 already handles this
message "Name cannot be empty"
end
# CORRECT - let attribute constraints handle basic validation
attribute :name, :string do
allow_nil? false
constraints min_length: 1
end
```
## Using Preparations
Preparations modify queries before they're executed. They are used to add filters, sorts, or other query modifications based on the query context.
Common preparation patterns:
```elixir
# Built-in preparations
prepare build(sort: [created_at: :desc])
prepare build(filter: [active: true])
# Conditional preparations with where clauses
prepare build(filter: [visible: true]) do
where argument_equals(:include_hidden, false)
end
# only_when_valid? - skip preparation if prior validations failed
prepare expensive_preparation() do
only_when_valid? true
end
# Action-specific vs global preparations
actions do
read :recent do
prepare build(sort: [created_at: :desc], limit: 10)
end
end
preparations do
prepare build(filter: [deleted: false]), on: [:read, :update]
end
```
## Using Changes
Changes allow you to modify the changeset before it gets processed by an action. Unlike validations, changes can manipulate attribute values, add attributes, or perform other data transformations.
Common change patterns:
```elixir
# Built-in changes with conditions
change set_attribute(:status, "pending")
change relate_actor(:creator) do
where present(:actor)
end
change atomic_update(:counter, expr(^counter + 1))
# Action-specific vs global changes
actions do
create :sign_up do
change set_attribute(:joined_at, expr(now())) # Only for this action
end
end
changes do
change set_attribute(:updated_at, expr(now())), on: :update # Multiple actions
change manage_relationship(:items, type: :append), on: [:create, :update]
end
```
- Create **custom change modules** for reusable transformation logic:
```elixir
defmodule MyApp.Changes.SlugifyTitle do
use Ash.Resource.Change
def change(changeset, _opts, _context) do
title = Ash.Changeset.get_attribute(changeset, :title)
if title do
slug = title |> String.downcase() |> String.replace(~r/[^a-z0-9]+/, "-")
Ash.Changeset.change_attribute(changeset, :slug, slug)
else
changeset
end
end
end
# Usage in resource:
change {MyApp.Changes.SlugifyTitle, []}
```
- Create a **change module with lifecycle hooks** to handle complex multi-step operations:
```elixir
defmodule MyApp.Changes.ProcessOrder do
use Ash.Resource.Change
def change(changeset, _opts, context) do
changeset
|> Ash.Changeset.before_transaction(fn changeset ->
# Runs before the transaction starts
# Use for external API calls, logging, etc.
MyApp.ExternalService.reserve_inventory(changeset, scope: context)
changeset
end)
|> Ash.Changeset.before_action(fn changeset ->
# Runs inside the transaction before the main action
# Use for related database changes in the same transaction
Ash.Changeset.change_attribute(changeset, :processed_at, DateTime.utc_now())
end)
|> Ash.Changeset.after_action(fn changeset, result ->
# Runs inside the transaction after the main action, only on success
# Use for related database changes that depend on the result
MyApp.Inventory.update_stock_levels(result, scope: context)
{changeset, result}
end)
|> Ash.Changeset.after_transaction(fn changeset,
{:ok, result} ->
# Runs after the transaction completes (success or failure)
# Use for notifications, external systems, etc.
MyApp.Mailer.send_order_confirmation(result, scope: context)
{changeset, result}
{:error, error} ->
# Runs after the transaction completes (success or failure)
# Use for notifications, external systems, etc.
MyApp.Mailer.send_order_issue_notice(result, scope: context)
{:error, error}
end)
end
end
# Usage in resource:
change {MyApp.Changes.ProcessOrder, []}
```
## Atomic Changes
Atomic changes execute directly in the database as part of the update query, without requiring the record to be loaded first. This provides better performance and correct behavior under concurrent updates.
**Why atomic matters:**
- Avoids race conditions (e.g., incrementing a counter)
- Better performance (no round-trip to load the record)
- Required for bulk operations to work efficiently
**Built-in atomic changes:**
```elixir
# Increment a counter atomically
change atomic_update(:view_count, expr(view_count + 1))
# Set a value using an expression
change set_attribute(:updated_at, expr(now()))
```
**Making custom changes atomic:**
Implement the `atomic/3` callback to support atomic execution:
```elixir
defmodule MyApp.Changes.IncrementVersion do
use Ash.Resource.Change
@impl true
def change(changeset, _opts, _context) do
# Fallback for non-atomic execution
current = Ash.Changeset.get_attribute(changeset, :version) || 0
Ash.Changeset.change_attribute(changeset, :version, current + 1)
end
@impl true
def atomic(_changeset, _opts, _context) do
# Atomic implementation - runs in the database
{:atomic, %{version: expr(coalesce(version, 0) + 1)}}
end
end
```
## Using `require_atomic? false`
By default, update and destroy actions require all changes and validations to support atomic execution. If they don't, the action will raise an error.
**IMPORTANT:** When you see `require_atomic? false` on an action, carefully consider whether it is truly necessary. This option should be used sparingly.
**When `require_atomic? false` is needed:**
- The action has `before_action` or `around_action` hooks that need to read or modify the record
- A change reads the current record state (e.g., `Ash.Changeset.get_data/2`) and cannot be rewritten atomically
- Complex validations that cannot be expressed as database expressions
**When `require_atomic? false` is NOT needed:**
- Simple attribute transformations (these can usually be made atomic)
- Setting timestamps or default values (use `expr(now())` instead)
- Incrementing counters (use `atomic_update/2`)
- After-action hooks (these don't prevent atomic execution)
- After-transaction hooks (these don't prevent atomic execution)
```elixir
actions do
update :update do
# AVOID unless truly necessary
require_atomic? false
end
update :increment_views do
# GOOD - fully atomic, no need to disable
change atomic_update(:view_count, expr(view_count + 1))
end
end
```
If you find yourself adding `require_atomic? false`, first check if your changes and validations can be rewritten with `atomic/3` callbacks. Only disable atomic requirements when the action genuinely needs to read or manipulate the record in hooks.
## Custom Modules vs. Anonymous Functions
Prefer to put code in its own module and refer to that in changes, preparations, validations etc.
For example, prefer this:
```elixir
defmodule MyApp.MyDomain.MyResource.Changes.SlugifyName do
use Ash.Resource.Change
def change(changeset, _, _) do
Ash.Changeset.before_action(changeset, fn changeset, _ ->
slug = MyApp.Slug.get()
Ash.Changeset.force_change_attribute(changeset, :slug, slug)
end)
end
end
change MyApp.MyDomain.MyResource.Changes.SlugifyName
```
## Action Types
- **Read**: For retrieving records
- **Create**: For creating records
- **Update**: For changing records
- **Destroy**: For removing records
- **Generic**: For custom operations that don't fit the other types

View File

@@ -0,0 +1,105 @@
# Aggregates
Aggregates allow you to retrieve summary information over groups of related data, like counts, sums, or averages. Define aggregates in the `aggregates` block of a resource.
Aggregates can work over relationships or directly over unrelated resources:
```elixir
aggregates do
# Related aggregates - use relationship path
count :published_post_count, :posts do
filter expr(published == true)
end
sum :total_sales, :orders, :amount
exists :is_admin, :roles do
filter expr(name == "admin")
end
# Unrelated aggregates - use resource module directly
count :matching_profiles_count, Profile do
filter expr(name == parent(name))
end
sum :total_report_score, Report, :score do
filter expr(author_name == parent(name))
end
exists :has_reports, Report do
filter expr(author_name == parent(name))
end
end
```
For unrelated aggregates, use `parent/1` to reference fields from the source resource.
## Aggregate Types
- **count**: Counts related items meeting criteria
- **sum**: Sums a field across related items
- **exists**: Returns boolean indicating if matching related items exist (also supports unrelated resources)
- **first**: Gets the first related value matching criteria
- **list**: Lists the related values for a specific field
- **max**: Gets the maximum value of a field
- **min**: Gets the minimum value of a field
- **avg**: Gets the average value of a field
## Using Aggregates
```elixir
# Using code interface options (preferred)
users = MyDomain.list_users!(
load: [:published_post_count, :total_sales],
query: [
filter: [published_post_count: [greater_than: 5]],
sort: [published_post_count: :desc]
]
)
# Manual query building (for complex cases)
User |> Ash.Query.filter(published_post_count > 5) |> Ash.read!()
# Loading on existing records
Ash.load!(users, :published_post_count)
```
### Join Filters
For complex aggregates involving multiple relationships, use join filters:
```elixir
aggregates do
sum :redeemed_deal_amount, [:redeems, :deal], :amount do
# Filter on the aggregate as a whole
filter expr(redeems.redeemed == true)
# Apply filters to specific relationship steps
join_filter :redeems, expr(redeemed == true)
join_filter [:redeems, :deal], expr(active == parent(require_active))
end
end
```
## Inline Aggregates
Use aggregates inline within expressions:
```elixir
# Related inline aggregates
calculate :grade_percentage, :decimal, expr(
count(answers, query: [filter: expr(correct == true)]) * 100 /
count(answers)
)
# Unrelated inline aggregates
calculate :profile_count, :integer, expr(
count(Profile, filter: expr(name == parent(name)))
)
calculate :stats, :map, expr(%{
profiles: count(Profile, filter: expr(active == true)),
reports: count(Report, filter: expr(author_name == parent(name))),
has_active_profile: exists(Profile, active == true and name == parent(name))
})
```

View File

@@ -0,0 +1,5 @@
# Rules for working with Ash
## Understanding Ash
Ash is an opinionated, composable framework for building applications in Elixir. It provides a declarative approach to modeling your domain with resources at the center. Read documentation *before* attempting to use its features. Do not assume that you have prior knowledge of the framework or its conventions.

View File

@@ -0,0 +1,516 @@
# Rules for working with Ash AI
## Understanding Ash AI
Ash AI is an extension for the Ash framework that integrates AI capabilities with Ash resources. It provides tools for vectorization, embedding generation, LLM interaction, and tooling for AI models.
## Core Concepts
- **Vectorization**: Convert text attributes into vector embeddings for semantic search
- **AI Tools**: Expose Ash actions as tools for LLMs
- **Prompt-backed Actions**: Create actions where the implementation is handled by an LLM
- **MCP Server**: Expose your tools to Machine Context Protocol clients
## Vectorization
Vectorization allows you to convert text data into embeddings that can be used for semantic search.
### Setting Up Vectorization
Add vectorization to a resource by including the `AshAi` extension and defining a vectorize block:
```elixir
defmodule MyApp.Artist do
use Ash.Resource, extensions: [AshAi]
vectorize do
# For creating a single vector from multiple attributes
full_text do
text(fn record ->
"""
Name: #{record.name}
Biography: #{record.biography}
"""
end)
# Optional - only rebuild embeddings when these attributes change
used_attributes [:name, :biography]
end
# Choose a strategy for updating embeddings
strategy :ash_oban
# Specify your embedding model implementation
embedding_model MyApp.OpenAiEmbeddingModel
end
# Rest of resource definition...
end
```
### Embedding Models
Create a module that implements the `AshAi.EmbeddingModel` behaviour to generate embeddings:
```elixir
defmodule MyApp.OpenAiEmbeddingModel do
use AshAi.EmbeddingModel
@impl true
def dimensions(_opts), do: 3072
@impl true
def generate(texts, _opts) do
api_key = System.fetch_env!("OPEN_AI_API_KEY")
headers = [
{"Authorization", "Bearer #{api_key}"},
{"Content-Type", "application/json"}
]
body = %{
"input" => texts,
"model" => "text-embedding-3-large"
}
response =
Req.post!("https://api.openai.com/v1/embeddings",
json: body,
headers: headers
)
case response.status do
200 ->
response.body["data"]
|> Enum.map(fn %{"embedding" => embedding} -> embedding end)
|> then(&{:ok, &1})
_status ->
{:error, response.body}
end
end
end
```
### Vectorization Strategies
Choose the appropriate strategy based on your performance requirements:
1. **`:after_action`** (default): Updates embeddings synchronously after each create and update action
- Simple but can make your app slow
- Not recommended for production use with many records
2. **`:ash_oban`**: Updates embeddings asynchronously using Ash Oban
- Requires `ash_oban` extension
- Better for production use
3. **`:manual`**: No automatic updates; you control when embeddings are updated
- Most flexible but requires you to manage when to update embeddings
### Using the Vectors for Search
Use vector expressions in filters and sorts:
```elixir
read :semantic_search do
argument :query, :string, allow_nil?: false
prepare before_action(fn query, context ->
case MyApp.OpenAiEmbeddingModel.generate([query.arguments.query], []) do
{:ok, [search_vector]} ->
Ash.Query.filter(
query,
vector_cosine_distance(full_text_vector, ^search_vector) < 0.5
)
|> Ash.Query.sort([
{
calc(vector_cosine_distance(
full_text_vector,
^search_vector
)),
:asc
}
])
{:error, error} ->
{:error, error}
end
end)
end
```
### Authorization for Vectorization
If you're using policies, add a bypass to allow embedding updates:
```elixir
bypass action(:ash_ai_update_embeddings) do
authorize_if AshAi.Checks.ActorIsAshAi
end
```
## AI Tools
Expose your Ash actions as tools for LLMs to use by configuring them in your domain:
```elixir
defmodule MyApp.Blog do
use Ash.Domain, extensions: [AshAi]
tools do
tool :read_posts, MyApp.Blog.Post, :read do
description "customize the tool description"
end
tool :create_post, MyApp.Blog.Post, :create
tool :publish_post, MyApp.Blog.Post, :publish
tool :read_comments, MyApp.Blog.Comment, :read
end
# Rest of domain definition...
end
```
### Tool Data Access Rules
Tools have different access levels for different operations:
1. **Filtering/Sorting/Aggregation**: Only attributes with `public?: true` can be used
2. **Arguments**: Only action arguments with `public?: true` are exposed to tools
3. **Response data**: Public attributes are returned by default
4. **Loading data**: The `load` option is used to include relationships, calculations, or additional attributes in responses (both public and private)
Example:
```elixir
# Resource definition
defmodule MyApp.Blog.Post do
attributes do
attribute :title, :string, public?: true
attribute :content, :string, public?: true
attribute :internal_notes, :string # Default is public?: false
attribute :view_count, :integer, public?: true
end
relationships do
belongs_to :author, MyApp.Accounts.User, public?: true
end
end
# Tool definition
tools do
# Returns only public attributes (title, content, view_count)
tool :read_posts, MyApp.Blog.Post, :read
# Returns public attributes plus loaded fields (including private ones)
tool :read_posts_with_all_details, MyApp.Blog.Post, :read do
load [:author, :internal_notes]
end
end
```
With this configuration:
- Tools can only filter/sort by `title`, `content`, and `view_count`
- `internal_notes` cannot be used for filtering, sorting, or aggregation
- `internal_notes` CAN be returned when explicitly loaded via the `load` option
- The `author` relationship can include both public and private attributes when loaded
This provides flexibility while maintaining control over data access:
- Private data is protected from queries and operations
- Private data can still be included in responses when explicitly loaded
- The `load` option serves dual purposes: loading relationships/calculations and making any loaded attributes visible (including private ones)
### Using Tools in LangChain
Add your Ash AI tools to a LangChain chain:
```elixir
chain =
%{
llm: LangChain.ChatModels.ChatOpenAI.new!(%{model: "gpt-4o"}),
verbose: true
}
|> LangChain.Chains.LLMChain.new!()
|> AshAi.setup_ash_ai(otp_app: :my_app, tools: [:list, :of, :tools])
```
## Structured Outputs (Prompt-Backed Actions)
Create actions that use LLMs for their implementation:
```elixir
action :analyze_sentiment, :atom do
constraints one_of: [:positive, :negative]
description """
Analyzes the sentiment of a given piece of text to determine if it is overall positive or negative.
"""
argument :text, :string do
allow_nil? false
description "The text for analysis"
end
run prompt(
LangChain.ChatModels.ChatOpenAI.new!(%{model: "gpt-4o"}),
# Allow the model to use tools
tools: true,
# Or restrict to specific tools
# tools: [:list, :of, :tool, :names],
# Optionally provide a custom prompt template
# prompt: "Analyze the sentiment of the following text: <%= @input.arguments.text %>"
)
end
```
### Structured Outputs with Custom Types
The action's return type provides the JSON schema automatically. For complex structured outputs, you can use any Ash type, including `Ash.TypedStruct`:
```elixir
# Example using Ash.TypedStruct
defmodule JobListing do
use Ash.TypedStruct
typed_struct do
field :title, :string, allow_nil?: false
field :company, :string, allow_nil?: false
field :location, :string
field :salary_range, :string
field :requirements, {:array, :string}
end
end
# Use it as the return type for your action
action :parse_raw, JobListing do
argument :raw_content, :string, allow_nil?: false
run prompt(
fn _input, _context ->
LangChain.ChatModels.ChatOpenAI.new!(%{
model: "gpt-4o-mini",
api_key: System.get_env("OPENAI_API_KEY"),
temperature: 0.1
})
end,
prompt: """
Parse this job listing into structured data following the exact schema.
Extract all available information and return as JSON:
<%= @input.arguments.raw_content %>
""",
tools: false
)
end
```
### Dynamic LLM Configuration
For runtime configuration (like environment variables), use a function to define the LLM:
```elixir
action :analyze_sentiment, :atom do
argument :text, :string, allow_nil?: false
run prompt(
fn _input, _context ->
LangChain.ChatModels.ChatOpenAI.new!(%{
model: "gpt-4o",
# this can also be configured in application config, see langchain docs for more.
api_key: System.get_env("OPENAI_API_KEY"),
endpoint: System.get_env("OPENAI_ENDPOINT")
})
end,
tools: false
)
end
```
The function receives:
1. `input` - The action input
2. `context` - The execution context
### Prompt Format Options
The `prompt` option supports multiple formats for maximum flexibility:
#### 1. String (EEx Template)
Simple string templates with access to `@input` and `@context`:
```elixir
run prompt(
ChatOpenAI.new!(%{model: "gpt-4o"}),
prompt: "Analyze the sentiment of: <%= @input.arguments.text %>"
)
```
#### 2. System/User Tuple
Separate system and user messages (both support EEx templates):
```elixir
run prompt(
ChatOpenAI.new!(%{model: "gpt-4o"}),
prompt: {"You are a sentiment analyzer", "Analyze: <%= @input.arguments.text %>"}
)
```
#### 3. LangChain Messages List
For complex multi-turn conversations or image analysis:
```elixir
run prompt(
ChatOpenAI.new!(%{model: "gpt-4o"}),
prompt: [
Message.new_system!("You are an expert assistant"),
Message.new_user!("Hello, how can you help me?"),
Message.new_assistant!("I can help with various tasks"),
Message.new_user!("Great! Please analyze this data")
]
)
```
For image analysis with templates:
```elixir
run prompt(
ChatOpenAI.new!(%{model: "gpt-4o"}),
prompt: [
Message.new_system!("You are an expert at image analysis"),
Message.new_user!([
PromptTemplate.from_template!("Extra context: <%= @input.arguments.context %>"),
ContentPart.image!("<%= @input.arguments.image_data %>", media: :jpg, detail: "low")
])
]
)
```
#### 4. Dynamic Function
Return any of the above formats dynamically based on input:
```elixir
run prompt(
ChatOpenAI.new!(%{model: "gpt-4o"}),
prompt: fn input, context ->
base = [Message.new_system!("You are helpful")]
history = input.arguments.conversation_history
|> Enum.map(fn %{"role" => role, "content" => content} ->
case role do
"user" -> Message.new_user!(content)
"assistant" -> Message.new_assistant!(content)
end
end)
base ++ history
end
)
```
#### Template Processing
- **String prompts**: Processed as EEx templates with `@input` and `@context` variables
- **Messages with PromptTemplate**: Processed using LangChain's `apply_prompt_templates`
- **Functions**: Can return any supported format for dynamic generation
If no custom prompt is provided, a default template is used that includes the action name, description, and argument details.
### Adapters
Adapters control how the LLM is called to generate structured outputs. AshAi automatically selects the appropriate adapter based on your LLM, but you can override this with the `:adapter` option.
#### Default Adapter Selection
- **OpenAI API endpoints**: Uses `AshAi.Actions.Prompt.Adapter.StructuredOutput` (leverages OpenAI's structured output features)
- **Non-OpenAI endpoints**: Uses `AshAi.Actions.Prompt.Adapter.RequestJson` (requests JSON in the prompt)
- **Anthropic**: Uses `AshAi.Actions.Prompt.Adapter.CompletionTool` (uses tool calling for structured outputs)
#### Custom Adapter Configuration
You can specify a custom adapter or adapter options:
```elixir
# Use a specific adapter
run prompt(
ChatOpenAI.new!(%{model: "gpt-4o"}),
adapter: AshAi.Actions.Prompt.Adapter.RequestJson,
tools: false
)
# Use an adapter with custom options
run prompt(
ChatOpenAI.new!(%{model: "gpt-4o"}),
adapter: {AshAi.Actions.Prompt.Adapter.StructuredOutput, [some_option: :value]},
tools: false
)
```
#### Available Adapters
- **`StructuredOutput`**: Best for OpenAI models, uses native structured output capabilities
- **`RequestJson`**: Works with any model, requests JSON format in the prompt
- **`CompletionTool`**: Uses tool calling to generate structured outputs, good for models that support function calling
### Best Practices for Prompt-Backed Actions
- Write clear, detailed descriptions for the action and its arguments
- Use constraints when appropriate to restrict outputs
- Choose the appropriate prompt format for your use case:
- Simple string templates for basic prompts
- System/user tuples for role-based interactions
- Message lists for complex conversations or multi-modal inputs
- Functions for dynamic prompt generation
- Test thoroughly with different inputs to ensure reliable results
## Model Context Protocol (MCP) Server
### Development MCP Server
For development environments, add the dev MCP server to your Phoenix endpoint:
```elixir
if code_reloading? do
socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
plug AshAi.Mcp.Dev,
protocol_version_statement: "2024-11-05",
otp_app: :your_app
plug Phoenix.LiveReloader
plug Phoenix.CodeReloader
end
```
### Production MCP Server
For production environments, set up authentication and add the MCP router:
```elixir
# Add api_key strategy to your auth pipeline
pipeline :mcp do
plug AshAuthentication.Strategy.ApiKey.Plug,
resource: YourApp.Accounts.User,
required?: false # Set to true if all tools require authentication
end
# In your router
scope "/mcp" do
pipe_through :mcp
forward "/", AshAi.Mcp.Router,
tools: [
# List your tools here
:read_posts,
:create_post,
:analyze_sentiment
],
protocol_version_statement: "2024-11-05",
otp_app: :my_app
end
```
## Testing
When testing AI components:
- Mock embedding model responses for consistent test results
- Test vector search with known embeddings
- For prompt-backed actions, consider using deterministic test models
- Verify tool access and permissions work as expected

View File

@@ -0,0 +1,372 @@
# AshAuthentication Usage Rules
## Core Concepts
- **Strategies**: password, OAuth2, magic_link, api_key authentication methods
- **Tokens**: JWT for stateless authentication
- **UserIdentity**: links users to OAuth2 providers
- **Add-ons**: confirmation, logout-everywhere functionality
- **Actions**: auto-generated by strategies (register, sign_in, etc.), can be overridden on the resource
## Key Principles
- Always use secrets management - never hardcode credentials
- Enable tokens for magic_link, confirmation, OAuth2
- UserIdentity resource optional for OAuth2 (required for multiple providers per user)
- API keys require strict policy controls and expiration management
- Use prefixes for API keys to enable secret scanning compliance
- Check existing strategies: `AshAuthentication.Info.strategies/1`
## Strategy Selection
**Password** - Email/password authentication
- Requires: `:email`, `:hashed_password` attributes, unique identity
**Magic Link** - Passwordless email authentication
- Requires: `:email` attribute, sender implementation, tokens enabled
**API Key** - Token-based authentication for APIs
- Requires: API key resource, relationship to user, sign-in action
**OAuth2** - Social/enterprise login (GitHub, Google, Auth0, Apple, OIDC, Slack)
- Requires: custom actions, secrets
- Optional: UserIdentity resource (for multiple providers per user)
## Password Strategy
```elixir
authentication do
strategies do
password :password do
identity_field :email
hashed_password_field :hashed_password
resettable do
sender MyApp.PasswordResetSender
end
end
end
end
# Required attributes:
attributes do
attribute :email, :ci_string, allow_nil?: false, public?: true
attribute :hashed_password, :string, allow_nil?: false, sensitive?: true
end
identities do
identity :unique_email, [:email]
end
```
## Magic Link Strategy
```elixir
authentication do
strategies do
magic_link do
identity_field :email
sender MyApp.MagicLinkSender
end
end
end
# Sender implementation required:
defmodule MyApp.MagicLinkSender do
use AshAuthentication.Sender
def send(user_or_email, token, _opts) do
MyApp.Emails.deliver_magic_link(user_or_email, token)
end
end
```
## API Key Strategy
```elixir
# 1. Create API key resource
defmodule MyApp.Accounts.ApiKey do
use Ash.Resource,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
actions do
defaults [:read, :destroy]
create :create do
primary? true
accept [:user_id, :expires_at]
change {AshAuthentication.Strategy.ApiKey.GenerateApiKey, prefix: :myapp, hash: :api_key_hash}
end
end
attributes do
uuid_primary_key :id
attribute :api_key_hash, :binary, allow_nil?: false, sensitive?: true
attribute :expires_at, :utc_datetime_usec, allow_nil?: false
end
relationships do
belongs_to :user, MyApp.Accounts.User, allow_nil?: false
end
calculations do
calculate :valid, :boolean, expr(expires_at > now())
end
identities do
identity :unique_api_key, [:api_key_hash]
end
policies do
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
authorize_if always()
end
end
end
# 2. Add strategy to user resource
authentication do
strategies do
api_key do
api_key_relationship :valid_api_keys
api_key_hash_attribute :api_key_hash
end
end
end
# 3. Add relationship to user
relationships do
has_many :valid_api_keys, MyApp.Accounts.ApiKey do
filter expr(valid)
end
end
# 4. Add sign-in action to user
actions do
read :sign_in_with_api_key do
argument :api_key, :string, allow_nil?: false
prepare AshAuthentication.Strategy.ApiKey.SignInPreparation
end
end
```
**Security considerations:**
- API keys are hashed for storage security
- Use policies to restrict API key access to specific actions
- Check `user.__metadata__[:using_api_key?]` to detect API key authentication
- Access the API key via `user.__metadata__[:api_key]` for permission checks
## OAuth2 Strategies
**Supported providers:** github, google, auth0, apple, oidc, slack
**Required for all OAuth2:**
- Custom `register_with_[provider]` action
- Secrets management
- Tokens enabled
**Optional for all OAuth2:**
- UserIdentity resource (for multiple providers per user)
### OAuth2 Configuration Pattern
```elixir
# Strategy configuration
authentication do
strategies do
github do # or google, auth0, apple, oidc, slack
client_id MyApp.Secrets
client_secret MyApp.Secrets
redirect_uri MyApp.Secrets
# auth0 also needs: base_url
# apple also needs: team_id, private_key_id, private_key_path
# oidc also needs: openid_configuration_uri
identity_resource MyApp.Accounts.UserIdentity
end
end
end
# Required action (replace 'github' with provider name)
actions do
create :register_with_github do
argument :user_info, :map, allow_nil?: false
argument :oauth_tokens, :map, allow_nil?: false
upsert? true
upsert_identity :unique_email
change AshAuthentication.GenerateTokenChange
# If UserIdentity resource is being used
change AshAuthentication.Strategy.OAuth2.IdentityChange
change fn changeset, _ctx ->
user_info = Ash.Changeset.get_argument(changeset, :user_info)
Ash.Changeset.change_attributes(changeset, Map.take(user_info, ["email"]))
end
end
end
```
## Add-ons
### Confirmation
```elixir
authentication do
tokens do
enabled? true
token_resource MyApp.Accounts.Token
end
add_ons do
confirmation :confirm do
monitor_fields [:email]
sender MyApp.ConfirmationSender
end
end
end
```
### Log Out Everywhere
```elixir
authentication do
tokens do
store_all_tokens? true
end
add_ons do
log_out_everywhere do
apply_on_password_change? true
end
end
end
```
## Working with Authentication
### Strategy Protocol
```elixir
# Get and use strategies
strategy = AshAuthentication.Info.strategy!(MyApp.User, :password)
{:ok, user} = AshAuthentication.Strategy.action(strategy, :sign_in, params)
# List strategies
strategies = AshAuthentication.Info.strategies(MyApp.User)
```
### Token Operations
```elixir
# User/subject conversion
subject = AshAuthentication.user_to_subject(user)
{:ok, user} = AshAuthentication.subject_to_user(subject, MyApp.User)
# Token management
AshAuthentication.TokenResource.revoke(MyApp.Token, token)
```
### Policies
```elixir
policies do
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
authorize_if always()
end
end
```
## Common Implementation Patterns
### Pattern: Multiple Authentication Methods
When users need multiple ways to authenticate:
```elixir
authentication do
tokens do
enabled? true
token_resource MyApp.Accounts.Token
end
strategies do
password :password do
identity_field :email
hashed_password_field :hashed_password
end
github do
client_id MyApp.Secrets
client_secret MyApp.Secrets
redirect_uri MyApp.Secrets
identity_resource MyApp.Accounts.UserIdentity
end
magic_link do
identity_field :email
sender MyApp.MagicLinkSender
end
end
end
```
### Pattern: OAuth2 with User Registration
When new users can register via OAuth2:
```elixir
actions do
create :register_with_github do
argument :user_info, :map, allow_nil?: false
argument :oauth_tokens, :map, allow_nil?: false
upsert? true
upsert_identity :email
change AshAuthentication.GenerateTokenChange
change fn changeset, _ctx ->
user_info = Ash.Changeset.get_argument(changeset, :user_info)
changeset
|> Ash.Changeset.change_attribute(:email, user_info["email"])
|> Ash.Changeset.change_attribute(:name, user_info["name"])
end
end
end
```
### Pattern: Custom Token Configuration
When you need specific token behavior:
```elixir
authentication do
tokens do
enabled? true
token_resource MyApp.Accounts.Token
signing_secret MyApp.Secrets
token_lifetime {24, :hours}
store_all_tokens? true # For logout-everywhere functionality
require_token_presence_for_authentication? false
end
end
```
## Customizing Authentication Actions
When customizing generated authentication actions (register, sign_in, etc.):
**Key Security Rules:**
- Always mark credentials with `sensitive?: true` (passwords, API keys, tokens)
- Use `public?: false` for internal fields and highly sensitive PII
- Use `public?: true` for identity fields and UI display data
- Include required authentication changes (`GenerateTokenChange`, `HashPasswordChange`, etc.)
**Argument Handling:**
- All arguments must be used in `accept` or `change set_attribute()`
- Use `allow_nil?: false` for required arguments
- OAuth2 data must be extracted in changes, not accepted directly
**Example Custom Registration:**
```elixir
create :register_with_password do
argument :password, :string, allow_nil?: false, sensitive?: true
argument :first_name, :string, allow_nil?: false
accept [:email, :first_name]
change AshAuthentication.GenerateTokenChange
change AshAuthentication.Strategy.Password.HashPasswordChange
end
```
For more guidance, see the "Customizing Authentication Actions" section in the getting started guide.

View File

@@ -0,0 +1,5 @@
# Rules for working with AshGraphql
## Understanding AshGraphql
AshGraphql is a package for integrating Ash Framework with GraphQL. It provides tools for generating GraphQL types, queries, mutations, and subscriptions from your Ash resources. AshGraphql leverages Absinthe under the hood to create a seamless integration between your Ash resources and GraphQL API.

View File

@@ -0,0 +1,109 @@
# Rules for working with AshJsonApi
## Understanding AshJsonApi
AshJsonApi is a package for integrating Ash Framework with the JSON:API specification. It provides tools for generating JSON:API compliant endpoints from your Ash resources. AshJsonApi allows you to expose your Ash resources through a standardized RESTful API, supporting all JSON:API features like filtering, sorting, pagination, includes, and relationships.
## Domain Configuration
AshJsonApi works by extending your Ash domains and resources with JSON:API capabilities. First, add the AshJsonApi extension to your domain.
### Setting Up Your Domain
```elixir
defmodule MyApp.Blog do
use Ash.Domain,
extensions: [
AshJsonApi.Domain
]
json_api do
# Define JSON:API-specific settings for this domain
authorize? true
# You can define routes at the domain level
routes do
base_route "/posts", MyApp.Blog.Post do
get :read
index :read
post :create
patch :update
delete :destroy
end
end
end
resources do
resource MyApp.Blog.Post
resource MyApp.Blog.Comment
end
end
```
## Resource Configuration
Each resource that you want to expose via JSON:API needs to include the AshJsonApi.Resource extension.
### Setting Up Resources
```elixir
defmodule MyApp.Blog.Post do
use Ash.Resource,
domain: MyApp.Blog,
extensions: [AshJsonApi.Resource]
attributes do
uuid_primary_key :id
attribute :title, :string
attribute :body, :string
attribute :published, :boolean
end
relationships do
belongs_to :author, MyApp.Accounts.User
has_many :comments, MyApp.Blog.Comment
end
json_api do
# The JSON:API type name (required)
type "post"
end
actions do
defaults [:create, :read, :update, :destroy]
read :list_published do
filter expr(published == true)
end
update :publish do
accept []
change set_attribute(:published, true)
end
end
end
```
## Route Types
AshJsonApi supports various route types according to the JSON:API spec:
- `get` - Fetch a single resource by ID
- `index` - List resources, with support for filtering, sorting, and pagination
- `post` - Create a new resource
- `patch` - Update an existing resource
- `delete` - Destroy an existing resource
- `related` - Fetch related resources (e.g., `/posts/123/comments`)
- `relationship` - Fetch relationship data (e.g., `/posts/123/relationships/comments`)
- `post_to_relationship` - Add to a relationship
- `patch_relationship` - Replace a relationship
- `delete_from_relationship` - Remove from a relationship
## JSON:API Pagination, Filtering, and Sorting
AshJsonApi supports standard JSON:API query parameters:
- Filter: `?filter[attribute]=value`
- Sort: `?sort=attribute,-other_attribute` (descending with `-`)
- Pagination: `?page[number]=2&page[size]=10`
- Includes: `?include=author,comments.author`

View File

@@ -0,0 +1,5 @@
# Rules for working with AshPhoenix
## Understanding AshPhoenix
AshPhoenix is a package for integrating Ash Framework with Phoenix Framework. It provides tools for integrating with Phoenix forms (`AshPhoenix.Form`), Phoenix LiveViews (`AshPhoenix.LiveView`), and more. AshPhoenix makes it seamless to use Phoenix's powerful UI capabilities with Ash's data management features.

View File

@@ -0,0 +1,7 @@
# Rules for working with AshPostgres
## Understanding AshPostgres
AshPostgres is the PostgreSQL data layer for Ash Framework. It's the most fully-featured Ash data layer and should be your default choice unless you have specific requirements for another data layer. Any PostgreSQL version higher than 13 is fully supported.
Remember that using AshPostgres provides a full-featured PostgreSQL data layer for your Ash application, giving you both the structure and declarative approach of Ash along with the power and flexibility of PostgreSQL.

View File

@@ -0,0 +1,412 @@
# AshTypescript Usage Rules
## Quick Reference
**Critical**: Add `AshTypescript.Rpc` extension to domain, run `mix ash_typescript.codegen`
**Authentication**: Use `buildCSRFHeaders()` for Phoenix CSRF protection
**Controller Routes**: Use `AshTypescript.TypedController` for controller-style actions with `conn` access
**Typed Channels**: Use `AshTypescript.TypedChannel` for typed PubSub event subscriptions
**Validation**: Always verify generated TypeScript compiles
## Essential Syntax Table
| Pattern | Syntax | Example |
|---------|--------|---------|
| **Domain Setup** | `use Ash.Domain, extensions: [AshTypescript.Rpc]` | Required extension |
| **RPC Action** | `rpc_action :name, :action_type` | `rpc_action :list_todos, :read` |
| **Basic Call** | `functionName({ fields: [...] })` | `listTodos({ fields: ["id", "title"] })` |
| **Field Selection** | `["field1", {"nested": ["field2"]}]` | Relationships in objects |
| **Union Fields** | `{ unionField: ["member1", {"member2": [...]}] }` | Selective union member access |
| **Calculation (no args)** | `{ calc: ["field1", ...] }` | Simple nested syntax |
| **Calculation (with args)** | `{ calc: { args: {...}, fields: [...] } }` | Args + fields object |
| **Filter Syntax** | `{ field: { eq: value } }` | Always use operator objects |
| **Sort String** | `"-field1,field2"` | Dash prefix = descending |
| **CSRF Headers** | `headers: buildCSRFHeaders()` | Phoenix CSRF protection |
| **Input Args** | `input: { argName: value }` | Action arguments |
| **Identity (PK)** | `identity: "id-123"` | Primary key lookup |
| **Identity (Named)** | `identity: { email: "a@b.com" }` | Named identity lookup |
| **Identities Config** | `identities: [:_primary_key, :email]` | Allowed lookup methods |
| **Actor-Scoped** | `identities: []` | No identity param needed |
| **Get Action** | `get?: true` or `get_by: [:email]` | Single record lookup |
| **Not Found** | `not_found_error?: false` | Return null instead of error |
| **Custom Fetch** | `customFetch: myFetchFn` | Replace native fetch |
| **Pagination** | `page: { limit: 10 }` | Offset/keyset pagination |
| **Disable Filter** | `enable_filter?: false` | Disable client filtering |
| **Disable Sort** | `enable_sort?: false` | Disable client sorting |
| **Allowed Loads** | `allowed_loads: [:user, comments: [:author]]` | Whitelist loadable fields |
| **Denied Loads** | `denied_loads: [:user]` | Blacklist loadable fields |
| **Field Mapping** | `field_names [field_1: "field1"]` | Map invalid field names |
| **Arg Mapping** | `argument_names [action: [arg_1: "arg1"]]` | Map invalid arg names |
| **Type Mapping** | `def typescript_field_names, do: [...]` | NewType/TypedStruct callback |
| **Metadata Config** | `show_metadata: [:field1]` | Control metadata exposure |
| **Metadata Mapping** | `metadata_field_names: [field_1: "field1"]` | Map metadata names |
| **Metadata (Read)** | `metadataFields: ["field1"]` | Merged into records |
| **Metadata (Mutation)** | `result.metadata.field1` | Separate metadata field |
| **Domain Namespace** | `typescript_rpc do namespace :api` | Default for all resources |
| **Resource Namespace** | `resource X do namespace :todos` | Override domain default |
| **Action Namespace** | `namespace: :custom` | Override resource default |
| **Deprecation** | `deprecated: true` or `"message"` | Mark action deprecated |
| **Related Actions** | `see: [:create_todo]` | Link in JSDoc |
| **Description** | `description: "Custom desc"` | Override JSDoc description |
| **Channel Function** | `actionNameChannel({channel, resultHandler})` | Phoenix channel RPC |
| **Validation Fn** | `validateActionName({...})` | Client-side validation |
| **Type Overrides** | `type_mapping_overrides: [{Module, "TSType"}]` | Map dependency types |
| **Typed Controller** | `use AshTypescript.TypedController` | Controller-style routes |
| **Controller Module** | `typed_controller do module_name MyWeb.Ctrl` | Generated controller module |
| **Verb Shortcut** | `get :auth do run fn ... end end` | Preferred route syntax |
| **Positional Method** | `route :login, :post do run fn ... end end` | Method as 2nd arg |
| **Default GET** | `route :home do run fn ... end end` | Method defaults to :get |
| **Route Argument** | `argument :code, :string, allow_nil?: false` | Colocated in route |
| **Route Namespace** | `namespace "auth"` | Inside typed_controller or route do block |
| **Route Description** | `description "..."` | JSDoc on route (inside do block) |
| **Route Deprecated** | `deprecated true` | Deprecation notice (inside do block) |
| **Route @see Tags** | `see [:auth, :logout]` | JSDoc `@see` cross-references |
| **Typed Controllers** | `config :ash_typescript, typed_controllers: [M]` | Module discovery |
| **Router Config** | `config :ash_typescript, router: MyWeb.Router` | Path introspection |
| **Routes Output** | `config :ash_typescript, routes_output_file: "routes.ts"` | Route file path |
| **Paths-Only Mode** | `config :ash_typescript, typed_controller_mode: :paths_only` | Skip fetch functions |
| **GET Query Params** | `argument :q, :string, allow_nil?: false` on GET route | Becomes `?q=value` |
| **Typed Channel** | `use AshTypescript.TypedChannel` | Server-push event subscriptions |
| **Channel Topic** | `typed_channel do topic "org:*"` | Wildcard or static topic |
| **Channel Resource** | `resource MyApp.Post do publish :event end` | Declare events per resource |
| **Channel Create** | `createOrgChannel(socket, suffix)` | Factory with branded type |
| **Channel Subscribe** | `onOrgChannelMessages(channel, handlers)` | Multi-event subscription |
| **Channel Unsubscribe** | `unsubscribeOrgChannel(channel, refs)` | Cleanup all refs |
| **Typed Channels** | `config :ash_typescript, typed_channels: [M]` | Module discovery |
| **Channels Output** | `config :ash_typescript, typed_channels_output_file: "..."` | Channel functions file |
| **JSON Manifest** | `config :ash_typescript, json_manifest_file: "manifest.json"` | Machine-readable action metadata |
| **Manifest Filename** | `json_manifest_filename_format: :relative` | `:relative`, `:absolute`, or `:basename` |
## Action Feature Matrix
| Action Type | Fields | Filter | Page | Sort | Input | Identity |
|-------------|--------|--------|------|------|-------|----------|
| **read** | ✓ | ✓* | ✓ | ✓* | ✓ | - |
| **read (get?/get_by)** | ✓ | - | - | - | ✓ | - |
| **create** | ✓ | - | - | - | ✓ | - |
| **update** | ✓ | - | - | - | ✓ | ✓ |
| **destroy** | - | - | - | - | ✓ | ✓ |
*Can be disabled with `enable_filter?: false` / `enable_sort?: false`
## Core Patterns
### Basic Setup
```elixir
defmodule MyApp.Domain do
use Ash.Domain, extensions: [AshTypescript.Rpc]
typescript_rpc do
resource MyApp.Todo do
rpc_action :list_todos, :read
rpc_action :create_todo, :create
rpc_action :update_todo, :update
end
end
end
```
### TypeScript Usage
```typescript
// Read with all features
const todos = await listTodos({
fields: ["id", "title", { user: ["name"] }],
filter: { completed: { eq: false } },
page: { limit: 10 },
sort: "-createdAt",
headers: buildCSRFHeaders()
});
// Update requires identity
await updateTodo({
identity: "todo-123",
input: { title: "Updated" },
fields: ["id", "title"]
});
// Phoenix channel
createTodoChannel({
channel: myChannel,
input: { title: "New" },
fields: ["id"],
resultHandler: (r) => console.log(r.data)
});
```
### Field Name Mapping (Invalid Names)
```elixir
# Resource attributes/calculations
typescript do
field_names [field_1: "field1", is_active?: "isActive"]
argument_names [search: [filter_1: "filter1"]]
end
# Custom types (NewType, TypedStruct, map constraints)
def typescript_field_names, do: [field_1: "field1"]
# Metadata fields
rpc_action :read, :read_with_meta,
metadata_field_names: [meta_1: "meta1"]
```
## Typed Controller (Route Helpers)
### When to Use
| Use Case | Extension |
|----------|-----------|
| Data operations with field selection, filtering, pagination | `AshTypescript.Rpc` + `AshTypescript.Resource` |
| Controller actions (Inertia renders, redirects, file downloads) | `AshTypescript.TypedController` |
### Setup
```elixir
defmodule MyApp.Session do
use AshTypescript.TypedController
typed_controller do
module_name MyAppWeb.SessionController
# Verb shortcut (preferred)
get :auth do
run fn conn, _params -> render_inertia(conn, "Auth") end
end
# Verb shortcut with args
post :login do
see [:auth, :logout]
run fn conn, _params -> Plug.Conn.send_resp(conn, 200, "OK") end
argument :code, :string, allow_nil?: false
argument :remember_me, :boolean
end
# Positional method arg
route :logout, :post do
run fn conn, _params -> Plug.Conn.send_resp(conn, 200, "OK") end
end
# Default method (GET when omitted)
route :home do
run fn conn, _params -> Plug.Conn.send_resp(conn, 200, "Home") end
end
end
end
```
### Generated TypeScript
```typescript
// GET → path helper
authPath() // → "/auth"
// GET with query args → path with query params
searchPath({ q: "test", page: 1 }) // → "/search?q=test&page=1"
// POST → typed async function (via executeTypedControllerRequest helper)
login({ code: "abc" }, { headers: { "X-CSRF-Token": token } })
// PATCH with path params + input
updateProvider({ provider: "github" }, { enabled: true })
```
**Function parameter order**: `path` (if path params) → `input` (if args) → `config?: TypedControllerConfig`
**Modes**: `:full` generates path helpers + fetch functions (+ Zod schemas if enabled). `:paths_only` generates only path helpers.
### Typed Controller Constraints
- Handlers must return `%Plug.Conn{}` directly — no `{:ok, conn}` wrapping
- Multi-mount requires unique `as:` options on scopes for disambiguation
- Not an Ash resource — standalone Spark DSL with colocated arguments
- Path param `allow_nil?` must match presence: always present → `false`, sometimes present (multi-mount) → `true`
## Typed Channel (Event Subscriptions)
### When to Use
| Use Case | Extension |
|----------|-----------|
| Data operations with field selection, filtering, pagination | `AshTypescript.Rpc` + `AshTypescript.Resource` |
| Controller actions (Inertia renders, redirects, file downloads) | `AshTypescript.TypedController` |
| Server pushes events to clients (notifications, updates) | `AshTypescript.TypedChannel` |
### Setup
```elixir
defmodule MyAppWeb.OrgChannel do
use AshTypescript.TypedChannel
use Phoenix.Channel
typed_channel do
topic "org:*"
resource MyApp.Post do
publish :post_created
publish :post_updated
end
end
@impl true
def join("org:" <> org_id, _payload, socket), do: {:ok, socket}
end
```
Resources must have `pub_sub` publications with matching `event:` names. Add `returns:` to publications for typed payloads (otherwise `unknown`).
### Generated TypeScript
```typescript
// Create branded channel + subscribe
const channel = createOrgChannel(socket, orgId);
channel.join();
const refs = onOrgChannelMessages(channel, {
post_created: (payload) => console.log(payload), // typed payload
post_updated: (payload) => updatePost(payload),
});
// Single event: onOrgChannelMessage(channel, "post_created", handler)
// Cleanup
unsubscribeOrgChannel(channel, refs);
```
### Topic Patterns
| Topic Pattern | Factory Signature |
|--------------|-------------------|
| `"org:*"` (wildcard) | `createOrgChannel(socket, suffix)` |
| `"global"` (no wildcard) | `createGlobalChannel(socket)` |
### Typed Channel Constraints
- Event names must be unique across all resources in a channel
- Publications need `public?: true` (warning if missing)
- Publications need `returns:` option for typed payloads (warning if missing, falls back to `unknown`)
- Channel types go in `ash_types.ts`; channel functions go in `typed_channels_output_file`
## JSON Manifest (Third-Party Integrations)
When `json_manifest_file` is configured, `mix ash_typescript.codegen` generates a machine-readable JSON manifest. This enables third-party packages (e.g., TanStack Query wrappers) to introspect the generated API without coupling to ash_typescript internals.
```elixir
config :ash_typescript,
json_manifest_file: "assets/js/ash_rpc_manifest.json",
json_manifest_filename_format: :relative # :relative | :absolute | :basename
```
The manifest contains:
- **`files`** — generated file locations with `importPath` (for TS imports, always relative, no `.ts`) and `filename` (format controlled by config)
- **`actions`** — every RPC action with: `functionName`, `actionType` (read/create/update/destroy/action), `get`, `namespace`, `types` (result, fields, input, config, filterInput — only present when applicable), `pagination`, `enableFilter`, `enableSort`, `variants`/`variantNames`, `deprecated`, `see`, `input` (none/optional/required)
- **`typedControllerRoutes`** — each route with: `functionName`, `method`, `path`, `pathParams`, `mutation`, `types`
- **`version`** — semver string (currently `"1.0"`) for consumer compatibility
### Consumer Example
```typescript
import manifest from "./ash_rpc_manifest.json";
for (const action of manifest.actions) {
const isQuery = action.actionType === "read";
// Import from manifest.files.rpc.importPath
// Generate queryOptions/mutationOptions wrappers
}
```
## Common Gotchas
| Error Pattern | Fix |
|---------------|-----|
| Missing `extensions: [AshTypescript.Rpc]` | Add to domain |
| Missing `typescript` block on resource | Add `AshTypescript.Resource` extension + `typescript do type_name "X" end` |
| No `rpc_action` declarations | Explicitly declare each action |
| Filter syntax `{ field: false }` | Use operators: `{ field: { eq: false } }` |
| Missing `fields` parameter | Always include `fields: [...]` |
| Get action error on not found | Add `not_found_error?: false` |
| Invalid field name `field_1` or `is_active?` | Add field mapping |
| Identity not found | Check `identities` config; use `{ field: value }` for named |
| Load not allowed/denied | Check `allowed_loads`/`denied_loads` config |
| Channel/validation fn undefined | Enable in config |
| Typed controller 500 error | Handler must return `%Plug.Conn{}` |
| Routes not generated | Set `typed_controllers:`, `router:`, and `routes_output_file:` in config |
| Multi-mount ambiguity error | Add unique `as:` option to each scope |
| Path param without matching argument | Add `argument :param, :string` to route |
| Path param `allow_nil?` mismatch | Always-present → `false`; sometimes-present → `true` |
| Route hooks not firing | Check `typed_controller_import_into_generated` + hook names |
| Typed channel event not found | Event name must match `event:` option on resource's `pub_sub` publication |
| Duplicate channel event names | Use unique event names across all resources in one channel |
| Channel payload is `unknown` | Add `returns:` option to the resource's `pub_sub` publication |
| Typed channels not generated | Set `typed_channels:` and `typed_channels_output_file:` in config |
## Error Quick Reference
| Error Contains | Fix |
|----------------|-----|
| "Property does not exist" | Run `mix ash_typescript.codegen` |
| "fields is required" | Add `fields: [...]` |
| "No domains found" | Use `MIX_ENV=test` for test resources |
| "Action not found" | Add `rpc_action` declaration |
| "403 Forbidden" | Use `buildCSRFHeaders()` |
| "Invalid field names" | Add mapping (see Field Name Mapping) |
| "load_not_allowed" / "load_denied" | Check load restrictions config |
| "allow_nil?: true" + path param | Set `allow_nil?: false` for always-present path params |
| "allow_nil?: false" + sometimes-present | Use `allow_nil?: true` for multi-mount path params |
| "No publication with event X found" | Check `event:` option on resource's `pub_sub` block |
| "Duplicate event names found" | Use unique event names per channel |
## Configuration
```elixir
config :ash_typescript,
output_file: "assets/js/ash_rpc.ts",
run_endpoint: "/rpc/run",
validate_endpoint: "/rpc/validate",
generate_validation_functions: false,
generate_phx_channel_rpc_actions: false,
generate_zod_schemas: false,
require_tenant_parameters: false,
not_found_error?: true,
# JSDoc/Manifest
add_ash_internals_to_jsdoc: false,
add_ash_internals_to_manifest: false,
manifest_file: nil,
json_manifest_file: nil, # Machine-readable JSON manifest for third-party tools
json_manifest_filename_format: :relative, # :relative | :absolute | :basename
source_path_prefix: nil, # For monorepos: "backend"
# Warnings
warn_on_missing_rpc_config: true,
warn_on_non_rpc_references: true,
# Dev codegen behavior
always_regenerate: false,
# Imports/Types
import_into_generated: [%{import_name: "CustomTypes", file: "./customTypes"}],
type_mapping_overrides: [{MyApp.CustomType, "string"}],
# Typed Controller (route helpers)
typed_controllers: [MyApp.Session],
router: MyAppWeb.Router,
routes_output_file: "assets/js/routes.ts",
typed_controller_mode: :full, # :full or :paths_only
typed_controller_path_params_style: :object, # :object or :args
# Optional: lifecycle hooks, custom imports, error handling
# typed_controller_before_request_hook: "RouteHooks.beforeRequest",
# typed_controller_after_request_hook: "RouteHooks.afterRequest",
# typed_controller_hook_context_type: "RouteHooks.RouteHookContext",
# typed_controller_import_into_generated: [%{import_name: "RouteHooks", file: "./routeHooks"}],
# typed_controller_error_handler: {MyApp.ErrorHandler, :handle, []},
# typed_controller_show_raised_errors: false # true only in dev
# Typed Channel (event subscriptions)
typed_channels: [MyApp.OrgChannel],
typed_channels_output_file: "assets/js/ash_typed_channels.ts"
```
## Commands
```bash
mix ash_typescript.codegen # Generate
mix ash_typescript.codegen --check # Verify up-to-date (CI)
mix ash_typescript.codegen --dry-run # Preview
npx tsc ash_rpc.ts --noEmit # Validate TS
```

View File

@@ -0,0 +1,180 @@
# Authorization
- When performing administrative actions, you can bypass authorization with `authorize?: false`
- To run actions as a particular user, look that user up and pass it as the `actor` option
- Always set the actor on the query/changeset/input, not when calling the action
- Use policies to define authorization rules
```elixir
# Good
Post
|> Ash.Query.for_read(:read, %{}, actor: current_user)
|> Ash.read!()
# BAD, DO NOT DO THIS
Post
|> Ash.Query.for_read(:read, %{})
|> Ash.read!(actor: current_user)
```
## Policies
To use policies, add the `Ash.Policy.Authorizer` to your resource:
```elixir
defmodule MyApp.Post do
use Ash.Resource,
domain: MyApp.Blog,
authorizers: [Ash.Policy.Authorizer]
# Rest of resource definition...
end
```
## Policy Basics
Policies determine what actions on a resource are permitted for a given actor. Define policies in the `policies` block:
```elixir
policies do
# A simple policy that applies to all read actions
policy action_type(:read) do
# Authorize if record is public
authorize_if expr(public == true)
# Authorize if actor is the owner
authorize_if relates_to_actor_via(:owner)
end
# A policy for create actions
policy action_type(:create) do
# Only allow active users to create records
forbid_unless actor_attribute_equals(:active, true)
# Ensure the record being created relates to the actor
authorize_if relating_to_actor(:owner)
end
end
```
## Policy Evaluation Flow
Policies evaluate from top to bottom with the following logic:
1. All policies that apply to an action must pass for the action to be allowed
2. Within each policy, checks evaluate from top to bottom
3. The first check that produces a decision determines the policy result
4. If no check produces a decision, the policy defaults to forbidden
## IMPORTANT: Policy Check Logic
**the first check that yields a result determines the policy outcome**
```elixir
# WRONG - This is OR logic, not AND logic!
policy action_type(:update) do
authorize_if actor_attribute_equals(:admin?, true) # If this passes, policy passes
authorize_if relates_to_actor_via(:owner) # Only checked if first fails
end
```
To require BOTH conditions in that example, you would use `forbid_unless` for the first condition:
```elixir
# CORRECT - This requires BOTH conditions
policy action_type(:update) do
forbid_unless actor_attribute_equals(:admin?, true) # Must be admin
authorize_if relates_to_actor_via(:owner) # AND must be owner
end
```
Alternative patterns for AND logic:
- Use multiple separate policies (each must pass independently)
- Use a single complex expression with `expr(condition1 and condition2)`
- Use `forbid_unless` for required conditions, then `authorize_if` for the final check
## Bypass Policies
Use bypass policies to allow certain actors to bypass other policy restrictions. This should be used almost exclusively for admin bypasses.
```elixir
policies do
# Bypass policy for admins - if this passes, other policies don't need to pass
bypass actor_attribute_equals(:admin, true) do
authorize_if always()
end
# Regular policies follow...
policy action_type(:read) do
# ...
end
end
```
## Field Policies
Field policies control access to specific fields (attributes, calculations, aggregates):
```elixir
field_policies do
# Only supervisors can see the salary field
field_policy :salary do
authorize_if actor_attribute_equals(:role, :supervisor)
end
# Allow access to all other fields
field_policy :* do
authorize_if always()
end
end
```
## Policy Checks
There are two main types of checks used in policies:
1. **Simple checks** - Return true/false answers (e.g., "is the actor an admin?")
2. **Filter checks** - Return filters to apply to data (e.g., "only show records owned by the actor")
You can use built-in checks or create custom ones:
```elixir
# Built-in checks
authorize_if actor_attribute_equals(:role, :admin)
authorize_if relates_to_actor_via(:owner)
authorize_if expr(public == true)
# Custom check module
authorize_if MyApp.Checks.ActorHasPermission
```
### Custom Policy Checks
Create custom checks by implementing `Ash.Policy.SimpleCheck` or `Ash.Policy.FilterCheck`:
```elixir
# Simple check - returns true/false
defmodule MyApp.Checks.ActorHasRole do
use Ash.Policy.SimpleCheck
def match?(%{role: actor_role}, _context, opts) do
actor_role == (opts[:role] || :admin)
end
def match?(_, _, _), do: false
end
# Filter check - returns query filter
defmodule MyApp.Checks.VisibleToUserLevel do
use Ash.Policy.FilterCheck
def filter(actor, _authorizer, _opts) do
expr(visibility_level <= ^actor.user_level)
end
end
# Usage
policy action_type(:read) do
authorize_if {MyApp.Checks.ActorHasRole, role: :manager}
authorize_if MyApp.Checks.VisibleToUserLevel
end
```

View File

@@ -0,0 +1,149 @@
# Calculations
Calculations allow you to define derived values based on a resource's attributes or related data. Define calculations in the `calculations` block of a resource:
```elixir
calculations do
# Simple expression calculation
calculate :full_name, :string, expr(first_name <> " " <> last_name)
# Expression with conditions
calculate :status_label, :string, expr(
cond do
status == :active -> "Active"
status == :pending -> "Pending Review"
true -> "Inactive"
end
)
# Using module calculations for more complex logic
calculate :risk_score, :integer, {MyApp.Calculations.RiskScore, min: 0, max: 100}
end
```
## Expression Calculations
Expression calculations use Ash expressions and can be pushed down to the data layer when possible:
```elixir
calculations do
# Simple string concatenation
calculate :full_name, :string, expr(first_name <> " " <> last_name)
# Math operations
calculate :total_with_tax, :decimal, expr(amount * (1 + tax_rate))
# Date manipulation
calculate :days_since_created, :integer, expr(
date_diff(^now(), inserted_at, :day)
)
end
```
## Expressions
In order to use expressions outside of resources, changes, preparations etc. you will need to use `Ash.Expr`.
It provides both `expr/1` and template helpers like `actor/1` and `arg/1`.
For example:
```elixir
import Ash.Expr
Author
|> Ash.Query.aggregate(:count_of_my_favorited_posts, :count, [:posts], query: [
filter: expr(favorited_by(user_id: ^actor(:id)))
])
```
See the expressions guide for more information on what is available in expresisons and
how to use them.
## Module Calculations
For complex calculations, create a module that implements `Ash.Resource.Calculation`:
```elixir
defmodule MyApp.Calculations.FullName do
use Ash.Resource.Calculation
# Validate and transform options
@impl true
def init(opts) do
{:ok, Map.put_new(opts, :separator, " ")}
end
# Specify what data needs to be loaded
@impl true
def load(_query, _opts, _context) do
[:first_name, :last_name]
end
# Implement the calculation logic
@impl true
def calculate(records, opts, _context) do
Enum.map(records, fn record ->
[record.first_name, record.last_name]
|> Enum.reject(&is_nil/1)
|> Enum.join(opts.separator)
end)
end
end
# Usage in a resource
calculations do
calculate :full_name, :string, {MyApp.Calculations.FullName, separator: ", "}
end
```
## Calculations with Arguments
You can define calculations that accept arguments:
```elixir
calculations do
calculate :full_name, :string, expr(first_name <> ^arg(:separator) <> last_name) do
argument :separator, :string do
allow_nil? false
default " "
constraints [allow_empty?: true, trim?: false]
end
end
end
```
## Using Calculations
```elixir
# Using code interface options (preferred)
users = MyDomain.list_users!(load: [full_name: [separator: ", "]])
# Filtering and sorting
users = MyDomain.list_users!(
query: [
filter: [full_name: [separator: " ", value: "John Doe"]],
sort: [full_name: {[separator: " "], :asc}]
]
)
# Manual query building (for complex cases)
User |> Ash.Query.load(full_name: [separator: ", "]) |> Ash.read!()
# Loading on existing records
Ash.load!(users, :full_name)
```
### Code Interface for Calculations
Define calculation functions on your domain for standalone use:
```elixir
# In your domain
resource User do
define_calculation :full_name, args: [:first_name, :last_name, {:optional, :separator}]
end
# Then call it directly
MyDomain.full_name("John", "Doe", ", ") # Returns "John, Doe"
```

View File

@@ -0,0 +1,134 @@
# Code Interfaces
Use code interfaces on domains to define the contract for calling into Ash resources. See the [Code interface guide for more](https://hexdocs.pm/ash/code-interfaces.html).
Define code interfaces on the domain, like this:
```elixir
resource ResourceName do
define :fun_name, action: :action_name
end
```
For more complex interfaces with custom transformations:
```elixir
define :custom_action do
action :action_name
args [:arg1, :arg2]
custom_input :arg1, MyType do
transform do
to :target_field
using &MyModule.transform_function/1
end
end
end
```
Prefer using the primary read action for "get" style code interfaces, and using `get_by` when the field you are looking up by is the primary key or has an `identity` on the resource.
```elixir
resource ResourceName do
define :get_thing, action: :read, get_by: [:id]
end
```
**Avoid direct Ash calls in web modules** - Don't use `Ash.get!/2` and `Ash.load!/2` directly in LiveViews/Controllers, similar to avoiding `Repo.get/2` outside context modules:
You can also pass additional inputs in to code interfaces before the options:
```elixir
resource ResourceName do
define :create, action: :action_name, args: [:field1]
end
```
```elixir
Domain.create!(field1_value, %{field2: field2_value}, actor: current_user)
```
You should generally prefer using this map of extra inputs over defining optional arguments.
```elixir
# BAD - in LiveView/Controller
group = MyApp.Resource |> Ash.get!(id) |> Ash.load!(rel: [:nested])
# GOOD - use code interface with get_by
resource DashboardGroup do
define :get_dashboard_group_by_id, action: :read, get_by: [:id]
end
# Then call:
MyApp.Domain.get_dashboard_group_by_id!(id, load: [rel: [:nested]])
```
**Code interface options** - Prefer passing options directly to code interface functions rather than building queries manually:
```elixir
# PREFERRED - Use the query option for filter, sort, limit, etc.
# the query option is passed to `Ash.Query.build/2`
posts = MyApp.Blog.list_posts!(
query: [
filter: [status: :published],
sort: [published_at: :desc],
limit: 10
],
load: [author: :profile, comments: [:author]]
)
# All query-related options go in the query parameter
users = MyApp.Accounts.list_users!(
query: [filter: [active: true], sort: [created_at: :desc]],
load: [:profile]
)
# AVOID - Verbose manual query building
query = MyApp.Post |> Ash.Query.filter(...) |> Ash.Query.load(...)
posts = Ash.read!(query)
```
Supported options: `load:`, `query:` (which accepts `filter:`, `sort:`, `limit:`, `offset:`, etc.), `page:`, `stream?:`
**Using Scopes in LiveViews** - When using `Ash.Scope`, the scope will typically be assigned to `scope` in LiveViews and used like so:
```elixir
# In your LiveView
MyApp.Blog.create_post!("new post", scope: socket.assigns.scope)
```
Inside action hooks and callbacks, use the provided `context` parameter as your scope instead:
```elixir
|> Ash.Changeset.before_transaction(fn changeset, context ->
MyApp.ExternalService.reserve_inventory(changeset, scope: context)
changeset
end)
```
## Authorization Functions
For each action defined in a code interface, Ash automatically generates corresponding authorization check functions:
- `can_action_name?(actor, params \\ %{}, opts \\ [])` - Returns `true`/`false` for authorization checks
- `can_action_name(actor, params \\ %{}, opts \\ [])` - Returns `{:ok, true/false}` or `{:error, reason}`
Example usage:
```elixir
# Check if user can create a post
if MyApp.Blog.can_create_post?(current_user) do
# Show create button
end
# Check if user can update a specific post
if MyApp.Blog.can_update_post?(current_user, post) do
# Show edit button
end
# Check if user can destroy a specific comment
if MyApp.Blog.can_destroy_comment?(current_user, comment) do
# Show delete button
end
```
These functions are particularly useful for conditional rendering of UI elements based on user permissions.

View File

@@ -0,0 +1,7 @@
# Code Structure & Organization
- Organize code around domains and resources
- Each resource should be focused and well-named
- Create domain-specific actions rather than generic CRUD operations
- Put business logic inside actions rather than in external modules
- Use resources to model your domain entities

View File

@@ -0,0 +1,44 @@
# Data Layers
Data layers determine how resources are stored and retrieved. Examples of data layers:
- **Postgres**: For storing resources in PostgreSQL (via `AshPostgres`)
- **ETS**: For in-memory storage (`Ash.DataLayer.Ets`)
- **Mnesia**: For distributed storage (`Ash.DataLayer.Mnesia`)
- **Embedded**: For resources embedded in other resources (`data_layer: :embedded`) (typically JSON under the hood)
- **Ash.DataLayer.Simple**: For resources that aren't persisted at all. Leave off the data layer, as this is the default.
Specify a data layer when defining a resource:
```elixir
defmodule MyApp.Post do
use Ash.Resource,
domain: MyApp.Blog,
data_layer: AshPostgres.DataLayer
postgres do
table "posts"
repo MyApp.Repo
end
# ... attributes, relationships, etc.
end
```
For embedded resources:
```elixir
defmodule MyApp.Address do
use Ash.Resource,
data_layer: :embedded
attributes do
attribute :street, :string
attribute :city, :string
attribute :state, :string
attribute :zip, :string
end
end
```
Each data layer has its own configuration options and capabilities. Refer to the rules & documentation of the specific data layer package for more details.

View File

@@ -0,0 +1,31 @@
# Exists Expressions
Use `exists/2` to check for the existence of records, either through relationships or unrelated resources:
### Related Exists
```elixir
# Check if user has any admin roles
Ash.Query.filter(User, exists(roles, name == "admin"))
# Check if post has comments with high scores
Ash.Query.filter(Post, exists(comments, score > 50))
```
### Unrelated Exists
```elixir
# Check if any profile exists with the same name
Ash.Query.filter(User, exists(Profile, name == parent(name)))
# Check if user has any reports
Ash.Query.filter(User, exists(Report, author_name == parent(name)))
# Complex existence checks
Ash.Query.filter(User,
active == true and
exists(Profile, active == true and name == parent(name))
)
```
Unrelated exists expressions automatically apply authorization using the target resource's primary read action. Use `parent/1` to reference fields from the source resource.

View File

@@ -0,0 +1,4 @@
# Generating Code
Use `mix ash.gen.*` tasks as a basis for code generation when possible. Check the task docs with `mix help <task>`.
Be sure to use `--yes` to bypass confirmation prompts. Use `--yes --dry-run` to preview the changes.

View File

@@ -0,0 +1,3 @@
# Migrations and Schema Changes
After creating or modifying Ash code, run `mix ash.codegen <short_name_describing_changes>` to ensure any required additional changes are made (like migrations are generated). The name of the migration should be lower_snake_case. In a longer running dev session it's usually better to use `mix ash.codegen --dev` as you go and at the end run the final codegen with a sensible name describing all the changes made in the session.

View File

@@ -0,0 +1,28 @@
# Ash.Query.filter is a macro
**Important**: You must `require Ash.Query` if you want to use `Ash.Query.filter/2`, as it is a macro.
If you see errors like the following:
```
Ash.Query.filter(MyResource, id == ^id)
error: misplaced operator ^id
The pin operator ^ is supported only inside matches or inside custom macros...
```
```
iex(3)> Ash.Query.filter(MyResource, something == true)
error: undefined variable "something"
└─ iex:3
```
You are very likely missing a `require Ash.Query`
## Common Query Operations
- **Filter**: `Ash.Query.filter(query, field == value)`
- **Sort**: `Ash.Query.sort(query, field: :asc)`
- **Load relationships**: `Ash.Query.load(query, [:author, :comments])`
- **Limit**: `Ash.Query.limit(query, 10)`
- **Offset**: `Ash.Query.offset(query, 20)`

View File

@@ -0,0 +1,3 @@
# Querying Data
Use `Ash.Query` to build queries for reading data from your resources. The query module provides a declarative way to filter, sort, and load data.

View File

@@ -0,0 +1,170 @@
# Relationships
Relationships describe connections between resources and are a core component of Ash. Define relationships in the `relationships` block of a resource.
## Best Practices for Relationships
- Be descriptive with relationship names (e.g., use `:authored_posts` instead of just `:posts`)
- Configure foreign key constraints in your data layer if they have them (see `references` in AshPostgres)
- Always choose the appropriate relationship type based on your domain model
### Relationship Types
- For Polymorphic relationships, you can model them using `Ash.Type.Union`; see the “Polymorphic Relationships” guide for more information.
```elixir
relationships do
# belongs_to - adds foreign key to source resource
belongs_to :owner, MyApp.User do
allow_nil? false
attribute_type :integer # defaults to :uuid
end
# has_one - foreign key on destination resource
has_one :profile, MyApp.Profile
# has_many - foreign key on destination resource, returns list
has_many :posts, MyApp.Post do
filter expr(published == true)
sort published_at: :desc
end
# many_to_many - requires join resource
many_to_many :tags, MyApp.Tag do
through MyApp.PostTag
source_attribute_on_join_resource :post_id
destination_attribute_on_join_resource :tag_id
end
end
```
The join resource must be defined separately:
```elixir
defmodule MyApp.PostTag do
use Ash.Resource,
data_layer: AshPostgres.DataLayer
attributes do
uuid_primary_key :id
# Add additional attributes if you need metadata on the relationship
attribute :added_at, :utc_datetime_usec do
default &DateTime.utc_now/0
end
end
relationships do
belongs_to :post, MyApp.Post, primary_key?: true, allow_nil?: false
belongs_to :tag, MyApp.Tag, primary_key?: true, allow_nil?: false
end
actions do
defaults [:read, :destroy, create: :*, update: :*]
end
end
```
## Loading Relationships
```elixir
# Using code interface options (preferred)
post = MyDomain.get_post!(id, load: [:author, comments: [:author]])
# Complex loading with filters
posts = MyDomain.list_posts!(
query: [load: [comments: [filter: [is_approved: true], limit: 5]]]
)
# Manual query building (for complex cases)
MyApp.Post
|> Ash.Query.load(comments: MyApp.Comment |> Ash.Query.filter(is_approved == true))
|> Ash.read!()
# Loading on existing records
Ash.load!(post, :author)
```
Prefer to use the `strict?` option when loading to only load necessary fields on related data.
```elixir
MyApp.Post
|> Ash.Query.load([comments: [:title]], strict?: true)
```
## Managing Relationships
There are two primary ways to manage relationships in Ash:
### 1. Using `change manage_relationship/2-3` in Actions
Use this when input comes from action arguments:
```elixir
actions do
update :update do
# Define argument for the related data
argument :comments, {:array, :map} do
allow_nil? false
end
argument :new_tags, {:array, :map}
# Link argument to relationship management
change manage_relationship(:comments, type: :append)
# For different argument and relationship names
change manage_relationship(:new_tags, :tags, type: :append)
end
end
```
### 2. Using `Ash.Changeset.manage_relationship/3-4` in Custom Changes
Use this when building values programmatically:
```elixir
defmodule MyApp.Changes.AssignTeamMembers do
use Ash.Resource.Change
def change(changeset, _opts, context) do
members = determine_team_members(changeset, context.actor)
Ash.Changeset.manage_relationship(
changeset,
:members,
members,
type: :append_and_remove
)
end
end
```
### Quick Reference - Management Types
- `:append` - Add new related records, ignore existing
- `:append_and_remove` - Add new related records, remove missing
- `:remove` - Remove specified related records
- `:direct_control` - Full CRUD control (create/update/destroy)
- `:create` - Only create new records
### Quick Reference - Common Options
- `on_lookup: :relate` - Look up and relate existing records
- `on_no_match: :create` - Create if no match found
- `on_match: :update` - Update existing matches
- `on_missing: :destroy` - Delete records not in input
- `value_is_key: :name` - Use field as key for simple values
For comprehensive documentation, see the [Managing Relationships](https://hexdocs.pm/ash/relationships.html#managing-relationships) section.
### Examples
Creating a post with tags:
```elixir
MyDomain.create_post!(%{
title: "New Post",
body: "Content here...",
tags: [%{name: "elixir"}, %{name: "ash"}] # Creates new tags
})
# Updating a post to replace its tags
MyDomain.update_post!(post, %{
tags: [tag1.id, tag2.id] # Replaces tags with existing ones by ID
})
```

View File

@@ -0,0 +1,59 @@
# Testing
When testing resources:
- Test your domain actions through the code interface
- Use test utilities in `Ash.Test`
- Test authorization policies work as expected using `Ash.can?`
- Use `authorize?: false` in tests where authorization is not the focus
- Write generators using `Ash.Generator`
- Prefer to use raising versions of functions whenever possible, as opposed to pattern matching
## Preventing Deadlocks in Concurrent Tests
When running tests concurrently, using fixed values for identity attributes can cause deadlock errors. Multiple tests attempting to create records with the same unique values will conflict.
### Use Globally Unique Values
Always use globally unique values for identity attributes in tests:
```elixir
# BAD - Can cause deadlocks in concurrent tests
%{email: "test@example.com", username: "testuser"}
# GOOD - Use globally unique values
%{
email: "test-#{System.unique_integer([:positive])}@example.com",
username: "user_#{System.unique_integer([:positive])}",
slug: "post-#{System.unique_integer([:positive])}"
}
```
### Creating Reusable Test Generators
For better organization, create a generator module:
```elixir
defmodule MyApp.TestGenerators do
use Ash.Generator
def user(opts \\ []) do
changeset_generator(
User,
:create,
defaults: [
email: "user-#{System.unique_integer([:positive])}@example.com",
username: "user_#{System.unique_integer([:positive])}"
],
overrides: opts
)
end
end
# In your tests
test "concurrent user creation" do
users = MyApp.TestGenerators.generate_many(user(), 10)
# Each user has unique identity attributes
end
```
This applies to ANY field used in identity constraints, not just primary keys. Using globally unique values prevents frustrating intermittent test failures in CI environments.

View File

@@ -0,0 +1,56 @@
---
name: phoenix-framework
description: "Use this skill working with Phoenix Framework. Consult this when working with the web layer, controllers, views, liveviews etc."
metadata:
managed-by: usage-rules
---
<!-- usage-rules-skill-start -->
## Additional References
- [ecto](references/ecto.md)
- [elixir](references/elixir.md)
- [html](references/html.md)
- [liveview](references/liveview.md)
- [phoenix](references/phoenix.md)
- [phoenix_ecto](references/phoenix_ecto.md)
- [phoenix_html](references/phoenix_html.md)
- [phoenix_live_dashboard](references/phoenix_live_dashboard.md)
- [phoenix_live_reload](references/phoenix_live_reload.md)
- [phoenix_live_view](references/phoenix_live_view.md)
## Searching Documentation
```sh
mix usage_rules.search_docs "search term" -p phoenix -p phoenix_ecto -p phoenix_html -p phoenix_live_dashboard -p phoenix_live_reload -p phoenix_live_view
```
## Available Mix Tasks
- `mix compile.phoenix`
- `mix phx` - Prints Phoenix help information
- `mix phx.digest` - Digests and compresses static files
- `mix phx.digest.clean` - Removes old versions of static assets.
- `mix phx.gen` - Lists all available Phoenix generators
- `mix phx.gen.auth` - Generates authentication logic for a resource
- `mix phx.gen.auth.hashing_library`
- `mix phx.gen.auth.injector`
- `mix phx.gen.auth.migration`
- `mix phx.gen.cert` - Generates a self-signed certificate for HTTPS testing
- `mix phx.gen.channel` - Generates a Phoenix channel
- `mix phx.gen.context` - Generates a context with functions around an Ecto schema
- `mix phx.gen.embedded` - Generates an embedded Ecto schema file
- `mix phx.gen.html` - Generates context and controller for an HTML resource
- `mix phx.gen.json` - Generates context and controller for a JSON resource
- `mix phx.gen.live` - Generates LiveView, templates, and context for a resource
- `mix phx.gen.notifier` - Generates a notifier that delivers emails by default
- `mix phx.gen.presence` - Generates a Presence tracker
- `mix phx.gen.release` - Generates release files and optional Dockerfile for release-based deployments
- `mix phx.gen.schema` - Generates an Ecto schema and migration file
- `mix phx.gen.secret` - Generates a secret
- `mix phx.gen.socket` - Generates a Phoenix socket handler
- `mix phx.routes` - Prints all routes
- `mix phx.server` - Starts applications and their servers
- `mix compile.phoenix_live_view`
- `mix phoenix_live_view.upgrade`
<!-- usage-rules-skill-end -->

View File

@@ -0,0 +1,9 @@
## Ecto Guidelines
- **Always** preload Ecto associations in queries when they'll be accessed in templates, ie a message that needs to reference the `message.user.email`
- Remember `import Ecto.Query` and other supporting modules when you write `seeds.exs`
- `Ecto.Schema` fields always use the `:string` type, even for `:text`, columns, ie: `field :name, :string`
- `Ecto.Changeset.validate_number/2` **DOES NOT SUPPORT the `:allow_nil` option**. By default, Ecto validations only run if a change for the given field exists and the change value is not nil, so such as option is never needed
- You **must** use `Ecto.Changeset.get_field(changeset, :field)` to access changeset fields
- Fields which are set programmatically, such as `user_id`, must not be listed in `cast` calls or similar for security purposes. Instead they must be explicitly set when creating the struct
- **Always** invoke `mix ecto.gen.migration migration_name_using_underscores` when generating migration files, so the correct timestamp and conventions are applied

View File

@@ -0,0 +1,54 @@
## Elixir guidelines
- Elixir lists **do not support index based access via the access syntax**
**Never do this (invalid)**:
i = 0
mylist = ["blue", "green"]
mylist[i]
Instead, **always** use `Enum.at`, pattern matching, or `List` for index based list access, ie:
i = 0
mylist = ["blue", "green"]
Enum.at(mylist, i)
- Elixir variables are immutable, but can be rebound, so for block expressions like `if`, `case`, `cond`, etc
you *must* bind the result of the expression to a variable if you want to use it and you CANNOT rebind the result inside the expression, ie:
# INVALID: we are rebinding inside the `if` and the result never gets assigned
if connected?(socket) do
socket = assign(socket, :val, val)
end
# VALID: we rebind the result of the `if` to a new variable
socket =
if connected?(socket) do
assign(socket, :val, val)
end
- **Never** nest multiple modules in the same file as it can cause cyclic dependencies and compilation errors
- **Never** use map access syntax (`changeset[:field]`) on structs as they do not implement the Access behaviour by default. For regular structs, you **must** access the fields directly, such as `my_struct.field` or use higher level APIs that are available on the struct if they exist, `Ecto.Changeset.get_field/2` for changesets
- Elixir's standard library has everything necessary for date and time manipulation. Familiarize yourself with the common `Time`, `Date`, `DateTime`, and `Calendar` interfaces by accessing their documentation as necessary. **Never** install additional dependencies unless asked or for date/time parsing (which you can use the `date_time_parser` package)
- Don't use `String.to_atom/1` on user input (memory leak risk)
- Predicate function names should not start with `is_` and should end in a question mark. Names like `is_thing` should be reserved for guards
- Elixir's builtin OTP primitives like `DynamicSupervisor` and `Registry`, require names in the child spec, such as `{DynamicSupervisor, name: MyApp.MyDynamicSup}`, then you can use `DynamicSupervisor.start_child(MyApp.MyDynamicSup, child_spec)`
- Use `Task.async_stream(collection, callback, options)` for concurrent enumeration with back-pressure. The majority of times you will want to pass `timeout: :infinity` as option
## Mix guidelines
- Read the docs and options before using tasks (by using `mix help task_name`)
- To debug test failures, run tests in a specific file with `mix test test/my_test.exs` or run all previously failed tests with `mix test --failed`
- `mix deps.clean --all` is **almost never needed**. **Avoid** using it unless you have good reason
## Test guidelines
- **Always use `start_supervised!/1`** to start processes in tests as it guarantees cleanup between tests
- **Avoid** `Process.sleep/1` and `Process.alive?/1` in tests
- Instead of sleeping to wait for a process to finish, **always** use `Process.monitor/1` and assert on the DOWN message:
ref = Process.monitor(pid)
assert_receive {:DOWN, ^ref, :process, ^pid, :normal}
- Instead of sleeping to synchronize before the next call, **always** use `_ = :sys.get_state/1` to ensure the process has handled prior messages

View File

@@ -0,0 +1,76 @@
## Phoenix HTML guidelines
- Phoenix templates **always** use `~H` or .html.heex files (known as HEEx), **never** use `~E`
- **Always** use the imported `Phoenix.Component.form/1` and `Phoenix.Component.inputs_for/1` function to build forms. **Never** use `Phoenix.HTML.form_for` or `Phoenix.HTML.inputs_for` as they are outdated
- When building forms **always** use the already imported `Phoenix.Component.to_form/2` (`assign(socket, form: to_form(...))` and `<.form for={@form} id="msg-form">`), then access those forms in the template via `@form[:field]`
- **Always** add unique DOM IDs to key elements (like forms, buttons, etc) when writing templates, these IDs can later be used in tests (`<.form for={@form} id="product-form">`)
- For "app wide" template imports, you can import/alias into the `my_app_web.ex`'s `html_helpers` block, so they will be available to all LiveViews, LiveComponent's, and all modules that do `use MyAppWeb, :html` (replace "my_app" by the actual app name)
- Elixir supports `if/else` but **does NOT support `if/else if` or `if/elsif`**. **Never use `else if` or `elseif` in Elixir**, **always** use `cond` or `case` for multiple conditionals.
**Never do this (invalid)**:
<%= if condition do %>
...
<% else if other_condition %>
...
<% end %>
Instead **always** do this:
<%= cond do %>
<% condition -> %>
...
<% condition2 -> %>
...
<% true -> %>
...
<% end %>
- HEEx require special tag annotation if you want to insert literal curly's like `{` or `}`. If you want to show a textual code snippet on the page in a `<pre>` or `<code>` block you *must* annotate the parent tag with `phx-no-curly-interpolation`:
<code phx-no-curly-interpolation>
let obj = {key: "val"}
</code>
Within `phx-no-curly-interpolation` annotated tags, you can use `{` and `}` without escaping them, and dynamic Elixir expressions can still be used with `<%= ... %>` syntax
- HEEx class attrs support lists, but you must **always** use list `[...]` syntax. You can use the class list syntax to conditionally add classes, **always do this for multiple class values**:
<a class={[
"px-2 text-white",
@some_flag && "py-5",
if(@other_condition, do: "border-red-500", else: "border-blue-100"),
...
]}>Text</a>
and **always** wrap `if`'s inside `{...}` expressions with parens, like done above (`if(@other_condition, do: "...", else: "...")`)
and **never** do this, since it's invalid (note the missing `[` and `]`):
<a class={
"px-2 text-white",
@some_flag && "py-5"
}> ...
=> Raises compile syntax error on invalid HEEx attr syntax
- **Never** use `<% Enum.each %>` or non-for comprehensions for generating template content, instead **always** use `<%= for item <- @collection do %>`
- HEEx HTML comments use `<%!-- comment --%>`. **Always** use the HEEx HTML comment syntax for template comments (`<%!-- comment --%>`)
- HEEx allows interpolation via `{...}` and `<%= ... %>`, but the `<%= %>` **only** works within tag bodies. **Always** use the `{...}` syntax for interpolation within tag attributes, and for interpolation of values within tag bodies. **Always** interpolate block constructs (if, cond, case, for) within tag bodies using `<%= ... %>`.
**Always** do this:
<div id={@id}>
{@my_assign}
<%= if @some_block_condition do %>
{@another_assign}
<% end %>
</div>
and **Never** do this the program will terminate with a syntax error:
<%!-- THIS IS INVALID NEVER EVER DO THIS --%>
<div id="<%= @invalid_interpolation %>">
{if @invalid_block_construct do}
{end}
</div>

View File

@@ -0,0 +1,231 @@
## Phoenix LiveView guidelines
- **Never** use the deprecated `live_redirect` and `live_patch` functions, instead **always** use the `<.link navigate={href}>` and `<.link patch={href}>` in templates, and `push_navigate` and `push_patch` functions LiveViews
- **Avoid LiveComponent's** unless you have a strong, specific need for them
- LiveViews should be named like `AppWeb.WeatherLive`, with a `Live` suffix. When you go to add LiveView routes to the router, the default `:browser` scope is **already aliased** with the `AppWeb` module, so you can just do `live "/weather", WeatherLive`
### LiveView streams
- **Always** use LiveView streams for collections for assigning regular lists to avoid memory ballooning and runtime termination with the following operations:
- basic append of N items - `stream(socket, :messages, [new_msg])`
- resetting stream with new items - `stream(socket, :messages, [new_msg], reset: true)` (e.g. for filtering items)
- prepend to stream - `stream(socket, :messages, [new_msg], at: -1)`
- deleting items - `stream_delete(socket, :messages, msg)`
- When using the `stream/3` interfaces in the LiveView, the LiveView template must 1) always set `phx-update="stream"` on the parent element, with a DOM id on the parent element like `id="messages"` and 2) consume the `@streams.stream_name` collection and use the id as the DOM id for each child. For a call like `stream(socket, :messages, [new_msg])` in the LiveView, the template would be:
<div id="messages" phx-update="stream">
<div :for={{id, msg} <- @streams.messages} id={id}>
{msg.text}
</div>
</div>
- LiveView streams are *not* enumerable, so you cannot use `Enum.filter/2` or `Enum.reject/2` on them. Instead, if you want to filter, prune, or refresh a list of items on the UI, you **must refetch the data and re-stream the entire stream collection, passing reset: true**:
def handle_event("filter", %{"filter" => filter}, socket) do
# re-fetch the messages based on the filter
messages = list_messages(filter)
{:noreply,
socket
|> assign(:messages_empty?, messages == [])
# reset the stream with the new messages
|> stream(:messages, messages, reset: true)}
end
- LiveView streams *do not support counting or empty states*. If you need to display a count, you must track it using a separate assign. For empty states, you can use Tailwind classes:
<div id="tasks" phx-update="stream">
<div class="hidden only:block">No tasks yet</div>
<div :for={{id, task} <- @streams.tasks} id={id}>
{task.name}
</div>
</div>
The above only works if the empty state is the only HTML block alongside the stream for-comprehension.
- When updating an assign that should change content inside any streamed item(s), you MUST re-stream the items
along with the updated assign:
def handle_event("edit_message", %{"message_id" => message_id}, socket) do
message = Chat.get_message!(message_id)
edit_form = to_form(Chat.change_message(message, %{content: message.content}))
# re-insert message so @editing_message_id toggle logic takes effect for that stream item
{:noreply,
socket
|> stream_insert(:messages, message)
|> assign(:editing_message_id, String.to_integer(message_id))
|> assign(:edit_form, edit_form)}
end
And in the template:
<div id="messages" phx-update="stream">
<div :for={{id, message} <- @streams.messages} id={id} class="flex group">
{message.username}
<%= if @editing_message_id == message.id do %>
<%!-- Edit mode --%>
<.form for={@edit_form} id="edit-form-#{message.id}" phx-submit="save_edit">
...
</.form>
<% end %>
</div>
</div>
- **Never** use the deprecated `phx-update="append"` or `phx-update="prepend"` for collections
### LiveView JavaScript interop
- Remember anytime you use `phx-hook="MyHook"` and that JS hook manages its own DOM, you **must** also set the `phx-update="ignore"` attribute
- **Always** provide an unique DOM id alongside `phx-hook` otherwise a compiler error will be raised
LiveView hooks come in two flavors, 1) colocated js hooks for "inline" scripts defined inside HEEx,
and 2) external `phx-hook` annotations where JavaScript object literals are defined and passed to the `LiveSocket` constructor.
#### Inline colocated js hooks
**Never** write raw embedded `<script>` tags in heex as they are incompatible with LiveView.
Instead, **always use a colocated js hook script tag (`:type={Phoenix.LiveView.ColocatedHook}`)
when writing scripts inside the template**:
<input type="text" name="user[phone_number]" id="user-phone-number" phx-hook=".PhoneNumber" />
<script :type={Phoenix.LiveView.ColocatedHook} name=".PhoneNumber">
export default {
mounted() {
this.el.addEventListener("input", e => {
let match = this.el.value.replace(/\D/g, "").match(/^(\d{3})(\d{3})(\d{4})$/)
if(match) {
this.el.value = `${match[1]}-${match[2]}-${match[3]}`
}
})
}
}
</script>
- colocated hooks are automatically integrated into the app.js bundle
- colocated hooks names **MUST ALWAYS** start with a `.` prefix, i.e. `.PhoneNumber`
#### External phx-hook
External JS hooks (`<div id="myhook" phx-hook="MyHook">`) must be placed in `assets/js/` and passed to the
LiveSocket constructor:
const MyHook = {
mounted() { ... }
}
let liveSocket = new LiveSocket("/live", Socket, {
hooks: { MyHook }
});
#### Pushing events between client and server
Use LiveView's `push_event/3` when you need to push events/data to the client for a phx-hook to handle.
**Always** return or rebind the socket on `push_event/3` when pushing events:
# re-bind socket so we maintain event state to be pushed
socket = push_event(socket, "my_event", %{...})
# or return the modified socket directly:
def handle_event("some_event", _, socket) do
{:noreply, push_event(socket, "my_event", %{...})}
end
Pushed events can then be picked up in a JS hook with `this.handleEvent`:
mounted() {
this.handleEvent("my_event", data => console.log("from server:", data));
}
Clients can also push an event to the server and receive a reply with `this.pushEvent`:
mounted() {
this.el.addEventListener("click", e => {
this.pushEvent("my_event", { one: 1 }, reply => console.log("got reply from server:", reply));
})
}
Where the server handled it via:
def handle_event("my_event", %{"one" => 1}, socket) do
{:reply, %{two: 2}, socket}
end
### LiveView tests
- `Phoenix.LiveViewTest` module and `LazyHTML` (included) for making your assertions
- Form tests are driven by `Phoenix.LiveViewTest`'s `render_submit/2` and `render_change/2` functions
- Come up with a step-by-step test plan that splits major test cases into small, isolated files. You may start with simpler tests that verify content exists, gradually add interaction tests
- **Always reference the key element IDs you added in the LiveView templates in your tests** for `Phoenix.LiveViewTest` functions like `element/2`, `has_element/2`, selectors, etc
- **Never** tests again raw HTML, **always** use `element/2`, `has_element/2`, and similar: `assert has_element?(view, "#my-form")`
- Instead of relying on testing text content, which can change, favor testing for the presence of key elements
- Focus on testing outcomes rather than implementation details
- Be aware that `Phoenix.Component` functions like `<.form>` might produce different HTML than expected. Test against the output HTML structure, not your mental model of what you expect it to be
- When facing test failures with element selectors, add debug statements to print the actual HTML, but use `LazyHTML` selectors to limit the output, ie:
html = render(view)
document = LazyHTML.from_fragment(html)
matches = LazyHTML.filter(document, "your-complex-selector")
IO.inspect(matches, label: "Matches")
### Form handling
#### Creating a form from params
If you want to create a form based on `handle_event` params:
def handle_event("submitted", params, socket) do
{:noreply, assign(socket, form: to_form(params))}
end
When you pass a map to `to_form/1`, it assumes said map contains the form params, which are expected to have string keys.
You can also specify a name to nest the params:
def handle_event("submitted", %{"user" => user_params}, socket) do
{:noreply, assign(socket, form: to_form(user_params, as: :user))}
end
#### Creating a form from changesets
When using changesets, the underlying data, form params, and errors are retrieved from it. The `:as` option is automatically computed too. E.g. if you have a user schema:
defmodule MyApp.Users.User do
use Ecto.Schema
...
end
And then you create a changeset that you pass to `to_form`:
%MyApp.Users.User{}
|> Ecto.Changeset.change()
|> to_form()
Once the form is submitted, the params will be available under `%{"user" => user_params}`.
In the template, the form form assign can be passed to the `<.form>` function component:
<.form for={@form} id="todo-form" phx-change="validate" phx-submit="save">
<.input field={@form[:field]} type="text" />
</.form>
Always give the form an explicit, unique DOM ID, like `id="todo-form"`.
#### Avoiding form errors
**Always** use a form assigned via `to_form/2` in the LiveView, and the `<.input>` component in the template. In the template **always access forms this**:
<%!-- ALWAYS do this (valid) --%>
<.form for={@form} id="my-form">
<.input field={@form[:field]} type="text" />
</.form>
And **never** do this:
<%!-- NEVER do this (invalid) --%>
<.form for={@changeset} id="my-form">
<.input field={@changeset[:field]} type="text" />
</.form>
- You are FORBIDDEN from accessing the changeset in the template as it will cause errors
- **Never** use `<.form let={f} ...>` in the template, instead **always use `<.form for={@form} ...>`**, then drive all form references from the form assign as in `@form[:field]`. The UI should **always** be driven by a `to_form/2` assigned in the LiveView module that is derived from a changeset

View File

@@ -0,0 +1,15 @@
## Phoenix guidelines
- Remember Phoenix router `scope` blocks include an optional alias which is prefixed for all routes within the scope. **Always** be mindful of this when creating routes within a scope to avoid duplicate module prefixes.
- You **never** need to create your own `alias` for route definitions! The `scope` provides the alias, ie:
scope "/admin", AppWeb.Admin do
pipe_through :browser
live "/users", UserLive, :index
end
the UserLive route would point to the `AppWeb.Admin.UserLive` module
- `Phoenix.View` no longer is needed or included with Phoenix, don't use it

0
.codex Normal file
View File

40
.env.example Normal file
View 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
View File

@@ -1,3 +1,6 @@
# .env
.env
# The directory Mix will write compiled artifacts to. # The directory Mix will write compiled artifacts to.
/_build/ /_build/
@@ -35,3 +38,6 @@ mixer-*.tar
npm-debug.log npm-debug.log
/assets/node_modules/ /assets/node_modules/
# Ralph code claude files
/.ralph/
.ralphrc

438
AGENTS.md
View File

@@ -45,329 +45,137 @@ custom classes must fully style the input
<!-- usage-rules-start --> <!-- usage-rules-start -->
<!-- usage_rules-start -->
## usage_rules usage
_A config-driven dev tool for Elixir projects to manage AGENTS.md files and agent skills from dependencies_
<!-- phoenix:elixir-start --> ## Using Usage Rules
## Elixir guidelines
- Elixir lists **do not support index based access via the access syntax** Many packages have usage rules, which you should *thoroughly* consult before taking any
action. These usage rules contain guidelines and rules *directly from the package authors*.
They are your best source of knowledge for making decisions.
**Never do this (invalid)**: ## Modules & functions in the current app and dependencies
i = 0 When looking for docs for modules & functions that are dependencies of the current project,
mylist = ["blue", "green"] or for Elixir itself, use `mix usage_rules.docs`
mylist[i]
Instead, **always** use `Enum.at`, pattern matching, or `List` for index based list access, ie: ```
# Search a whole module
mix usage_rules.docs Enum
i = 0 # Search a specific function
mylist = ["blue", "green"] mix usage_rules.docs Enum.zip
Enum.at(mylist, i)
- Elixir variables are immutable, but can be rebound, so for block expressions like `if`, `case`, `cond`, etc # Search a specific function & arity
you *must* bind the result of the expression to a variable if you want to use it and you CANNOT rebind the result inside the expression, ie: mix usage_rules.docs Enum.zip/1
```
# INVALID: we are rebinding inside the `if` and the result never gets assigned
if connected?(socket) do
socket = assign(socket, :val, val)
end
# VALID: we rebind the result of the `if` to a new variable ## Searching Documentation
socket =
if connected?(socket) do
assign(socket, :val, val)
end
- **Never** nest multiple modules in the same file as it can cause cyclic dependencies and compilation errors You should also consult the documentation of any tools you are using, early and often. The best
- **Never** use map access syntax (`changeset[:field]`) on structs as they do not implement the Access behaviour by default. For regular structs, you **must** access the fields directly, such as `my_struct.field` or use higher level APIs that are available on the struct if they exist, `Ecto.Changeset.get_field/2` for changesets way to accomplish this is to use the `usage_rules.search_docs` mix task. Once you have
- Elixir's standard library has everything necessary for date and time manipulation. Familiarize yourself with the common `Time`, `Date`, `DateTime`, and `Calendar` interfaces by accessing their documentation as necessary. **Never** install additional dependencies unless asked or for date/time parsing (which you can use the `date_time_parser` package) found what you are looking for, use the links in the search results to get more detail. For example:
```
# Search docs for all packages in the current application, including Elixir
mix usage_rules.search_docs Enum.zip
# Search docs for specific packages
mix usage_rules.search_docs Req.get -p req
# Search docs for multi-word queries
mix usage_rules.search_docs "making requests" -p req
# Search only in titles (useful for finding specific functions/modules)
mix usage_rules.search_docs "Enum.zip" --query-by title
```
<!-- usage_rules-end -->
<!-- usage_rules:elixir-start -->
## usage_rules:elixir usage
# Elixir Core Usage Rules
## Pattern Matching
- Use pattern matching over conditional logic when possible
- Prefer to match on function heads instead of using `if`/`else` or `case` in function bodies
- `%{}` matches ANY map, not just empty maps. Use `map_size(map) == 0` guard to check for truly empty maps
## Error Handling
- Use `{:ok, result}` and `{:error, reason}` tuples for operations that can fail
- Avoid raising exceptions for control flow
- Use `with` for chaining operations that return `{:ok, _}` or `{:error, _}`
## Common Mistakes to Avoid
- Elixir has no `return` statement, nor early returns. The last expression in a block is always returned.
- Don't use `Enum` functions on large collections when `Stream` is more appropriate
- Avoid nested `case` statements - refactor to a single `case`, `with` or separate functions
- Don't use `String.to_atom/1` on user input (memory leak risk) - Don't use `String.to_atom/1` on user input (memory leak risk)
- Predicate function names should not start with `is_` and should end in a question mark. Names like `is_thing` should be reserved for guards - Lists and enumerables cannot be indexed with brackets. Use pattern matching or `Enum` functions
- Elixir's builtin OTP primitives like `DynamicSupervisor` and `Registry`, require names in the child spec, such as `{DynamicSupervisor, name: MyApp.MyDynamicSup}`, then you can use `DynamicSupervisor.start_child(MyApp.MyDynamicSup, child_spec)` - Prefer `Enum` functions like `Enum.reduce` over recursion
- Use `Task.async_stream(collection, callback, options)` for concurrent enumeration with back-pressure. The majority of times you will want to pass `timeout: :infinity` as option - When recursion is necessary, prefer to use pattern matching in function heads for base case detection
- Using the process dictionary is typically a sign of unidiomatic code
## Mix guidelines - Only use macros if explicitly requested
- There are many useful standard library functions, prefer to use them where possible
- Read the docs and options before using tasks (by using `mix help task_name`)
- To debug test failures, run tests in a specific file with `mix test test/my_test.exs` or run all previously failed tests with `mix test --failed` ## Function Design
- `mix deps.clean --all` is **almost never needed**. **Avoid** using it unless you have good reason - Use guard clauses: `when is_binary(name) and byte_size(name) > 0`
- Prefer multiple function clauses over complex conditional logic
## Test guidelines - Name functions descriptively: `calculate_total_price/2` not `calc/2`
- Predicate function names should not start with `is` and should end in a question mark.
- **Always use `start_supervised!/1`** to start processes in tests as it guarantees cleanup between tests - Names like `is_thing` should be reserved for guards
- **Avoid** `Process.sleep/1` and `Process.alive?/1` in tests
- Instead of sleeping to wait for a process to finish, **always** use `Process.monitor/1` and assert on the DOWN message: ## Data Structures
- Use structs over maps when the shape is known: `defstruct [:name, :age]`
ref = Process.monitor(pid) - Prefer keyword lists for options: `[timeout: 5000, retries: 3]`
assert_receive {:DOWN, ^ref, :process, ^pid, :normal} - Use maps for dynamic key-value data
- Prefer to prepend to lists `[new | list]` not `list ++ [new]`
- Instead of sleeping to synchronize before the next call, **always** use `_ = :sys.get_state/1` to ensure the process has handled prior messages
<!-- phoenix:elixir-end --> ## Mix Tasks
<!-- phoenix:phoenix-start --> - Use `mix help` to list available mix tasks
## Phoenix guidelines - Use `mix help task_name` to get docs for an individual task
- Read the docs and options fully before using tasks
- Remember Phoenix router `scope` blocks include an optional alias which is prefixed for all routes within the scope. **Always** be mindful of this when creating routes within a scope to avoid duplicate module prefixes.
## Testing
- You **never** need to create your own `alias` for route definitions! The `scope` provides the alias, ie: - Run tests in a specific file with `mix test test/my_test.exs` and a specific test with the line number `mix test path/to/test.exs:123`
- Limit the number of failed tests with `mix test --max-failures n`
scope "/admin", AppWeb.Admin do - Use `@tag` to tag specific tests, and `mix test --only tag` to run only those tests
pipe_through :browser - Use `assert_raise` for testing expected exceptions: `assert_raise ArgumentError, fn -> invalid_function() end`
- Use `mix help test` to for full documentation on running tests
live "/users", UserLive, :index
end ## Debugging
the UserLive route would point to the `AppWeb.Admin.UserLive` module - Use `dbg/1` to print values while debugging. This will display the formatted value and other relevant information in the console.
- `Phoenix.View` no longer is needed or included with Phoenix, don't use it <!-- usage_rules:elixir-end -->
<!-- phoenix:phoenix-end --> <!-- usage_rules:otp-start -->
## usage_rules:otp usage
# OTP Usage Rules
<!-- phoenix:html-start -->
## Phoenix HTML guidelines ## GenServer Best Practices
- Keep state simple and serializable
- Phoenix templates **always** use `~H` or .html.heex files (known as HEEx), **never** use `~E` - Handle all expected messages explicitly
- **Always** use the imported `Phoenix.Component.form/1` and `Phoenix.Component.inputs_for/1` function to build forms. **Never** use `Phoenix.HTML.form_for` or `Phoenix.HTML.inputs_for` as they are outdated - Use `handle_continue/2` for post-init work
- When building forms **always** use the already imported `Phoenix.Component.to_form/2` (`assign(socket, form: to_form(...))` and `<.form for={@form} id="msg-form">`), then access those forms in the template via `@form[:field]` - Implement proper cleanup in `terminate/2` when necessary
- **Always** add unique DOM IDs to key elements (like forms, buttons, etc) when writing templates, these IDs can later be used in tests (`<.form for={@form} id="product-form">`)
- For "app wide" template imports, you can import/alias into the `my_app_web.ex`'s `html_helpers` block, so they will be available to all LiveViews, LiveComponent's, and all modules that do `use MyAppWeb, :html` (replace "my_app" by the actual app name) ## Process Communication
- Use `GenServer.call/3` for synchronous requests expecting replies
- Elixir supports `if/else` but **does NOT support `if/else if` or `if/elsif`**. **Never use `else if` or `elseif` in Elixir**, **always** use `cond` or `case` for multiple conditionals. - Use `GenServer.cast/2` for fire-and-forget messages.
- When in doubt, use `call` over `cast`, to ensure back-pressure
**Never do this (invalid)**: - Set appropriate timeouts for `call/3` operations
<%= if condition do %> ## Fault Tolerance
... - Set up processes such that they can handle crashing and being restarted by supervisors
<% else if other_condition %> - Use `:max_restarts` and `:max_seconds` to prevent restart loops
...
<% end %> ## Task and Async
- Use `Task.Supervisor` for better fault tolerance
Instead **always** do this: - Handle task failures with `Task.yield/2` or `Task.shutdown/2`
- Set appropriate task timeouts
<%= cond do %> - Use `Task.async_stream/3` for concurrent enumeration with back-pressure
<% condition -> %>
... <!-- usage_rules:otp-end -->
<% condition2 -> %> <!-- usage-rules-end -->
...
<% true -> %>
...
<% end %>
- HEEx require special tag annotation if you want to insert literal curly's like `{` or `}`. If you want to show a textual code snippet on the page in a `<pre>` or `<code>` block you *must* annotate the parent tag with `phx-no-curly-interpolation`:
<code phx-no-curly-interpolation>
let obj = {key: "val"}
</code>
Within `phx-no-curly-interpolation` annotated tags, you can use `{` and `}` without escaping them, and dynamic Elixir expressions can still be used with `<%= ... %>` syntax
- HEEx class attrs support lists, but you must **always** use list `[...]` syntax. You can use the class list syntax to conditionally add classes, **always do this for multiple class values**:
<a class={[
"px-2 text-white",
@some_flag && "py-5",
if(@other_condition, do: "border-red-500", else: "border-blue-100"),
...
]}>Text</a>
and **always** wrap `if`'s inside `{...}` expressions with parens, like done above (`if(@other_condition, do: "...", else: "...")`)
and **never** do this, since it's invalid (note the missing `[` and `]`):
<a class={
"px-2 text-white",
@some_flag && "py-5"
}> ...
=> Raises compile syntax error on invalid HEEx attr syntax
- **Never** use `<% Enum.each %>` or non-for comprehensions for generating template content, instead **always** use `<%= for item <- @collection do %>`
- HEEx HTML comments use `<%!-- comment --%>`. **Always** use the HEEx HTML comment syntax for template comments (`<%!-- comment --%>`)
- HEEx allows interpolation via `{...}` and `<%= ... %>`, but the `<%= %>` **only** works within tag bodies. **Always** use the `{...}` syntax for interpolation within tag attributes, and for interpolation of values within tag bodies. **Always** interpolate block constructs (if, cond, case, for) within tag bodies using `<%= ... %>`.
**Always** do this:
<div id={@id}>
{@my_assign}
<%= if @some_block_condition do %>
{@another_assign}
<% end %>
</div>
and **Never** do this the program will terminate with a syntax error:
<%!-- THIS IS INVALID NEVER EVER DO THIS --%>
<div id="<%= @invalid_interpolation %>">
{if @invalid_block_construct do}
{end}
</div>
<!-- phoenix:html-end -->
<!-- phoenix:liveview-start -->
## Phoenix LiveView guidelines
- **Never** use the deprecated `live_redirect` and `live_patch` functions, instead **always** use the `<.link navigate={href}>` and `<.link patch={href}>` in templates, and `push_navigate` and `push_patch` functions LiveViews
- **Avoid LiveComponent's** unless you have a strong, specific need for them
- LiveViews should be named like `AppWeb.WeatherLive`, with a `Live` suffix. When you go to add LiveView routes to the router, the default `:browser` scope is **already aliased** with the `AppWeb` module, so you can just do `live "/weather", WeatherLive`
### LiveView streams
- **Always** use LiveView streams for collections for assigning regular lists to avoid memory ballooning and runtime termination with the following operations:
- basic append of N items - `stream(socket, :messages, [new_msg])`
- resetting stream with new items - `stream(socket, :messages, [new_msg], reset: true)` (e.g. for filtering items)
- prepend to stream - `stream(socket, :messages, [new_msg], at: -1)`
- deleting items - `stream_delete(socket, :messages, msg)`
- When using the `stream/3` interfaces in the LiveView, the LiveView template must 1) always set `phx-update="stream"` on the parent element, with a DOM id on the parent element like `id="messages"` and 2) consume the `@streams.stream_name` collection and use the id as the DOM id for each child. For a call like `stream(socket, :messages, [new_msg])` in the LiveView, the template would be:
<div id="messages" phx-update="stream">
<div :for={{id, msg} <- @streams.messages} id={id}>
{msg.text}
</div>
</div>
- LiveView streams are *not* enumerable, so you cannot use `Enum.filter/2` or `Enum.reject/2` on them. Instead, if you want to filter, prune, or refresh a list of items on the UI, you **must refetch the data and re-stream the entire stream collection, passing reset: true**:
def handle_event("filter", %{"filter" => filter}, socket) do
# re-fetch the messages based on the filter
messages = list_messages(filter)
{:noreply,
socket
|> assign(:messages_empty?, messages == [])
# reset the stream with the new messages
|> stream(:messages, messages, reset: true)}
end
- LiveView streams *do not support counting or empty states*. If you need to display a count, you must track it using a separate assign. For empty states, you can use Tailwind classes:
<div id="tasks" phx-update="stream">
<div class="hidden only:block">No tasks yet</div>
<div :for={{id, task} <- @streams.tasks} id={id}>
{task.name}
</div>
</div>
The above only works if the empty state is the only HTML block alongside the stream for-comprehension.
- When updating an assign that should change content inside any streamed item(s), you MUST re-stream the items
along with the updated assign:
def handle_event("edit_message", %{"message_id" => message_id}, socket) do
message = Chat.get_message!(message_id)
edit_form = to_form(Chat.change_message(message, %{content: message.content}))
# re-insert message so @editing_message_id toggle logic takes effect for that stream item
{:noreply,
socket
|> stream_insert(:messages, message)
|> assign(:editing_message_id, String.to_integer(message_id))
|> assign(:edit_form, edit_form)}
end
And in the template:
<div id="messages" phx-update="stream">
<div :for={{id, message} <- @streams.messages} id={id} class="flex group">
{message.username}
<%= if @editing_message_id == message.id do %>
<%!-- Edit mode --%>
<.form for={@edit_form} id="edit-form-#{message.id}" phx-submit="save_edit">
...
</.form>
<% end %>
</div>
</div>
- **Never** use the deprecated `phx-update="append"` or `phx-update="prepend"` for collections
### LiveView JavaScript interop
- Remember anytime you use `phx-hook="MyHook"` and that JS hook manages its own DOM, you **must** also set the `phx-update="ignore"` attribute
- **Always** provide an unique DOM id alongside `phx-hook` otherwise a compiler error will be raised
LiveView hooks come in two flavors, 1) colocated js hooks for "inline" scripts defined inside HEEx,
and 2) external `phx-hook` annotations where JavaScript object literals are defined and passed to the `LiveSocket` constructor.
#### Inline colocated js hooks
**Never** write raw embedded `<script>` tags in heex as they are incompatible with LiveView.
Instead, **always use a colocated js hook script tag (`:type={Phoenix.LiveView.ColocatedHook}`)
when writing scripts inside the template**:
<input type="text" name="user[phone_number]" id="user-phone-number" phx-hook=".PhoneNumber" />
<script :type={Phoenix.LiveView.ColocatedHook} name=".PhoneNumber">
export default {
mounted() {
this.el.addEventListener("input", e => {
let match = this.el.value.replace(/\D/g, "").match(/^(\d{3})(\d{3})(\d{4})$/)
if(match) {
this.el.value = `${match[1]}-${match[2]}-${match[3]}`
}
})
}
}
</script>
- colocated hooks are automatically integrated into the app.js bundle
- colocated hooks names **MUST ALWAYS** start with a `.` prefix, i.e. `.PhoneNumber`
#### External phx-hook
External JS hooks (`<div id="myhook" phx-hook="MyHook">`) must be placed in `assets/js/` and passed to the
LiveSocket constructor:
const MyHook = {
mounted() { ... }
}
let liveSocket = new LiveSocket("/live", Socket, {
hooks: { MyHook }
});
#### Pushing events between client and server
Use LiveView's `push_event/3` when you need to push events/data to the client for a phx-hook to handle.
**Always** return or rebind the socket on `push_event/3` when pushing events:
# re-bind socket so we maintain event state to be pushed
socket = push_event(socket, "my_event", %{...})
# or return the modified socket directly:
def handle_event("some_event", _, socket) do
{:noreply, push_event(socket, "my_event", %{...})}
end
Pushed events can then be picked up in a JS hook with `this.handleEvent`:
mounted() {
this.handleEvent("my_event", data => console.log("from server:", data));
}
Clients can also push an event to the server and receive a reply with `this.pushEvent`:
mounted() {
this.el.addEventListener("click", e => {
this.pushEvent("my_event", { one: 1 }, reply => console.log("got reply from server:", reply));
})
}
Where the server handled it via:
def handle_event("my_event", %{"one" => 1}, socket) do
{:reply, %{two: 2}, socket}
end
### LiveView tests
- `Phoenix.LiveViewTest` module and `LazyHTML` (included) for making your assertions
- Form tests are driven by `Phoenix.LiveViewTest`'s `render_submit/2` and `render_change/2` functions
- Come up with a step-by-step test plan that splits major test cases into small, isolated files. You may start with simpler tests that verify content exists, gradually add interaction tests
- **Always reference the key element IDs you added in the LiveView templates in your tests** for `Phoenix.LiveViewTest` functions like `element/2`, `has_element/2`, selectors, etc
- **Never** tests again raw HTML, **always** use `element/2`, `has_element/2`, and similar: `assert has_element?(view, "#my-form")`
- Instead of relying on testing text content, which can change, favor testing for the presence of key elements
- Focus on testing outcomes rather than implementation details
- Be aware that `Phoenix.Component` functions like `<.form>` might produce different HTML than expected. Test against the output HTML structure, not your mental model of what you expect it to be
- When facing test failures with element selectors, add debug statements to print the actual HTML, but use `LazyHTML` selectors to limit the output, ie:
html = render(view)
document = LazyHTML.from_fragment(html)
matches = LazyHTML.filter(document, "your-complex-selector")
IO.inspect(matches, label: "Matches")

View File

@@ -1,18 +1,92 @@
# Mixer # 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 ## Stack
* Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server`
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/ - Elixir 1.15+ and Erlang/OTP (via [asdf](https://asdf-vm.com) or system package manager)
* Guides: https://hexdocs.pm/phoenix/overview.html - PostgreSQL running locally (default: `postgres`/`postgres` on `localhost:5432`)
* Docs: https://hexdocs.pm/phoenix - ClickHouse running locally (default: `default`/no password on `localhost:8123`, database `mixer_metrics`)
* Forum: https://elixirforum.com/c/phoenix-forum - MinIO running locally on `localhost:9000` with credentials `minioadmin`/`minioadmin`
* Source: https://github.com/phoenixframework/phoenix
### 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 |

View File

@@ -152,15 +152,6 @@ html, body {
margin: 0 auto; 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 ── */ /* ── Sidebar ── */
.mx-sidebar { .mx-sidebar {
position: sticky; position: sticky;
@@ -312,6 +303,12 @@ html, body {
user-select: none; user-select: none;
} }
.mx-tweet-avatar--lg {
width: 56px;
height: 56px;
font-size: 1.25rem;
}
.mx-compose-body { flex: 1; } .mx-compose-body { flex: 1; }
.mx-compose-textarea, .mx-edit-textarea { .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-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-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 { .mx-tweet-text {
font-size: 1rem; font-size: 1rem;
line-height: 1.6; line-height: 1.6;
@@ -474,6 +495,46 @@ html, body {
word-break: break-word; word-break: break-word;
} }
.mx-tweet-footer {
display: flex;
align-items: center;
margin-top: 0.875rem;
}
.mx-like-btn {
display: inline-flex;
align-items: center;
gap: 0.45rem;
border: 1px solid var(--mx-border2);
border-radius: 999px;
background: color-mix(in oklch, var(--mx-surface2) 72%, transparent);
color: var(--mx-fg2);
cursor: pointer;
font-family: 'DM Mono', monospace;
font-size: 0.75rem;
line-height: 1;
padding: 0.45rem 0.75rem;
transition: transform 0.15s, border-color 0.15s, color 0.15s, background 0.15s;
}
.mx-like-btn:hover:not(:disabled) {
color: var(--mx-red);
border-color: color-mix(in oklch, var(--mx-red) 35%, transparent);
background: color-mix(in oklch, var(--mx-red) 10%, transparent);
transform: translateY(-1px);
}
.mx-like-btn:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.mx-like-btn-active {
color: var(--mx-red);
border-color: color-mix(in oklch, var(--mx-red) 35%, transparent);
background: color-mix(in oklch, var(--mx-red) 12%, transparent);
}
/* ── Edit ── */ /* ── Edit ── */
.mx-edit-area { margin-top: 0.25rem; } .mx-edit-area { margin-top: 0.25rem; }
@@ -581,3 +642,585 @@ html, body {
border: 1px solid var(--mx-border2); border: 1px solid var(--mx-border2);
color: var(--mx-accent2); color: var(--mx-accent2);
} }
/* ── Tweet Detail Page ── */
.mx-detail {
padding: 1rem 1.5rem;
}
.mx-detail-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
}
.mx-back-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: var(--mx-fg2);
font-size: 0.85rem;
text-decoration: none;
padding: 0.4rem 0.75rem;
border-radius: var(--mx-radius-sm);
border: 1px solid var(--mx-border);
transition: background 0.15s;
}
.mx-back-btn:hover { background: var(--mx-surface2); }
.mx-detail-author {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
.mx-detail-body {
padding: 0.5rem 0;
}
.mx-detail-content {
font-size: 1.1rem;
line-height: 1.6;
color: var(--mx-fg);
margin-bottom: 1rem;
white-space: pre-wrap;
word-break: break-word;
}
.mx-detail-media {
display: flex;
flex-direction: column;
gap: 0.75rem;
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;
border: none;
padding: 0;
cursor: pointer;
border-radius: var(--mx-radius-sm);
overflow: hidden;
display: block;
width: 100%;
}
.mx-media-thumb img,
.mx-media-thumb video {
width: 100%;
border-radius: var(--mx-radius-sm);
display: block;
}
.mx-media-thumb:hover { opacity: 0.85; }
/* ── Media Lightbox ── */
.mx-lightbox {
position: fixed;
inset: 0;
z-index: 200;
background: rgba(0, 0, 0, 0.92);
display: flex;
align-items: center;
justify-content: center;
}
.mx-lightbox-close {
position: absolute;
top: 1rem;
right: 1rem;
background: rgba(255, 255, 255, 0.1);
border: none;
color: #fff;
cursor: pointer;
font-size: 1.1rem;
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.mx-lightbox-close:hover { background: rgba(255, 255, 255, 0.2); }
.mx-lightbox-content {
max-width: 90vw;
max-height: 90vh;
}
.mx-lightbox-media {
max-width: 90vw;
max-height: 90vh;
border-radius: var(--mx-radius-sm);
display: block;
}
/* ── Context Menu ── */
.mx-context-menu {
position: fixed;
z-index: 300;
min-width: 160px;
background: var(--mx-surface2);
border: 1px solid var(--mx-border2);
border-radius: var(--mx-radius-sm);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35), 0 2px 6px rgba(0, 0, 0, 0.2);
overflow: hidden;
padding: 4px 0;
animation: mx-ctx-in 0.1s ease;
}
@keyframes mx-ctx-in {
from { opacity: 0; transform: scale(0.96) translateY(-4px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.mx-context-menu-item {
display: flex;
align-items: center;
width: 100%;
padding: 0.45rem 0.875rem;
background: none;
border: none;
color: var(--mx-fg);
font-size: 0.875rem;
font-family: inherit;
cursor: pointer;
text-align: left;
transition: background 0.1s;
}
.mx-context-menu-item:hover {
background: color-mix(in oklch, var(--mx-accent) 14%, transparent);
}
.mx-context-menu-separator {
height: 1px;
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
View 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>
);
}

View File

@@ -2,7 +2,7 @@
// Do not edit this file manually // Do not edit this file manually
import type { AshRpcError, ConditionalPaginatedResultMixed, InferResult, SortString, UUID, UnifiedFieldSelection, ValidationResult, mediaFilterInput, mediaResourceSchema, mediaSortField, tweetsFilterInput, tweetsResourceSchema, tweetsSortField } 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"; export type * from "./ash_types";
// Helper Functions // Helper Functions
@@ -201,6 +201,423 @@ 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>[];
export type InferReadUserResult<
Fields extends ReadUserFields | undefined,
Page extends ReadUserConfig["page"] = undefined
> = ConditionalPaginatedResultMixed<Page, Array<InferResult<usersResourceSchema, Fields>>, {
results: Array<InferResult<usersResourceSchema, Fields>>;
hasMore: boolean;
limit: number;
offset: number;
count?: number | null;
type: "offset";
}, {
results: Array<InferResult<usersResourceSchema, Fields>>;
hasMore: boolean;
limit: number;
after: string | null;
before: string | null;
previousPage: string;
nextPage: string;
count?: number | null;
type: "keyset";
}>;
export type ReadUserConfig = {
tenant?: string;
fields: ReadUserFields;
filter?: usersFilterInput;
sort?: SortString<usersSortField> | SortString<usersSortField>[];
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 ReadUserResult<Fields extends ReadUserFields, Page extends ReadUserConfig["page"] = undefined> = | { success: true; data: InferReadUserResult<Fields, Page>; }
| { success: false; errors: AshRpcError[]; }
;
/**
* Read User records
*
* @ashActionType :read
*/
export async function readUser<Fields extends ReadUserFields, Config extends ReadUserConfig = ReadUserConfig>(
config: Config & { fields: Fields }
): Promise<ReadUserResult<Fields, Config["page"]>> {
const payload = {
action: "read_user",
...(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<ReadUserResult<Fields, Config["page"]>>(
payload,
config
);
}
/**
* Validate: Read User records
*
* @ashActionType :read
* @validation true
*/
export async function validateReadUser(
config: {
tenant?: string;
headers?: Record<string, string>;
fetchOptions?: RequestInit;
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}
): Promise<ValidationResult> {
const payload = {
action: "read_user",
...(config.tenant !== undefined && { tenant: config.tenant })
};
return executeValidationRpcRequest<ValidationResult>(
payload,
config
);
}
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>[]; export type ReadMediaFields = UnifiedFieldSelection<mediaResourceSchema>[];
@@ -304,6 +721,7 @@ export async function validateReadMedia(
export type CreateTweetInput = { export type CreateTweetInput = {
content: string; content: string;
parentTweetId?: UUID | null;
mediaId?: UUID; mediaId?: UUID;
}; };
@@ -435,6 +853,141 @@ export async function validateDestroyTweet(
} }
export type LikeTweetFields = UnifiedFieldSelection<tweetsResourceSchema>[];
export type InferLikeTweetResult<
Fields extends LikeTweetFields | undefined,
> = InferResult<tweetsResourceSchema, Fields>;
export type LikeTweetResult<Fields extends LikeTweetFields | undefined = undefined> = | { success: true; data: InferLikeTweetResult<Fields>; }
| { success: false; errors: AshRpcError[]; }
;
/**
* Update an existing Tweet
*
* @ashActionType :update
*/
export async function likeTweet<Fields extends LikeTweetFields | undefined = undefined>(
config: {
tenant?: string;
identity: UUID;
fields?: Fields;
headers?: Record<string, string>;
fetchOptions?: RequestInit;
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}
): Promise<LikeTweetResult<Fields extends undefined ? [] : Fields>> {
const payload = {
action: "like_tweet",
...(config.tenant !== undefined && { tenant: config.tenant }),
identity: config.identity,
...(config.fields !== undefined && { fields: config.fields })
};
return executeActionRpcRequest<LikeTweetResult<Fields extends undefined ? [] : Fields>>(
payload,
config
);
}
/**
* Validate: Update an existing Tweet
*
* @ashActionType :update
* @validation true
*/
export async function validateLikeTweet(
config: {
tenant?: string;
identity: UUID | string;
headers?: Record<string, string>;
fetchOptions?: RequestInit;
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}
): Promise<ValidationResult> {
const payload = {
action: "like_tweet",
...(config.tenant !== undefined && { tenant: config.tenant }),
identity: config.identity
};
return executeValidationRpcRequest<ValidationResult>(
payload,
config
);
}
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>[]; export type ReadTweetFields = UnifiedFieldSelection<tweetsResourceSchema>[];
@@ -536,11 +1089,76 @@ export async function validateReadTweet(
} }
export type UnlikeTweetFields = UnifiedFieldSelection<tweetsResourceSchema>[];
export type InferUnlikeTweetResult<
Fields extends UnlikeTweetFields | undefined,
> = InferResult<tweetsResourceSchema, Fields>;
export type UnlikeTweetResult<Fields extends UnlikeTweetFields | undefined = undefined> = | { success: true; data: InferUnlikeTweetResult<Fields>; }
| { success: false; errors: AshRpcError[]; }
;
/**
* Update an existing Tweet
*
* @ashActionType :update
*/
export async function unlikeTweet<Fields extends UnlikeTweetFields | undefined = undefined>(
config: {
tenant?: string;
identity: UUID;
fields?: Fields;
headers?: Record<string, string>;
fetchOptions?: RequestInit;
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}
): Promise<UnlikeTweetResult<Fields extends undefined ? [] : Fields>> {
const payload = {
action: "unlike_tweet",
...(config.tenant !== undefined && { tenant: config.tenant }),
identity: config.identity,
...(config.fields !== undefined && { fields: config.fields })
};
return executeActionRpcRequest<UnlikeTweetResult<Fields extends undefined ? [] : Fields>>(
payload,
config
);
}
/**
* Validate: Update an existing Tweet
*
* @ashActionType :update
* @validation true
*/
export async function validateUnlikeTweet(
config: {
tenant?: string;
identity: UUID | string;
headers?: Record<string, string>;
fetchOptions?: RequestInit;
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}
): Promise<ValidationResult> {
const payload = {
action: "unlike_tweet",
...(config.tenant !== undefined && { tenant: config.tenant }),
identity: config.identity
};
return executeValidationRpcRequest<ValidationResult>(
payload,
config
);
}
export type UpdateTweetInput = { export type UpdateTweetInput = {
content?: string; content?: string;
likes?: number;
userId?: UUID;
state?: "posted" | "drafted";
}; };
export type UpdateTweetFields = UnifiedFieldSelection<tweetsResourceSchema>[]; export type UpdateTweetFields = UnifiedFieldSelection<tweetsResourceSchema>[];

View File

@@ -4,6 +4,51 @@
export type UUID = string; 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" | "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" | "username" | "displayName" | "avatarUrl";
id: UUID;
email: string;
username: string | null;
displayName: string | null;
avatarUrl: string | null;
};
// media Schema // media Schema
export type mediaResourceSchema = { export type mediaResourceSchema = {
@@ -13,6 +58,7 @@ export type mediaResourceSchema = {
s3Key: string; s3Key: string;
userId: UUID; userId: UUID;
tweetId: UUID | null; tweetId: UUID | null;
user: { __type: "Relationship"; __resource: usersResourceSchema; };
tweet: { __type: "Relationship"; __resource: tweetsResourceSchema | null; }; tweet: { __type: "Relationship"; __resource: tweetsResourceSchema | null; };
}; };
@@ -31,12 +77,23 @@ export type mediaAttributesOnlySchema = {
// tweets Schema // tweets Schema
export type tweetsResourceSchema = { export type tweetsResourceSchema = {
__type: "Resource"; __type: "Resource";
__primitiveFields: "id" | "content" | "likes" | "userId" | "state"; __primitiveFields: "id" | "content" | "likes" | "userId" | "insertedAt" | "state" | "parentTweetId" | "commentCount" | "likedByMe" | "userEmail" | "userUsername" | "userDisplayName" | "userAvatarUrl";
id: UUID; id: UUID;
content: string; content: string;
likes: number; likes: number;
userId: UUID; userId: UUID;
insertedAt: UtcDateTimeUsec;
state: "posted" | "drafted"; 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; }; media: { __type: "Relationship"; __array: true; __resource: mediaResourceSchema; };
}; };
@@ -44,15 +101,106 @@ export type tweetsResourceSchema = {
export type tweetsAttributesOnlySchema = { export type tweetsAttributesOnlySchema = {
__type: "Resource"; __type: "Resource";
__primitiveFields: "id" | "content" | "likes" | "userId" | "state"; __primitiveFields: "id" | "content" | "likes" | "userId" | "insertedAt" | "state" | "parentTweetId";
id: UUID; id: UUID;
content: string; content: string;
likes: number; likes: number;
userId: UUID; userId: UUID;
insertedAt: UtcDateTimeUsec;
state: "posted" | "drafted"; 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>;
not?: Array<usersFilterInput>;
id?: {
eq?: UUID;
notEq?: UUID;
in?: Array<UUID>;
};
email?: {
eq?: string;
notEq?: string;
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;
};
};
export type mediaFilterInput = { export type mediaFilterInput = {
and?: Array<mediaFilterInput>; and?: Array<mediaFilterInput>;
or?: Array<mediaFilterInput>; or?: Array<mediaFilterInput>;
@@ -84,6 +232,8 @@ export type mediaFilterInput = {
}; };
user?: usersFilterInput;
tweet?: tweetsFilterInput; tweet?: tweetsFilterInput;
}; };
@@ -120,29 +270,108 @@ export type tweetsFilterInput = {
in?: Array<UUID>; in?: Array<UUID>;
}; };
insertedAt?: {
eq?: UtcDateTimeUsec;
notEq?: UtcDateTimeUsec;
greaterThan?: UtcDateTimeUsec;
greaterThanOrEqual?: UtcDateTimeUsec;
lessThan?: UtcDateTimeUsec;
lessThanOrEqual?: UtcDateTimeUsec;
in?: Array<UtcDateTimeUsec>;
};
state?: { state?: {
eq?: "posted" | "drafted"; eq?: "posted" | "drafted";
notEq?: "posted" | "drafted"; notEq?: "posted" | "drafted";
in?: Array<"posted" | "drafted">; in?: Array<"posted" | "drafted">;
}; };
parentTweetId?: {
eq?: UUID;
notEq?: UUID;
in?: Array<UUID>;
isNil?: boolean;
};
userEmail?: {
eq?: string;
notEq?: string;
in?: Array<string>;
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;
isNil?: boolean;
};
user?: usersFilterInput;
parentTweet?: tweetsFilterInput;
comments?: tweetsFilterInput;
media?: mediaFilterInput; media?: mediaFilterInput;
}; };
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 const mediaFilterFields = ["id", "s3Key", "userId", "tweetId", "user", "tweet"] as const;
export type mediaFilterField = (typeof mediaFilterFields)[number]; export type mediaFilterField = (typeof mediaFilterFields)[number];
export const tweetsFilterFields = ["id", "content", "likes", "userId", "state", "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 type tweetsFilterField = (typeof tweetsFilterFields)[number];
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 const mediaSortFields = ["id", "s3Key", "userId", "tweetId"] as const;
export type mediaSortField = (typeof mediaSortFields)[number]; export type mediaSortField = (typeof mediaSortFields)[number];
export const tweetsSortFields = ["id", "content", "likes", "userId", "state"] 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]; export type tweetsSortField = (typeof tweetsSortFields)[number];

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

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

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

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

View 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">330 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} />;
}

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

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

View 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
View 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
View 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
View 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,
};
}

View File

@@ -1,591 +1,6 @@
import React, { createContext, useContext, useState, useRef, useEffect } from "react"; import React from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { import { App } from "./App";
QueryClient,
QueryClientProvider,
useQuery,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import {
createTweet,
readTweet,
destroyTweet,
updateTweet,
buildCSRFHeaders,
} from "./ash_rpc";
import { uploadFile } from "./upload";
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 10_000 } },
});
// ── Types ──────────────────────────────────────────────────────────────────────
type MediaItem = { id: string; s3Key: string };
type Tweet = { id: string; content: string; userId: string; state: string; media?: MediaItem[] };
// ── Auth context ───────────────────────────────────────────────────────────────
const AuthCtx = createContext({ email: "", userId: "" });
// ── Helpers ────────────────────────────────────────────────────────────────────
function timeAgo(): string {
return "just now";
}
function getAssetHost(): string {
const appEl = document.getElementById("app");
return appEl?.dataset.assetHost ?? "http://localhost:9000";
}
// ── Components ─────────────────────────────────────────────────────────────────
function Spinner() {
return (
<div style={{ display: "flex", justifyContent: "center", padding: "2rem" }}>
<div className="mx-spinner" />
</div>
);
}
function ErrorBanner({ message }: { message: string }) {
return (
<div className="mx-error-banner">
<span className="mx-error-icon"></span>
{message}
</div>
);
}
function CharCount({ current, max }: { current: number; max: number }) {
const remaining = max - current;
const pct = current / max;
const color =
pct > 0.9 ? "#ef4444" : pct > 0.75 ? "#f59e0b" : "var(--mx-muted)";
return (
<span style={{ color, fontSize: "0.75rem", fontVariantNumeric: "tabular-nums" }}>
{remaining}
</span>
);
}
function ComposeTweet({ onSuccess }: { onSuccess?: () => void }) {
const [text, setText] = useState("");
const [error, setError] = useState<string | null>(null);
const [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 MAX = 280;
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"] });
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;
// Reset the input so the same file can be re-selected after removal
e.target.value = "";
// Revoke any previous object URL to avoid memory leaks
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);
}
// Auto-resize textarea
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}, [text]);
return (
<div className="mx-compose">
<div className="mx-compose-avatar">
<span>M</span>
</div>
<div className="mx-compose-body">
<textarea
ref={textareaRef}
className="mx-compose-textarea"
placeholder="What's mixing?"
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
rows={2}
maxLength={MAX + 1}
/>
{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>
);
}
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>
);
}
function TweetCard({ tweet }: { tweet: Tweet }) {
const { userId: currentUserId } = useContext(AuthCtx);
const canModify = !!currentUserId && tweet.userId === currentUserId;
const [editing, setEditing] = useState(false);
const [editText, setEditText] = useState(tweet.content);
const [error, setError] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const qc = useQueryClient();
const deleteMutation = useMutation({
mutationFn: async () => {
const res = await destroyTweet({
identity: tweet.id,
headers: buildCSRFHeaders(),
});
if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed to delete");
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["tweets"] }),
onError: (e: Error) => setError(e.message),
});
const updateMutation = useMutation({
mutationFn: async (content: string) => {
const res = await updateTweet({
identity: tweet.id,
input: { content },
fields: ["id", "content", "userId", "state"],
headers: buildCSRFHeaders(),
});
if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed to update");
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["tweets"] });
setEditing(false);
setError(null);
},
onError: (e: Error) => setError(e.message),
});
function saveEdit() {
const trimmed = editText.trim();
if (!trimmed) return;
updateMutation.mutate(trimmed);
}
return (
<article className="mx-tweet">
<div className="mx-tweet-avatar">
<span>M</span>
</div>
<div className="mx-tweet-body">
<div className="mx-tweet-header">
<span className="mx-tweet-handle">@mixer</span>
<span className="mx-tweet-dot">·</span>
<span className="mx-tweet-time">{timeAgo()}</span>
{canModify && (
<div className="mx-tweet-actions">
<button
className="mx-action-btn"
title="Edit"
onClick={() => {
setEditText(tweet.content);
setEditing(true);
setConfirmDelete(false);
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zm17.71-10.04a1 1 0 0 0 0-1.41l-2.31-2.31a1 1 0 0 0-1.41 0l-1.79 1.79 3.75 3.75 1.76-1.82z" />
</svg>
</button>
<button
className={`mx-action-btn mx-action-delete${confirmDelete ? " mx-action-confirm" : ""}`}
title={confirmDelete ? "Confirm delete" : "Delete"}
onClick={() => {
if (!confirmDelete) {
setConfirmDelete(true);
setTimeout(() => setConfirmDelete(false), 3000);
} else {
deleteMutation.mutate();
}
}}
>
{deleteMutation.isPending ? (
<span style={{ fontSize: "0.65rem" }}></span>
) : confirmDelete ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" />
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
</svg>
)}
</button>
</div>
)}
</div>
{editing ? (
<div className="mx-edit-area">
<textarea
className="mx-edit-textarea"
value={editText}
onChange={(e) => setEditText(e.target.value)}
autoFocus
rows={3}
/>
{error && <p className="mx-compose-error">{error}</p>}
<div className="mx-edit-footer">
<button
className="mx-btn-cancel"
onClick={() => {
setEditing(false);
setError(null);
}}
>
Cancel
</button>
<button
className="mx-btn-save"
onClick={saveEdit}
disabled={!editText.trim() || updateMutation.isPending}
>
{updateMutation.isPending ? "Saving…" : "Save"}
</button>
</div>
</div>
) : (
<p className="mx-tweet-text">{tweet.content}</p>
)}
{tweet.media && tweet.media.length > 0 && (
<TweetMedia media={tweet.media} />
)}
{error && !editing && <p className="mx-compose-error">{error}</p>}
</div>
</article>
);
}
function Feed() {
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: ["tweets"],
queryFn: async () => {
const res = await readTweet({
fields: ["id", "content", "userId", "state", { media: ["id", "s3Key"] }],
sort: "-id",
headers: buildCSRFHeaders(),
});
if (!res.success) throw new Error("Failed to load tweets");
const tweets = Array.isArray(res.data) ? res.data : (res.data as any)?.results ?? [];
return tweets as Tweet[];
},
});
if (isLoading) return <Spinner />;
if (isError) {
return (
<ErrorBanner message={(error as Error)?.message ?? "Could not load tweets"} />
);
}
const tweets = data ?? [];
if (tweets.length === 0) {
return (
<div className="mx-empty">
<div className="mx-empty-icon"></div>
<p className="mx-empty-title">Nothing posted yet</p>
<p className="mx-empty-sub">Be the first to mix something in.</p>
</div>
);
}
return (
<div className="mx-feed">
{tweets.map((t) => (
<TweetCard key={t.id} tweet={t} />
))}
</div>
);
}
function RefreshButton() {
const qc = useQueryClient();
const [spinning, setSpinning] = useState(false);
async function refresh() {
setSpinning(true);
await qc.invalidateQueries({ queryKey: ["tweets"] });
setTimeout(() => setSpinning(false), 600);
}
return (
<button className="mx-refresh-btn" onClick={refresh} title="Refresh feed">
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="currentColor"
style={{
transition: "transform 0.6s ease",
transform: spinning ? "rotate(360deg)" : "rotate(0deg)",
}}
>
<path d="M17.65 6.35A7.958 7.958 0 0 0 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z" />
</svg>
</button>
);
}
function App() {
const appEl = document.getElementById("app")!;
const email = appEl.dataset.currentUserEmail ?? "";
const userId = appEl.dataset.currentUserId ?? "";
return (
<AuthCtx.Provider value={{ email, userId }}>
<QueryClientProvider client={queryClient}>
<div className="mx-root">
<aside className="mx-sidebar">
<div className="mx-logo">
<span className="mx-logo-icon"></span>
<span className="mx-logo-text">Mixer</span>
</div>
<nav className="mx-nav">
<a className="mx-nav-item mx-nav-active" href="#">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" />
</svg>
Feed
</a>
</nav>
<div className="mx-sidebar-footer">
{email ? (
<>
<span className="mx-version" style={{ color: "var(--mx-fg2)" }}>{email}</span>
<a className="mx-auth-link" href="/auth/sign-out">Sign out</a>
</>
) : (
<>
<a className="mx-auth-link" href="/register">Create account</a>
<a className="mx-auth-link" href="/auth/sign-in">Sign in</a>
</>
)}
<span className="mx-version">v0.1.0</span>
</div>
</aside>
<main className="mx-main">
<header className="mx-header">
<h1 className="mx-header-title">Feed</h1>
<RefreshButton />
</header>
<div className="mx-compose-wrapper">
{email ? (
<ComposeTweet />
) : (
<div className="mx-signin-cta">
<p>Sign in to start mixing.</p>
<a className="mx-btn-post" href="/register">Sign in</a>
</div>
)}
</div>
<div className="mx-divider" />
<Feed />
</main>
<div className="mx-rightbar">
<div className="mx-info-card">
<h3 className="mx-info-title">About Mixer</h3>
<p className="mx-info-body">
A minimal social feed built with Ash Framework, Phoenix, and React.
</p>
<div className="mx-stack">
{["Ash 3", "Phoenix 1.8", "AshTypescript", "React 19"].map((s) => (
<span key={s} className="mx-tag">{s}</span>
))}
</div>
</div>
</div>
</div>
</QueryClientProvider>
</AuthCtx.Provider>
);
}
// ── Bootstrap ──────────────────────────────────────────────────────────────────
const root = createRoot(document.getElementById("app")!); const root = createRoot(document.getElementById("app")!);
root.render( root.render(

34
assets/js/types.ts Normal file
View 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" };

View File

@@ -9,6 +9,29 @@ export interface UploadError {
error: string; 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( export async function uploadFile(
file: File, file: File,
csrfToken: string csrfToken: string

32
assets/js/utils.ts Normal file
View 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";
}

View File

@@ -28,7 +28,9 @@
"*": ["../deps/*"] "*": ["../deps/*"]
}, },
"allowJs": true, "allowJs": true,
"noEmit": true "noEmit": true,
"target": "es5",
"lib": ["ES2015", "DOM"]
}, },
"include": ["js/**/*"] "include": ["js/**/*"]
} }

View File

@@ -8,9 +8,7 @@
import Config import Config
config :waffle, config :waffle,
storage: Waffle.Storage.S3, storage: Waffle.Storage.S3
bucket: "mixer-bucket",
asset_host: "http://localhost:9000"
config :ex_aws, config :ex_aws,
json_codec: Jason json_codec: Jason
@@ -96,7 +94,7 @@ config :spark,
] ]
config :mixer, config :mixer,
ecto_repos: [Mixer.Repo], ecto_repos: [Mixer.Repo, Mixer.ClickhouseRepo],
generators: [timestamp_type: :utc_datetime], generators: [timestamp_type: :utc_datetime],
ash_domains: [Mixer.Accounts, Mixer.Posts], ash_domains: [Mixer.Accounts, Mixer.Posts],
ash_authentication: [return_error_on_invalid_magic_link_token?: true] ash_authentication: [return_error_on_invalid_magic_link_token?: true]
@@ -128,7 +126,17 @@ config :esbuild,
args: args:
~w(js/index.tsx js/app.js --bundle --target=es2022 --outdir=../priv/static/assets --external:/fonts/* --external:/images/* --alias:@=. --splitting --format=esm), ~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__), 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) # Configure tailwind (the version is required)
@@ -150,6 +158,11 @@ config :logger, :default_formatter,
# Use Jason for JSON parsing in Phoenix # Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason 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 # Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above. # of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs" import_config "#{config_env()}.exs"

View File

@@ -102,3 +102,16 @@ config :ex_aws, :s3,
host: "localhost", host: "localhost",
port: 9000, port: 9000,
virtual_host: false virtual_host: false
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: ""

View File

@@ -11,6 +11,7 @@ config :mixer, MixerWeb.Endpoint, cache_static_manifest: "priv/static/cache_mani
# known as HSTS. If you have a health check endpoint, you may want to exclude it below. # known as HSTS. If you have a health check endpoint, you may want to exclude it below.
# Note `:force_ssl` is required to be set at compile-time. # Note `:force_ssl` is required to be set at compile-time.
config :mixer, MixerWeb.Endpoint, config :mixer, MixerWeb.Endpoint,
server: true,
force_ssl: [ force_ssl: [
rewrite_on: [:x_forwarded_proto], rewrite_on: [:x_forwarded_proto],
exclude: [ exclude: [
@@ -20,7 +21,7 @@ config :mixer, MixerWeb.Endpoint,
] ]
# Configure Swoosh API Client # Configure Swoosh API Client
config :swoosh, api_client: Swoosh.ApiClient.Req config :swoosh, api_client: Swoosh.ApiClient.Hackney
# Disable Swoosh Local Memory Storage # Disable Swoosh Local Memory Storage
config :swoosh, local: false config :swoosh, local: false

View File

@@ -22,6 +22,11 @@ end
config :mixer, MixerWeb.Endpoint, http: [port: String.to_integer(System.get_env("PORT", "4000"))] 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 if config_env() == :prod do
database_url = database_url =
System.get_env("DATABASE_URL") || System.get_env("DATABASE_URL") ||
@@ -40,6 +45,19 @@ if config_env() == :prod do
# pool_count: 4, # pool_count: 4,
socket_options: maybe_ipv6 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. # 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 # 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 # 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 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") config :mixer, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
@@ -72,6 +90,37 @@ if config_env() == :prod do
System.get_env("TOKEN_SIGNING_SECRET") || System.get_env("TOKEN_SIGNING_SECRET") ||
raise("Missing environment variable `TOKEN_SIGNING_SECRET`!") raise("Missing environment variable `TOKEN_SIGNING_SECRET`!")
# Configure S3-compatible storage (MinIO/Garege/AWS S3)
config :ex_aws,
access_key_id:
System.get_env("S3_ACCESS_KEY_ID") ||
raise("Missing environment variable `S3_ACCESS_KEY_ID`!"),
secret_access_key:
System.get_env("S3_SECRET_ACCESS_KEY") ||
raise("Missing environment variable `S3_SECRET_ACCESS_KEY`!")
config :ex_aws, :s3,
scheme: System.get_env("S3_SCHEME", "https://"),
host:
System.get_env("S3_HOST") ||
raise("Missing environment variable `S3_HOST`!"),
port: String.to_integer(System.get_env("S3_PORT", "80")),
virtual_host: System.get_env("S3_VIRTUAL_HOST", "false") == "true"
config :waffle,
bucket:
System.get_env("S3_BUCKET") ||
raise("Missing environment variable `S3_BUCKET`!"),
asset_host:
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 # ## SSL Support
# #
# To get SSL working, you will need to add the `https` key # To get SSL working, you will need to add the `https` key

View File

@@ -42,3 +42,12 @@ config :phoenix_live_view,
# Sort query params output of verified routes for robust url comparisons # Sort query params output of verified routes for robust url comparisons
config :phoenix, config :phoenix,
sort_verified_routes_query_params: true 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
View 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)

View File

@@ -1,5 +1,18 @@
defmodule Mixer.Accounts do defmodule Mixer.Accounts do
use Ash.Domain, otp_app: :mixer, extensions: [AshAdmin.Domain] 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 admin do
show? true show? true
@@ -9,5 +22,7 @@ defmodule Mixer.Accounts do
resource Mixer.Accounts.Token resource Mixer.Accounts.Token
resource Mixer.Accounts.User resource Mixer.Accounts.User
resource Mixer.Accounts.ApiKey resource Mixer.Accounts.ApiKey
resource Mixer.Accounts.Follow
end end
end end

View 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

View 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

View File

@@ -1,10 +1,12 @@
defmodule Mixer.Accounts.User do defmodule Mixer.Accounts.User do
import Ash.Expr
use Ash.Resource, use Ash.Resource,
otp_app: :mixer, otp_app: :mixer,
domain: Mixer.Accounts, domain: Mixer.Accounts,
data_layer: AshPostgres.DataLayer, data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer], authorizers: [Ash.Policy.Authorizer],
extensions: [AshAuthentication] extensions: [AshAuthentication, AshTypescript.Resource]
authentication do authentication do
add_ons do add_ons do
@@ -35,6 +37,7 @@ defmodule Mixer.Accounts.User do
password :password do password :password do
identity_field :email identity_field :email
hash_provider AshAuthentication.BcryptProvider hash_provider AshAuthentication.BcryptProvider
require_confirmed_with :confirmed_at
resettable do resettable do
sender Mixer.Accounts.User.Senders.SendPasswordResetEmail sender Mixer.Accounts.User.Senders.SendPasswordResetEmail
@@ -66,6 +69,10 @@ defmodule Mixer.Accounts.User do
repo Mixer.Repo repo Mixer.Repo
end end
typescript do
type_name "users"
end
actions do actions do
defaults [:read] defaults [:read]
@@ -170,9 +177,21 @@ defmodule Mixer.Accounts.User do
sensitive? true sensitive? true
end 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 # Sets the email from the argument
change set_attribute(:email, arg(:email)) change set_attribute(:email, arg(:email))
# Sets the username from the argument
change set_attribute(:username, arg(:username))
# Hashes the provided password # Hashes the provided password
change AshAuthentication.Strategy.Password.HashPasswordChange change AshAuthentication.Strategy.Password.HashPasswordChange
@@ -204,6 +223,18 @@ defmodule Mixer.Accounts.User do
get_by :email get_by :email
end 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 update :reset_password_with_token do
argument :reset_token, :string do argument :reset_token, :string do
allow_nil? false allow_nil? false
@@ -249,6 +280,15 @@ defmodule Mixer.Accounts.User do
allow_nil? true allow_nil? true
end 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? true
upsert_identity :unique_email upsert_identity :unique_email
upsert_fields [:email] upsert_fields [:email]
@@ -259,6 +299,37 @@ defmodule Mixer.Accounts.User do
change {AshAuthentication.Strategy.RememberMe.MaybeGenerateTokenChange, change {AshAuthentication.Strategy.RememberMe.MaybeGenerateTokenChange,
strategy_name: :remember_me} 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 metadata :token, :string do
allow_nil? false allow_nil? false
end end
@@ -282,6 +353,18 @@ defmodule Mixer.Accounts.User do
bypass AshAuthentication.Checks.AshAuthenticationInteraction do bypass AshAuthentication.Checks.AshAuthenticationInteraction do
authorize_if always() authorize_if always()
end end
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 end
attributes do attributes do
@@ -297,6 +380,23 @@ defmodule Mixer.Accounts.User do
end end
attribute :confirmed_at, :utc_datetime_usec 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 end
relationships do relationships do
@@ -304,10 +404,45 @@ defmodule Mixer.Accounts.User do
filter expr(valid) filter expr(valid)
end end
has_many :tweet_likes, Mixer.Posts.TweetLike
has_many :tweets, Mixer.Posts.Tweet 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 end
identities do identities do
identity :unique_email, [:email] identity :unique_email, [:email]
identity :unique_username, [:username] do
eager_check_with Mixer.Accounts
message "is already taken"
nils_distinct? true
end
end end
end end

View File

@@ -21,8 +21,7 @@ defmodule Mixer.Accounts.User.Senders.SendMagicLinkEmail do
end end
new() new()
# TODO: Replace with your email |> from({"noreply", "noreply@jimweaver.com"})
|> from({"noreply", "noreply@example.com"})
|> to(to_string(email)) |> to(to_string(email))
|> subject("Your login link") |> subject("Your login link")
|> html_body(body(token: token, email: email)) |> html_body(body(token: token, email: email))
@@ -31,10 +30,86 @@ defmodule Mixer.Accounts.User.Senders.SendMagicLinkEmail do
defp body(params) do defp body(params) do
# NOTE: You may have to change this to match your magic link acceptance URL. # 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> <!DOCTYPE html>
<p><a href="#{url(~p"/magic_link/#{params[:token]}")}">#{url(~p"/magic_link/#{params[:token]}")}</a></p> <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;">&nbsp;</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
end end

View File

@@ -13,8 +13,7 @@ defmodule Mixer.Accounts.User.Senders.SendNewUserConfirmationEmail do
@impl true @impl true
def send(user, token, _) do def send(user, token, _) do
new() new()
# TODO: Replace with your email |> from({"noreply", "noreply@jimweaver.com"})
|> from({"noreply", "noreply@example.com"})
|> to(to_string(user.email)) |> to(to_string(user.email))
|> subject("Confirm your email address") |> subject("Confirm your email address")
|> html_body(body(token: token)) |> html_body(body(token: token))
@@ -22,11 +21,86 @@ defmodule Mixer.Accounts.User.Senders.SendNewUserConfirmationEmail do
end end
defp body(params) do 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> <!DOCTYPE html>
<p><a href="#{url}">#{url}</a></p> <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;">&nbsp;</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
end end

View File

@@ -13,8 +13,7 @@ defmodule Mixer.Accounts.User.Senders.SendPasswordResetEmail do
@impl true @impl true
def send(user, token, _) do def send(user, token, _) do
new() new()
# TODO: Replace with your email |> from({"noreply", "noreply@jimweaver.com"})
|> from({"noreply", "noreply@example.com"})
|> to(to_string(user.email)) |> to(to_string(user.email))
|> subject("Reset your password") |> subject("Reset your password")
|> html_body(body(token: token)) |> html_body(body(token: token))
@@ -22,11 +21,86 @@ defmodule Mixer.Accounts.User.Senders.SendPasswordResetEmail do
end end
defp body(params) do 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> <!DOCTYPE html>
<p><a href="#{url}">#{url}</a></p> <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;">&nbsp;</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
end end

View File

@@ -10,6 +10,10 @@ defmodule Mixer.Application do
children = [ children = [
MixerWeb.Telemetry, MixerWeb.Telemetry,
Mixer.Repo, 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}, {DNSCluster, query: Application.get_env(:mixer, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: Mixer.PubSub}, {Phoenix.PubSub, name: Mixer.PubSub},
# Start a worker by calling: Mixer.Worker.start_link(arg) # Start a worker by calling: Mixer.Worker.start_link(arg)

View 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
View 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
View 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

View 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

View File

@@ -3,19 +3,13 @@ defmodule Mixer.Posts do
otp_app: :mixer, otp_app: :mixer,
extensions: [AshTypescript.Rpc, AshAdmin.Domain] extensions: [AshTypescript.Rpc, AshAdmin.Domain]
admin do
show? true
end
resources do
resource Mixer.Posts.Tweet
resource Mixer.Posts.Media
end
typescript_rpc do typescript_rpc do
resource Mixer.Posts.Tweet do resource Mixer.Posts.Tweet do
rpc_action :create_tweet, :create rpc_action :create_tweet, :create
rpc_action :like_tweet, :like
rpc_action :read_tweet, :read rpc_action :read_tweet, :read
rpc_action :read_following_feed, :following_feed
rpc_action :unlike_tweet, :unlike
rpc_action :update_tweet, :update rpc_action :update_tweet, :update
rpc_action :destroy_tweet, :destroy rpc_action :destroy_tweet, :destroy
end end
@@ -24,4 +18,14 @@ defmodule Mixer.Posts do
rpc_action :read_media, :read rpc_action :read_media, :read
end end
end end
admin do
show? true
end
resources do
resource Mixer.Posts.Tweet
resource Mixer.Posts.TweetLike
resource Mixer.Posts.Media
end
end end

View File

@@ -11,6 +11,10 @@ defmodule Mixer.Posts.Media do
postgres do postgres do
table "media" table "media"
repo Mixer.Repo repo Mixer.Repo
references do
reference :tweet, on_delete: :delete
end
end end
typescript do typescript do
@@ -34,6 +38,24 @@ defmodule Mixer.Posts.Media do
end end
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 attributes do
uuid_primary_key :id uuid_primary_key :id
@@ -60,22 +82,4 @@ defmodule Mixer.Posts.Media do
public? true public? true
end end
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 end

View File

@@ -10,7 +10,8 @@ defmodule Mixer.Posts.MediaUploader do
if ext in @extensions, do: :ok, else: {:error, "unsupported file type #{ext}"} if ext in @extensions, do: :ok, else: {:error, "unsupported file type #{ext}"}
end end
def storage_dir(_version, {_file, scope}), do: "uploads/media/#{scope.id}" def storage_dir(_version, {_file, scope}),
do: "uploads/media/#{scope.user_id}/#{scope.media_id}"
def filename(_version, {file, _scope}) do def filename(_version, {file, _scope}) do
Path.basename(file.file_name, Path.extname(file.file_name)) Path.basename(file.file_name, Path.extname(file.file_name))

View File

@@ -1,4 +1,7 @@
defmodule Mixer.Posts.Tweet do defmodule Mixer.Posts.Tweet do
import Ash.Expr
require Ash.Query
use Ash.Resource, use Ash.Resource,
otp_app: :mixer, otp_app: :mixer,
domain: Mixer.Posts, domain: Mixer.Posts,
@@ -9,10 +12,10 @@ defmodule Mixer.Posts.Tweet do
postgres do postgres do
table "tweets" table "tweets"
repo Mixer.Repo repo Mixer.Repo
end
typescript do references do
type_name "tweets" reference :parent_tweet, on_delete: :delete
end
end end
state_machine do state_machine do
@@ -24,15 +27,27 @@ defmodule Mixer.Posts.Tweet do
end end
end end
typescript do
type_name "tweets"
end
actions do actions do
defaults [:read, :destroy, update: :*] defaults [:read]
read :following_feed do
filter expr(
user_id == ^actor(:id) or
exists(user.followers, follower_id == ^actor(:id))
)
end
create :create do create :create do
upsert? true upsert? true
accept [:content] accept [:content, :parent_tweet_id]
argument :media_id, :uuid, allow_nil?: true argument :media_id, :uuid, allow_nil?: true
change relate_actor(:user) change relate_actor(:user)
change transition_state(:posted) change transition_state(:posted)
change fn changeset, context -> change fn changeset, context ->
case Ash.Changeset.get_argument(changeset, :media_id) do case Ash.Changeset.get_argument(changeset, :media_id) do
nil -> nil ->
@@ -42,13 +57,143 @@ defmodule Mixer.Posts.Tweet do
Ash.Changeset.after_action(changeset, fn _changeset, tweet -> Ash.Changeset.after_action(changeset, fn _changeset, tweet ->
Mixer.Posts.Media Mixer.Posts.Media
|> Ash.get!(media_id, authorize?: false) |> 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!() |> Ash.update!()
{:ok, tweet} {:ok, tweet}
end) end)
end 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
accept [:content]
end
update :like do
accept []
require_atomic? false
change fn changeset, context ->
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} ->
Ash.get(__MODULE__, tweet.id, authorize?: false)
{:error, error} ->
{:error, error}
end
end)
end
end
update :unlike do
accept []
require_atomic? false
change fn changeset, context ->
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} ->
Ash.get(__MODULE__, tweet.id, authorize?: false)
{:error, error} ->
{:error, error}
end
end)
end
end
update :increment_likes do
accept []
require_atomic? false
change atomic_update(:likes, expr(likes + 1))
end
update :decrement_likes do
accept []
require_atomic? false
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
end end
@@ -70,6 +215,12 @@ defmodule Mixer.Posts.Tweet do
allow_nil? false allow_nil? false
public? true public? true
end end
create_timestamp :inserted_at do
public? true
end
update_timestamp :updated_at
end end
relationships do relationships do
@@ -80,22 +231,124 @@ defmodule Mixer.Posts.Tweet do
public? true public? true
end 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 has_many :media, Mixer.Posts.Media do
public? true public? true
end end
has_many :tweet_likes, Mixer.Posts.TweetLike
end
calculations 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 end
policies do aggregates do
policy action_type(:read) do count :comment_count, :comments do
authorize_if always() public? true
end end
policy action_type(:create) do exists :liked_by_me, :tweet_likes do
authorize_if actor_present() public? true
end filter expr(user_id == ^actor(:id))
policy action_type([:destroy, :update]) do
authorize_if relates_to_actor_via(:user)
end end
end end
defp ensure_like(_tweet, nil), do: {:error, Ash.Error.Forbidden.exception([])}
defp ensure_like(tweet, actor) do
case get_like(tweet.id, actor.id) do
{:ok, nil} ->
case create_like(tweet.id, actor) do
{:ok, like} ->
{:created, like}
{:error, error} ->
case get_like(tweet.id, actor.id) do
{:ok, nil} ->
{:error, error}
{:ok, like} ->
{:noop, like}
{:error, error} ->
{:error, error}
end
end
{:ok, like} ->
{:noop, like}
{:error, error} ->
{:error, error}
end
end
defp remove_like(_tweet, nil), do: {:error, Ash.Error.Forbidden.exception([])}
defp remove_like(tweet, actor) do
case get_like(tweet.id, actor.id) do
{:ok, nil} ->
{:noop, nil}
{:ok, like} ->
case Ash.destroy(like, actor: actor) do
:ok -> {:deleted, like}
{:ok, _destroyed_like} -> {:deleted, like}
{:error, error} -> {:error, error}
end
{:error, error} ->
{:error, error}
end
end
defp create_like(tweet_id, actor) do
Mixer.Posts.TweetLike
|> Ash.Changeset.for_create(:create, %{tweet_id: tweet_id}, actor: actor)
|> Ash.create()
end
defp get_like(tweet_id, user_id) do
Mixer.Posts.TweetLike
|> Ash.Query.filter(expr(tweet_id == ^tweet_id and user_id == ^user_id))
|> Ash.read_one(authorize?: false)
end
defp increment_likes(tweet, actor) do
tweet
|> Ash.Changeset.for_update(:increment_likes, %{}, actor: actor)
|> Ash.update(authorize?: false)
end
defp decrement_likes(tweet, actor) do
tweet
|> Ash.Changeset.for_update(:decrement_likes, %{}, actor: actor)
|> Ash.update(authorize?: false)
end
end end

View File

@@ -0,0 +1,69 @@
defmodule Mixer.Posts.TweetLike do
use Ash.Resource,
otp_app: :mixer,
domain: Mixer.Posts,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
postgres do
table "tweet_likes"
repo Mixer.Repo
references do
reference :tweet, on_delete: :delete
end
end
actions do
defaults [:read, :destroy]
create :create do
accept [:tweet_id]
change relate_actor(:user)
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
attribute :tweet_id, :uuid do
allow_nil? false
end
attribute :user_id, :uuid do
allow_nil? false
end
end
relationships do
belongs_to :tweet, Mixer.Posts.Tweet do
attribute_type :uuid
attribute_writable? true
allow_nil? false
end
belongs_to :user, Mixer.Accounts.User do
attribute_type :uuid
attribute_writable? true
allow_nil? false
end
end
identities do
identity :unique_user_tweet, [:tweet_id, :user_id]
end
end

View File

@@ -9,12 +9,15 @@ defmodule MixerWeb.AuthOverrides do
# For a complete reference, see https://hexdocs.pm/ash_authentication_phoenix/ui-overrides.html # For a complete reference, see https://hexdocs.pm/ash_authentication_phoenix/ui-overrides.html
# override AshAuthentication.Phoenix.Components.Banner do override AshAuthentication.Phoenix.Components.Banner do
# set :image_url, "https://media.giphy.com/media/g7GKcSzwQfugw/giphy.gif" set :image_url, nil
# set :text_class, "bg-red-500" set :dark_image_url, nil
# end set :text, "⬡ Mixer"
set :text_class, "text-3xl font-bold tracking-tight"
end
# override AshAuthentication.Phoenix.Components.SignIn do # Inject the username field into the password registration form
# set :show_banner, false override AshAuthentication.Phoenix.Components.Password do
# end set :register_extra_component, &MixerWeb.AuthComponents.username_field/1
end
end end

View 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

View File

@@ -4,8 +4,23 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} /> <meta name="csrf-token" content={get_csrf_token()} />
<.live_title default="Mixer" suffix=" · Phoenix Framework"> <link rel="icon" href={~p"/favicon.ico"} sizes="any" />
{assigns[:page_title]} <% 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> </.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} /> <link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
<script defer phx-track-static type="module" src={~p"/assets/app.js"}> <script defer phx-track-static type="module" src={~p"/assets/app.js"}>

View File

@@ -8,7 +8,37 @@ SPDX-License-Identifier: MIT
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} /> <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"} /> <link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
</head> </head>
<body> <body>

View File

@@ -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. 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 end
conn conn

View File

@@ -2,18 +2,53 @@ defmodule MixerWeb.PageController do
use MixerWeb, :controller use MixerWeb, :controller
def home(conn, _params) do 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 end
def index(conn, _params) do def index(conn, _params) do
render_spa(conn, %{page: "feed", tweet_id: nil, user_id: nil})
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
def user_show(conn, %{"user_id" => user_id}) do
render_spa(conn, %{page: "user-detail", tweet_id: nil, user_id: user_id})
end
defp render_spa(conn, %{page: page, tweet_id: tweet_id, user_id: user_id}) do
asset_host = Application.get_env(:waffle, :asset_host, "http://localhost:3900") asset_host = Application.get_env(:waffle, :asset_host, "http://localhost:3900")
bucket = Application.get_env(:waffle, :bucket, "mixer-bucket") bucket = Application.get_env(:waffle, :bucket, "mixer-bucket")
conn conn
|> put_root_layout(html: {MixerWeb.Layouts, :spa_root}) |> put_root_layout(html: {MixerWeb.Layouts, :spa_root})
|> render(:index, |> render(:index,
current_user: conn.assigns[:current_user], current_user: conn.assigns[:current_user],
media_host: "#{asset_host}/#{bucket}" media_host: "#{asset_host}/#{bucket}",
) page: page,
tweet_id: tweet_id,
user_id: user_id
)
end end
end end

View File

@@ -5,7 +5,7 @@
<p class="text-base-content/60 text-lg mb-10">A social feed built with Ash &amp; Phoenix.</p> <p class="text-base-content/60 text-lg mb-10">A social feed built with Ash &amp; Phoenix.</p>
<div class="flex flex-col sm:flex-row gap-3 justify-center"> <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="/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> </div>
<p class="mt-8 text-sm text-base-content/40"> <p class="mt-8 text-sm text-base-content/40">
Already have an account? Already have an account?

View File

@@ -1,5 +1,15 @@
<div id="app" <div
data-current-user-id={if @current_user, do: @current_user.id, else: ""} id="app"
data-current-user-email={if @current_user, do: @current_user.email, else: ""} data-current-user-id={if @current_user, do: @current_user.id, else: ""}
data-asset-host={@media_host}> 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> </div>

View File

@@ -2,6 +2,7 @@ defmodule MixerWeb.UploadController do
use MixerWeb, :controller use MixerWeb, :controller
alias Mixer.Posts.MediaUploader alias Mixer.Posts.MediaUploader
alias Mixer.Accounts.AvatarUploader
def create(conn, %{"file" => %Plug.Upload{} = upload}) do def create(conn, %{"file" => %Plug.Upload{} = upload}) do
actor = conn.assigns[:current_user] actor = conn.assigns[:current_user]
@@ -11,15 +12,17 @@ defmodule MixerWeb.UploadController do
|> put_status(:unauthorized) |> put_status(:unauthorized)
|> json(%{error: "authentication required"}) |> json(%{error: "authentication required"})
else else
scope = %{id: Ash.UUID.generate()} media_id = Ash.UUID.generate()
scope = %{user_id: actor.id, media_id: media_id}
case MediaUploader.store({upload, scope}) do case MediaUploader.store({upload, scope}) do
{:ok, file_name} -> {:ok, file_name} ->
s3_key = "uploads/media/#{scope.id}/#{file_name}" s3_key = "uploads/media/#{scope.user_id}/#{scope.media_id}/#{file_name}"
url = MediaUploader.url({file_name, scope}) url = MediaUploader.url({file_name, scope})
Mixer.Posts.Media Mixer.Posts.Media
|> Ash.Changeset.for_create(:upload, %{s3_key: s3_key}, actor: actor) |> Ash.Changeset.for_create(:upload, %{s3_key: s3_key}, actor: actor)
|> Ash.Changeset.force_change_attribute(:id, media_id)
|> Ash.create() |> Ash.create()
|> case do |> case do
{:ok, media} -> {:ok, media} ->
@@ -44,4 +47,50 @@ defmodule MixerWeb.UploadController do
|> put_status(:bad_request) |> put_status(:bad_request)
|> json(%{error: "no file provided"}) |> json(%{error: "no file provided"})
end 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 end

View 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

View File

@@ -39,9 +39,15 @@ defmodule MixerWeb.Router do
get "/", PageController, :home get "/", PageController, :home
get "/feed", PageController, :index 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/run", AshTypescriptRpcController, :run
post "/rpc/validate", AshTypescriptRpcController, :validate post "/rpc/validate", AshTypescriptRpcController, :validate
post "/upload", UploadController, :create post "/upload", UploadController, :create
post "/upload/avatar", UploadController, :upload_avatar
auth_routes AuthController, Mixer.Accounts.User, path: "/auth" auth_routes AuthController, Mixer.Accounts.User, path: "/auth"
sign_out_route AuthController sign_out_route AuthController
@@ -69,6 +75,7 @@ defmodule MixerWeb.Router do
# Remove this if you do not use the magic link strategy. # Remove this if you do not use the magic link strategy.
magic_sign_in_route(Mixer.Accounts.User, :magic_link, magic_sign_in_route(Mixer.Accounts.User, :magic_link,
live_view: MixerWeb.MagicSignInLive,
auth_routes_prefix: "/auth", auth_routes_prefix: "/auth",
overrides: [MixerWeb.AuthOverrides, Elixir.AshAuthentication.Phoenix.Overrides.DaisyUI] overrides: [MixerWeb.AuthOverrides, Elixir.AshAuthentication.Phoenix.Overrides.DaisyUI]
) )

29
mix.exs
View File

@@ -91,7 +91,8 @@ defmodule Mixer.MixProject do
{:ex_aws, "~> 2.1.2"}, {:ex_aws, "~> 2.1.2"},
{:ex_aws_s3, "~> 2.0"}, {:ex_aws_s3, "~> 2.0"},
{:hackney, "~> 1.9"}, {:hackney, "~> 1.9"},
{:sweet_xml, "~> 0.6"} {:sweet_xml, "~> 0.6"},
{:ecto_ch, "~> 0.3"}
] ]
end end
@@ -133,17 +134,39 @@ defmodule Mixer.MixProject do
build: [ build: [
"ash-framework": [ "ash-framework": [
# The description tells people how to use this skill. # 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 # Include all Ash dependencies
usage_rules: [:ash, ~r/^ash_/] usage_rules: [:ash, ~r/^ash_/]
], ],
"phoenix-framework": [ "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 # Include all Phoenix dependencies
usage_rules: [:phoenix, ~r/^phoenix_/] usage_rules: [:phoenix, ~r/^phoenix_/]
] ]
] ]
] ]
] ]
[
file: "AGENTS.md",
usage_rules: ["usage_rules:all"],
skills: [
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.",
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.",
usage_rules: [:phoenix, ~r/^phoenix_/]
]
]
]
]
end end
end end

View File

@@ -20,6 +20,7 @@
"castore": {:hex, :castore, "1.0.18", "5e43ef0ec7d31195dfa5a65a86e6131db999d074179d2ba5a8de11fe14570f55", [:mix], [], "hexpm", "f393e4fe6317829b158fb74d86eb681f737d2fe326aa61ccf6293c4104957e34"}, "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"}, "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"}, "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"}, "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"}, "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
"conv_case": {:hex, :conv_case, "0.2.3", "c1455c27d3c1ffcdd5f17f1e91f40b8a0bc0a337805a6e8302f441af17118ed8", [:mix], [], "hexpm", "88f29a3d97d1742f9865f7e394ed3da011abb7c5e8cc104e676fdef6270d4b4a"}, "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"}, "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
"dotenvy": {:hex, :dotenvy, "1.1.1", "00e318f3c51de9fafc4b48598447e386f19204dc18ca69886905bb8f8b08b667", [:mix], [], "hexpm", "c8269471b5701e9e56dc86509c1199ded2b33dce088c3471afcfef7839766d8e"}, "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": {: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"}, "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"}, "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"}, "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},

View File

@@ -0,0 +1,4 @@
[
import_deps: [:ecto_ch],
inputs: ["*.exs"]
]

View File

@@ -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
(510 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
View 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.

View File

@@ -18,7 +18,8 @@ defmodule Mixer.Repo.Migrations.SetupPostsAndTweets do
name: "tweets_user_id_fkey", name: "tweets_user_id_fkey",
type: :uuid, type: :uuid,
prefix: "public" prefix: "public"
), null: false ),
null: false
add :state, :text, null: false, default: "drafted" add :state, :text, null: false, default: "drafted"
end end

View File

@@ -22,7 +22,8 @@ defmodule Mixer.Repo.Migrations.AddPostsMediaS3 do
name: "media_tweet_id_fkey", name: "media_tweet_id_fkey",
type: :uuid, type: :uuid,
prefix: "public" prefix: "public"
), null: false ),
null: false
end end
end end

View File

@@ -17,7 +17,8 @@ defmodule Mixer.Repo.Migrations.AddUserIdToMediaAndAllowNullTweetId do
name: "media_user_id_fkey", name: "media_user_id_fkey",
type: :uuid, type: :uuid,
prefix: "public" prefix: "public"
), null: false ),
null: false
end end
end end

View File

@@ -0,0 +1,49 @@
defmodule Mixer.Repo.Migrations.AddTweetLikes 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(:tweet_likes, primary_key: false) do
add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true
add :tweet_id,
references(:tweets,
column: :id,
name: "tweet_likes_tweet_id_fkey",
type: :uuid,
prefix: "public"
),
null: false
add :user_id,
references(:users,
column: :id,
name: "tweet_likes_user_id_fkey",
type: :uuid,
prefix: "public"
),
null: false
end
create unique_index(:tweet_likes, [:tweet_id, :user_id],
name: "tweet_likes_unique_user_tweet_index"
)
end
def down do
drop_if_exists unique_index(:tweet_likes, [:tweet_id, :user_id],
name: "tweet_likes_unique_user_tweet_index"
)
drop constraint(:tweet_likes, "tweet_likes_tweet_id_fkey")
drop constraint(:tweet_likes, "tweet_likes_user_id_fkey")
drop table(:tweet_likes)
end
end

View File

@@ -0,0 +1,63 @@
defmodule Mixer.Repo.Migrations.CascadeDeleteTweetRelations 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
drop constraint(:tweet_likes, "tweet_likes_tweet_id_fkey")
alter table(:tweet_likes) do
modify :tweet_id,
references(:tweets,
column: :id,
name: "tweet_likes_tweet_id_fkey",
type: :uuid,
prefix: "public",
on_delete: :delete_all
)
end
drop constraint(:media, "media_tweet_id_fkey")
alter table(:media) do
modify :tweet_id,
references(:tweets,
column: :id,
name: "media_tweet_id_fkey",
type: :uuid,
prefix: "public",
on_delete: :delete_all
)
end
end
def down do
drop constraint(:media, "media_tweet_id_fkey")
alter table(:media) do
modify :tweet_id,
references(:tweets,
column: :id,
name: "media_tweet_id_fkey",
type: :uuid,
prefix: "public"
)
end
drop constraint(:tweet_likes, "tweet_likes_tweet_id_fkey")
alter table(:tweet_likes) do
modify :tweet_id,
references(:tweets,
column: :id,
name: "tweet_likes_tweet_id_fkey",
type: :uuid,
prefix: "public"
)
end
end
end

View File

@@ -0,0 +1,28 @@
defmodule Mixer.Repo.Migrations.AddTimestampsToTweets 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 :inserted_at, :utc_datetime_usec,
null: false,
default: fragment("(now() AT TIME ZONE 'utc')")
add :updated_at, :utc_datetime_usec,
null: false,
default: fragment("(now() AT TIME ZONE 'utc')")
end
end
def down do
alter table(:tweets) do
remove :updated_at
remove :inserted_at
end
end
end

Some files were not shown because too many files have changed in this diff Show More