Compare commits

...

6 Commits

14 changed files with 390 additions and 26 deletions

View File

@@ -303,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 {
@@ -964,4 +970,6 @@ html, body {
} }
.mx-header { padding: 0.75rem 1rem; } .mx-header { padding: 0.75rem 1rem; }
.mx-detail { padding: 0.875rem 1rem; } .mx-detail { padding: 0.875rem 1rem; }
/* 5-item nav: slightly smaller labels so nothing wraps */
.mx-mobile-nav-item { font-size: 0.6rem; }
} }

View File

@@ -843,6 +843,73 @@ export async function validateLikeTweet(
} }
export type ReadFollowingFeedFields = UnifiedFieldSelection<tweetsResourceSchema>[];
export type InferReadFollowingFeedResult<
Fields extends ReadFollowingFeedFields,
> = Array<InferResult<tweetsResourceSchema, Fields>>;
export type ReadFollowingFeedResult<Fields extends ReadFollowingFeedFields> = | { success: true; data: InferReadFollowingFeedResult<Fields>; }
| { success: false; errors: AshRpcError[]; }
;
/**
* Read Tweet records
*
* @ashActionType :read
*/
export async function readFollowingFeed<Fields extends ReadFollowingFeedFields>(
config: {
tenant?: string;
fields: Fields;
filter?: tweetsFilterInput;
sort?: SortString<tweetsSortField> | SortString<tweetsSortField>[];
headers?: Record<string, string>;
fetchOptions?: RequestInit;
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}
): Promise<ReadFollowingFeedResult<Fields>> {
const payload = {
action: "read_following_feed",
...(config.tenant !== undefined && { tenant: config.tenant }),
...(config.fields !== undefined && { fields: config.fields }),
...(config.filter && { filter: config.filter }),
...(config.sort && { sort: Array.isArray(config.sort) ? config.sort.join(",") : config.sort })
};
return executeActionRpcRequest<ReadFollowingFeedResult<Fields>>(
payload,
config
);
}
/**
* Validate: Read Tweet records
*
* @ashActionType :read
* @validation true
*/
export async function validateReadFollowingFeed(
config: {
tenant?: string;
headers?: Record<string, string>;
fetchOptions?: RequestInit;
customFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}
): Promise<ValidationResult> {
const payload = {
action: "read_following_feed",
...(config.tenant !== undefined && { tenant: config.tenant })
};
return executeValidationRpcRequest<ValidationResult>(
payload,
config
);
}
export type ReadTweetFields = UnifiedFieldSelection<tweetsResourceSchema>[]; export type ReadTweetFields = UnifiedFieldSelection<tweetsResourceSchema>[];

View File

