feat: frontend layout skeleton

This commit is contained in:
Sam 2022-05-10 16:33:29 +02:00
parent 2e4b8b9823
commit 9c5a9a72d0
20 changed files with 1401 additions and 87 deletions

35
backend/db/field.go Normal file
View File

@ -0,0 +1,35 @@
package db
import (
"context"
"emperror.dev/errors"
"github.com/georgysavva/scany/pgxscan"
"github.com/rs/xid"
)
type Field struct {
ID int64 `json:"-"`
Name string `json:"name"`
Favourite []string `json:"favourite"`
Okay []string `json:"okay"`
Jokingly []string `json:"jokingly"`
FriendsOnly []string `json:"friends_only"`
Avoid []string `json:"avoid"`
}
// UserFields returns the fields associated with the given user ID.
func (db *DB) UserFields(ctx context.Context, id xid.ID) (fs []Field, err error) {
sql, args, err := sq.
Select("id", "name", "favourite", "okay", "jokingly", "friends_only", "avoid").
From("user_fields").Where("user_id = ?", id).OrderBy("id ASC").ToSql()
if err != nil {
return nil, errors.Wrap(err, "building sql")
}
err = pgxscan.Select(ctx, db, &fs, sql, args...)
if err != nil {
return nil, errors.Cause(err)
}
return fs, nil
}

View File

