switch frontend to preact

This commit is contained in:
sam 2023-09-15 16:33:25 +02:00
parent 6f1b94c040
commit 0a94e2bf93
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
57 changed files with 3366 additions and 1803 deletions

View file

@ -1,9 +0,0 @@
<script lang="ts">
import { meAccount } from "./lib/store";
</script>
<main>
<p>
Username: {$meAccount.username}, ID: {$meAccount.id}
</p>
</main>

View file

@ -0,0 +1,32 @@
import { Router, Route, Switch } from "wouter-preact";
import PostPage from "$pages/blog/post";
import HomeTimeline from "$pages/timeline/home";
export default function AppRouter() {
return (
<>
<Router base="/web">
<Switch>
{/* Posts */}
<Route path="/@:username/posts/:postId">
<PostPage />
</Route>
{/* User timelines */}
<Route path="/@:username/with_replies">Replies!</Route>
<Route path="/@:username/media">Media!</Route>
<Route path="/@:username">User!</Route>
{/* Home */}
<Route path="/local">Local timeline</Route>
<Route path="/global">Global timeline</Route>
<Route path="/notifications">Notifications</Route>
<Route path="/">
<HomeTimeline />
</Route>
</Switch>
</Router>
</>
);
}

View file

@ -0,0 +1,9 @@
import { Helmet } from "react-helmet";
export default function DefaultHead() {
return (
<Helmet>
<title>Mercury</title>
</Helmet>
);
}

View file

@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

20
frontend/src/app.tsx Normal file
View file