@@ -5,12 +5,14 @@ import {
QueryClient, QueryClient,
QueryClientProvider, QueryClientProvider,
useQuery, useQuery,
useInfiniteQuery,
useMutation, useMutation,
useQueryClient, useQueryClient,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { import {
createTweet, createTweet,
readTweet, readTweet,
readFollowingFeed,
destroyTweet, destroyTweet,
likeTweet, likeTweet,
unlikeTweet, unlikeTweet,
@@ -223,6 +225,7 @@ function ComposeTweet({ onSuccess }: { onSuccess?: () => void }) {
}, },
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ["tweets"] }); qc.invalidateQueries({ queryKey: ["tweets"] });
qc.invalidateQueries({ queryKey: ["following_tweets"] });
setText(""); setText("");
setError(null); setError(null);
setMediaId(null); setMediaId(null);
@@ -476,7 +479,10 @@ function TweetCard({ tweet }: { tweet: Tweet }) {
}); });
if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed to delete"); if (!res.success) throw new Error(res.errors?.[0]?.message ?? "Failed to delete");
}, },
onSuccess: () => qc.invalidateQueries({ queryKey: ["tweets"] }), onSuccess: () => {
qc.invalidateQueries({ queryKey: ["tweets"] });
qc.invalidateQueries({ queryKey: ["following_tweets"] });
},
onError: (e: Error) => setError(e.message), onError: (e: Error) => setError(e.message),
}); });
@@ -492,6 +498,7 @@ function TweetCard({ tweet }: { tweet: Tweet }) {
}, },
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ["tweets"] }); qc.invalidateQueries({ queryKey: ["tweets"] });
qc.invalidateQueries({ queryKey: ["following_tweets"] });
setEditing(false); setEditing(false);
setError(null); setError(null);
}, },
@@ -510,6 +517,7 @@ function TweetCard({ tweet }: { tweet: Tweet }) {
}, },
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ["tweets"] }); qc.invalidateQueries({ queryKey: ["tweets"] });
qc.invalidateQueries({ queryKey: ["following_tweets"] });
setError(null); setError(null);
}, },
onError: (e: Error) => setError(e.message), onError: (e: Error) => setError(e.message),
@@ -861,21 +869,148 @@ function TweetDetail({ tweetId }: { tweetId: string }) {
); );
} }
const FEED_PAGE_SIZE = 10;
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", "userId", "state", "userEmail", "insertedAt", { media: ["id", "s3Key"] }],
sort: "-insertedAt",
page: { limit: FEED_PAGE_SIZE, offset: pageParam },
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>
);
}
function Feed() { function Feed() {
const { data, isLoading, isError, error, refetch } = useQuery({ const sentinelRef = useRef<HTMLDivElement>(null);
const {
data,
isLoading,
isError,
error,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ["tweets"], queryKey: ["tweets"],
queryFn: async () => { queryFn: async ({ pageParam }: { pageParam: number }) => {
const res = await readTweet({ const res = await readTweet({
fields: ["id", "content", "likes", "likedByMe", "userId", "state", "userEmail", "insertedAt", { media: ["id", "s3Key"] }], fields: ["id", "content", "likes", "likedByMe", "userId", "state", "userEmail", "insertedAt", { media: ["id", "s3Key"] }],
sort: "-insertedAt", sort: "-insertedAt",
page: { limit: FEED_PAGE_SIZE, offset: pageParam },
headers: buildCSRFHeaders(), headers: buildCSRFHeaders(),
}); });
if (!res.success) throw new Error("Failed to load tweets"); if (!res.success) throw new Error("Failed to load tweets");
const tweets = Array.isArray(res.data) ? res.data : (res.data as any)?.results ?? []; const pageData = res.data as any;
return tweets as Tweet[]; 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,
}); });
// IntersectionObserver — fires fetchNextPage when the sentinel div scrolls into view
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 (isLoading) return <Spinner />;
if (isError) { if (isError) {
return ( return (
@@ -883,7 +1018,7 @@ function Feed() {
); );
} }
const tweets = data ?? []; const tweets = data?.pages.flatMap((p) => p.tweets) ?? [];
if (tweets.length === 0) { if (tweets.length === 0) {
return ( return (
@@ -900,16 +1035,19 @@ function Feed() {
{tweets.map((t) => ( {tweets.map((t) => (
<TweetCard key={t.id} tweet={t} /> <TweetCard key={t.id} tweet={t} />
))} ))}
{/* Sentinel element — entering the viewport triggers loading the next page */}
<div ref={sentinelRef} style={{ height: "1px" }} />
{isFetchingNextPage && <Spinner />}
</div> </div>
); );
} }
function RefreshButton() { function RefreshButton({ queryKey = ["tweets"] }: { queryKey?: string[] }) {
const qc = useQueryClient(); const qc = useQueryClient();
const [spinning, setSpinning] = useState(false); const [spinning, setSpinning] = useState(false);
async function refresh() { async function refresh() {
setSpinning(true); setSpinning(true);
await qc.invalidateQueries({ queryKey: ["tweets"] }); await qc.invalidateQueries({ queryKey });
setTimeout(() => setSpinning(false), 600); setTimeout(() => setSpinning(false), 600);
} }
return ( return (
@@ -1070,7 +1208,7 @@ function UserList() {
); );
} }
function UserDetail({ userId }: { userId: string }) { function UserDetail({ userId, isStandalone = false }: { userId: string; isStandalone?: boolean }) {
const { userId: currentUserId } = useContext(AuthCtx); const { userId: currentUserId } = useContext(AuthCtx);
const { follow, unfollow, isPending } = useFollowUser(userId); const { follow, unfollow, isPending } = useFollowUser(userId);
const { data: user, isLoading, isError } = useQuery({ const { data: user, isLoading, isError } = useQuery({
@@ -1090,11 +1228,13 @@ function UserDetail({ userId }: { userId: string }) {
if (isLoading) return <Spinner />; if (isLoading) return <Spinner />;
if (isError || !user) return <ErrorBanner message="Could not load user" />; if (isError || !user) return <ErrorBanner message="Could not load user" />;
const canFollow = !!currentUserId && currentUserId !== userId; const isOwnProfile = currentUserId === userId;
const canFollow = !!currentUserId && !isOwnProfile;
const amIFollowing = user.amIFollowing ?? false; const amIFollowing = user.amIFollowing ?? false;
return ( return (
<div className="mx-detail"> <div className="mx-detail">
{!isStandalone && (
<div className="mx-detail-header"> <div className="mx-detail-header">
<a href="/users" className="mx-back-btn"> <a href="/users" className="mx-back-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"> <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
@@ -1103,17 +1243,23 @@ function UserDetail({ userId }: { userId: string }) {
Back Back
</a> </a>
</div> </div>
)}
<div className="mx-detail-body"> <div className="mx-detail-body">
<div className="mx-detail-author"> <div className="mx-detail-author">
<div className="mx-tweet-avatar"> <div className="mx-tweet-avatar mx-tweet-avatar--lg">
<span>M</span> <span>{user.email?.[0]?.toUpperCase() ?? "M"}</span>
</div> </div>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}> <div style={{ display: "flex", alignItems: "center", gap: "12px", flexWrap: "wrap" }}>
<span className="mx-tweet-handle">{user.email}</span> <span className="mx-tweet-handle">{user.email}</span>
{canFollow && ( {canFollow && (
<FollowButton amIFollowing={amIFollowing} isPending={isPending} onToggle={amIFollowing ? unfollow : follow} /> <FollowButton amIFollowing={amIFollowing} isPending={isPending} onToggle={amIFollowing ? unfollow : follow} />
)} )}
{isOwnProfile && isStandalone && (
<a href="/sign-out" className="mx-btn-cancel" style={{ textDecoration: "none", fontSize: "0.8rem" }}>
Sign out
</a>
)}
</div> </div>
<div style={{ fontSize: "0.85rem", color: "var(--mx-muted)", marginTop: "6px", display: "flex", gap: "16px" }}> <div style={{ fontSize: "0.85rem", color: "var(--mx-muted)", marginTop: "6px", display: "flex", gap: "16px" }}>
<span><strong>{user.followerCount ?? 0}</strong> followers</span> <span><strong>{user.followerCount ?? 0}</strong> followers</span>
@@ -1126,6 +1272,25 @@ function UserDetail({ userId }: { userId: string }) {
); );
} }
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 <UserDetail userId={userId} isStandalone />;
}
// ── Mobile bottom nav ───────────────────────────────────────────────────────── // ── Mobile bottom nav ─────────────────────────────────────────────────────────
function MobileNav({ function MobileNav({
@@ -1136,7 +1301,9 @@ function MobileNav({
onCompose: () => void; onCompose: () => void;
}) { }) {
const onFeedPage = page === "feed" || page === "tweet"; const onFeedPage = page === "feed" || page === "tweet";
const onFollowingPage = page === "following";
const onUsersPage = page === "users" || page === "user-detail"; const onUsersPage = page === "users" || page === "user-detail";
const onProfilePage = page === "profile";
return ( return (
<nav className="mx-mobile-nav"> <nav className="mx-mobile-nav">
@@ -1150,6 +1317,16 @@ function MobileNav({
<span>Feed</span> <span>Feed</span>
</a> </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 <button
className="mx-mobile-nav-compose" className="mx-mobile-nav-compose"
onClick={onCompose} onClick={onCompose}
@@ -1179,6 +1356,16 @@ function MobileNav({
</svg> </svg>
<span>Users</span> <span>Users</span>
</a> </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> </nav>
); );
} }
@@ -1228,7 +1415,9 @@ function App() {
const isDesktop = useIsDesktop(); const isDesktop = useIsDesktop();
const onFeedPage = page === "feed" || page === "tweet"; const onFeedPage = page === "feed" || page === "tweet";
const onFollowingPage = page === "following";
const onUsersPage = page === "users" || page === "user-detail"; const onUsersPage = page === "users" || page === "user-detail";
const onProfilePage = page === "profile";
function renderMain() { function renderMain() {
switch (page) { switch (page) {
@@ -1241,6 +1430,16 @@ function App() {
<TweetDetail tweetId={tweetId!} /> <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": case "users":
return ( return (
<> <>
@@ -1259,6 +1458,15 @@ function App() {
<UserDetail userId={profileUserId!} /> <UserDetail userId={profileUserId!} />
</> </>
); );
case "profile":
return (
<>
<header className="mx-header">
<h1 className="mx-header-title">My Profile</h1>
</header>
<MyProfile />
</>
);
default: default:
return ( return (
<> <>
@@ -1303,12 +1511,24 @@ function App() {
</svg> </svg>
Feed Feed
</a> </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"> <a className={`mx-nav-item${onUsersPage ? " mx-nav-active" : ""}`} href="/users">
<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="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" /> <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> </svg>
Users Users
</a> </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> </nav>
<div className="mx-sidebar-footer"> <div className="mx-sidebar-footer">
{email ? ( {email ? (

View File

@@ -37,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

View File

@@ -8,6 +8,7 @@ defmodule Mixer.Posts do
rpc_action :create_tweet, :create rpc_action :create_tweet, :create
rpc_action :like_tweet, :like 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 :unlike_tweet, :unlike
rpc_action :update_tweet, :update rpc_action :update_tweet, :update
rpc_action :destroy_tweet, :destroy rpc_action :destroy_tweet, :destroy

View File

@@ -30,6 +30,13 @@ defmodule Mixer.Posts.Tweet do
actions do actions do
defaults [:read, :destroy] defaults [:read, :destroy]
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]

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

@@ -5,7 +5,9 @@ defmodule MixerWeb.PageController do
if conn.assigns[:current_user] do if conn.assigns[:current_user] do
redirect(conn, to: ~p"/feed") redirect(conn, to: ~p"/feed")
else else
render(conn, :home) conn
|> assign(:page_title, "Mixer")
|> render(:home)
end end
end end
@@ -17,6 +19,14 @@ defmodule MixerWeb.PageController do
render_spa(conn, %{page: "tweet", tweet_id: tweet_id, user_id: nil}) render_spa(conn, %{page: "tweet", tweet_id: tweet_id, user_id: nil})
end 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 def users_index(conn, _params) do
render_spa(conn, %{page: "users", tweet_id: nil, user_id: nil}) render_spa(conn, %{page: "users", tweet_id: nil, user_id: nil})
end end

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 "/following", PageController, :following
get "/profile", PageController, :profile
get "/users", PageController, :users_index get "/users", PageController, :users_index
get "/users/:user_id", PageController, :user_show get "/users/:user_id", PageController, :user_show
post "/rpc/run", AshTypescriptRpcController, :run post "/rpc/run", AshTypescriptRpcController, :run

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 B

After

Width:  |  Height:  |  Size: 15 KiB