feat: frontend layout skeleton
This commit is contained in:
parent
2e4b8b9823
commit
9c5a9a72d0
20 changed files with 1401 additions and 87 deletions
35
backend/db/field.go
Normal file
35
backend/db/field.go
Normal 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
|
||||||
|
}
|
|
@ -18,16 +18,12 @@ type GetUserResponse struct {
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 |
|
@ -0,0 +1,3 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
5
frontend/src/lib/Container.tsx
Normal file
5
frontend/src/lib/Container.tsx
Normal 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>;
|
||||||
|
}
|
91
frontend/src/lib/Navigation.tsx
Normal file
91
frontend/src/lib/Navigation.tsx
Normal 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
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
45
frontend/src/lib/logo.tsx
Normal file
File diff suppressed because one or more lines are too long
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
||||||
|
|
23
frontend/src/pages/Home.tsx
Normal file
23
frontend/src/pages/Home.tsx
Normal 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;
|
51
frontend/src/pages/User.tsx
Normal file
51
frontend/src/pages/User.tsx
Normal 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;
|
|
@ -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
6
postcss.config.js
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
8
tailwind.config.js
Normal file
8
tailwind.config.js
Normal 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")],
|
||||||
|
};
|
0
frontend/src/vite-env.d.ts → vite-env.d.ts
vendored
0
frontend/src/vite-env.d.ts → vite-env.d.ts
vendored
Loading…
Reference in a new issue