@ -0,0 +1,20 @@
import { Provider } from "react-redux";
import { CssBaseline } from "@mui/material";
import store from "$lib/store";
import Layout from "$components/layout";
import DefaultHead from "./DefaultHead";
export default function App() {
return (
<>
<CssBaseline>
<Provider store={store}>
<DefaultHead />
<Layout />
</Provider>
</CssBaseline>
</>
);
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="27.68" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 296"><path fill="#673AB8" d="m128 0l128 73.9v147.8l-128 73.9L0 221.7V73.9z"></path><path fill="#FFF" d="M34.865 220.478c17.016 21.78 71.095 5.185 122.15-34.704c51.055-39.888 80.24-88.345 63.224-110.126c-17.017-21.78-71.095-5.184-122.15 34.704c-51.055 39.89-80.24 88.346-63.224 110.126Zm7.27-5.68c-5.644-7.222-3.178-21.402 7.573-39.253c11.322-18.797 30.541-39.548 54.06-57.923c23.52-18.375 48.303-32.004 69.281-38.442c19.922-6.113 34.277-5.075 39.92 2.148c5.644 7.223 3.178 21.403-7.573 39.254c-11.322 18.797-30.541 39.547-54.06 57.923c-23.52 18.375-48.304 32.004-69.281 38.441c-19.922 6.114-34.277 5.076-39.92-2.147Z"></path><path fill="#FFF" d="M220.239 220.478c17.017-21.78-12.169-70.237-63.224-110.126C105.96 70.464 51.88 53.868 34.865 75.648c-17.017 21.78 12.169 70.238 63.224 110.126c51.055 39.889 105.133 56.485 122.15 34.704Zm-7.27-5.68c-5.643 7.224-19.998 8.262-39.92 2.148c-20.978-6.437-45.761-20.066-69.28-38.441c-23.52-18.376-42.74-39.126-54.06-57.923c-10.752-17.851-13.218-32.03-7.575-39.254c5.644-7.223 19.999-8.261 39.92-2.148c20.978 6.438 45.762 20.067 69.281 38.442c23.52 18.375 42.739 39.126 54.06 57.923c10.752 17.85 13.218 32.03 7.574 39.254Z"></path><path fill="#FFF" d="M127.552 167.667c10.827 0 19.603-8.777 19.603-19.604c0-10.826-8.776-19.603-19.603-19.603c-10.827 0-19.604 8.777-19.604 19.603c0 10.827 8.777 19.604 19.604 19.604Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,62 @@
import { Home, Notifications, People, Public } from "@mui/icons-material";
import {
Drawer,
List,
ListItemButton,
ListItemIcon,
ListItemText,
} from "@mui/material";
import { useLocation } from "wouter-preact";
export default function DesktopNav() {
const [location, navigate] = useLocation();
return (
<>
<Drawer
sx={{ width: 200, display: { xs: "none", lg: "block" } }}
variant="permanent"
anchor="left"
>
<List>
<ListItemButton
onClick={() => navigate("/web")}
selected={location === "/web"}
>
<ListItemIcon>
<Home />
</ListItemIcon>
<ListItemText primary="Home" />
</ListItemButton>
<ListItemButton
onClick={() => navigate("/web/notifications")}
selected={location === "/web/notifications"}
>
<ListItemIcon>
<Notifications />
</ListItemIcon>
<ListItemText primary="Notifications" />
</ListItemButton>
<ListItemButton
onClick={() => navigate("/web/local")}
selected={location === "/web/local"}
>
<ListItemIcon>
<People />
</ListItemIcon>
<ListItemText primary="Local timeline" />
</ListItemButton>
<ListItemButton
onClick={() => navigate("/web/global")}
selected={location === "/web/global"}
>
<ListItemIcon>
<Public />
</ListItemIcon>
<ListItemText primary="Global timeline" />
</ListItemButton>
</List>
</Drawer>
</>
);
}

View file

@ -0,0 +1,43 @@
import { Paper, BottomNavigation, BottomNavigationAction } from "@mui/material";
import { useLocation } from "wouter-preact";
import { Home, Notifications, People, Public } from "@mui/icons-material";
export default function MobileNav() {
const [location, navigate] = useLocation();
return (
<Paper
sx={{
position: "fixed",
bottom: 0,
left: 0,
right: 0,
display: { xs: "block", lg: "none" },
}}
elevation={3}
>
<BottomNavigation
showLabels={false}
value={location}
onChange={(_, newValue) => navigate(newValue)}
>
<BottomNavigationAction icon={<Home />} label="Home" value="/web" />
<BottomNavigationAction
icon={<Notifications />}
label="Notifications"
value="/web/notifications"
/>
<BottomNavigationAction
icon={<People />}
label="Local"
value="/web/local"
/>
<BottomNavigationAction
icon={<Public />}
label="Global"
value="/web/global"
/>
</BottomNavigation>
</Paper>
);
}

View file

@ -0,0 +1,19 @@
import { Stack } from "@mui/material";
import AppRouter from "$/AppRouter";
import DesktopNav from "./DesktopNav";
import MobileNav from "./MobileNav";
export default function Layout() {
return (
<>
<Stack direction={{ xs: "column", lg: "row" }}>
<DesktopNav />
<Stack direction="row">
<AppRouter />
</Stack>
<MobileNav />
</Stack>
</>
);
}

View file

@ -0,0 +1,10 @@
.post-Post--account {
display: flex;
flex-direction: row;
}
.post-Post--names {
margin: 0 0.5rem;
display: flex;
flex-direction: column;
}

View file

@ -0,0 +1,58 @@
import { Avatar, Card, CardContent, Tooltip, Typography } from "@mui/material";
import { decodeTime } from "ulid";
import { DateTime } from "luxon";
import type { Post } from "$lib/api/entities/post";
import humanizeDuration from "$/lib/duration";
import "./Post.css";
import { renderMarkdown } from "$/lib/markdown";
interface Props {
post: Post;
}
export default function Post(props: Props) {
return (
<Card>
<CardContent>
<div className="post-Post--account">
<Avatar sx={{ width: 48, height: 48 }}>
{props.post.blog.name.slice(0, 1).toUpperCase()}
</Avatar>
<div className="post-Post--names">
<Typography>{props.post.blog.name}</Typography>
<Typography color="grey">@{props.post.blog.name}</Typography>
</div>
<Timestamp timestamp={decodeTime(props.post.id)} />
</div>
<PostContent post={props.post} />
</CardContent>
</Card>
);
}
function Timestamp({ timestamp }: { timestamp: number }) {
const msAgo = new Date().getTime() - timestamp;
const timeString = DateTime.fromMillis(timestamp).toLocaleString(
DateTime.DATETIME_SHORT,
);
return (
<>
<Tooltip title={timeString}>
<Typography variant="subtitle2">{humanizeDuration(msAgo)}</Typography>
</Tooltip>
</>
);
}
function PostContent({ post, small = true }: { post: Post; small?: boolean }) {
return (
<>
<Typography variant={small ? "body2" : "body1"}>
<p dangerouslySetInnerHTML={{ __html: renderMarkdown(post.content) }} />
</Typography>
</>
);
}

24
frontend/src/i18n.ts Normal file
View file

@ -0,0 +1,24 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import en from "$/translations/en.json";
import enPirate from "$/translations/en.pirate.json";
const resources = {
en: {
translation: en,
},
"en-PR": {
translation: enPirate,
},
};
i18n.use(initReactI18next).init({
resources,
lng: "en-PR",
interpolation: {
escapeValue: false,
},
});
export default i18n;

View file

@ -1,10 +0,0 @@
<script lang="ts">
let count: number = 0
const increment = () => {
count += 2
}
</script>
<button on:click={increment}>
count == {count}
</button>

View file

@ -0,0 +1,8 @@
import { Account } from "./account";
export interface Blog {
id: string;
name: string;
domain: string | null;
bio: Account;
}

View file

@ -0,0 +1,5 @@
export interface Error {
code: number;
message: string;
details?: string;
}

View file

@ -0,0 +1,9 @@
import { Blog } from "./blog";
export interface Post {
id: string;
content: string | null;
source: string | null;
visibility: "public" | "unlisted" | "followers" | "direct";
blog: Blog;
}

View file

@ -0,0 +1,33 @@
import axios from "axios";
import type { Error } from "./entities/error";
export async function apiFetch<T>(
path: string,
data:
| {
method?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data?: any;
token?: string;
headers?: Record<string, string>;
version?: number;
}
| undefined = undefined,
) {
try {
const resp = await axios<T>({
method: data?.method || "GET",
url: `/api/v${data?.version || 1}${path}`,
data: data,
});
return resp.data;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
if (err.response) {
throw err as Error;
}
throw err;
}
}

View file

@ -0,0 +1,23 @@
import { humanizer } from "humanize-duration";
const defaultHumanizer = humanizer({
round: true,
largest: 1,
spacer: "",
languages: {
en: {
y: () => "y",
mo: () => "mo",
w: () => "w",
d: () => "d",
h: () => "h",
m: () => "m",
s: () => "s",
ms: () => "ms",
},
},
});
export default function humanizeDuration(ms: number) {
return defaultHumanizer(ms);
}

View file

@ -0,0 +1,5 @@
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
import type { Store, Dispatch } from "./store";
export const useAppDispatch: () => Dispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<Store> = useSelector;

View file

@ -0,0 +1,14 @@
import MarkdownIt from "markdown-it";
import sanitize from "sanitize-html";
const md = new MarkdownIt({
html: false,
breaks: true,
linkify: true,
}).disable(["heading", "lheading", "table"]);
export function renderMarkdown(src: string | null) {
return src ? sanitize(md.render(src)) : null;
}
export const charCount = (str: string) => [...str].length;

View file

@ -1,6 +0,0 @@
import { writable } from "svelte/store";
import type { MeAccount } from "./api/account";
export const meAccount = writable(
JSON.parse(document.getElementById("accountData").innerHTML) as MeAccount,
);

View file

@ -0,0 +1,33 @@
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
import type { Account, MeAccount } from "$lib/api/entities/account";
const accountsSlice = createSlice({
name: "accounts",
initialState: {
currentAccount: JSON.parse(
document.getElementById("accountData")!.innerHTML,
) as MeAccount,
accounts: {},
usernames: {},
} as {
currentAccount: MeAccount;
accounts: Record<string, Account>;
usernames: Record<string, string>;
},
reducers: {
setAccount(state, action: PayloadAction<Account>) {
state.usernames[action.payload.username] = action.payload.id; // TODO: change to acct-equivalent field
state.accounts[action.payload.id] = action.payload;
},
removeAccount(state, action: PayloadAction<string>) {
delete state.accounts[action.payload];
},
setCurrentAccount(state, action: PayloadAction<MeAccount>) {
state.currentAccount = action.payload;
state.accounts[action.payload.id] = action.payload;
},
},
});
export const accounts = accountsSlice.reducer;
export const { setAccount, removeAccount } = accountsSlice.actions;

View file

@ -0,0 +1,15 @@
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
import type { Post } from "$lib/api/entities/post";
export const mercuryApi = createApi({
reducerPath: "mercuryApi",
baseQuery: fetchBaseQuery({ baseUrl: "/api/v1" }),
endpoints: (builder) => ({
getPostById: builder.query<Post, string>({
query: (id) => `/posts/${id}`,
}),
}),
});
export const { useGetPostByIdQuery } = mercuryApi;

View file

@ -0,0 +1,22 @@
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
import type { Blog } from "$lib/api/entities/blog";
const blogSlice = createSlice({
name: "blogs",
initialState: { blogs: {}, blogNames: {} } as {
blogs: Record<string, Blog>;
blogNames: Record<string, string>;
},
reducers: {
setBlog(state, action: PayloadAction<Blog>) {
state.blogNames[action.payload.name] = action.payload.id;
state.blogs[action.payload.id] = action.payload;
},
removeBlog(state, action: PayloadAction<string>) {
delete state.blogs[action.payload];
},
},
});
export const blogs = blogSlice.reducer;
export const { setBlog, removeBlog } = blogSlice.actions;

View file

@ -0,0 +1,23 @@
import { configureStore } from "@reduxjs/toolkit";
import { setupListeners } from "@reduxjs/toolkit/query/react";
import { mercuryApi } from "./api";
import { accounts } from "./accounts";
import { blogs } from "./blogs";
import { posts } from "./posts";
const store = configureStore({
reducer: {
accounts,
blogs,
posts,
[mercuryApi.reducerPath]: mercuryApi.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(mercuryApi.middleware),
});
setupListeners(store.dispatch);
export default store;
export type Store = ReturnType<typeof store.getState>;
export type Dispatch = typeof store.dispatch;

View file

@ -0,0 +1,18 @@
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
import type { Post } from "$lib/api/entities/post";
const postsSlice = createSlice({
name: "posts",
initialState: {} as Record<string, Post>,
reducers: {
setPost(state, action: PayloadAction<Post>) {
state[action.payload.id] = action.payload;
},
removePost(state, action: PayloadAction<string>) {
delete state[action.payload];
},
},
});
export const posts = postsSlice.reducer;
export const { setPost, removePost } = postsSlice.actions;

View file

@ -0,0 +1,18 @@
import type { Store } from "$lib/store";
export const getAccount = (state: Store, id: string) =>
id in state.accounts.accounts ? state.accounts.accounts[id] : undefined;
export const getCurrentAccount = (state: Store) =>
state.accounts.currentAccount;
export const getAccountByName = (state: Store, username: string) => {
const id =
username in state.accounts.usernames
? state.accounts.usernames[username]
: undefined;
if (!id) return undefined;
return id in state.accounts.accounts
? state.accounts.accounts[id]
: undefined;
};

View file

@ -0,0 +1,11 @@
import type { Store } from "$lib/store";
export const getBlog = (state: Store, id: string) =>
id in state.blogs.blogs ? state.blogs.blogs[id] : undefined;
export const getBlogByBlogName = (state: Store, name: string) => {
const id =
name in state.blogs.blogNames ? state.blogs.blogNames[name] : undefined;
if (!id) return undefined;
return id in state.blogs.blogs ? state.blogs.blogs[id] : undefined;
};

View file

@ -0,0 +1,3 @@
export { getPost } from "./posts";
export { getBlog, getBlogByBlogName } from "./blogs";
export { getAccount, getAccountByName, getCurrentAccount } from "./accounts";

View file

@ -0,0 +1,5 @@
import type { Store } from "$lib/store";
/** Gets a status from the store by ID. */
export const getPost = (state: Store, id: string) =>
id in state.posts ? state.posts[id] : undefined;

View file

@ -1,8 +0,0 @@
import "./app.css";
import App from "./App.svelte";
const app = new App({
target: document.getElementById("app"),
});
export default app;

10
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import { render } from "preact";
import "@fontsource/roboto/300.css";
import "@fontsource/roboto/400.css";
import "@fontsource/roboto/500.css";
import "@fontsource/roboto/700.css";
import App from "./app.tsx";
import "./i18n";
render(<App />, document.getElementById("app")!);

View file

View file

@ -0,0 +1,30 @@
import { useGetPostByIdQuery } from "$/lib/store/api";
import { CircularProgress } from "@mui/material";
import { Redirect, useRoute } from "wouter-preact";
import Post from "$components/post/Post";
export default function PostPage() {
const [, params] = useRoute("/@:username/posts/:postId");
const { data, error, isLoading } = useGetPostByIdQuery(params!.postId);
if (error) {
throw error;
} else if (isLoading || !data) {
return <CircularProgress />;
}
if (data.blog.name !== params!.username) {
return (
<>
<Redirect to={`/web/@${data.blog.name}/posts/${data.id}`} />
<p>hi world!</p>
</>
);
}
return (
<>
<Post post={data} />
</>
);
}

View file

@ -0,0 +1,17 @@
import { useAppSelector } from "$/lib/hooks";
import { getCurrentAccount } from "$/lib/store/selectors";
export default function HomeTimeline() {
const account = useAppSelector(getCurrentAccount);
return (
<>
<ul>
<li>Username: {account.username}</li>
<li>ID: {account.id}</li>
<li>Domain: {account.domain}</li>
<li>Email: {account.email}</li>
</ul>
</>
);
}

View file

@ -0,0 +1,5 @@
{
"navbar": {
"homeTimeline": "Home timeline"
}
}

View file

@ -0,0 +1,5 @@
{
"navbar": {
"homeTimeline": "Yar friends' bottles"
}
}

View file

@ -1,2 +1 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />