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
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.
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. */
media backdrop, which stays dark in both themes. */
:root[data-theme="dark"] {
color-scheme: dark;
/* ── Surfaces ────────────────────────────────────────────────────────── */
--color-background: #161615;
--color-sidebar: #111111;
--color-card: #1F1F1E;
--color-fields: #1A1A19;
--color-dropzone: #1C1C1B;
/* ── Surfaces ── near-black canvas, neutral cards lifting off it ─────── */
--color-background: #0E0E10;
--color-sidebar: #09090B;
--color-card: #17171A;
--color-fields: #121215;
--color-dropzone: #141417;
--color-media-backdrop: #0B0B0B;
--color-media-backdrop: #000000;
/* ── Ink ─────────────────────────────────────────────────────────────── */
--color-text-primary: #F5F4EF;
--color-text-strong: #E8E6E0;
--color-text-body: #C9C7C0;
--color-text-secondary: #A2A099;
--color-muted: #87857F;
--color-faint-mono: #6C6B66;
--color-text-primary: #F4F4F5;
--color-text-strong: #E3E3E7;
--color-text-body: #BFBFC6;
--color-text-secondary: #94949D;
--color-muted: #76767F;
--color-faint-mono: #6A6A73;
/* ── 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;
/* ── 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;
/* ── Status ──────────────────────────────────────────────────────────── */
--color-live: #E0644E;
--color-error: #E0644E;
--color-live: #F2604A;
--color-error: #F2604A;
/* ── Ink on accent surfaces ── the accents are light here, so the ink that
sits on them flips dark ─────────────────────────────────────────────── */
--color-on-accent: #1A1410;
--color-on-accent: #17100B;
/* ── Lines, chips, placeholders ──────────────────────────────────────── */
--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);
--color-hairline: #232327;
--color-hover: #26262B;
--color-selected: #2A1F18;
--color-ring: #33333A;
--color-inactive: #2C2C32;
--color-inactive-strong: #55555E;
}
:root {

View File

@@ -1,22 +1,18 @@
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) => stringToDate(b.createdAt).getTime() - stringToDate(a.createdAt).getTime())))
.catch((err) => setError(err))
.finally(() => setLoading(false));
.then((data) => setClips(data.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())))
.catch((err) => setError(err));
}, []);
const handleDelete = async (id: number) => {
@@ -32,10 +28,8 @@ const MyClips = () => {
<div className="p-6">
<h1 className="text-4xl font-heading text-text-primary mb-6">My Clips</h1>
<TimeGroupedGrid
items={clips}
getDate={(clip) => clip.createdAt}
renderItem={(clip) => (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{clips.map((clip) => (
<VideoCard
key={clip.id}
id={clip.id}
@@ -45,10 +39,10 @@ const MyClips = () => {
onEdit={() => navigate(`/clips/${clip.id}/edit`)}
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">
<p className="text-base">No clips yet.</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 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) => stringToDate(b.createdAt).getTime() - stringToDate(a.createdAt).getTime())))
.catch((err) => setError(err instanceof Error ? err.message : "Failed to load vods"))
.finally(() => setLoading(false));
.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"));
}, []);
const handleDelete = async (id: number) => {
@@ -32,10 +28,8 @@ const MyVods = () => {
<div className="p-6">
<h1 className="text-4xl font-heading text-text-primary mb-6">My VoDs</h1>
<TimeGroupedGrid
items={vods}
getDate={(vod) => vod.createdAt}
renderItem={(vod) => (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{vods.map((vod) => (
<VideoCard
key={vod.id}
id={vod.id}
@@ -48,10 +42,10 @@ const MyVods = () => {
onEdit={() => navigate(`/vods/${vod.id}/edit`)}
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">
<p className="text-base">No VoDs yet.</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`;
}
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 };
export { formatTime, stringToDate, dateToTimeAgo, formatLocalDate, formatLocalTime };

View File

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

View File

@@ -4,9 +4,12 @@ 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.JWTVerificationException;
import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.exceptions.TokenExpiredException;
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
@@ -57,7 +60,7 @@ public class JwtService {
}
return jwt.getClaim(USER_ID_CLAIM).asLong();
} catch (JWTVerificationException | IllegalArgumentException ignored) {
} catch (JwtException | IllegalArgumentException | TokenExpiredException | JWTDecodeException ignored) {
return null;
}
}