Added users page and user viewing

This commit is contained in:
2026-04-02 03:28:09 -04:00
parent 0f41e86cf0
commit 580265bc51
8 changed files with 355 additions and 39 deletions

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, 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,107 @@ export async function executeValidationRpcRequest<T>(
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 ReadMediaFields = UnifiedFieldSelection<mediaResourceSchema>[]; export type ReadMediaFields = UnifiedFieldSelection<mediaResourceSchema>[];

View File

@@ -6,6 +6,24 @@
export type UUID = string; export type UUID = string;
export type UtcDateTimeUsec = string; export type UtcDateTimeUsec = string;
// users Schema
export type usersResourceSchema = {
__type: "Resource";
__primitiveFields: "id" | "email";
id: UUID;
email: string;
};
export type usersAttributesOnlySchema = {
__type: "Resource";
__primitiveFields: "id" | "email";
id: UUID;
email: string;
};
// media Schema // media Schema
export type mediaResourceSchema = { export type mediaResourceSchema = {
__type: "Resource"; __type: "Resource";
@@ -14,6 +32,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; };
}; };
@@ -41,6 +60,7 @@ export type tweetsResourceSchema = {
state: "posted" | "drafted"; state: "posted" | "drafted";
likedByMe: boolean; likedByMe: boolean;
userEmail: string | null; userEmail: string | null;
user: { __type: "Relationship"; __resource: usersResourceSchema; };
media: { __type: "Relationship"; __array: true; __resource: mediaResourceSchema; }; media: { __type: "Relationship"; __array: true; __resource: mediaResourceSchema; };
}; };
@@ -58,6 +78,26 @@ export type tweetsAttributesOnlySchema = {
}; };
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>;
};
};
export type mediaFilterInput = { export type mediaFilterInput = {
and?: Array<mediaFilterInput>; and?: Array<mediaFilterInput>;
or?: Array<mediaFilterInput>; or?: Array<mediaFilterInput>;
@@ -89,6 +129,8 @@ export type mediaFilterInput = {
}; };
user?: usersFilterInput;
tweet?: tweetsFilterInput; tweet?: tweetsFilterInput;
}; };
@@ -154,11 +196,16 @@ export type tweetsFilterInput = {
isNil?: boolean; isNil?: boolean;
}; };
user?: usersFilterInput;
media?: mediaFilterInput; media?: mediaFilterInput;
}; };
export const usersFilterFields = ["id", "email"] 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];
@@ -166,6 +213,9 @@ export const tweetsFilterFields = ["id", "content", "likes", "userId", "inserted
export type tweetsFilterField = (typeof tweetsFilterFields)[number]; export type tweetsFilterField = (typeof tweetsFilterFields)[number];
export const usersSortFields = ["id", "email"] 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];

View File