@ -12,22 +12,18 @@ import (
) )
type GetUserResponse struct { type GetUserResponse struct {
ID xid.ID `json:"id"` ID xid.ID `json:"id"`
Username string `json:"username"` Username string `json:"username"`
DisplayName *string `json:"display_name"` DisplayName *string `json:"display_name"`
Bio *string `json:"bio"` Bio *string `json:"bio"`
AvatarURL *string `json:"avatar_url"` AvatarURL *string `json:"avatar_url"`
Links []string `json:"links"` Links []string `json:"links"`
Members []PartialMember `json:"members"`
Fields []db.Field `json:"fields"`
} }
type GetMeResponse struct { type GetMeResponse struct {
ID xid.ID `json:"id"` GetUserResponse
Username string `json:"username"`
DisplayName *string `json:"display_name"`
Bio *string `json:"bio"`
AvatarSource *string `json:"avatar_source"`
AvatarURL *string `json:"avatar_url"`
Links []string `json:"links"`
Discord *string `json:"discord"` Discord *string `json:"discord"`
DiscordUsername *string `json:"discord_username"` DiscordUsername *string `json:"discord_username"`
@ -39,7 +35,7 @@ type PartialMember struct {
AvatarURL *string `json:"avatar_url"` AvatarURL *string `json:"avatar_url"`
} }
func dbUserToResponse(u db.User) GetUserResponse { func dbUserToResponse(u db.User, fields []db.Field) GetUserResponse {
return GetUserResponse{ return GetUserResponse{
ID: u.ID, ID: u.ID,
Username: u.Username, Username: u.Username,
@ -47,6 +43,7 @@ func dbUserToResponse(u db.User) GetUserResponse {
Bio: u.Bio, Bio: u.Bio,
AvatarURL: u.AvatarURL, AvatarURL: u.AvatarURL,
Links: u.Links, Links: u.Links,
Fields: fields,
} }
} }
@ -58,7 +55,13 @@ func (s *Server) getUser(w http.ResponseWriter, r *http.Request) error {
if id, err := xid.FromString(userRef); err == nil { if id, err := xid.FromString(userRef); err == nil {
u, err := s.DB.User(ctx, id) u, err := s.DB.User(ctx, id)
if err == nil { if err == nil {
render.JSON(w, r, dbUserToResponse(u)) fields, err := s.DB.UserFields(ctx, u.ID)
if err != nil {
log.Errorf("Error getting user fields: %v", err)
return err
}
render.JSON(w, r, dbUserToResponse(u, fields))
return nil return nil
} else if err != db.ErrUserNotFound { } else if err != db.ErrUserNotFound {
log.Errorf("Error getting user by ID: %v", err) log.Errorf("Error getting user by ID: %v", err)
@ -78,6 +81,12 @@ func (s *Server) getUser(w http.ResponseWriter, r *http.Request) error {
return err return err
} }
render.JSON(w, r, dbUserToResponse(u)) fields, err := s.DB.UserFields(ctx, u.ID)
if err != nil {
log.Errorf("Error getting user fields: %v", err)
return err
}
render.JSON(w, r, dbUserToResponse(u, fields))
return nil return nil
} }

View File

@ -4,9 +4,9 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>pronouns</title> <title>pronouns.cc</title>
</head> </head>
<body> <body class="bg-white dark:bg-slate-800 text-black dark:text-white">
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>

View File

@ -1,43 +1,21 @@
import { Routes, Route } from "react-router-dom";
import "./App.css"; import "./App.css";
import Container from "./lib/Container";
import Navigation from "./lib/Navigation";
import Home from "./pages/Home";
import User from "./pages/User";
function App() { function App() {
return ( return (
<div className="container"> <>
<div className="card"> <Navigation />
<div className="card-image"> <Container>
<figure className="image is-4by3"> <Routes>
<img <Route path="/" element={<Home />} />
src="https://bulma.io/images/placeholders/1280x960.png" <Route path="/u/:username" element={<User />} />
alt="Placeholder image" </Routes>
/> </Container>
</figure> </>
</div>
<div className="card-content">
<div className="media">
<div className="media-left">
<figure className="image is-48x48">
<img
src="https://bulma.io/images/placeholders/96x96.png"
alt="Placeholder image"
/>
</figure>
</div>
<div className="media-content">
<p className="title is-4">John Smith</p>
<p className="subtitle is-6">@johnsmith</p>
</div>
</div>
<div className="content">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus
nec iaculis mauris. <a>@bulmaio</a>.<a href="#">#css</a>{" "}
<a href="#">#responsive</a>
<br />
<time dateTime="2016-1-1">11:09 PM - 1 Jan 2016</time>
</div>
</div>
</div>
</div>
); );
} }

View File

@ -1,15 +1,2 @@
<svg width="410" height="404" viewBox="0 0 410 404" fill="none" xmlns="http://www.w3.org/2000/svg"> <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<path d="M399.641 59.5246L215.643 388.545C211.844 395.338 202.084 395.378 198.228 388.618L10.5817 59.5563C6.38087 52.1896 12.6802 43.2665 21.0281 44.7586L205.223 77.6824C206.398 77.8924 207.601 77.8904 208.776 77.6763L389.119 44.8058C397.439 43.2894 403.768 52.1434 399.641 59.5246Z" fill="url(#paint0_linear)"/> <svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 47.5 47.5" style="enable-background:new 0 0 47.5 47.5;" xml:space="preserve" version="1.1" id="svg2"><metadata id="metadata8"><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/></cc:Work></rdf:RDF></metadata><defs id="defs6"><clipPath id="clipPath16" clipPathUnits="userSpaceOnUse"><path id="path18" d="M 0,38 38,38 38,0 0,0 0,38 Z"/></clipPath></defs><g transform="matrix(1.25,0,0,-1.25,0,47.5)" id="g10"><g id="g12"><g clip-path="url(#clipPath16)" id="g14"><g transform="translate(35.3467,20.1069)" id="g20"><path id="path22" style="fill:#aa8ed6;fill-opacity:1;fill-rule:nonzero;stroke:none" d="m 0,0 -8.899,3.294 -3.323,10.891 c -0.128,0.42 -0.516,0.708 -0.956,0.708 -0.439,0 -0.828,-0.288 -0.956,-0.708 L -17.456,3.294 -26.356,0 c -0.393,-0.146 -0.653,-0.52 -0.653,-0.938 0,-0.418 0.26,-0.793 0.653,-0.937 l 8.896,-3.293 3.323,-11.223 c 0.126,-0.425 0.516,-0.716 0.959,-0.716 0.443,0 0.833,0.291 0.959,0.716 l 3.324,11.223 8.896,3.293 c 0.392,0.144 0.652,0.519 0.652,0.937 C 0.653,-0.52 0.393,-0.146 0,0"/></g><g transform="translate(15.3472,9.1064)" id="g24"><path id="path26" style="fill:#fcab40;fill-opacity:1;fill-rule:nonzero;stroke:none" d="m 0,0 -2.313,0.856 -0.9,3.3 c -0.119,0.436 -0.514,0.738 -0.965,0.738 -0.451,0 -0.846,-0.302 -0.965,-0.738 l -0.9,-3.3 L -8.356,0 c -0.393,-0.145 -0.653,-0.52 -0.653,-0.937 0,-0.418 0.26,-0.793 0.653,-0.938 l 2.301,-0.853 0.907,-3.622 c 0.111,-0.444 0.511,-0.756 0.97,-0.756 0.458,0 0.858,0.312 0.97,0.756 L -2.301,-2.728 0,-1.875 c 0.393,0.145 0.653,0.52 0.653,0.938 C 0.653,-0.52 0.393,-0.145 0,0"/></g><g transform="translate(11.0093,30.769)" id="g28"><path id="path30" style="fill:#5dadec;fill-opacity:1;fill-rule:nonzero;stroke:none" d="M 0,0 -2.365,0.875 -3.24,3.24 c -0.146,0.393 -0.52,0.653 -0.938,0.653 -0.419,0 -0.793,-0.26 -0.938,-0.653 L -5.992,0.875 -8.356,0 c -0.393,-0.146 -0.653,-0.52 -0.653,-0.938 0,-0.418 0.26,-0.792 0.653,-0.938 l 2.364,-0.875 0.876,-2.365 c 0.145,-0.393 0.519,-0.653 0.938,-0.653 0.418,0 0.792,0.26 0.938,0.653 L -2.365,-2.751 0,-1.876 c 0.393,0.146 0.653,0.52 0.653,0.938 C 0.653,-0.52 0.393,-0.146 0,0"/></g></g></g></g></svg>
<path d="M292.965 1.5744L156.801 28.2552C154.563 28.6937 152.906 30.5903 152.771 32.8664L144.395 174.33C144.198 177.662 147.258 180.248 150.51 179.498L188.42 170.749C191.967 169.931 195.172 173.055 194.443 176.622L183.18 231.775C182.422 235.487 185.907 238.661 189.532 237.56L212.947 230.446C216.577 229.344 220.065 232.527 219.297 236.242L201.398 322.875C200.278 328.294 207.486 331.249 210.492 326.603L212.5 323.5L323.454 102.072C325.312 98.3645 322.108 94.137 318.036 94.9228L279.014 102.454C275.347 103.161 272.227 99.746 273.262 96.1583L298.731 7.86689C299.767 4.27314 296.636 0.855181 292.965 1.5744Z" fill="url(#paint1_linear)"/>
<defs>
<linearGradient id="paint0_linear" x1="6.00017" y1="32.9999" x2="235" y2="344" gradientUnits="userSpaceOnUse">
<stop stop-color="#41D1FF"/>
<stop offset="1" stop-color="#BD34FE"/>
</linearGradient>
<linearGradient id="paint1_linear" x1="194.651" y1="8.81818" x2="236.076" y2="292.989" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFEA83"/>
<stop offset="0.0833333" stop-color="#FFDD35"/>
<stop offset="1" stop-color="#FFA800"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

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

View File

@ -0,0 +1,5 @@
import React from "react";
export default function Container(props: React.PropsWithChildren<{}>) {
return <div className="m-2 lg:m-4">{props.children}</div>;
}

View File

@ -0,0 +1,91 @@
import Logo from "./logo";
import { useState } from "react";
import { Link } from "react-router-dom";
import { MoonStars, Sun, List } from "react-bootstrap-icons";
function Navigation() {
const [darkTheme, setDarkTheme] = useState<boolean>(
localStorage.theme === "dark" ||
(!("theme" in localStorage) &&
window.matchMedia("(prefers-color-scheme: dark)").matches)
);
const [showMenu, setShowMenu] = useState(false);
if (darkTheme) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
const storeTheme = (system: boolean) => {
if (system) {
localStorage.removeItem("theme");
} else {
localStorage.setItem("theme", darkTheme ? "dark" : "light");
}
};
return (
<div className="bg-white/75 dark:bg-slate-800/75 w-full backdrop-blur border-b-slate-200 dark:border-b-slate-900">
<div className="max-w-8xl mx-auto">
<div className="py-4 mx-4">
<div className="flex items-center">
<Link to="/">
<Logo />
</Link>
<div className="ml-auto flex items-center">
<nav className="hidden lg:flex">
<ul className="flex space-x-8 font-bold">
<li>
<Link
className="hover:text-sky-500 dark:hover:text-sky-400"
to="/login"
>
Log in
</Link>
</li>
</ul>
</nav>
<div className="flex border-l border-slate-200 ml-4 pl-4 lg:ml-6 lg:pl-6 lg:mr-2 dark:border-slate-500 space-x-2 lg:space-x-4">
<div
onClick={() => {
storeTheme(false);
setDarkTheme(!darkTheme);
}}
title={
darkTheme ? "Switch to light mode" : "Switch to dark mode"
}
className="cursor-pointer"
>
{darkTheme ? (
<Sun className="hover:text-sky-400" size={24} />
) : (
<MoonStars size={24} className="hover:text-sky-500" />
)}
</div>
<div onClick={() => setShowMenu(!showMenu)} title="Show menu" className="cursor-pointer flex lg:hidden">
<List className="dark:hover:text-sky-400 hover:text-sky-500" size={24} />
</div>
</div>
</div>
</div>
</div>
<nav className={`lg:hidden my-2 p-4 border-slate-200 dark:border-slate-500 border-t border-b ${showMenu ? "flex" : "hidden"}`}>
<ul className="flex space-x-8 font-bold">
<li>
<Link
className="hover:text-sky-500 dark:hover:text-sky-400"
to="/login"
>
Log in
</Link>
</li>
</ul>
</nav>
</div>
</div>
);
}
export default Navigation;

16
frontend/src/lib/logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

45
frontend/src/lib/logo.tsx Normal file

File diff suppressed because one or more lines are too long

View File

@ -1,20 +1,26 @@
import axios from "axios"; import axios from "axios";
import { atom } from "recoil"; import { atom, useRecoilState, useRecoilValue } from "recoil";
import type { APIError, MeUser } from "./types"; import { APIError, ErrorCode, MeUser } from "./types";
export const userState = atom<MeUser | null>({ export const userState = atom<MeUser>({
key: "userState", key: "userState",
default: getCurrentUser(), default: getCurrentUser(),
}); });
async function getCurrentUser(): Promise<MeUser | null> { async function getCurrentUser() {
const token = localStorage.getItem("pronouns-token"); const token = localStorage.getItem("pronouns-token");
if (!token) return null; if (!token) return null;
try { try {
const resp = await axios.get<MeUser | APIError>("/api/v1/users/@me"); const resp = await axios.get<MeUser | APIError>("/api/v1/users/@me");
if ("id" in resp.data) return resp.data as MeUser; if (resp.status === 200) {
return null; return resp.data as MeUser;
}
// if we got a forbidden error, the token is invalid
if ((resp.data as APIError).code === ErrorCode.Forbidden) {
localStorage.removeItem("pronouns-token");
}
} catch (e) { } catch (e) {
console.log("Error fetching /users/@me:", e); console.log("Error fetching /users/@me:", e);
} }

View File

@ -10,6 +10,22 @@ export interface MeUser {
discord_username: string | null; discord_username: string | null;
} }
export interface User {
id: string;
username: string;
display_name: string | null;
bio: string | null;
avatar_source: string | null;
links: string[] | null;
members: PartialMember[];
}
export interface PartialMember {
id: string;
name: string;
avatar_url: string | null;
}
export interface APIError { export interface APIError {
code: ErrorCode; code: ErrorCode;
message?: string; message?: string;

View File

@ -2,11 +2,21 @@ import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import { RecoilRoot } from "recoil"; import { RecoilRoot } from "recoil";
import * as Sentry from "@sentry/react";
import { BrowserTracing } from "@sentry/tracing";
import App from "./App"; import App from "./App";
import "../../node_modules/bulma/css/bulma.css";
import "./index.css"; import "./index.css";
if (import.meta.env.VITE_SENTRY_DSN) {
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN,
integrations: [new BrowserTracing()],
tracesSampleRate: 1.0,
});
}
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<RecoilRoot> <RecoilRoot>

View File

@ -0,0 +1,23 @@
import ReactMarkdown from "react-markdown";
// this is a temporary home page, which is why the markdown content is embedded
const md = `This will (one day) be a site to create pronoun cards for yourself,
similarly to [Pronouny](https://pronouny.xyz/) and [Pronouns.page](https://en.pronouns.page/).
You'll be able to create multiple profiles that are linked together,
useful for plurality ([what?](https://morethanone.info/)) and kin, or even just for fun!
For now though, there's just this landing page <3
(And no, the "Log in" button doesn't do anything either.)
Check out the (work in progress) source code on [GitLab](https://gitlab.com/1f320/pronouns)!`;
function Home() {
return (
<div className="prose prose-slate dark:prose-invert">
<ReactMarkdown>{md}</ReactMarkdown>
</div>
);
}
export default Home;

View File

@ -0,0 +1,51 @@
import React, { useState, useEffect } from "react";
import { useParams } from "react-router-dom";
import { ArrowClockwise } from "react-bootstrap-icons";
import { selectorFamily, useRecoilValue } from "recoil";
import axios from "axios";
import type { APIError, User } from "../lib/types";
const userPageState = selectorFamily({
key: "userPageState",
get: (username: string) => async () => {
const res = await axios.get(`/api/v1/users/${username}`);
if (res.status !== 200) {
throw res.data as APIError;
}
return res.data as User;
},
});
function UserPage() {
const params = useParams();
const [user, setUser] = useState<User>(null);
useEffect(() => {
axios.get(`/api/v1/users/${params.username}`).then((res) => {
if (res.status !== 200) throw res.data as APIError;
setUser(res.data as User);
});
}, []);
if (user == null) {
return (
<>
<ArrowClockwise />
<span>Loading...</span>
</>
);
}
return (
<>
<h1 className="text-xl font-bold">
{user.username} ({user.id})
</h1>
</>
);
}
export default UserPage;

View File

@ -8,17 +8,24 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@sentry/react": "^6.19.7",
"@sentry/tracing": "^6.19.7",
"axios": "^0.27.2", "axios": "^0.27.2",
"bulma": "^0.9.3",
"react": "^18.0.0", "react": "^18.0.0",
"react-bootstrap-icons": "^1.8.2",
"react-dom": "^18.0.0", "react-dom": "^18.0.0",
"react-markdown": "^8.0.3",
"react-router-dom": "6", "react-router-dom": "6",
"recoil": "^0.7.2" "recoil": "^0.7.2"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/typography": "^0.5.2",
"@types/react": "^18.0.0", "@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0", "@types/react-dom": "^18.0.0",
"@vitejs/plugin-react": "^1.3.0", "@vitejs/plugin-react": "^1.3.0",
"autoprefixer": "^10.4.7",
"postcss": "^8.4.13",
"tailwindcss": "^3.0.24",
"typescript": "^4.6.3", "typescript": "^4.6.3",
"vite": "^2.9.7" "vite": "^2.9.7"
} }

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

8
tailwind.config.js Normal file
View File

@ -0,0 +1,8 @@
module.exports = {
darkMode: "class",
content: ["./frontend/index.html", "./frontend/src/**/*.{vue,js,ts,jsx,tsx}"],
theme: {
extend: {},
},
plugins: [require("@tailwindcss/typography")],
};

1038
yarn.lock

File diff suppressed because it is too large Load Diff