Compare commits
2 commits
7aee99ac42
...
0a94e2bf93
Author | SHA1 | Date | |
---|---|---|---|
0a94e2bf93 | |||
6f1b94c040 |
67 changed files with 3413 additions and 1844 deletions
|
@ -5,7 +5,7 @@ tmp_dir = "tmp"
|
|||
args_bin = ["web"]
|
||||
bin = "./tmp/main"
|
||||
cmd = "go build -o ./tmp/main ."
|
||||
delay = 0
|
||||
delay = 1000
|
||||
exclude_dir = ["node_modules", "frontend", "assets", "tmp", "vendor", "testdata", "web/frontend/assets", "web/templates"]
|
||||
exclude_file = []
|
||||
exclude_regex = ["_test.go"]
|
||||
|
|
31
README.md
31
README.md
|
@ -1 +1,32 @@
|
|||
# Mercury
|
||||
|
||||
Maybe, one day, a social media server with ActivityPub support.
|
||||
Currently mostly just me trying to make an overengineered code structure work.
|
||||
|
||||
## Stack
|
||||
|
||||
- Database: PostgreSQL
|
||||
- Backend language: Go 1.19+
|
||||
- Router: [chi](https://github.com/go-chi/chi)
|
||||
- Templates: [pongo2](https://github.com/flosch/pongo2)
|
||||
- Command line: [cli](https://github.com/urfave/cli)
|
||||
- Frontend language: TypeScript
|
||||
- Framework: [Preact](https://preactjs.com/)
|
||||
- Bundler: [Vite](https://vitejs.dev/)
|
||||
|
||||
## License
|
||||
|
||||
Copyright (C) 2023 sam \<sleepycat.moe\>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
|
32
frontend/.eslintrc.cjs
Normal file
32
frontend/.eslintrc.cjs
Normal file
|
@ -0,0 +1,32 @@
|
|||
module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true,
|
||||
},
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:react/recommended",
|
||||
],
|
||||
overrides: [
|
||||
{
|
||||
env: {
|
||||
node: true,
|
||||
},
|
||||
files: [".eslintrc.{js,cjs}"],
|
||||
parserOptions: {
|
||||
sourceType: "script",
|
||||
},
|
||||
},
|
||||
],
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
},
|
||||
plugins: ["@typescript-eslint", "react"],
|
||||
rules: {
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"no-mixed-spaces-and-tabs": "off",
|
||||
},
|
||||
};
|
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
|
@ -0,0 +1,24 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
5
frontend/.prettierrc
Normal file
5
frontend/.prettierrc
Normal file
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all"
|
||||
}
|
|
@ -1,47 +0,0 @@
|
|||
# Svelte + TS + Vite
|
||||
|
||||
This template should help get you started developing with Svelte and TypeScript in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
|
||||
|
||||
## Need an official Svelte framework?
|
||||
|
||||
Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
|
||||
|
||||
## Technical considerations
|
||||
|
||||
**Why use this over SvelteKit?**
|
||||
|
||||
- It brings its own routing solution which might not be preferable for some users.
|
||||
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
|
||||
|
||||
This template contains as little as possible to get started with Vite + TypeScript + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
|
||||
|
||||
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
|
||||
|
||||
**Why `global.d.ts` instead of `compilerOptions.types` inside `jsconfig.json` or `tsconfig.json`?**
|
||||
|
||||
Setting `compilerOptions.types` shuts out all other types not explicitly listed in the configuration. Using triple-slash references keeps the default TypeScript setting of accepting type information from the entire workspace, while also adding `svelte` and `vite/client` type information.
|
||||
|
||||
**Why include `.vscode/extensions.json`?**
|
||||
|
||||
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
|
||||
|
||||
**Why enable `allowJs` in the TS template?**
|
||||
|
||||
While `allowJs: false` would indeed prevent the use of `.js` files in the project, it does not prevent the use of JavaScript syntax in `.svelte` files. In addition, it would force `checkJs: false`, bringing the worst of both worlds: not being able to guarantee the entire codebase is TypeScript, and also having worse typechecking for the existing JavaScript. In addition, there are valid use cases in which a mixed codebase may be relevant.
|
||||
|
||||
**Why is HMR not preserving my local component state?**
|
||||
|
||||
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/rixo/svelte-hmr#svelte-hmr).
|
||||
|
||||
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
|
||||
|
||||
```ts
|
||||
// store.ts
|
||||
// An extremely simple external store
|
||||
import { writable } from "svelte/store";
|
||||
export default writable(0);
|
||||
```
|
|
@ -2,12 +2,11 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + Svelte + TS</title>
|
||||
<title>Mercury</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -5,25 +5,48 @@
|
|||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^2.4.2",
|
||||
"@tsconfig/svelte": "^5.0.0",
|
||||
"autoprefixer": "^10.4.15",
|
||||
"postcss": "^8.4.29",
|
||||
"prettier": "^3.0.3",
|
||||
"sass": "^1.66.1",
|
||||
"svelte": "^4.0.5",
|
||||
"svelte-check": "^3.4.6",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"tslib": "^2.6.0",
|
||||
"typescript": "^5.0.2",
|
||||
"vite": "^4.4.5"
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"normalize.css": "^8.0.1"
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@fontsource/roboto": "^5.0.8",
|
||||
"@heroicons/react": "^2.0.18",
|
||||
"@mui/icons-material": "^5.14.9",
|
||||
"@mui/material": "^5.14.9",
|
||||
"@reduxjs/toolkit": "^1.9.5",
|
||||
"axios": "^1.5.0",
|
||||
"humanize-duration": "^3.29.0",
|
||||
"i18next": "^23.5.1",
|
||||
"luxon": "^3.4.3",
|
||||
"markdown-it": "^13.0.1",
|
||||
"preact": "^10.16.0",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-i18next": "^13.2.2",
|
||||
"react-redux": "^8.1.2",
|
||||
"redux": "^4.2.1",
|
||||
"sanitize-html": "^2.11.0",
|
||||
"ulid": "^2.3.0",
|
||||
"wouter-preact": "^2.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@preact/preset-vite": "^2.5.0",
|
||||
"@types/humanize-duration": "^3.27.1",
|
||||
"@types/luxon": "^3.3.2",
|
||||
"@types/markdown-it": "^13.0.1",
|
||||
"@types/node": "^20.6.0",
|
||||
"@types/react-helmet": "^6.1.6",
|
||||
"@types/sanitize-html": "^2.9.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.7.0",
|
||||
"@typescript-eslint/parser": "^6.7.0",
|
||||
"autoprefixer": "^10.4.15",
|
||||
"eslint": "^8.49.0",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"postcss": "^8.4.29",
|
||||
"prettier": "^3.0.3",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"typescript": "^5.0.2",
|
||||
"vite": "^4.4.5"
|
||||
}
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,6 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { meAccount } from "./lib/store";
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<p>
|
||||
Username: {$meAccount.username}, ID: {$meAccount.id}
|
||||
</p>
|
||||
</main>
|
32
frontend/src/AppRouter.tsx
Normal file
32
frontend/src/AppRouter.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
9
frontend/src/DefaultHead.tsx
Normal file
9
frontend/src/DefaultHead.tsx
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { Helmet } from "react-helmet";
|
||||
|
||||
export default function DefaultHead() {
|
||||
return (
|
||||
<Helmet>
|
||||
<title>Mercury</title>
|
||||
</Helmet>
|
||||
);
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
20
frontend/src/app.tsx
Normal file
20
frontend/src/app.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
1
frontend/src/assets/preact.svg
Normal file
1
frontend/src/assets/preact.svg
Normal 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 |
|
@ -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 |
|
@ -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 |
62
frontend/src/components/layout/DesktopNav.tsx
Normal file
62
frontend/src/components/layout/DesktopNav.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
43
frontend/src/components/layout/MobileNav.tsx
Normal file
43
frontend/src/components/layout/MobileNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
19
frontend/src/components/layout/index.tsx
Normal file
19
frontend/src/components/layout/index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
10
frontend/src/components/post/Post.css
Normal file
10
frontend/src/components/post/Post.css
Normal 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;
|
||||
}
|
58
frontend/src/components/post/Post.tsx
Normal file
58
frontend/src/components/post/Post.tsx
Normal 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
24
frontend/src/i18n.ts
Normal 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;
|
|
@ -1,10 +0,0 @@
|
|||
<script lang="ts">
|
||||
let count: number = 0
|
||||
const increment = () => {
|
||||
count += 2
|
||||
}
|
||||
</script>
|
||||
|
||||
<button on:click={increment}>
|
||||
count == {count}
|
||||
</button>
|
8
frontend/src/lib/api/entities/blog.ts
Normal file
8
frontend/src/lib/api/entities/blog.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { Account } from "./account";
|
||||
|
||||
export interface Blog {
|
||||
id: string;
|
||||
name: string;
|
||||
domain: string | null;
|
||||
bio: Account;
|
||||
}
|
5
frontend/src/lib/api/entities/error.ts
Normal file
5
frontend/src/lib/api/entities/error.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export interface Error {
|
||||
code: number;
|
||||
message: string;
|
||||
details?: string;
|
||||
}
|
9
frontend/src/lib/api/entities/post.ts
Normal file
9
frontend/src/lib/api/entities/post.ts
Normal 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;
|
||||
}
|
33
frontend/src/lib/api/fetch.ts
Normal file
33
frontend/src/lib/api/fetch.ts
Normal 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;
|
||||
}
|
||||
}
|
23
frontend/src/lib/duration.ts
Normal file
23
frontend/src/lib/duration.ts
Normal 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);
|
||||
}
|
5
frontend/src/lib/hooks.ts
Normal file
5
frontend/src/lib/hooks.ts
Normal 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;
|
14
frontend/src/lib/markdown.ts
Normal file
14
frontend/src/lib/markdown.ts
Normal 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;
|
|
@ -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,
|
||||
);
|
33
frontend/src/lib/store/accounts.ts
Normal file
33
frontend/src/lib/store/accounts.ts
Normal 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;
|
15
frontend/src/lib/store/api.ts
Normal file
15
frontend/src/lib/store/api.ts
Normal 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;
|
22
frontend/src/lib/store/blogs.ts
Normal file
22
frontend/src/lib/store/blogs.ts
Normal 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;
|
23
frontend/src/lib/store/index.ts
Normal file
23
frontend/src/lib/store/index.ts
Normal 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;
|
18
frontend/src/lib/store/posts.ts
Normal file
18
frontend/src/lib/store/posts.ts
Normal 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;
|
18
frontend/src/lib/store/selectors/accounts.ts
Normal file
18
frontend/src/lib/store/selectors/accounts.ts
Normal 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;
|
||||
};
|
11
frontend/src/lib/store/selectors/blogs.ts
Normal file
11
frontend/src/lib/store/selectors/blogs.ts
Normal 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;
|
||||
};
|
3
frontend/src/lib/store/selectors/index.ts
Normal file
3
frontend/src/lib/store/selectors/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export { getPost } from "./posts";
|
||||
export { getBlog, getBlogByBlogName } from "./blogs";
|
||||
export { getAccount, getAccountByName, getCurrentAccount } from "./accounts";
|
5
frontend/src/lib/store/selectors/posts.ts
Normal file
5
frontend/src/lib/store/selectors/posts.ts
Normal 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;
|
|
@ -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
10
frontend/src/main.tsx
Normal 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")!);
|
0
frontend/src/pages/blog/index.tsx
Normal file
0
frontend/src/pages/blog/index.tsx
Normal file
30
frontend/src/pages/blog/post/index.tsx
Normal file
30
frontend/src/pages/blog/post/index.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
17
frontend/src/pages/timeline/home/index.tsx
Normal file
17
frontend/src/pages/timeline/home/index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
5
frontend/src/translations/en.json
Normal file
5
frontend/src/translations/en.json
Normal file
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"navbar": {
|
||||
"homeTimeline": "Home timeline"
|
||||
}
|
||||
}
|
5
frontend/src/translations/en.pirate.json
Normal file
5
frontend/src/translations/en.pirate.json
Normal file
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"navbar": {
|
||||
"homeTimeline": "Yar friends' bottles"
|
||||
}
|
||||
}
|
1
frontend/src/vite-env.d.ts
vendored
1
frontend/src/vite-env.d.ts
vendored
|
@ -1,2 +1 @@
|
|||
/// <reference types="svelte" />
|
||||
/// <reference types="vite/client" />
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
export default {
|
||||
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess(),
|
||||
};
|
|
@ -1,6 +1,11 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{svelte,js,ts,jsx,tsx}"],
|
||||
content: [
|
||||
"../web/frontend/app.html",
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
|
|
|
@ -1,20 +1,36 @@
|
|||
{
|
||||
"extends": "@tsconfig/svelte/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
/**
|
||||
* Typecheck JS in `.svelte` and `.js` files by default.
|
||||
* Disable checkJs if you'd like to use dynamic types in JS.
|
||||
* Note that setting allowJs false does not prevent the use
|
||||
* of JS in `.svelte` files.
|
||||
*/
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"isolatedModules": true
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"react": ["./node_modules/preact/compat/"],
|
||||
"react-dom": ["./node_modules/preact/compat/"],
|
||||
"$/*": ["./src/*"],
|
||||
"$lib/*": ["src/lib/*"],
|
||||
"$components/*": ["src/components/*"],
|
||||
"$public/*": ["public/*"],
|
||||
"$pages/*": ["src/pages/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.js", "src/**/*.svelte"],
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
|
|
@ -3,7 +3,8 @@
|
|||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler"
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
|
|
@ -1,10 +1,20 @@
|
|||
import { defineConfig } from "vite";
|
||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||
import preact from "@preact/preset-vite";
|
||||
import { resolve } from "path";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
plugins: [preact()],
|
||||
build: {
|
||||
manifest: "manifest.json",
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
$: resolve(__dirname, "./src/"),
|
||||
$lib: resolve(__dirname, "./src/lib/"),
|
||||
$components: resolve(__dirname, "./src/components/"),
|
||||
$pages: resolve(__dirname, "./src/pages/"),
|
||||
$public: resolve(__dirname, "./public/"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -4,9 +4,14 @@
|
|||
|
||||
create table account_follows (
|
||||
account_id text not null references accounts (id) on delete cascade,
|
||||
blog_id text not null references blogs (id) on delete cascade
|
||||
blog_id text not null references blogs (id) on delete cascade,
|
||||
|
||||
primary key (account_id, blog_id)
|
||||
);
|
||||
|
||||
create index account_follows_account_id_idx on account_follows (account_id);
|
||||
|
||||
-- +migrate Down
|
||||
|
||||
drop index account_follows_account_id_idx;
|
||||
drop table account_follows;
|
||||
|
|
|
@ -19,22 +19,26 @@ func NewTimelineStore(q Querier) *TimelineStore {
|
|||
}
|
||||
|
||||
type TimelinePost struct {
|
||||
database.Post
|
||||
database.Blog
|
||||
Post database.Post `db:"p"`
|
||||
Blog database.Blog `db:"b"`
|
||||
Account database.Account `db:"a"`
|
||||
}
|
||||
|
||||
func (s *TimelineStore) Home(ctx context.Context, accountID ulid.ULID, limit int, before, after *ulid.ULID) ([]TimelinePost, error) {
|
||||
q := sqlf.Sprintf("SELECT p.*, b.account_id, b.name, b.domain FROM posts p JOIN blogs b ON b.id = p.blog_id")
|
||||
q := sqlf.Sprintf(`SELECT p.id as "p.id", p.blog_id as "p.blog_id", p.content as "p.content", p.source as "p.source", p.visibility as "p.visibility",
|
||||
b.id as "b.id", b.name as "b.name", b.domain as "b.domain", b.bio as "b.bio", b.account_id as "b.account_id",
|
||||
a.id as "a.id", a.username as "a.username", a.domain as "a.domain"
|
||||
FROM posts p JOIN blogs b ON b.id = p.blog_id JOIN accounts a on a.id = b.account_id`)
|
||||
|
||||
q = sqlf.Sprintf("%v WHERE (blog_id IN (%s) OR blog_id IN (%s))", q,
|
||||
sqlf.Sprintf("SELECT id FROM blogs WHERE account_id = %s", accountID),
|
||||
sqlf.Sprintf("SELECT blog_id FROM account_follows WHERE account_id = %s", accountID))
|
||||
|
||||
if before != nil {
|
||||
q = sqlf.Sprintf("%v AND id < %s", q, *before)
|
||||
q = sqlf.Sprintf("%v AND p.id < %s", q, *before)
|
||||
}
|
||||
if after != nil {
|
||||
q = sqlf.Sprintf("%v AND id > %s", q, *after)
|
||||
q = sqlf.Sprintf("%v AND p.id > %s", q, *after)
|
||||
}
|
||||
|
||||
q = sqlf.Sprintf("%v AND (visibility != %s OR (b.account_id = %s OR %s IN (%s)))", q, database.DirectVisibility, accountID, accountID,
|
||||
|
@ -43,7 +47,7 @@ func (s *TimelineStore) Home(ctx context.Context, accountID ulid.ULID, limit int
|
|||
if limit <= 0 || limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
q = sqlf.Sprintf("%v ORDER BY id DESC LIMIT %d", q, limit)
|
||||
q = sqlf.Sprintf("%v ORDER BY p.id DESC LIMIT %d", q, limit)
|
||||
|
||||
return Select[TimelinePost](ctx, s.q, q)
|
||||
}
|
||||
|
|
2871
pnpm-lock.yaml
2871
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
@ -12,13 +12,7 @@ type Blog struct {
|
|||
Domain *string `json:"domain"`
|
||||
Bio string `json:"bio"`
|
||||
|
||||
Account blogPartialAccount `json:"account"`
|
||||
}
|
||||
|
||||
type blogPartialAccount struct {
|
||||
ID ulid.ULID `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Domain *string `json:"domain"`
|
||||
Account Account `json:"account"`
|
||||
}
|
||||
|
||||
func DBBlogToBlog(b database.Blog, a database.Account) Blog {
|
||||
|
@ -27,10 +21,6 @@ func DBBlogToBlog(b database.Blog, a database.Account) Blog {
|
|||
Name: b.Name,
|
||||
Domain: b.Domain,
|
||||
Bio: b.Bio,
|
||||
Account: blogPartialAccount{
|
||||
ID: a.ID,
|
||||
Username: a.Username,
|
||||
Domain: a.Domain,
|
||||
},
|
||||
Account: DBAccountToAccount(a),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,25 +11,15 @@ type Post struct {
|
|||
Source *string `json:"source"`
|
||||
Visibility database.PostVisibility `json:"visibility"`
|
||||
|
||||
Blog postPartialBlog `json:"blog"`
|
||||
Blog Blog `json:"blog"`
|
||||
}
|
||||
|
||||
type postPartialBlog struct {
|
||||
ID ulid.ULID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Domain *string `json:"domain"`
|
||||
}
|
||||
|
||||
func DBPostToPost(p database.Post, b database.Blog) Post {
|
||||
func DBPostToPost(p database.Post, b database.Blog, a database.Account) Post {
|
||||
return Post{
|
||||
ID: p.ID,
|
||||
Content: p.Content,
|
||||
Source: p.Source,
|
||||
Visibility: p.Visibility,
|
||||
Blog: postPartialBlog{
|
||||
ID: p.BlogID,
|
||||
Name: b.Name,
|
||||
Domain: b.Domain,
|
||||
},
|
||||
Blog: DBBlogToBlog(b, a),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -75,6 +75,12 @@ func (app *App) Create(w http.ResponseWriter, r *http.Request) (api.Post, error)
|
|||
return api.Post{}, err
|
||||
}
|
||||
|
||||
acct, err := app.Account(conn).ByID(ctx, blog.AccountID)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("fetching account")
|
||||
return api.Post{}, err
|
||||
}
|
||||
|
||||
if blog.AccountID != token.UserID {
|
||||
return api.Post{}, api.Error{Code: api.ErrNotYourObject}
|
||||
}
|
||||
|
@ -87,5 +93,5 @@ func (app *App) Create(w http.ResponseWriter, r *http.Request) (api.Post, error)
|
|||
}
|
||||
|
||||
// TODO: federate post + push to websockets
|
||||
return api.DBPostToPost(post, blog), nil
|
||||
return api.DBPostToPost(post, blog, acct), nil
|
||||
}
|
||||
|
|
|
@ -40,5 +40,11 @@ func (app *App) GetID(w http.ResponseWriter, r *http.Request) (api.Post, error)
|
|||
return api.Post{}, err
|
||||
}
|
||||
|
||||
return api.DBPostToPost(post, blog), nil
|
||||
acct, err := app.Account(conn).ByID(ctx, blog.AccountID)
|
||||
if err != nil {
|
||||
log.Err(err).Str("id", blog.AccountID.String()).Msg("fetching account from database")
|
||||
return api.Post{}, err
|
||||
}
|
||||
|
||||
return api.DBPostToPost(post, blog, acct), nil
|
||||
}
|
||||
|
|
|
@ -36,11 +36,12 @@ func (app *App) Home(w http.ResponseWriter, r *http.Request) (timelineResponse,
|
|||
posts, err := app.Timeline().Home(ctx, token.UserID, limit, before, after)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("getting posts from database")
|
||||
return timelineResponse{}, err
|
||||
}
|
||||
|
||||
resp := timelineResponse{Posts: make([]api.Post, len(posts))}
|
||||
for i := range posts {
|
||||
resp.Posts[i] = api.DBPostToPost(posts[i].Post, posts[i].Blog)
|
||||
resp.Posts[i] = api.DBPostToPost(posts[i].Post, posts[i].Blog, posts[i].Account)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
|
|
|
@ -61,9 +61,11 @@ func (app *App) APIAuth(scope database.TokenScope, anonAccess bool) func(next ht
|
|||
render.JSON(w, r, api.Error{
|
||||
Code: api.ErrInvalidToken,
|
||||
Message: api.ErrCodeMessage(api.ErrInvalidToken),
|
||||
Details: "No token supplied",
|
||||
})
|
||||
return
|
||||
}
|
||||
header = cookie.Value
|
||||
}
|
||||
|
||||
token, err := app.ParseToken(r.Context(), header)
|
||||
|
@ -72,6 +74,7 @@ func (app *App) APIAuth(scope database.TokenScope, anonAccess bool) func(next ht
|
|||
render.JSON(w, r, api.Error{
|
||||
Code: api.ErrInvalidToken,
|
||||
Message: api.ErrCodeMessage(api.ErrInvalidToken),
|
||||
Details: "Could not parse token",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
@ -81,6 +84,7 @@ func (app *App) APIAuth(scope database.TokenScope, anonAccess bool) func(next ht
|
|||
render.JSON(w, r, api.Error{
|
||||
Code: api.ErrInvalidToken,
|
||||
Message: api.ErrCodeMessage(api.ErrInvalidToken),
|
||||
Details: "Token is expired",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
@ -7,7 +7,7 @@
|
|||
|
||||
{{.Vue.RenderTags}}
|
||||
</head>
|
||||
<body>
|
||||
<body class="bg-background-200 dark:bg-background-900 text-textLight dark:text-textDark">
|
||||
<div id="app"></div>
|
||||
|
||||
<script type="text/json" id="accountData">{{.AccountData}}</script>
|
||||
|
|
|
@ -40,8 +40,8 @@ func New(app *app.App) *Frontend {
|
|||
glue, err := vueglue.NewVueGlue(&vueglue.ViteConfig{
|
||||
Environment: "development",
|
||||
AssetsPath: "frontend",
|
||||
EntryPoint: "src/main.ts",
|
||||
Platform: "svelte",
|
||||
EntryPoint: "src/main.tsx",
|
||||
Platform: "vue",
|
||||
FS: os.DirFS("frontend"),
|
||||
})
|
||||
if err != nil {
|
||||
|
@ -55,8 +55,8 @@ func New(app *app.App) *Frontend {
|
|||
Environment: "production",
|
||||
URLPrefix: "/assets/",
|
||||
AssetsPath: "dist",
|
||||
EntryPoint: "src/main.ts",
|
||||
Platform: "svelte",
|
||||
EntryPoint: "src/main.tsx",
|
||||
Platform: "vue",
|
||||
FS: frontend.Embed,
|
||||
})
|
||||
if err != nil {
|
||||
|
|
Loading…
Reference in a new issue