@@ -15,6 +15,7 @@ import {
likeTweet, likeTweet,
unlikeTweet, unlikeTweet,
updateTweet, updateTweet,
readUser,
buildCSRFHeaders, buildCSRFHeaders,
} from "./ash_rpc"; } from "./ash_rpc";
import { uploadFile } from "./upload"; import { uploadFile } from "./upload";
@@ -25,6 +26,7 @@ const queryClient = new QueryClient({
// ── Types ────────────────────────────────────────────────────────────────────── // ── Types ──────────────────────────────────────────────────────────────────────
type User = { id: string; email: string };
type MediaItem = { id: string; s3Key: string }; type MediaItem = { id: string; s3Key: string };
type Tweet = { type Tweet = {
id: string; id: string;
@@ -772,11 +774,169 @@ function RefreshButton() {
); );
} }
function UserCard({ user }: { user: User }) {
return (
<article
className="mx-tweet"
style={{ cursor: "pointer" }}
onClick={() => { window.location.href = `/users/${user.id}`; }}
>
<div className="mx-tweet-avatar">
<span>M</span>
</div>
<div className="mx-tweet-body">
<div className="mx-tweet-header">
<span className="mx-tweet-handle">{user.email}</span>
</div>
</div>
</article>
);
}
function UserList() {
const { data, isLoading, isError, error } = useQuery({
queryKey: ["users"],
queryFn: async () => {
const res = await readUser({
fields: ["id", "email"],
headers: buildCSRFHeaders(),
});
if (!res.success) throw new Error("Failed to load users");
const users = Array.isArray(res.data) ? res.data : (res.data as any)?.results ?? [];
return users as User[];
},
});
if (isLoading) return <Spinner />;
if (isError) return <ErrorBanner message={(error as Error)?.message ?? "Could not load users"} />;
const users = data ?? [];
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>
);
}
function UserDetail({ userId }: { userId: string }) {
const { data: user, isLoading, isError } = useQuery({
queryKey: ["user", userId],
queryFn: async () => {
const res = await readUser({
fields: ["id", "email"],
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" />;
return (
<div className="mx-detail">
<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">
<div className="mx-tweet-avatar">
<span>M</span>
</div>
<span className="mx-tweet-handle">{user.email}</span>
</div>
</div>
</div>
);
}
function App() { function App() {
const appEl = document.getElementById("app")!; const appEl = document.getElementById("app")!;
const email = appEl.dataset.currentUserEmail ?? ""; const email = appEl.dataset.currentUserEmail ?? "";
const userId = appEl.dataset.currentUserId ?? ""; const userId = appEl.dataset.currentUserId ?? "";
const tweetId = appEl.dataset.tweetId || null; const tweetId = appEl.dataset.tweetId || null;
const page = appEl.dataset.page ?? "feed";
const profileUserId = appEl.dataset.userId || null;
const onFeedPage = page === "feed" || page === "tweet";
const onUsersPage = page === "users" || page === "user-detail";
function renderMain() {
switch (page) {
case "tweet":
return (
<>
<header className="mx-header">
<h1 className="mx-header-title">Tweet</h1>
</header>
<TweetDetail tweetId={tweetId!} />
</>
);
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!} />
</>
);
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 ( return (
<AuthCtx.Provider value={{ email, userId }}> <AuthCtx.Provider value={{ email, userId }}>
@@ -788,12 +948,18 @@ function App() {
<span className="mx-logo-text">Mixer</span> <span className="mx-logo-text">Mixer</span>
</div> </div>
<nav className="mx-nav"> <nav className="mx-nav">
<a className="mx-nav-item mx-nav-active" href="/feed"> <a className={`mx-nav-item${onFeedPage ? " mx-nav-active" : ""}`} href="/feed">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"> <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" /> <path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" />
</svg> </svg>
Feed Feed
</a> </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>
</nav> </nav>
<div className="mx-sidebar-footer"> <div className="mx-sidebar-footer">
{email ? ( {email ? (
@@ -812,36 +978,7 @@ function App() {
</aside> </aside>
<main className="mx-main"> <main className="mx-main">
{tweetId ? ( {renderMain()}
<>
<header className="mx-header">
<h1 className="mx-header-title">Tweet</h1>
</header>
<TweetDetail tweetId={tweetId} />
</>
) : (
<>
<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> </main>
<div className="mx-rightbar"> <div className="mx-rightbar">

View File

@@ -1,5 +1,5 @@
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]
admin do admin do
show? true show? true
@@ -10,4 +10,10 @@ defmodule Mixer.Accounts do
resource Mixer.Accounts.User resource Mixer.Accounts.User
resource Mixer.Accounts.ApiKey resource Mixer.Accounts.ApiKey
end end
typescript_rpc do
resource Mixer.Accounts.User do
rpc_action :read_user, :read
end
end
end end

View File

@@ -4,7 +4,7 @@ defmodule Mixer.Accounts.User do
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
@@ -66,6 +66,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]
@@ -282,6 +286,10 @@ 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
end end
attributes do attributes do

View File

@@ -6,14 +6,22 @@ defmodule MixerWeb.PageController do
end end
def index(conn, _params) do def index(conn, _params) do
render_spa(conn, nil) render_spa(conn, %{page: "feed", tweet_id: nil, user_id: nil})
end end
def show(conn, %{"tweet_id" => tweet_id}) do def show(conn, %{"tweet_id" => tweet_id}) do
render_spa(conn, tweet_id) render_spa(conn, %{page: "tweet", tweet_id: tweet_id, user_id: nil})
end end
defp render_spa(conn, tweet_id) do 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")
@@ -22,7 +30,9 @@ defmodule MixerWeb.PageController do
|> 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}",
tweet_id: tweet_id page: page,
tweet_id: tweet_id,
user_id: user_id
) )
end end
end end

View File

@@ -2,5 +2,7 @@
data-current-user-id={if @current_user, do: @current_user.id, else: ""} data-current-user-id={if @current_user, do: @current_user.id, else: ""}
data-current-user-email={if @current_user, do: @current_user.email, else: ""} data-current-user-email={if @current_user, do: @current_user.email, else: ""}
data-asset-host={@media_host} data-asset-host={@media_host}
data-tweet-id={@tweet_id || ""}> data-page={@page}
data-tweet-id={@tweet_id || ""}
data-user-id={@user_id || ""}>
</div> </div>

View File

@@ -40,6 +40,8 @@ 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 "/feed/:tweet_id", PageController, :show
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