3 Commits

Author SHA1 Message Date
c0dd480c76 Merge branch 'master' into test/add-refresh-tokens
All checks were successful
CI / Maven unit tests (push) Successful in 16s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-10-16 20:56:13 +01:00
417e52d261 add tests
All checks were successful
CI / Maven unit tests (push) Successful in 17s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-10-16 20:51:56 +01:00
12852e034d add backend tokens
All checks were successful
CI / Maven unit tests (push) Successful in 15s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-10-16 17:51:09 +01:00
11 changed files with 82 additions and 307 deletions

View File

@@ -8,9 +8,9 @@ type AuthContextValue = {
loading: boolean; loading: boolean;
/** Exchange a Google credential for a session, then load the user. */ /** Exchange a Google credential for a session, then load the user. */
login: (googleCredential: string) => Promise<void>; login: (googleCredential: string) => Promise<void>;
/** End the session and clear the user. The local session is cleared even if the server call fails. */ /** End the session and clear the user. */
logout: () => Promise<void>; logout: () => Promise<void>;
/** Re-fetch the current user (e.g. after a profile change). Token refresh is handled automatically. */ /** Re-fetch the current user (e.g. after a profile change). */
refresh: () => Promise<void>; refresh: () => Promise<void>;
}; };

View File

@@ -1,66 +1,19 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { User } from "../utils/types.ts"; import type { User } from "../utils/types.ts";
import { login as apiLogin, logout as apiLogout, getUser } from "../utils/api/users.ts"; import { login as apiLogin, logout as apiLogout, getUser } from "../utils/api/users.ts";
import { AuthContext } from "./AuthContext.ts"; import { AuthContext } from "./AuthContext.ts";
import { import {registerUnauthorizedHandler} from "../utils/api/client.ts";
refreshSession,
registerSessionRefreshedHandler,
registerUnauthorizedHandler,
} from "../utils/api/client.ts";
/** Refresh this long before the access token expires, so in-flight requests never see it lapse. */
const REFRESH_MARGIN_MS = 60_000;
/** How soon to try again when a scheduled refresh fails because the network is down. */
const RETRY_DELAY_MS = 30_000;
/** /**
* Owns the single source of truth for the authenticated user. Everything that * Owns the single source of truth for the authenticated user. Everything that
* needs to know who is logged in — the layout, the top bar, protected pages — * needs to know who is logged in — the layout, the top bar, protected pages —
* reads it through the `useAuth` hook rather than fetching the user itself. * reads it through the `useAuth` hook rather than fetching the user itself.
*
* It also keeps the session alive: the access token is refreshed shortly
* before it expires, rather than waiting for a request to fail. That matters
* for <video> elements, which load media straight from the API and can't
* retry on a 401 the way apiFetch does.
*/ */
const AuthProvider = ({ children }: { children: ReactNode }) => { const AuthProvider = ({ children }: { children: ReactNode }) => {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
/** When the next refresh is due (epoch ms), or null when there's no session to keep alive. */
const refreshDueRef = useRef<number | null>(null);
const cancelScheduledRefresh = useCallback(() => {
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
refreshTimerRef.current = null;
refreshDueRef.current = null;
}, []);
const endSession = useCallback(() => {
cancelScheduledRefresh();
setUser(null);
}, [cancelScheduledRefresh]);
const scheduleRefreshIn = useCallback((delayMs: number) => {
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
refreshDueRef.current = Date.now() + delayMs;
refreshTimerRef.current = setTimeout(() => {
refreshSession()
.then((refreshed) => { if (!refreshed) endSession(); })
.catch(() => scheduleRefreshIn(RETRY_DELAY_MS));
}, delayMs);
}, [endSession]);
// Every successful refresh — scheduled, or triggered by apiFetch on a 401 —
// reports the new access token lifetime here, so the timer always tracks
// the live cookie.
const scheduleRefreshBeforeExpiry = useCallback((accessTokenExpiresIn: number) => {
const lifetimeMs = accessTokenExpiresIn * 1000;
scheduleRefreshIn(Math.max(lifetimeMs - REFRESH_MARGIN_MS, lifetimeMs / 2));
}, [scheduleRefreshIn]);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
try { try {
setUser(await getUser()); setUser(await getUser());
@@ -71,50 +24,22 @@ const AuthProvider = ({ children }: { children: ReactNode }) => {
}, []); }, []);
useEffect(() => { useEffect(() => {
registerUnauthorizedHandler(endSession); refresh().finally(() => setLoading(false));
registerSessionRefreshedHandler(scheduleRefreshBeforeExpiry);
}, [endSession, scheduleRefreshBeforeExpiry]);
// Restore the session on page load. Refreshing first means a returning
// user whose access token expired while they were away is logged straight
// back in, and gives us the expiry to schedule the next refresh against.
useEffect(() => {
refreshSession()
.catch((error) => console.error("Failed to refresh session:", error))
.then(refresh)
.finally(() => setLoading(false));
}, [refresh]); }, [refresh]);
// Timers are throttled in background tabs and paused while the machine
// sleeps, so catch up as soon as the user comes back.
useEffect(() => {
const refreshIfDue = () => {
if (document.visibilityState !== "visible") return;
const due = refreshDueRef.current;
if (due !== null && Date.now() >= due) scheduleRefreshIn(0);
};
document.addEventListener("visibilitychange", refreshIfDue);
window.addEventListener("online", refreshIfDue);
return () => {
document.removeEventListener("visibilitychange", refreshIfDue);
window.removeEventListener("online", refreshIfDue);
};
}, [scheduleRefreshIn]);
useEffect(() => cancelScheduledRefresh, [cancelScheduledRefresh]);
const login = useCallback(async (googleCredential: string) => { const login = useCallback(async (googleCredential: string) => {
scheduleRefreshBeforeExpiry(await apiLogin(googleCredential)); await apiLogin(googleCredential);
await refresh(); await refresh();
}, [refresh, scheduleRefreshBeforeExpiry]); }, [refresh]);
const logout = useCallback(async () => { const logout = useCallback(async () => {
// Drop the local session even if the server call fails, so the user
// is never left looking logged in after clicking log out.
endSession();
await apiLogout(); await apiLogout();
}, [endSession]); await refresh();
}, [refresh]);
useEffect(() => {
registerUnauthorizedHandler(() => setUser(null));
}, []);
return ( return (
<AuthContext.Provider value={{ user, loading, login, logout, refresh }}> <AuthContext.Provider value={{ user, loading, login, logout, refresh }}>

View File

@@ -1,25 +0,0 @@
import type { ReactNode } from "react";
import { groupByTimeRange } from "../../utils/utils";
type props<T> = {
items: T[];
getDate: (item: T) => string;
renderItem: (item: T) => ReactNode;
}
const TimeGroupedGrid = <T,>({ items, getDate, renderItem }: props<T>) => {
return (
<div className="flex flex-col gap-8">
{groupByTimeRange(items, getDate).map((group) => (
<section key={group.label}>
<h2 className="text-xl font-heading text-text-primary mb-3">{group.label}</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{group.items.map(renderItem)}
</div>
</section>
))}
</div>
);
}
export default TimeGroupedGrid;

View File

@@ -56,61 +56,50 @@
/* Dark mode. Only token values change here: every component already styles /* Dark mode. Only token values change here: every component already styles
itself with the semantic utilities above, so the whole UI follows from this itself with the semantic utilities above, so the whole UI follows from this
one block. `--color-on-dark` is deliberately absent — it is the ink on the one block. `--color-on-dark` is deliberately absent — it is the ink on the
media backdrop, which stays dark in both themes. media backdrop, which stays dark in both themes. */
Soft charcoal rather than pure black, with only a trace of warmth so it
still sits with the paper tones of the light theme. Ink is a warm off-white
instead of #FFF, and terracotta becomes a gentle clay. Layering matches the
light theme: sidebar recessed, cards raised, fields inset into the card. */
:root[data-theme="dark"] { :root[data-theme="dark"] {
color-scheme: dark; color-scheme: dark;
/* ── Surfaces ────────────────────────────────────────────────────────── */ /* ── Surfaces ── near-black canvas, neutral cards lifting off it ─────── */
--color-background: #161615; --color-background: #0E0E10;
--color-sidebar: #111111; --color-sidebar: #09090B;
--color-card: #1F1F1E; --color-card: #17171A;
--color-fields: #1A1A19; --color-fields: #121215;
--color-dropzone: #1C1C1B; --color-dropzone: #141417;
--color-media-backdrop: #0B0B0B; --color-media-backdrop: #000000;
/* ── Ink ─────────────────────────────────────────────────────────────── */ /* ── Ink ─────────────────────────────────────────────────────────────── */
--color-text-primary: #F5F4EF; --color-text-primary: #F4F4F5;
--color-text-strong: #E8E6E0; --color-text-strong: #E3E3E7;
--color-text-body: #C9C7C0; --color-text-body: #BFBFC6;
--color-text-secondary: #A2A099; --color-text-secondary: #94949D;
--color-muted: #87857F; --color-muted: #76767F;
--color-faint-mono: #6C6B66; --color-faint-mono: #6A6A73;
/* ── Accents ── clay and sage; hover lightens rather than deepens ────── */ /* ── Accents ── brightened to carry on a dark canvas, so hover now lifts
--color-terracotta: #D97757; rather than deepens ─────────────────────────────────────────────────── */
--color-terracotta-hover: #E28B6E; --color-terracotta: #E07A4C;
--color-terracotta-light: #BC6A4E; --color-terracotta-hover: #F08F62;
--color-terracotta-disabled: #46372F; --color-terracotta-light: #F0A177;
--color-olive: #A7AF84; --color-terracotta-disabled: #5E463A;
--color-olive: #A3B678;
/* ── Status ──────────────────────────────────────────────────────────── */ /* ── Status ──────────────────────────────────────────────────────────── */
--color-live: #E0644E; --color-live: #F2604A;
--color-error: #E0644E; --color-error: #F2604A;
/* ── Ink on accent surfaces ── the accents are light here, so the ink that /* ── Ink on accent surfaces ── the accents are light here, so the ink that
sits on them flips dark ─────────────────────────────────────────────── */ sits on them flips dark ─────────────────────────────────────────────── */
--color-on-accent: #1A1410; --color-on-accent: #17100B;
/* ── Lines, chips, placeholders ──────────────────────────────────────── */ /* ── Lines, chips, placeholders ──────────────────────────────────────── */
--color-hairline: #282827; --color-hairline: #232327;
--color-hover: #2A2A29; --color-hover: #26262B;
--color-selected: #2A2522; --color-selected: #2A1F18;
--color-ring: #3B3B3A; --color-ring: #33333A;
--color-inactive: #333332; --color-inactive: #2C2C32;
--color-inactive-strong: #5A5955; --color-inactive-strong: #55555E;
}
/* Tailwind's shadows default to 10% black, which vanishes on a dark canvas.
`--tw-shadow-color` does not inherit, so it is set on the shadowed elements
themselves; a deeper warm-black keeps cards and menus visibly lifted. */
:root[data-theme="dark"] :where(.shadow-sm, .shadow-md) {
--tw-shadow-color: rgb(0 0 0 / 0.45);
} }
:root { :root {

View File

@@ -1,22 +1,18 @@
import VideoCard from "../components/video/VideoCard"; import VideoCard from "../components/video/VideoCard";
import TimeGroupedGrid from "../components/video/TimeGroupedGrid";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { getClips, deleteClip } from "../utils/api/clips"; import { getClips, deleteClip } from "../utils/api/clips";
import { stringToDate } from "../utils/utils";
import type { Clip } from "../utils/types"; import type { Clip } from "../utils/types";
const MyClips = () => { const MyClips = () => {
const [clips, setClips] = useState<Clip[]>([]); const [clips, setClips] = useState<Clip[]>([]);
const [error, setError] = useState<null | string>(null); const [error, setError] = useState<null | string>(null);
const [loading, setLoading] = useState(true);
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
getClips() getClips()
.then((data) => setClips(data.sort((a, b) => stringToDate(b.createdAt).getTime() - stringToDate(a.createdAt).getTime()))) .then((data) => setClips(data.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())))
.catch((err) => setError(err)) .catch((err) => setError(err));
.finally(() => setLoading(false));
}, []); }, []);
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
@@ -32,10 +28,8 @@ const MyClips = () => {
<div className="p-6"> <div className="p-6">
<h1 className="text-4xl font-heading text-text-primary mb-6">My Clips</h1> <h1 className="text-4xl font-heading text-text-primary mb-6">My Clips</h1>
<TimeGroupedGrid <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
items={clips} {clips.map((clip) => (
getDate={(clip) => clip.createdAt}
renderItem={(clip) => (
<VideoCard <VideoCard
key={clip.id} key={clip.id}
id={clip.id} id={clip.id}
@@ -45,10 +39,10 @@ const MyClips = () => {
onEdit={() => navigate(`/clips/${clip.id}/edit`)} onEdit={() => navigate(`/clips/${clip.id}/edit`)}
onDelete={() => handleDelete(clip.id)} onDelete={() => handleDelete(clip.id)}
/> />
)} ))}
/> </div>
{clips.length === 0 && !error && !loading && ( {clips.length === 0 && !error && (
<div className="flex flex-col items-center justify-center py-16 text-muted"> <div className="flex flex-col items-center justify-center py-16 text-muted">
<p className="text-base">No clips yet.</p> <p className="text-base">No clips yet.</p>
<p className="text-sm mt-1">Upload your first clip to get started.</p> <p className="text-sm mt-1">Upload your first clip to get started.</p>

View File

@@ -1,22 +1,18 @@
import VideoCard from "../components/video/VideoCard"; import VideoCard from "../components/video/VideoCard";
import TimeGroupedGrid from "../components/video/TimeGroupedGrid";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { getVods, deleteVod } from "../utils/api/vods"; import { getVods, deleteVod } from "../utils/api/vods";
import { stringToDate } from "../utils/utils";
import type { Vod } from "../utils/types"; import type { Vod } from "../utils/types";
const MyVods = () => { const MyVods = () => {
const [vods, setVods] = useState<Vod[]>([]); const [vods, setVods] = useState<Vod[]>([]);
const [error, setError] = useState<null | string>(null); const [error, setError] = useState<null | string>(null);
const [loading, setLoading] = useState(true);
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
getVods() getVods()
.then((data) => setVods(data.sort((a, b) => stringToDate(b.createdAt).getTime() - stringToDate(a.createdAt).getTime()))) .then((data) => setVods(data.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())))
.catch((err) => setError(err instanceof Error ? err.message : "Failed to load vods")) .catch((err) => setError(err instanceof Error ? err.message : "Failed to load vods"));
.finally(() => setLoading(false));
}, []); }, []);
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
@@ -32,10 +28,8 @@ const MyVods = () => {
<div className="p-6"> <div className="p-6">
<h1 className="text-4xl font-heading text-text-primary mb-6">My VoDs</h1> <h1 className="text-4xl font-heading text-text-primary mb-6">My VoDs</h1>
<TimeGroupedGrid <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
items={vods} {vods.map((vod) => (
getDate={(vod) => vod.createdAt}
renderItem={(vod) => (
<VideoCard <VideoCard
key={vod.id} key={vod.id}
id={vod.id} id={vod.id}
@@ -48,10 +42,10 @@ const MyVods = () => {
onEdit={() => navigate(`/vods/${vod.id}/edit`)} onEdit={() => navigate(`/vods/${vod.id}/edit`)}
onDelete={() => handleDelete(vod.id)} onDelete={() => handleDelete(vod.id)}
/> />
)} ))}
/> </div>
{vods.length === 0 && !error && !loading && ( {vods.length === 0 && !error && (
<div className="flex flex-col items-center justify-center py-16 text-muted"> <div className="flex flex-col items-center justify-center py-16 text-muted">
<p className="text-base">No VoDs yet.</p> <p className="text-base">No VoDs yet.</p>
<p className="text-sm mt-1">Your stream recordings will appear here.</p> <p className="text-sm mt-1">Your stream recordings will appear here.</p>

View File

@@ -1,5 +1,4 @@
import {config} from "../../config.ts"; import {config} from "../../config.ts";
import type { APIResponse } from "../types.ts";
const API_URL = config.apiUrl; const API_URL = config.apiUrl;
@@ -8,72 +7,28 @@ class AuthError extends Error {
} }
let onUnauthorized: (() => void) | null = null; let onUnauthorized: (() => void) | null = null;
let onSessionRefreshed: ((accessTokenExpiresIn: number) => void) | null = null;
let refreshInFlight: Promise<boolean> | null = null;
export function registerUnauthorizedHandler(handler: () => void) { export function registerUnauthorizedHandler(handler: () => void) {
onUnauthorized = handler; onUnauthorized = handler;
} }
/** Called with the new access token lifetime (in seconds) whenever the session is refreshed. */
export function registerSessionRefreshedHandler(handler: (accessTokenExpiresIn: number) => void) {
onSessionRefreshed = handler;
}
/** /**
* Exchanges the refresh token cookie for new access and refresh token cookies. * Performs a request against the API, sending session cookies and funnelling
* Concurrent callers share a single request, so a burst of 401s only rotates * expired/missing sessions through the registered unauthorized handler so the
* the refresh token once. * UI can drop back to a logged out state. Returns the raw response so callers
* * that need something other than JSON (blobs, streams) can read it themselves.
* @returns true if the session was refreshed, false if it has ended (no valid
* refresh token). Throws on network errors so a dropped connection isn't
* mistaken for being logged out.
*/
function refreshSession(): Promise<boolean> {
refreshInFlight ??= requestRefresh().finally(() => { refreshInFlight = null; });
return refreshInFlight;
}
async function requestRefresh(): Promise<boolean> {
const response = await fetch(API_URL + '/api/v1/users/refresh', {
method: 'POST',
credentials: 'include',
});
if (!response.ok) return false;
const result: APIResponse = await response.json();
onSessionRefreshed?.(result.data.accessTokenExpiresIn);
return true;
}
/**
* Performs a request against the API, sending session cookies. An expired
* access token is refreshed and the request retried once, so callers only see
* an AuthError when the session has really ended — at which point the
* registered unauthorized handler drops the UI back to a logged out state.
* Returns the raw response so callers that need something other than JSON
* (blobs, streams) can read it themselves.
*/ */
async function apiFetchRaw(url: string, options: RequestInit = {}): Promise<Response> { async function apiFetchRaw(url: string, options: RequestInit = {}): Promise<Response> {
const send = () => fetch(url, { ...options, credentials: 'include' }); const response = await fetch(url, {
...options,
credentials: 'include'
});
let response = await send(); if (response.status == 401 || response.status == 403) {
if (response.status == 401 && await refreshSession()) {
response = await send();
}
if (response.status == 401) {
onUnauthorized?.(); onUnauthorized?.();
throw new AuthError(); throw new AuthError();
} }
// Logged in, but not allowed to access this resource: don't end the session.
if (response.status == 403) {
throw new AuthError();
}
if (!response.ok) { if (!response.ok) {
const text = await response.text(); const text = await response.text();
let message = text; let message = text;
@@ -90,4 +45,4 @@ async function apiFetch<T>(url: string, options: RequestInit = {}): Promise<T> {
return text ? JSON.parse(text) : (undefined as T); return text ? JSON.parse(text) : (undefined as T);
} }
export { API_URL, AuthError, apiFetch, apiFetchRaw, refreshSession }; export { API_URL, AuthError, apiFetch, apiFetchRaw };

View File

@@ -1,17 +1,10 @@
import type { APIResponse, User } from "../types.ts"; import type { APIResponse, User } from "../types.ts";
import { API_URL } from "./client.ts"; import { API_URL, apiFetch } from "./client.ts";
// These deliberately use fetch directly rather than apiFetch: they run when // login and getUser deliberately use fetch directly: they run when there may be
// there may be no session at all, and apiFetch's refresh-and-retry on 401 // no session at all, and getUser is what the unauthorized handler calls to
// would either loop or log the user out from under the auth provider. // refresh — routing them through apiFetch would loop on a 401.
const login = async (googleToken: string): Promise<string> => {
/**
* Exchanges a Google credential for a session. The tokens are set as HttpOnly
* cookies; only the access token lifetime is returned.
*
* @returns seconds until the access token expires
*/
const login = async (googleToken: string): Promise<number> => {
const response = await fetch(API_URL + '/api/v1/users/login', { const response = await fetch(API_URL + '/api/v1/users/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -24,17 +17,12 @@ const login = async (googleToken: string): Promise<number> => {
const result: APIResponse = await response.json(); const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Login failed: ${result.message}`); if (result.status === 'error') throw new Error(`Login failed: ${result.message}`);
return result.data.accessTokenExpiresIn; return result.data.token;
}; };
/** Revokes the session and clears the auth cookies. */
const logout = async (): Promise<void> => { const logout = async (): Promise<void> => {
const response = await fetch(API_URL + '/api/v1/users/logout', { const result = await apiFetch<APIResponse>(API_URL + '/api/v1/users/logout', { method: 'POST' });
method: 'POST', if (result?.status === 'error') throw new Error(`Logout failed: ${result.message}`);
credentials: 'include',
});
if (!response.ok) throw new Error(`Logout failed: ${response.status}`);
}; };
const getUser = async (): Promise<User | null> => { const getUser = async (): Promise<User | null> => {

View File

@@ -46,52 +46,4 @@ function dateToTimeAgo(date: Date): string {
return `${s} second${s !== 1 ? 's' : ''} ago`; return `${s} second${s !== 1 ? 's' : ''} ago`;
} }
function startOfDay(date: Date): Date { export { formatTime, stringToDate, dateToTimeAgo, formatLocalDate, formatLocalTime };
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
// Weeks start on Monday.
function startOfWeek(date: Date): Date {
const day = startOfDay(date);
day.setDate(day.getDate() - ((day.getDay() + 6) % 7));
return day;
}
// Buckets a date relative to now. Anything before last month falls back to its
// month and year, so older items still group sensibly.
function timeRangeLabel(date: Date, now: Date = new Date()): string {
const today = startOfDay(now);
const yesterday = new Date(today);
yesterday.setDate(today.getDate() - 1);
const thisWeek = startOfWeek(now);
const lastWeek = new Date(thisWeek);
lastWeek.setDate(thisWeek.getDate() - 7);
const thisMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
if (date >= today) return "Today";
if (date >= yesterday) return "Yesterday";
if (date >= thisWeek) return "This Week";
if (date >= lastWeek) return "Last Week";
if (date >= thisMonth) return "This Month";
if (date >= lastMonth) return "Last Month";
return date.toLocaleDateString(undefined, { month: "long", year: "numeric" });
}
type TimeGroup<T> = { label: string, items: T[] };
// Expects items already sorted newest first; groups keep that order.
function groupByTimeRange<T>(items: T[], getDateString: (item: T) => string): TimeGroup<T>[] {
const now = new Date();
const groups: TimeGroup<T>[] = [];
for (const item of items) {
const label = timeRangeLabel(stringToDate(getDateString(item)), now);
const last = groups[groups.length - 1];
if (last?.label === label) last.items.push(item);
else groups.push({ label, items: [item] });
}
return groups;
}
export { formatTime, stringToDate, dateToTimeAgo, formatLocalDate, formatLocalTime, timeRangeLabel, groupByTimeRange };
export type { TimeGroup };

View File

@@ -11,7 +11,7 @@ public class ClipOptions {
private String description; private String description;
@NotNull @NotNull
@PositiveOrZero @Positive
private Float startPoint; private Float startPoint;
@NotNull @Positive @NotNull @Positive

View File

@@ -4,9 +4,12 @@ import java.util.Date;
import com.auth0.jwt.JWT; import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier; import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm; import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTVerificationException; import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import com.auth0.jwt.interfaces.DecodedJWT; import com.auth0.jwt.interfaces.DecodedJWT;
import com.ddf.vodsystem.dto.properties.AuthProperties; import com.ddf.vodsystem.dto.properties.AuthProperties;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.oauth2.jwt.JwtException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@Service @Service
@@ -57,7 +60,7 @@ public class JwtService {
} }
return jwt.getClaim(USER_ID_CLAIM).asLong(); return jwt.getClaim(USER_ID_CLAIM).asLong();
} catch (JWTVerificationException | IllegalArgumentException ignored) { } catch (JwtException | IllegalArgumentException | TokenExpiredException | JWTDecodeException ignored) {
return null; return null;
} }
} }