feat(frontend): segment clips by time range #57
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;
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
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 = () => {
|
||||||
@@ -12,7 +14,7 @@ const MyClips = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getClips()
|
getClips()
|
||||||
.then((data) => setClips(data.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())))
|
.then((data) => setClips(data.sort((a, b) => stringToDate(b.createdAt).getTime() - stringToDate(a.createdAt).getTime())))
|
||||||
.catch((err) => setError(err))
|
.catch((err) => setError(err))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
@@ -30,8 +32,10 @@ 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>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
<TimeGroupedGrid
|
||||||
{clips.map((clip) => (
|
items={clips}
|
||||||
|
getDate={(clip) => clip.createdAt}
|
||||||
|
renderItem={(clip) => (
|
||||||
<VideoCard
|
<VideoCard
|
||||||
key={clip.id}
|
key={clip.id}
|
||||||
id={clip.id}
|
id={clip.id}
|
||||||
@@ -41,8 +45,8 @@ 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 && !loading && (
|
||||||
<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">
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
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 = () => {
|
||||||
@@ -12,7 +14,7 @@ const MyVods = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getVods()
|
getVods()
|
||||||
.then((data) => setVods(data.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())))
|
.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"))
|
.catch((err) => setError(err instanceof Error ? err.message : "Failed to load vods"))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
@@ -30,8 +32,10 @@ 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>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
<TimeGroupedGrid
|
||||||
{vods.map((vod) => (
|
items={vods}
|
||||||
|
getDate={(vod) => vod.createdAt}
|
||||||
|
renderItem={(vod) => (
|
||||||
<VideoCard
|
<VideoCard
|
||||||
key={vod.id}
|
key={vod.id}
|
||||||
id={vod.id}
|
id={vod.id}
|
||||||
@@ -44,8 +48,8 @@ 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 && !loading && (
|
||||||
<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">
|
||||||
|
|||||||
@@ -46,4 +46,52 @@ function dateToTimeAgo(date: Date): string {
|
|||||||
return `${s} second${s !== 1 ? 's' : ''} ago`;
|
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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user