Merge branch 'feature/notices'
This commit is contained in:
commit
bad1df395a
|
@ -0,0 +1,66 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"emperror.dev/errors"
|
||||
"github.com/georgysavva/scany/v2/pgxscan"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type Notice struct {
|
||||
ID int
|
||||
Notice string
|
||||
StartTime time.Time
|
||||
EndTime time.Time
|
||||
}
|
||||
|
||||
func (db *DB) Notices(ctx context.Context) (ns []Notice, err error) {
|
||||
sql, args, err := sq.Select("*").From("notices").OrderBy("id DESC").ToSql()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "building sql")
|
||||
}
|
||||
|
||||
err = pgxscan.Select(ctx, db, &ns, sql, args...)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "executing query")
|
||||
}
|
||||
return NotNull(ns), nil
|
||||
}
|
||||
|
||||
func (db *DB) CreateNotice(ctx context.Context, notice string, start, end time.Time) (n Notice, err error) {
|
||||
sql, args, err := sq.Insert("notices").SetMap(map[string]any{
|
||||
"notice": notice,
|
||||
"start_time": start,
|
||||
"end_time": end,
|
||||
}).Suffix("RETURNING *").ToSql()
|
||||
if err != nil {
|
||||
return n, errors.Wrap(err, "building sql")
|
||||
}
|
||||
|
||||
err = pgxscan.Get(ctx, db, &n, sql, args...)
|
||||
if err != nil {
|
||||
return n, errors.Wrap(err, "executing query")
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
const ErrNoNotice = errors.Sentinel("no current notice")
|
||||
|
||||
func (db *DB) CurrentNotice(ctx context.Context) (n Notice, err error) {
|
||||
sql, args, err := sq.Select("*").From("notices").Where("end_time > ?", time.Now()).OrderBy("id DESC").Limit(1).ToSql()
|
||||
if err != nil {
|
||||
return n, errors.Wrap(err, "building sql")
|
||||
}
|
||||
|
||||
err = pgxscan.Get(ctx, db, &n, sql, args...)
|
||||
if err != nil {
|
||||
if errors.Cause(err) == pgx.ErrNoRows {
|
||||
return n, ErrNoNotice
|
||||
}
|
||||
|
||||
return n, errors.Wrap(err, "executing query")
|
||||
}
|
||||
return n, nil
|
||||
}
|
|
@ -10,6 +10,7 @@ import (
|
|||
type UserSettings struct {
|
||||
ReadChangelog string `json:"read_changelog"`
|
||||
ReadSettingsNotice string `json:"read_settings_notice"`
|
||||
ReadGlobalNotice int `json:"read_global_notice"`
|
||||
}
|
||||
|
||||
func (db *DB) UpdateUserSettings(ctx context.Context, id xid.ID, us UserSettings) error {
|
||||
|
|
|
@ -58,13 +58,9 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
|
|||
} else {
|
||||
id, err := common.ParseSnowflake(chi.URLParam(r, "memberRef"))
|
||||
if err != nil {
|
||||
log.Debugf("%v/%v is not valid snowflake", chi.URLParam(r, "memberRef"), id)
|
||||
|
||||
return server.APIError{Code: server.ErrMemberNotFound}
|
||||
}
|
||||
|
||||
log.Debugf("%v/%v is valid snowflake", chi.URLParam(r, "memberRef"), id)
|
||||
|
||||
m, err = s.DB.MemberBySnowflake(ctx, common.MemberID(id))
|
||||
if err != nil {
|
||||
if err == db.ErrMemberNotFound {
|
||||
|
|
|
@ -4,6 +4,8 @@ import (
|
|||
"net/http"
|
||||
"os"
|
||||
|
||||
"codeberg.org/pronounscc/pronouns.cc/backend/db"
|
||||
"codeberg.org/pronounscc/pronouns.cc/backend/log"
|
||||
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/render"
|
||||
|
@ -25,6 +27,12 @@ type MetaResponse struct {
|
|||
Users MetaUsers `json:"users"`
|
||||
Members int64 `json:"members"`
|
||||
RequireInvite bool `json:"require_invite"`
|
||||
Notice *MetaNotice `json:"notice"`
|
||||
}
|
||||
|
||||
type MetaNotice struct {
|
||||
ID int `json:"id"`
|
||||
Notice string `json:"notice"`
|
||||
}
|
||||
|
||||
type MetaUsers struct {
|
||||
|
@ -39,6 +47,18 @@ func (s *Server) meta(w http.ResponseWriter, r *http.Request) error {
|
|||
|
||||
numUsers, numMembers, activeDay, activeWeek, activeMonth := s.DB.Counts(ctx)
|
||||
|
||||
var notice *MetaNotice
|
||||
if n, err := s.DB.CurrentNotice(ctx); err != nil {
|
||||
if err == db.ErrNoNotice {
|
||||
log.Errorf("getting notice: %v", err)
|
||||
}
|
||||
} else {
|
||||
notice = &MetaNotice{
|
||||
ID: n.ID,
|
||||
Notice: n.Notice,
|
||||
}
|
||||
}
|
||||
|
||||
render.JSON(w, r, MetaResponse{
|
||||
GitRepository: server.Repository,
|
||||
GitCommit: server.Revision,
|
||||
|
@ -50,6 +70,7 @@ func (s *Server) meta(w http.ResponseWriter, r *http.Request) error {
|
|||
},
|
||||
Members: numMembers,
|
||||
RequireInvite: os.Getenv("REQUIRE_INVITE") == "true",
|
||||
Notice: notice,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
package mod
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"codeberg.org/pronounscc/pronouns.cc/backend/common"
|
||||
"codeberg.org/pronounscc/pronouns.cc/backend/server"
|
||||
"emperror.dev/errors"
|
||||
"github.com/aarondl/opt/omit"
|
||||
"github.com/go-chi/render"
|
||||
)
|
||||
|
||||
type createNoticeRequest struct {
|
||||
Notice string `json:"notice"`
|
||||
Start omit.Val[time.Time] `json:"start"`
|
||||
End time.Time `json:"end"`
|
||||
}
|
||||
|
||||
type noticeResponse struct {
|
||||
ID int `json:"id"`
|
||||
Notice string `json:"notice"`
|
||||
StartTime time.Time `json:"start"`
|
||||
EndTime time.Time `json:"end"`
|
||||
}
|
||||
|
||||
func (s *Server) createNotice(w http.ResponseWriter, r *http.Request) error {
|
||||
var req createNoticeRequest
|
||||
err := render.Decode(r, &req)
|
||||
if err != nil {
|
||||
return server.APIError{Code: server.ErrBadRequest}
|
||||
}
|
||||
|
||||
if common.StringLength(&req.Notice) > 2000 {
|
||||
return server.APIError{Code: server.ErrBadRequest, Details: "Notice is too long, max 2000 characters"}
|
||||
}
|
||||
|
||||
start := req.Start.GetOr(time.Now())
|
||||
if req.End.IsZero() {
|
||||
return server.APIError{Code: server.ErrBadRequest, Details: "`end` is missing or invalid"}
|
||||
}
|
||||
|
||||
n, err := s.DB.CreateNotice(r.Context(), req.Notice, start, req.End)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "creating notice")
|
||||
}
|
||||
|
||||
render.JSON(w, r, noticeResponse{
|
||||
ID: n.ID,
|
||||
Notice: n.Notice,
|
||||
StartTime: n.StartTime,
|
||||
EndTime: n.EndTime,
|
||||
})
|
||||
return nil
|
||||
}
|
|
@ -22,6 +22,8 @@ func Mount(srv *server.Server, r chi.Router) {
|
|||
r.Get("/reports/by-reporter/{id}", server.WrapHandler(s.getReportsByReporter))
|
||||
|
||||
r.Patch("/reports/{id}", server.WrapHandler(s.resolveReport))
|
||||
|
||||
r.Post("/notices", server.WrapHandler(s.createNotice))
|
||||
})
|
||||
|
||||
r.With(MustAdmin).Handle("/metrics", promhttp.Handler())
|
||||
|
|
|
@ -12,6 +12,7 @@ import (
|
|||
type PatchSettingsRequest struct {
|
||||
ReadChangelog omitnull.Val[string] `json:"read_changelog"`
|
||||
ReadSettingsNotice omitnull.Val[string] `json:"read_settings_notice"`
|
||||
ReadGlobalNotice omitnull.Val[int] `json:"read_global_notice"`
|
||||
}
|
||||
|
||||
func (s *Server) PatchSettings(w http.ResponseWriter, r *http.Request) (err error) {
|
||||
|
@ -34,6 +35,9 @@ func (s *Server) PatchSettings(w http.ResponseWriter, r *http.Request) (err erro
|
|||
if !req.ReadSettingsNotice.IsUnset() {
|
||||
u.Settings.ReadSettingsNotice = req.ReadSettingsNotice.GetOrZero()
|
||||
}
|
||||
if !req.ReadGlobalNotice.IsUnset() {
|
||||
u.Settings.ReadGlobalNotice = req.ReadGlobalNotice.GetOrZero()
|
||||
}
|
||||
|
||||
err = s.DB.UpdateUserSettings(ctx, u.ID, u.Settings)
|
||||
if err != nil {
|
||||
|
|
|
@ -62,6 +62,12 @@ export interface MeUser extends User {
|
|||
timezone: string | null;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
read_changelog: string;
|
||||
read_settings_notice: string;
|
||||
read_global_notice: number;
|
||||
}
|
||||
|
||||
export interface Field {
|
||||
name: string;
|
||||
entries: FieldEntry[];
|
||||
|
|
|
@ -11,9 +11,16 @@ export async function apiFetch<T>(
|
|||
body,
|
||||
token,
|
||||
headers,
|
||||
}: { method?: string; body?: any; token?: string; headers?: Record<string, string> },
|
||||
version,
|
||||
}: {
|
||||
method?: string;
|
||||
body?: any;
|
||||
token?: string;
|
||||
headers?: Record<string, string>;
|
||||
version?: number;
|
||||
},
|
||||
) {
|
||||
const resp = await fetch(`${PUBLIC_BASE_URL}/api/v1${path}`, {
|
||||
const resp = await fetch(`${PUBLIC_BASE_URL}/api/v${version || 1}${path}`, {
|
||||
method: method || "GET",
|
||||
headers: {
|
||||
...(token ? { Authorization: token } : {}),
|
||||
|
@ -28,12 +35,18 @@ export async function apiFetch<T>(
|
|||
return data as T;
|
||||
}
|
||||
|
||||
export const apiFetchClient = async <T>(path: string, method = "GET", body: any = null) => {
|
||||
export const apiFetchClient = async <T>(
|
||||
path: string,
|
||||
method = "GET",
|
||||
body: any = null,
|
||||
version = 1,
|
||||
) => {
|
||||
try {
|
||||
const data = await apiFetch<T>(path, {
|
||||
method,
|
||||
body,
|
||||
token: localStorage.getItem("pronouns-token") || undefined,
|
||||
version,
|
||||
});
|
||||
return data;
|
||||
} catch (e) {
|
||||
|
@ -55,9 +68,16 @@ export async function fastFetch(
|
|||
body,
|
||||
token,
|
||||
headers,
|
||||
}: { method?: string; body?: any; token?: string; headers?: Record<string, string> },
|
||||
version,
|
||||
}: {
|
||||
method?: string;
|
||||
body?: any;
|
||||
token?: string;
|
||||
headers?: Record<string, string>;
|
||||
version?: number;
|
||||
},
|
||||
) {
|
||||
const resp = await fetch(`${PUBLIC_BASE_URL}/api/v1${path}`, {
|
||||
const resp = await fetch(`${PUBLIC_BASE_URL}/api/v${version || 1}${path}`, {
|
||||
method: method || "GET",
|
||||
headers: {
|
||||
...(token ? { Authorization: token } : {}),
|
||||
|
@ -71,12 +91,18 @@ export async function fastFetch(
|
|||
}
|
||||
|
||||
/** Fetches the specified path without parsing the response body. */
|
||||
export const fastFetchClient = async (path: string, method = "GET", body: any = null) => {
|
||||
export const fastFetchClient = async (
|
||||
path: string,
|
||||
method = "GET",
|
||||
body: any = null,
|
||||
version = 1,
|
||||
) => {
|
||||
try {
|
||||
await fastFetch(path, {
|
||||
method,
|
||||
body,
|
||||
token: localStorage.getItem("pronouns-token") || undefined,
|
||||
version,
|
||||
});
|
||||
} catch (e) {
|
||||
if ((e as APIError).code === ErrorCode.InvalidToken) {
|
||||
|
|
|
@ -11,6 +11,7 @@ export interface MetaResponse {
|
|||
users: MetaUsers;
|
||||
members: number;
|
||||
require_invite: boolean;
|
||||
notice: { id: number; notice: string } | null;
|
||||
}
|
||||
|
||||
export interface MetaUsers {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { writable } from "svelte/store";
|
||||
import { browser } from "$app/environment";
|
||||
|
||||
import type { MeUser } from "./api/entities";
|
||||
import type { MeUser, Settings } from "./api/entities";
|
||||
|
||||
const initialUserValue = null;
|
||||
export const userStore = writable<MeUser | null>(initialUserValue);
|
||||
|
@ -13,4 +13,10 @@ const initialThemeValue = browser
|
|||
|
||||
export const themeStore = writable<string>(initialThemeValue);
|
||||
|
||||
const defaultSettingsValue = {
|
||||
settings: { read_changelog: "0.0.0", read_settings_notice: "0" } as Settings,
|
||||
current: false,
|
||||
};
|
||||
export const settingsStore = writable(defaultSettingsValue);
|
||||
|
||||
export const CURRENT_CHANGELOG = "0.6.0";
|
||||
|
|
|
@ -7,8 +7,18 @@ const md = new MarkdownIt({
|
|||
linkify: true,
|
||||
}).disable(["heading", "lheading", "link", "table", "blockquote"]);
|
||||
|
||||
const unsafeMd = new MarkdownIt({
|
||||
html: false,
|
||||
breaks: true,
|
||||
linkify: true,
|
||||
});
|
||||
|
||||
export function renderMarkdown(src: string | null) {
|
||||
return src ? sanitize(md.render(src)) : null;
|
||||
}
|
||||
|
||||
export function renderUnsafeMarkdown(src: string) {
|
||||
return sanitize(unsafeMd.render(src));
|
||||
}
|
||||
|
||||
export const charCount = (str: string) => [...str].length;
|
||||
|
|
|
@ -22,7 +22,8 @@ export const load = (async () => {
|
|||
},
|
||||
members: 0,
|
||||
require_invite: false,
|
||||
};
|
||||
notice: null,
|
||||
} as MetaResponse;
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
|
|
|
@ -10,9 +10,13 @@
|
|||
import Navigation from "./nav/Navigation.svelte";
|
||||
import type { LayoutData } from "./$types";
|
||||
import { version } from "$app/environment";
|
||||
import { settingsStore } from "$lib/store";
|
||||
import { toastStore } from "$lib/toast";
|
||||
import Toast from "$lib/components/Toast.svelte";
|
||||
import { Icon } from "sveltestrap";
|
||||
import { Alert, Icon } from "sveltestrap";
|
||||
import { apiFetchClient } from "$lib/api/fetch";
|
||||
import type { Settings } from "$lib/api/entities";
|
||||
import { renderUnsafeMarkdown } from "$lib/utils";
|
||||
|
||||
export let data: LayoutData;
|
||||
|
||||
|
@ -21,6 +25,20 @@
|
|||
if (versionParts.length >= 3) commit = versionParts[2].slice(1);
|
||||
|
||||
const versionMismatch = data.git_commit !== commit && data.git_commit !== "[unknown]";
|
||||
|
||||
const readNotice = async () => {
|
||||
try {
|
||||
const resp = await apiFetchClient<Settings>(
|
||||
"/users/@me/settings",
|
||||
"PATCH",
|
||||
{ read_global_notice: data.notice!.id },
|
||||
2,
|
||||
);
|
||||
settingsStore.set({ current: true, settings: resp });
|
||||
} catch (e) {
|
||||
console.log("updating settings:", e);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
@ -34,6 +52,12 @@
|
|||
<div class="flex-grow-1">
|
||||
<Navigation commit={data.git_commit} />
|
||||
<div class="container">
|
||||
{#if data.notice && $settingsStore.current && data.notice.id > $settingsStore.settings.read_global_notice}
|
||||
<Alert color="secondary" isOpen={true} toggle={() => readNotice()}>
|
||||
{@html renderUnsafeMarkdown(data.notice.notice)}
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
<slot />
|
||||
<div class="position-absolute top-0 start-50 translate-middle-x">
|
||||
{#each $toastStore as toast}
|
||||
|
|
|
@ -17,13 +17,14 @@
|
|||
} from "sveltestrap";
|
||||
|
||||
import Logo from "./Logo.svelte";
|
||||
import { userStore, themeStore, CURRENT_CHANGELOG } from "$lib/store";
|
||||
import { userStore, themeStore, CURRENT_CHANGELOG, settingsStore } from "$lib/store";
|
||||
import {
|
||||
ErrorCode,
|
||||
type APIError,
|
||||
type MeUser,
|
||||
type Report,
|
||||
type Warning,
|
||||
type Settings,
|
||||
} from "$lib/api/entities";
|
||||
import { apiFetch, apiFetchClient } from "$lib/api/fetch";
|
||||
import { addToast } from "$lib/toast";
|
||||
|
@ -37,14 +38,11 @@
|
|||
let isAdmin = false;
|
||||
let numReports = 0;
|
||||
let numWarnings = 0;
|
||||
let changelogRead = "99.99.99";
|
||||
|
||||
$: currentUser = $userStore;
|
||||
$: theme = $themeStore;
|
||||
|
||||
onMount(() => {
|
||||
changelogRead = localStorage.getItem("changelog-read") || "0.0.0";
|
||||
|
||||
const localUser = localStorage.getItem("pronouns-user");
|
||||
userStore.set(localUser ? JSON.parse(localUser) : null);
|
||||
|
||||
|
@ -78,6 +76,38 @@
|
|||
});
|
||||
}
|
||||
|
||||
apiFetchClient<Settings>("/users/@me/settings", "GET", null, 2)
|
||||
.then((data) => {
|
||||
settingsStore.set({ current: true, settings: data });
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log("getting user settings:", e);
|
||||
});
|
||||
|
||||
// TODO: is there a cleaner way to do this? also, remove this eventually
|
||||
const oldChangelogRead = localStorage.getItem("changelog-read");
|
||||
const oldSettingsNotice = localStorage.getItem("alert-1681976313");
|
||||
if (oldChangelogRead || oldSettingsNotice) {
|
||||
localStorage.removeItem("changelog-read");
|
||||
localStorage.removeItem("alert-1681976313");
|
||||
|
||||
apiFetchClient<Settings>(
|
||||
"/users/@me/settings",
|
||||
"PATCH",
|
||||
{
|
||||
read_changelog: oldChangelogRead ? oldChangelogRead : undefined,
|
||||
read_settings_notice: oldSettingsNotice ? "1681976313" : undefined,
|
||||
},
|
||||
2,
|
||||
)
|
||||
.then((data) => {
|
||||
settingsStore.set({ current: true, settings: data });
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log("updating user settings:", e);
|
||||
});
|
||||
}
|
||||
|
||||
apiFetchClient<Warning[]>("/auth/warnings")
|
||||
.then((warnings) => {
|
||||
if (warnings.length !== 0) {
|
||||
|
@ -157,7 +187,7 @@
|
|||
</NavLink>
|
||||
</NavItem>
|
||||
{/if}
|
||||
{#if changelogRead < CURRENT_CHANGELOG}
|
||||
{#if $settingsStore.current && $settingsStore.settings.read_changelog < CURRENT_CHANGELOG}
|
||||
<NavItem>
|
||||
<NavLink href="/page/changelog" active={$page.url.pathname === "/page/changelog"}>
|
||||
Changelog <Badge color="secondary">v{CURRENT_CHANGELOG}</Badge>
|
||||
|
|
|
@ -6,12 +6,13 @@
|
|||
type APIError,
|
||||
MAX_MEMBERS,
|
||||
MAX_FIELDS,
|
||||
type Settings,
|
||||
} from "$lib/api/entities";
|
||||
import { apiFetchClient, fastFetchClient } from "$lib/api/fetch";
|
||||
import { usernameRegex } from "$lib/api/regex";
|
||||
import ErrorAlert from "$lib/components/ErrorAlert.svelte";
|
||||
import FallbackImage from "$lib/components/FallbackImage.svelte";
|
||||
import { userStore } from "$lib/store";
|
||||
import { settingsStore, userStore } from "$lib/store";
|
||||
import { addToast } from "$lib/toast";
|
||||
import {
|
||||
Alert,
|
||||
|
@ -91,21 +92,36 @@
|
|||
}
|
||||
};
|
||||
|
||||
const DONATE_ALERT_STORE = "alert-1681976313";
|
||||
let donateAlertOpen = false;
|
||||
const closeDonateAlert = () => {
|
||||
donateAlertOpen = false;
|
||||
localStorage.setItem(DONATE_ALERT_STORE, "dismissed");
|
||||
};
|
||||
const CURRENT_ALERT = "1681976313";
|
||||
const closeDonateAlert = async () => {
|
||||
try {
|
||||
const settings = await apiFetchClient<Settings>(
|
||||
"/users/@me/settings",
|
||||
"PATCH",
|
||||
{
|
||||
read_settings_notice: CURRENT_ALERT,
|
||||
},
|
||||
2,
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
if (!localStorage.getItem(DONATE_ALERT_STORE)) {
|
||||
donateAlertOpen = true;
|
||||
settingsStore.set({ current: true, settings });
|
||||
error = null;
|
||||
} catch (e) {
|
||||
error = e as APIError;
|
||||
}
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<Alert color="secondary" fade={false} isOpen={donateAlertOpen} toggle={closeDonateAlert}>
|
||||
{#if error}
|
||||
<ErrorAlert {error} />
|
||||
{/if}
|
||||
|
||||
<Alert
|
||||
color="secondary"
|
||||
fade={false}
|
||||
isOpen={$settingsStore.current && $settingsStore.settings.read_settings_notice < CURRENT_ALERT}
|
||||
toggle={closeDonateAlert}
|
||||
>
|
||||
If you find pronouns.cc useful and have the means, I would really appreciate a <a
|
||||
href="https://liberapay.com/u1f320/"
|
||||
target="_blank">donation</a
|
||||
|
@ -150,9 +166,6 @@
|
|||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
{#if error}
|
||||
<ErrorAlert {error} />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<p class="text-center">
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
-- 2023-09-09: Add global notices
|
||||
|
||||
-- +migrate Up
|
||||
|
||||
create table notices (
|
||||
id serial primary key,
|
||||
notice text not null,
|
||||
start_time timestamptz not null default now(),
|
||||
end_time timestamptz not null
|
||||
);
|
||||
|
||||
-- +migrate Down
|
||||
|
||||
drop table notices;
|
Loading…
Reference in New Issue