Compare commits

...

3 commits

16 changed files with 278 additions and 130 deletions

View file

@ -0,0 +1,26 @@
"""add user display_name
Revision ID: 99a3d2eb08f9
Revises: a9627f63fbed
Create Date: 2022-09-13 07:30:18.747880
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "99a3d2eb08f9"
down_revision = "a9627f63fbed"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"user", sa.Column("display_name", sa.String, nullable=True), schema="public"
)
def downgrade():
op.drop_column("user", "display_name", schema="public")

View file

@ -3,7 +3,7 @@ from contextvars import ContextVar
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime from datetime import datetime
import os import os
from os.path import join, dirname from os.path import exists, join, dirname
from json import loads from json import loads
import re import re
import math import math
@ -12,6 +12,7 @@ import random
import string import string
import secrets import secrets
from slugify import slugify from slugify import slugify
import logging
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -37,6 +38,8 @@ from sqlalchemy import (
from sqlalchemy.dialects.postgresql import HSTORE, UUID from sqlalchemy.dialects.postgresql import HSTORE, UUID
log = logging.getLogger(__name__)
Base = declarative_base() Base = declarative_base()
@ -351,6 +354,7 @@ class User(Base):
updated_at = Column(DateTime, nullable=False, server_default=NOW, onupdate=NOW) updated_at = Column(DateTime, nullable=False, server_default=NOW, onupdate=NOW)
sub = Column(String, unique=True, nullable=False) sub = Column(String, unique=True, nullable=False)
username = Column(String, unique=True, nullable=False) username = Column(String, unique=True, nullable=False)
display_name = Column(String, nullable=True)
email = Column(String, nullable=False) email = Column(String, nullable=False)
bio = Column(TEXT) bio = Column(TEXT)
image = Column(String) image = Column(String)
@ -371,11 +375,38 @@ class User(Base):
self.api_key = secrets.token_urlsafe(24) self.api_key = secrets.token_urlsafe(24)
def to_dict(self, for_user_id=None): def to_dict(self, for_user_id=None):
return { result = {
"username": self.username, "id": self.id,
"displayName": self.display_name or self.username,
"bio": self.bio, "bio": self.bio,
"image": self.image, "image": self.image,
} }
if for_user_id == self.id:
result["username"] = self.username
return result
async def rename(self, config, new_name):
old_name = self.username
renames = [
(join(basedir, old_name), join(basedir, new_name))
for basedir in [config.PROCESSING_OUTPUT_DIR, config.TRACKS_DIR]
]
for src, dst in renames:
if exists(dst):
raise FileExistsError(
f"cannot move {src!r} to {dst!r}, destination exists"
)
for src, dst in renames:
if not exists(src):
log.debug("Rename user %s: Not moving %s, not found", self.id, src)
else:
log.info("Rename user %s: Moving %s to %s", self.id, src, dst)
os.rename(src, dst)
self.username = new_name
class Comment(Base): class Comment(Base):
@ -403,7 +434,10 @@ class Comment(Base):
Comment.author = relationship("User", back_populates="authored_comments") Comment.author = relationship("User", back_populates="authored_comments")
User.authored_comments = relationship( User.authored_comments = relationship(
"Comment", order_by=Comment.created_at, back_populates="author", passive_deletes=True "Comment",
order_by=Comment.created_at,
back_populates="author",
passive_deletes=True,
) )
Track.author = relationship("User", back_populates="authored_tracks") Track.author = relationship("User", back_populates="authored_tracks")
@ -418,7 +452,10 @@ Track.comments = relationship(
OvertakingEvent.track = relationship("Track", back_populates="overtaking_events") OvertakingEvent.track = relationship("Track", back_populates="overtaking_events")
Track.overtaking_events = relationship( Track.overtaking_events = relationship(
"OvertakingEvent", order_by=OvertakingEvent.time, back_populates="track", passive_deletes=True "OvertakingEvent",
order_by=OvertakingEvent.time,
back_populates="track",
passive_deletes=True,
) )

View file

@ -1,5 +1,6 @@
import asyncio import asyncio
import logging import logging
import re
from requests.exceptions import RequestException from requests.exceptions import RequestException
@ -91,6 +92,15 @@ async def login_redirect(req):
preferred_username = userinfo["preferred_username"] preferred_username = userinfo["preferred_username"]
email = userinfo.get("email") email = userinfo.get("email")
clean_username = re.sub(r"[^a-zA-Z0-9_.-]", "", preferred_username)
if clean_username != preferred_username:
log.warning(
"Username %r contained invalid characters and was changed to %r",
preferred_username,
clean_username,
)
preferred_username = clean_username
if email is None: if email is None:
raise ValueError( raise ValueError(
"user has no email set, please configure keycloak to require emails" "user has no email set, please configure keycloak to require emails"
@ -128,16 +138,20 @@ async def login_redirect(req):
user = User(sub=sub, username=preferred_username, email=email) user = User(sub=sub, username=preferred_username, email=email)
req.ctx.db.add(user) req.ctx.db.add(user)
else: else:
log.info("Logged in known user (id: %s, sub: %s).", user.id, user.sub) log.info(
"Logged in known user (id: %s, sub: %s, %s).",
user.id,
user.sub,
preferred_username,
)
if email != user.email: if email != user.email:
log.debug("Updating user (id: %s) email from auth system.", user.id) log.debug("Updating user (id: %s) email from auth system.", user.id)
user.email = email user.email = email
# TODO: re-add username change when we can safely rename users if preferred_username != user.username:
# if preferred_username != user.username: log.debug("Updating user (id: %s) username from auth system.", user.id)
# log.debug("Updating user (id: %s) username from auth system.", user.id) await user.rename(req.app.config, preferred_username)
# user.username = preferred_username
await req.ctx.db.commit() await req.ctx.db.commit()
@ -156,4 +170,4 @@ async def logout(req):
auth_req = client.construct_EndSessionRequest(state=session["state"]) auth_req = client.construct_EndSessionRequest(state=session["state"])
logout_url = auth_req.request(client.end_session_endpoint) logout_url = auth_req.request(client.end_session_endpoint)
return redirect(logout_url+f"&redirect_uri={req.ctx.api_url}/logout") return redirect(logout_url + f"&redirect_uri={req.ctx.api_url}/logout")

View file

@ -66,12 +66,9 @@ def get_filter_options(
* start (datetime|None) * start (datetime|None)
* end (datetime|None) * end (datetime|None)
""" """
user_id = None user_id = req.ctx.get_single_arg("user", default=None, convert=int)
username = req.ctx.get_single_arg("user", default=None) if user_id is not None and (req.ctx.user is None or req.ctx.user.id != user_id):
if username is not None: raise Forbidden()
if req.ctx.user is None or req.ctx.user.username != username:
raise Forbidden()
user_id = req.ctx.user.id
parse_date = lambda s: dateutil.parser.parse(s) parse_date = lambda s: dateutil.parser.parse(s)
start = req.ctx.get_single_arg("start", default=None, convert=parse_date) start = req.ctx.get_single_arg("start", default=None, convert=parse_date)

View file

@ -63,13 +63,13 @@ async def _return_tracks(req, extend_query, limit, offset):
async def get_tracks(req): async def get_tracks(req):
limit = req.ctx.get_single_arg("limit", default=20, convert=int) limit = req.ctx.get_single_arg("limit", default=20, convert=int)
offset = req.ctx.get_single_arg("offset", default=0, convert=int) offset = req.ctx.get_single_arg("offset", default=0, convert=int)
author = req.ctx.get_single_arg("author", default=None, convert=str) # author = req.ctx.get_single_arg("author", default=None, convert=int)
def extend_query(q): def extend_query(q):
q = q.where(Track.public) q = q.where(Track.public)
if author is not None: # if author is not None:
q = q.where(User.username == author) # q = q.where(Track.author_id == author)
return q return q

View file

@ -12,7 +12,9 @@ from obs.api import __version__ as version
def user_to_json(user): def user_to_json(user):
return { return {
"id": user.id,
"username": user.username, "username": user.username,
"displayName": user.display_name,
"email": user.email, "email": user.email,
"bio": user.bio, "bio": user.bio,
"image": user.image, "image": user.image,
@ -36,6 +38,9 @@ async def put_user(req):
if key in data and isinstance(data[key], (str, type(None))): if key in data and isinstance(data[key], (str, type(None))):
setattr(user, key, data[key]) setattr(user, key, data[key])
if "displayName" in data:
user.display_name = data["displayName"] or None
if "areTracksVisibleForAll" in data: if "areTracksVisibleForAll" in data:
user.are_tracks_visible_for_all = bool(data["areTracksVisibleForAll"]) user.are_tracks_visible_for_all = bool(data["areTracksVisibleForAll"])

View file

@ -1,39 +1,42 @@
import React from 'react' import React from "react";
import {Comment} from 'semantic-ui-react' import { Comment } from "semantic-ui-react";
import classnames from 'classnames' import classnames from "classnames";
import './styles.less' import "./styles.less";
function hashCode(s) { function hashCode(s) {
let hash = 0 let hash = 0;
for (let i = 0; i < s.length; i++) { for (let i = 0; i < s.length; i++) {
hash = (hash << 5) - hash + s.charCodeAt(i) hash = (hash << 5) - hash + s.charCodeAt(i);
hash |= 0 hash |= 0;
} }
return hash return hash;
} }
function getColor(s) { function getColor(s) {
const h = Math.floor(hashCode(s)) % 360 const h = Math.floor(hashCode(s)) % 360;
return `hsl(${h}, 50%, 50%)` return `hsl(${h}, 50%, 50%)`;
} }
export default function Avatar({user, className}) { export default function Avatar({ user, className }) {
const {image, username} = user || {} const { image, displayName } = user || {};
if (image) { if (image) {
return <Comment.Avatar src={image} className={className} /> return <Comment.Avatar src={image} className={className} />;
} }
if (!username) { if (!displayName) {
return <div className={classnames(className, 'avatar', 'empty-avatar')} /> return <div className={classnames(className, "avatar", "empty-avatar")} />;
} }
const color = getColor(username) const color = getColor(displayName);
return ( return (
<div className={classnames(className, 'avatar', 'text-avatar')} style={{background: color}}> <div
{username && <span>{username[0]}</span>} className={classnames(className, "avatar", "text-avatar")}
style={{ background: color }}
>
{displayName && <span>{displayName[0]}</span>}
</div> </div>
) );
} }

View file

@ -169,7 +169,7 @@ function MapPage({ login }) {
const query = new URLSearchParams(); const query = new URLSearchParams();
if (login) { if (login) {
if (mapConfig.filters.currentUser) { if (mapConfig.filters.currentUser) {
query.append("user", login.username); query.append("user", login.id);
} }
if (mapConfig.filters.dateMode === "range") { if (mapConfig.filters.dateMode === "range") {

View file

@ -68,18 +68,37 @@ const SettingsPage = connect((state) => ({ login: state.login }), { setLogin })(
<Grid.Column width={8}> <Grid.Column width={8}>
<Header as="h2">{t("SettingsPage.profile.title")}</Header> <Header as="h2">{t("SettingsPage.profile.title")}</Header>
<Message info>{t("SettingsPage.profile.publicNotice")}</Message>
<Form onSubmit={handleSubmit(onSave)} loading={loading}> <Form onSubmit={handleSubmit(onSave)} loading={loading}>
<Ref innerRef={findInput(register)}> <Form.Field error={errors?.username}>
<Form.Input <label>{t("SettingsPage.profile.username.label")}</label>
error={errors?.username} <Ref innerRef={findInput(register)}>
label={t("SettingsPage.profile.username.label")} <Input
name="username" name="username"
defaultValue={login.username} defaultValue={login.username}
disabled disabled
/> />
</Ref> </Ref>
<small>{t("SettingsPage.profile.username.hint")}</small>
</Form.Field>
<Message info visible>
{t("SettingsPage.profile.publicNotice")}
</Message>
<Form.Field error={errors?.displayName}>
<label>{t("SettingsPage.profile.displayName.label")}</label>
<Ref innerRef={findInput(register)}>
<Input
name="displayName"
defaultValue={login.displayName}
placeholder={login.username}
/>
</Ref>
<small>
{t("SettingsPage.profile.displayName.fallbackNotice")}
</small>
</Form.Field>
<Form.Field error={errors?.bio}> <Form.Field error={errors?.bio}>
<label>{t("SettingsPage.profile.bio.label")}</label> <label>{t("SettingsPage.profile.bio.label")}</label>
<Ref innerRef={register}> <Ref innerRef={register}>
@ -103,7 +122,7 @@ const SettingsPage = connect((state) => ({ login: state.login }), { setLogin })(
<Divider /> <Divider />
<Stats user={login.username} /> <Stats user={login.id} />
</Grid.Column> </Grid.Column>
</Grid.Row> </Grid.Row>
</Grid> </Grid>
@ -140,7 +159,7 @@ function CopyInput({ value, ...props }) {
} }
position="top right" position="top right"
open={success != null} open={success != null}
content={success ? t('general.copied') : t('general.copyError')} content={success ? t("general.copied") : t("general.copyError")}
/> />
); );
} }

View file

@ -76,7 +76,7 @@ const TrackEditor = connect((state) => ({ login: state.login }))(
); );
const loading = busy || track == null; const loading = busy || track == null;
const isAuthor = login?.username === track?.author?.username; const isAuthor = login?.id === track?.author?.id;
// Navigate to track detials if we are not the author // Navigate to track detials if we are not the author
React.useEffect(() => { React.useEffect(() => {

View file

@ -60,7 +60,9 @@ export default function TrackComments({
<Comment key={comment.id}> <Comment key={comment.id}>
<Avatar user={comment.author} /> <Avatar user={comment.author} />
<Comment.Content> <Comment.Content>
<Comment.Author as="a">{comment.author.username}</Comment.Author> <Comment.Author as="a">
{comment.author.displayName}
</Comment.Author>
<Comment.Metadata> <Comment.Metadata>
<div> <div>
<FormattedDate date={comment.createdAt} relative /> <FormattedDate date={comment.createdAt} relative />
@ -69,7 +71,7 @@ export default function TrackComments({
<Comment.Text> <Comment.Text>
<Markdown>{comment.body}</Markdown> <Markdown>{comment.body}</Markdown>
</Comment.Text> </Comment.Text>
{login?.username === comment.author.username && ( {login?.id === comment.author.id && (
<Comment.Actions> <Comment.Actions>
<Comment.Action <Comment.Action
onClick={(e) => { onClick={(e) => {
@ -77,7 +79,7 @@ export default function TrackComments({
e.preventDefault(); e.preventDefault();
}} }}
> >
{t('general.delete')} {t("general.delete")}
</Comment.Action> </Comment.Action>
</Comment.Actions> </Comment.Actions>
)} )}

View file

@ -246,7 +246,7 @@ const TrackPage = connect((state) => ({ login: state.login }))(
[slug] [slug]
); );
const isAuthor = login?.username === data?.track?.author?.username; const isAuthor = login?.id === data?.track?.author?.id;
const { track, trackData, comments } = data || {}; const { track, trackData, comments } = data || {};
@ -355,7 +355,7 @@ const TrackPage = connect((state) => ({ login: state.login }))(
onConfirm={hideDownloadError} onConfirm={hideDownloadError}
header={t("TrackPage.downloadFailed")} header={t("TrackPage.downloadFailed")}
content={String(downloadError)} content={String(downloadError)}
confirmButton={t('general.ok')} confirmButton={t("general.ok")}
/> />
</Page> </Page>
); );

View file

@ -1,49 +1,65 @@
import React, {useCallback} from 'react' import React, { useCallback } from "react";
import {connect} from 'react-redux' import { connect } from "react-redux";
import {Button, Message, Item, Header, Loader, Pagination, Icon} from 'semantic-ui-react' import {
import {useObservable} from 'rxjs-hooks' Button,
import {Link} from 'react-router-dom' Message,
import {of, from, concat} from 'rxjs' Item,
import {map, switchMap, distinctUntilChanged} from 'rxjs/operators' Header,
import _ from 'lodash' Loader,
import {useTranslation, Trans as Translate} from 'react-i18next' Pagination,
Icon,
} from "semantic-ui-react";
import { useObservable } from "rxjs-hooks";
import { Link } from "react-router-dom";
import { of, from, concat } from "rxjs";
import { map, switchMap, distinctUntilChanged } from "rxjs/operators";
import _ from "lodash";
import { useTranslation, Trans as Translate } from "react-i18next";
import type {Track} from 'types' import type { Track } from "types";
import {Avatar, Page, StripMarkdown, FormattedDate, Visibility} from 'components' import {
import api from 'api' Avatar,
import {useQueryParam} from 'query' Page,
StripMarkdown,
FormattedDate,
Visibility,
} from "components";
import api from "api";
import { useQueryParam } from "query";
function TrackList({privateTracks}: {privateTracks: boolean}) { function TrackList({ privateTracks }: { privateTracks: boolean }) {
const [page, setPage] = useQueryParam<number>('page', 1, Number) const [page, setPage] = useQueryParam<number>("page", 1, Number);
const pageSize = 10 const pageSize = 10;
const data: { const data: {
tracks: Track[] tracks: Track[];
trackCount: number trackCount: number;
} | null = useObservable( } | null = useObservable(
(_$, inputs$) => (_$, inputs$) =>
inputs$.pipe( inputs$.pipe(
map(([page, privateTracks]) => { map(([page, privateTracks]) => {
const url = '/tracks' + (privateTracks ? '/feed' : '') const url = "/tracks" + (privateTracks ? "/feed" : "");
const query = {limit: pageSize, offset: pageSize * (page - 1)} const query = { limit: pageSize, offset: pageSize * (page - 1) };
return {url, query} return { url, query };
}), }),
distinctUntilChanged(_.isEqual), distinctUntilChanged(_.isEqual),
switchMap((request) => concat(of(null), from(api.get(request.url, {query: request.query})))) switchMap((request) =>
concat(of(null), from(api.get(request.url, { query: request.query })))
)
), ),
null, null,
[page, privateTracks] [page, privateTracks]
) );
const {tracks, trackCount} = data || {tracks: [], trackCount: 0} const { tracks, trackCount } = data || { tracks: [], trackCount: 0 };
const loading = !data const loading = !data;
const totalPages = Math.ceil(trackCount / pageSize) const totalPages = Math.ceil(trackCount / pageSize);
const {t} = useTranslation() const { t } = useTranslation();
return ( return (
<div> <div>
<Loader content={t('general.loading')} active={loading} /> <Loader content={t("general.loading")} active={loading} />
{!loading && totalPages > 1 && ( {!loading && totalPages > 1 && (
<Pagination <Pagination
activePage={page} activePage={page}
@ -55,14 +71,14 @@ function TrackList({privateTracks}: {privateTracks: boolean}) {
{tracks && tracks.length ? ( {tracks && tracks.length ? (
<Item.Group divided> <Item.Group divided>
{tracks.map((track: Track) => ( {tracks.map((track: Track) => (
<TrackListItem key={track.slug} {...{track, privateTracks}} /> <TrackListItem key={track.slug} {...{ track, privateTracks }} />
))} ))}
</Item.Group> </Item.Group>
) : ( ) : (
<NoPublicTracksMessage /> <NoPublicTracksMessage />
)} )}
</div> </div>
) );
} }
export function NoPublicTracksMessage() { export function NoPublicTracksMessage() {
@ -72,27 +88,27 @@ export function NoPublicTracksMessage() {
No public tracks yet. <Link to="/upload">Upload the first!</Link> No public tracks yet. <Link to="/upload">Upload the first!</Link>
</Translate> </Translate>
</Message> </Message>
) );
} }
function maxLength(t: string|null, max: number): string|null { function maxLength(t: string | null, max: number): string | null {
if (t && t.length > max) { if (t && t.length > max) {
return t.substring(0, max) + ' ...' return t.substring(0, max) + " ...";
} else { } else {
return t return t;
} }
} }
const COLOR_BY_STATUS = { const COLOR_BY_STATUS = {
error: 'red', error: "red",
complete: 'green', complete: "green",
created: 'gray', created: "gray",
queued: 'orange', queued: "orange",
processing: 'orange', processing: "orange",
} };
export function TrackListItem({track, privateTracks = false}) { export function TrackListItem({ track, privateTracks = false }) {
const {t} = useTranslation() const { t } = useTranslation();
return ( return (
<Item key={track.slug}> <Item key={track.slug}>
@ -101,10 +117,14 @@ export function TrackListItem({track, privateTracks = false}) {
</Item.Image> </Item.Image>
<Item.Content> <Item.Content>
<Item.Header as={Link} to={`/tracks/${track.slug}`}> <Item.Header as={Link} to={`/tracks/${track.slug}`}>
{track.title || t('general.unnamedTrack')} {track.title || t("general.unnamedTrack")}
</Item.Header> </Item.Header>
<Item.Meta> <Item.Meta>
{privateTracks ? null : <span>{t('TracksPage.createdBy', {author: track.author.username})}</span>} {privateTracks ? null : (
<span>
{t("TracksPage.createdBy", { author: track.author.displayName })}
</span>
)}
<span> <span>
<FormattedDate date={track.createdAt} /> <FormattedDate date={track.createdAt} />
</span> </span>
@ -116,45 +136,57 @@ export function TrackListItem({track, privateTracks = false}) {
<Item.Extra> <Item.Extra>
<Visibility public={track.public} /> <Visibility public={track.public} />
<span style={{marginLeft: '1em'}}> <span style={{ marginLeft: "1em" }}>
<Icon color={COLOR_BY_STATUS[track.processingStatus]} name="bolt" fitted /> <Icon
{' '} color={COLOR_BY_STATUS[track.processingStatus]}
name="bolt"
fitted
/>{" "}
{t(`TracksPage.processing.${track.processingStatus}`)} {t(`TracksPage.processing.${track.processingStatus}`)}
</span> </span>
</Item.Extra> </Item.Extra>
)} )}
</Item.Content> </Item.Content>
</Item> </Item>
) );
} }
function UploadButton({navigate, ...props}) { function UploadButton({ navigate, ...props }) {
const {t} = useTranslation() const { t } = useTranslation();
const onClick = useCallback( const onClick = useCallback(
(e) => { (e) => {
e.preventDefault() e.preventDefault();
navigate() navigate();
}, },
[navigate] [navigate]
) );
return ( return (
<Button onClick={onClick} {...props} color="green" style={{float: 'right'}}> <Button
{t('TracksPage.upload')} onClick={onClick}
{...props}
color="green"
style={{ float: "right" }}
>
{t("TracksPage.upload")}
</Button> </Button>
) );
} }
const TracksPage = connect((state) => ({login: (state as any).login}))(function TracksPage({login, privateTracks}) { const TracksPage = connect((state) => ({ login: (state as any).login }))(
const {t} = useTranslation() function TracksPage({ login, privateTracks }) {
const title = privateTracks ? t('TracksPage.titleUser') : t('TracksPage.titlePublic') const { t } = useTranslation();
const title = privateTracks
? t("TracksPage.titleUser")
: t("TracksPage.titlePublic");
return ( return (
<Page title={title}> <Page title={title}>
<Header as='h2'>{title}</Header> <Header as="h2">{title}</Header>
{privateTracks && <Link component={UploadButton} to="/upload" />} {privateTracks && <Link component={UploadButton} to="/upload" />}
<TrackList {...{privateTracks}} /> <TrackList {...{ privateTracks }} />
</Page> </Page>
) );
}) }
);
export default TracksPage export default TracksPage;

View file

@ -206,9 +206,15 @@ SettingsPage:
profile: profile:
title: Mein Profil title: Mein Profil
publicNotice: All diese Informationen sind öffentlich. publicNotice: Alle Informationen ab hier sind öffentlich.
username: username:
label: Kontoname label: Kontoname
hint: >
Der Kontoname kann beim Login-Dienst geändert werden, und wird beim
nächsten Login übernommen.
displayName:
label: Anzeigename
fallbackNotice: Sofern kein Anzeigename gewählt ist wird der Kontoname öffentlich angezeigt.
bio: bio:
label: Bio label: Bio
avatarUrl: avatarUrl:

View file

@ -210,9 +210,15 @@ SettingsPage:
profile: profile:
title: My profile title: My profile
publicNotice: All of this information is public. publicNotice: All of the information below is public.
username: username:
label: Username label: Username
hint: >
The username can be changed at the login provider and will be applied
when logging in the next time.
displayName:
label: Display name
fallbackNotice: As long as no display name is chosen, the username is shown publicly.
bio: bio:
label: Bio label: Bio
avatarUrl: avatarUrl:

View file

@ -1,7 +1,8 @@
import type {FeatureCollection, Feature, LineString, Point} from 'geojson' import type {FeatureCollection, Feature, LineString, Point} from 'geojson'
export type UserProfile = { export type UserProfile = {
username: string id: number | string
displayName: string
image?: string | null image?: string | null
bio?: string | null bio?: string | null
} }