Compare commits
3 commits
main
...
rename-use
Author | SHA1 | Date | |
---|---|---|---|
cd451a685d | |||
e8eaeab7dd | |||
0ab5a87c77 |
|
@ -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")
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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"])
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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") {
|
||||||
|
|
|
@ -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")}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(() => {
|
||||||
|
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue