3 Commits

Author SHA1 Message Date
4b4cc3145f fix frontend
All checks were successful
CI / Maven unit tests (push) Successful in 23s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
2026-10-16 20:54:07 +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
7 changed files with 51 additions and 144 deletions

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

@@ -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;
} }
} }