move next to web-front

This commit is contained in:
2026-05-17 01:08:19 +03:00
parent cc219cdb26
commit 9e53a7b4fc
52 changed files with 1442 additions and 175 deletions
@@ -0,0 +1,29 @@
import 'server-only';
import { unstable_cache } from 'next/cache';
import { DB_MEMORY_TTL_MS, NEXT_CACHE_REVALIDATE_SECONDS } from '@/shared/config/env';
import { calculateVideoStats } from '@/entities/video/model/video';
import { listVideos } from './videoRepository';
export const VIDEO_COLLECTION_TAG = 'video-collection';
export const getCachedVideoDashboard = unstable_cache(
async () => {
const videos = await listVideos();
return {
videos,
stats: calculateVideoStats(videos),
cache: {
generatedAt: new Date().toISOString(),
nextRevalidateSeconds: NEXT_CACHE_REVALIDATE_SECONDS,
memoryTtlMs: DB_MEMORY_TTL_MS,
},
};
},
['video-dashboard-v1'],
{
revalidate: NEXT_CACHE_REVALIDATE_SECONDS,
tags: [VIDEO_COLLECTION_TAG],
},
);
@@ -0,0 +1,21 @@
import 'server-only';
import { createVideo } from '@/entities/video/model/video';
import type { VideoDraft } from '@/entities/video/model/types';
import { readPrimitiveDb, writePrimitiveDb } from '@/shared/db/primitiveJsonDb';
export async function listVideos() {
const db = await readPrimitiveDb();
return [...db.videos].sort((left, right) => right.createdAt.localeCompare(left.createdAt));
}
export async function addVideo(draft: VideoDraft) {
const video = createVideo(draft);
await writePrimitiveDb((current) => ({
...current,
videos: [video, ...current.videos],
}));
return video;
}
@@ -0,0 +1,37 @@
export const videoStatuses = ['draft', 'published', 'archived'] as const;
export type VideoStatus = (typeof videoStatuses)[number];
export type Video = {
id: string;
title: string;
owner: string;
sourceUrl: string;
status: VideoStatus;
createdAt: string;
updatedAt: string;
};
export type VideoDraft = {
title: string;
owner: string;
sourceUrl: string;
status: VideoStatus;
};
export type VideoDashboardStats = {
total: number;
draft: number;
published: number;
archived: number;
};
export type VideoDashboardData = {
videos: Video[];
stats: VideoDashboardStats;
cache: {
generatedAt: string;
nextRevalidateSeconds: number;
memoryTtlMs: number;
};
};
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { calculateVideoStats, normalizeVideoDraft } from './video';
import type { Video } from './types';
describe('video model', () => {
it('normalizes sparse form input', () => {
expect(
normalizeVideoDraft({
title: ' Demo reel ',
owner: '',
sourceUrl: 'not a url',
status: 'unexpected',
}),
).toEqual({
title: 'Demo reel',
owner: 'Unassigned',
sourceUrl: 'https://example.com/video',
status: 'draft',
});
});
it('counts videos by status', () => {
const base = {
id: 'id',
title: 'Title',
owner: 'Ops',
sourceUrl: 'https://example.com/video',
createdAt: '2026-05-16T00:00:00.000Z',
updatedAt: '2026-05-16T00:00:00.000Z',
};
const videos: Video[] = [
{ ...base, id: 'draft', status: 'draft' },
{ ...base, id: 'published', status: 'published' },
{ ...base, id: 'archived', status: 'archived' },
{ ...base, id: 'published-2', status: 'published' },
];
expect(calculateVideoStats(videos)).toEqual({
total: 4,
draft: 1,
published: 2,
archived: 1,
});
});
});
@@ -0,0 +1,68 @@
import { videoStatuses, type Video, type VideoDashboardStats, type VideoDraft, type VideoStatus } from './types';
const fallbackUrl = 'https://example.com/video';
function normalizeText(value: string, fallback: string) {
const normalized = value.trim().replace(/\s+/g, ' ');
return normalized.length > 0 ? normalized : fallback;
}
function normalizeUrl(value: string) {
const normalized = value.trim();
if (normalized.length === 0) return fallbackUrl;
try {
return new URL(normalized).toString();
} catch {
return fallbackUrl;
}
}
export function isVideoStatus(value: string): value is VideoStatus {
return videoStatuses.includes(value as VideoStatus);
}
export function normalizeVideoDraft(input: {
title?: string;
owner?: string;
sourceUrl?: string;
status?: string;
}): VideoDraft {
return {
title: normalizeText(input.title ?? '', 'Untitled video'),
owner: normalizeText(input.owner ?? '', 'Unassigned'),
sourceUrl: normalizeUrl(input.sourceUrl ?? ''),
status: input.status && isVideoStatus(input.status) ? input.status : 'draft',
};
}
export function createVideoId(now = new Date()) {
const suffix = Math.random().toString(36).slice(2, 8);
return `video_${now.getTime().toString(36)}_${suffix}`;
}
export function createVideo(draft: VideoDraft, now = new Date()): Video {
const timestamp = now.toISOString();
return {
...draft,
id: createVideoId(now),
createdAt: timestamp,
updatedAt: timestamp,
};
}
export function calculateVideoStats(videos: Video[]): VideoDashboardStats {
return videos.reduce<VideoDashboardStats>(
(stats, video) => ({
...stats,
total: stats.total + 1,
[video.status]: stats[video.status] + 1,
}),
{
total: 0,
draft: 0,
published: 0,
archived: 0,
},
);
}