Compare commits
6 Commits
3f8a877671
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 79a779ad7c | |||
| 1923eab14e | |||
| cba1f9927e | |||
| 29a7573f5c | |||
| 2818d33dd5 | |||
| 34b150b77e |
@@ -8,9 +8,9 @@ type AuthContextValue = {
|
||||
loading: boolean;
|
||||
/** Exchange a Google credential for a session, then load the user. */
|
||||
login: (googleCredential: string) => Promise<void>;
|
||||
/** End the session and clear the user. */
|
||||
/** End the session and clear the user. The local session is cleared even if the server call fails. */
|
||||
logout: () => Promise<void>;
|
||||
/** Re-fetch the current user (e.g. after a profile change). */
|
||||
/** Re-fetch the current user (e.g. after a profile change). Token refresh is handled automatically. */
|
||||
refresh: () => Promise<void>;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,19 +1,66 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { User } from "../utils/types.ts";
|
||||
import { login as apiLogin, logout as apiLogout, getUser } from "../utils/api/users.ts";
|
||||
import { AuthContext } from "./AuthContext.ts";
|
||||
import {registerUnauthorizedHandler} from "../utils/api/client.ts";
|
||||
import {
|
||||
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
|
||||
* 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.
|
||||
*
|
||||
* 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 [user, setUser] = useState<User | null>(null);
|
||||
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 () => {
|
||||
try {
|
||||
setUser(await getUser());
|
||||
@@ -24,22 +71,50 @@ const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh().finally(() => setLoading(false));
|
||||
registerUnauthorizedHandler(endSession);
|
||||
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]);
|
||||
|
||||
// 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) => {
|
||||
await apiLogin(googleCredential);
|
||||
scheduleRefreshBeforeExpiry(await apiLogin(googleCredential));
|
||||
await refresh();
|
||||
}, [refresh]);
|
||||
}, [refresh, scheduleRefreshBeforeExpiry]);
|
||||
|
||||
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 refresh();
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
registerUnauthorizedHandler(() => setUser(null));
|
||||
}, []);
|
||||
}, [endSession]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, logout, refresh }}>
|
||||
|
||||
25
frontend/src/components/video/TimeGroupedGrid.tsx
Normal file
25
frontend/src/components/video/TimeGroupedGrid.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
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;
|
||||
@@ -56,50 +56,61 @@
|
||||
/* Dark mode. Only token values change here: every component already styles
|
||||
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
|
||||
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"] {
|
||||
color-scheme: dark;
|
||||
|
||||
/* ── Surfaces ── near-black canvas, neutral cards lifting off it ─────── */
|
||||
--color-background: #0E0E10;
|
||||
--color-sidebar: #09090B;
|
||||
--color-card: #17171A;
|
||||
--color-fields: #121215;
|
||||
--color-dropzone: #141417;
|
||||
/* ── Surfaces ────────────────────────────────────────────────────────── */
|
||||
--color-background: #161615;
|
||||
--color-sidebar: #111111;
|
||||
--color-card: #1F1F1E;
|
||||
--color-fields: #1A1A19;
|
||||
--color-dropzone: #1C1C1B;
|
||||
|
||||
--color-media-backdrop: #000000;
|
||||
--color-media-backdrop: #0B0B0B;
|
||||
|
||||
/* ── Ink ─────────────────────────────────────────────────────────────── */
|
||||
--color-text-primary: #F4F4F5;
|
||||
--color-text-strong: #E3E3E7;
|
||||
--color-text-body: #BFBFC6;
|
||||
--color-text-secondary: #94949D;
|
||||
--color-muted: #76767F;
|
||||
--color-faint-mono: #6A6A73;
|
||||
--color-text-primary: #F5F4EF;
|
||||
--color-text-strong: #E8E6E0;
|
||||
--color-text-body: #C9C7C0;
|
||||
--color-text-secondary: #A2A099;
|
||||
--color-muted: #87857F;
|
||||
--color-faint-mono: #6C6B66;
|
||||
|
||||
/* ── Accents ── brightened to carry on a dark canvas, so hover now lifts
|
||||
rather than deepens ─────────────────────────────────────────────────── */
|
||||
--color-terracotta: #E07A4C;
|
||||
--color-terracotta-hover: #F08F62;
|
||||
--color-terracotta-light: #F0A177;
|
||||
--color-terracotta-disabled: #5E463A;
|
||||
--color-olive: #A3B678;
|
||||
/* ── Accents ── clay and sage; hover lightens rather than deepens ────── */
|
||||
--color-terracotta: #D97757;
|
||||
--color-terracotta-hover: #E28B6E;
|
||||
--color-terracotta-light: #BC6A4E;
|
||||
--color-terracotta-disabled: #46372F;
|
||||
--color-olive: #A7AF84;
|
||||
|
||||
/* ── Status ──────────────────────────────────────────────────────────── */
|
||||
--color-live: #F2604A;
|
||||
--color-error: #F2604A;
|
||||
--color-live: #E0644E;
|
||||
--color-error: #E0644E;
|
||||
|
||||
/* ── Ink on accent surfaces ── the accents are light here, so the ink that
|
||||
sits on them flips dark ─────────────────────────────────────────────── */
|
||||
--color-on-accent: #17100B;
|
||||
--color-on-accent: #1A1410;
|
||||
|
||||
/* ── Lines, chips, placeholders ──────────────────────────────────────── */
|
||||
--color-hairline: #232327;
|
||||
--color-hover: #26262B;
|
||||
--color-selected: #2A1F18;
|
||||
--color-ring: #33333A;
|
||||
--color-inactive: #2C2C32;
|
||||
--color-inactive-strong: #55555E;
|
||||
--color-hairline: #282827;
|
||||
--color-hover: #2A2A29;
|
||||
--color-selected: #2A2522;
|
||||
--color-ring: #3B3B3A;
|
||||
--color-inactive: #333332;
|
||||
--color-inactive-strong: #5A5955;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import VideoCard from "../components/video/VideoCard";
|
||||
import TimeGroupedGrid from "../components/video/TimeGroupedGrid";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { getClips, deleteClip } from "../utils/api/clips";
|
||||
import { stringToDate } from "../utils/utils";
|
||||
import type { Clip } from "../utils/types";
|
||||
|
||||
const MyClips = () => {
|
||||
const [clips, setClips] = useState<Clip[]>([]);
|
||||
const [error, setError] = useState<null | string>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
getClips()
|
||||
.then((data) => setClips(data.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())))
|
||||
.catch((err) => setError(err));
|
||||
.then((data) => setClips(data.sort((a, b) => stringToDate(b.createdAt).getTime() - stringToDate(a.createdAt).getTime())))
|
||||
.catch((err) => setError(err))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
@@ -28,8 +32,10 @@ const MyClips = () => {
|
||||
<div className="p-6">
|
||||
<h1 className="text-4xl font-heading text-text-primary mb-6">My Clips</h1>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{clips.map((clip) => (
|
||||
<TimeGroupedGrid
|
||||
items={clips}
|
||||
getDate={(clip) => clip.createdAt}
|
||||
renderItem={(clip) => (
|
||||
<VideoCard
|
||||
key={clip.id}
|
||||
id={clip.id}
|
||||
@@ -39,10 +45,10 @@ const MyClips = () => {
|
||||
onEdit={() => navigate(`/clips/${clip.id}/edit`)}
|
||||
onDelete={() => handleDelete(clip.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
{clips.length === 0 && !error && (
|
||||
{clips.length === 0 && !error && !loading && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted">
|
||||
<p className="text-base">No clips yet.</p>
|
||||
<p className="text-sm mt-1">Upload your first clip to get started.</p>
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import VideoCard from "../components/video/VideoCard";
|
||||
import TimeGroupedGrid from "../components/video/TimeGroupedGrid";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { getVods, deleteVod } from "../utils/api/vods";
|
||||
import { stringToDate } from "../utils/utils";
|
||||
import type { Vod } from "../utils/types";
|
||||
|
||||
const MyVods = () => {
|
||||
const [vods, setVods] = useState<Vod[]>([]);
|
||||
const [error, setError] = useState<null | string>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
getVods()
|
||||
.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"));
|
||||
.then((data) => setVods(data.sort((a, b) => stringToDate(b.createdAt).getTime() - stringToDate(a.createdAt).getTime())))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : "Failed to load vods"))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
@@ -28,8 +32,10 @@ const MyVods = () => {
|
||||
<div className="p-6">
|
||||
<h1 className="text-4xl font-heading text-text-primary mb-6">My VoDs</h1>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{vods.map((vod) => (
|
||||
<TimeGroupedGrid
|
||||
items={vods}
|
||||
getDate={(vod) => vod.createdAt}
|
||||
renderItem={(vod) => (
|
||||
<VideoCard
|
||||
key={vod.id}
|
||||
id={vod.id}
|
||||
@@ -42,10 +48,10 @@ const MyVods = () => {
|
||||
onEdit={() => navigate(`/vods/${vod.id}/edit`)}
|
||||
onDelete={() => handleDelete(vod.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
{vods.length === 0 && !error && (
|
||||
{vods.length === 0 && !error && !loading && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted">
|
||||
<p className="text-base">No VoDs yet.</p>
|
||||
<p className="text-sm mt-1">Your stream recordings will appear here.</p>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {config} from "../../config.ts";
|
||||
import type { APIResponse } from "../types.ts";
|
||||
|
||||
const API_URL = config.apiUrl;
|
||||
|
||||
@@ -7,28 +8,72 @@ class AuthError extends Error {
|
||||
}
|
||||
|
||||
let onUnauthorized: (() => void) | null = null;
|
||||
let onSessionRefreshed: ((accessTokenExpiresIn: number) => void) | null = null;
|
||||
let refreshInFlight: Promise<boolean> | null = null;
|
||||
|
||||
export function registerUnauthorizedHandler(handler: () => void) {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a request against the API, sending session cookies and funnelling
|
||||
* expired/missing sessions through the registered unauthorized handler so the
|
||||
* 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.
|
||||
* Exchanges the refresh token cookie for new access and refresh token cookies.
|
||||
* Concurrent callers share a single request, so a burst of 401s only rotates
|
||||
* the refresh token once.
|
||||
*
|
||||
* @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.
|
||||
*/
|
||||
async function apiFetchRaw(url: string, options: RequestInit = {}): Promise<Response> {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
credentials: 'include'
|
||||
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.status == 401 || response.status == 403) {
|
||||
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> {
|
||||
const send = () => fetch(url, { ...options, credentials: 'include' });
|
||||
|
||||
let response = await send();
|
||||
|
||||
if (response.status == 401 && await refreshSession()) {
|
||||
response = await send();
|
||||
}
|
||||
|
||||
if (response.status == 401) {
|
||||
onUnauthorized?.();
|
||||
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) {
|
||||
const text = await response.text();
|
||||
let message = text;
|
||||
@@ -45,4 +90,4 @@ async function apiFetch<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
return text ? JSON.parse(text) : (undefined as T);
|
||||
}
|
||||
|
||||
export { API_URL, AuthError, apiFetch, apiFetchRaw };
|
||||
export { API_URL, AuthError, apiFetch, apiFetchRaw, refreshSession };
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import type { APIResponse, User } from "../types.ts";
|
||||
import { API_URL, apiFetch } from "./client.ts";
|
||||
import { API_URL } from "./client.ts";
|
||||
|
||||
// login and getUser deliberately use fetch directly: they run when there may be
|
||||
// no session at all, and getUser is what the unauthorized handler calls to
|
||||
// refresh — routing them through apiFetch would loop on a 401.
|
||||
const login = async (googleToken: string): Promise<string> => {
|
||||
// These deliberately use fetch directly rather than apiFetch: they run when
|
||||
// there may be no session at all, and apiFetch's refresh-and-retry on 401
|
||||
// would either loop or log the user out from under the auth provider.
|
||||
|
||||
/**
|
||||
* 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', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -17,12 +24,17 @@ const login = async (googleToken: string): Promise<string> => {
|
||||
const result: APIResponse = await response.json();
|
||||
if (result.status === 'error') throw new Error(`Login failed: ${result.message}`);
|
||||
|
||||
return result.data.token;
|
||||
return result.data.accessTokenExpiresIn;
|
||||
};
|
||||
|
||||
/** Revokes the session and clears the auth cookies. */
|
||||
const logout = async (): Promise<void> => {
|
||||
const result = await apiFetch<APIResponse>(API_URL + '/api/v1/users/logout', { method: 'POST' });
|
||||
if (result?.status === 'error') throw new Error(`Logout failed: ${result.message}`);
|
||||
const response = await fetch(API_URL + '/api/v1/users/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Logout failed: ${response.status}`);
|
||||
};
|
||||
|
||||
const getUser = async (): Promise<User | null> => {
|
||||
|
||||
@@ -46,4 +46,52 @@ function dateToTimeAgo(date: Date): string {
|
||||
return `${s} second${s !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
|
||||
export { formatTime, stringToDate, dateToTimeAgo, formatLocalDate, formatLocalTime };
|
||||
function startOfDay(date: Date): Date {
|
||||
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 };
|
||||
|
||||
@@ -11,7 +11,7 @@ public class ClipOptions {
|
||||
private String description;
|
||||
|
||||
@NotNull
|
||||
@Positive
|
||||
@PositiveOrZero
|
||||
private Float startPoint;
|
||||
|
||||
@NotNull @Positive
|
||||
|
||||
@@ -4,12 +4,9 @@ import java.util.Date;
|
||||
import com.auth0.jwt.JWT;
|
||||
import com.auth0.jwt.JWTVerifier;
|
||||
import com.auth0.jwt.algorithms.Algorithm;
|
||||
import com.auth0.jwt.exceptions.JWTDecodeException;
|
||||
import com.auth0.jwt.exceptions.TokenExpiredException;
|
||||
import com.auth0.jwt.exceptions.JWTVerificationException;
|
||||
import com.auth0.jwt.interfaces.DecodedJWT;
|
||||
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;
|
||||
|
||||
@Service
|
||||
@@ -60,7 +57,7 @@ public class JwtService {
|
||||
}
|
||||
|
||||
return jwt.getClaim(USER_ID_CLAIM).asLong();
|
||||
} catch (JwtException | IllegalArgumentException | TokenExpiredException | JWTDecodeException ignored) {
|
||||
} catch (JWTVerificationException | IllegalArgumentException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user