This commit is contained in:
Chris Grimmett 2023-10-09 02:20:56 -08:00
parent 9ad127b6e9
commit cd10a081a7
11 changed files with 219 additions and 93 deletions

53
app/components/tag.tsx Normal file
View File

@ -0,0 +1,53 @@
'use client';
import { ITagVodRelation } from "@/lib/tag-vod-relations"
import { isWithinInterval, subHours } from "date-fns";
import { faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { AuthContext, IUseAuth } from "./auth";
import { useContext, useEffect, useState } from "react";
import Link from 'next/link';
import { strapiUrl } from "@/lib/constants";
export interface ITagParams {
tvr: ITagVodRelation;
}
function isCreatedByMeRecently(userId: number | undefined, tvr: ITagVodRelation) {
if (!userId) return false;
if (userId !== tvr.creatorId) return false;
const last24H: Interval = { start: subHours(new Date(), 24), end: new Date() };
if (!isWithinInterval(new Date(tvr.createdAt), last24H)) return false;
return true;
}
async function handleDelete(authContext: IUseAuth | null, tvr: ITagVodRelation) {
if (!authContext) return;
const { authData } = authContext;
const res = await fetch(`${strapiUrl}/api/tag-vod-relations/deleteMine/${tvr.id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${authData?.accessToken}`,
'Content-Type': 'application/json'
}
})
if (!res.ok) throw new Error(res.statusText)
return true
}
export function Tag({ tvr }: ITagParams) {
const authContext = useContext(AuthContext);
const [shouldRenderDeleteButton, setShouldRenderDeleteButton] = useState<boolean>(false);
useEffect(() => {
setShouldRenderDeleteButton(isCreatedByMeRecently(authContext?.authData?.user?.id, tvr));
}, [authContext?.authData?.user?.id, tvr]);
return (
<span className="tags mr-2 mb-0" key={tvr.id}>
<span className="tag">{tvr.tag.name}</span>
{shouldRenderDeleteButton && <a onClick={() => handleDelete(authContext, tvr)} className="tag is-danger"><span className="icon is-small"><FontAwesomeIcon className="fas fa-trash" icon={faTrash}></FontAwesomeIcon></span></a>}
</span>
)
}

View File

@ -11,9 +11,10 @@ import { debounce } from 'lodash';
import { strapiUrl } from '@/lib/constants';
import { VideoContext } from './video-context';
import { useForm } from "react-hook-form";
import { assertTimestamp } from '@/lib/timestamps';
import { createTimestamp } from '@/lib/timestamps';
import { useRouter } from 'next/navigation';
import styles from '@/assets/styles/fp.module.css'
import qs from 'qs';
interface ITaggerProps {
vod: IVod;
@ -80,8 +81,20 @@ export function Tagger({ vod }: ITaggerProps): React.JSX.Element {
}
async function search(value: string) {
const query = qs.stringify(
{
filters: {
tags: {
publishedAt: {
$notNull: true
}
}
},
query: value
}
)
if (!value) return;
const res = await fetch(`${strapiUrl}/api/fuzzy-search/search?query=${value}`, {
const res = await fetch(`${strapiUrl}/api/fuzzy-search/search?${query}`, {
headers: {
'Authorization': `Bearer ${authData?.accessToken}`
}
@ -101,12 +114,15 @@ export function Tagger({ vod }: ITaggerProps): React.JSX.Element {
async function onError(errors: IErrorsObject) {
console.error('submit handler encoutnered an error')
console.error(errors)
setError('root.serverError', {
type: 'idk',
message: 'there was an Error '
})
}
async function onSubmit(values: { tagName: string, isTimestamp: boolean }) {
if (!authData) throw new Error('Must be logged in');
try {
console.log('asserting tvr')
const tvr = await createTagAndTvr(setError, authData, values.tagName, vod.id);
if (!tvr) {
setError('root.serverError', {
@ -115,14 +131,10 @@ export function Tagger({ vod }: ITaggerProps): React.JSX.Element {
})
return;
}
console.log('assertint timestamp')
if (values.isTimestamp) await assertTimestamp(setError, authData, tvr.tag.id, vod.id, timeStamp);
console.log("htere whas no werror becauzse we are ahere")
if (values.isTimestamp) await createTimestamp(setError, authData, tvr.tag.id, vod.id, timeStamp);
setValue('tagName', '');
router.refresh();
} catch (e) {
console.error('there was an error')
console.error(e)
setError('root.serverError', {
type: 'idk'
});

View File

@ -1,41 +1,78 @@
'use client';
import React, { useContext } from "react"
import React, { useContext, useState, useEffect, useCallback } from "react";
import { IVod } from "@/lib/vods";
import { IRawTimestamp, getRawTimestampsForVod } from "@/lib/timestamps";
import { useEffect, useState } from "react";
import { formatTimestamp, formatUrlTimestamp, parseUrlTimestamp } from "@/lib/dates";
import { VideoContext } from "./video-context";
import {
IRawTimestamp,
getRawTimestampsForVod,
deleteTimestamp
} from "@/lib/timestamps";
import {
formatTimestamp,
formatUrlTimestamp,
} from "@/lib/dates";
import Link from 'next/link';
import { faClock, faLink } from "@fortawesome/free-solid-svg-icons";
import { faClock, faLink, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ITagVodRelation } from "@/lib/tag-vod-relations";
import { AuthContext, IAuthData, IUseAuth } from "./auth";
import pThrottle from 'p-throttle';
import { isWithinInterval, subHours, type Interval } from 'date-fns'
import { useRouter } from 'next/navigation';
import styles from '@/assets/styles/fp.module.css'
import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
export interface ITimestampsProps {
vod: IVod;
selectedTag: string;
}
function isCreatedByMeRecently(authData: IAuthData, ts: IRawTimestamp) {
if (!authData?.user) return false;
if (authData.user.id !== ts.attributes.creatorId) return false;
const last24H: Interval = { start: subHours(new Date(), 24), end: new Date() };
if (!isWithinInterval(new Date(ts.attributes.createdAt), last24H)) return false;
return true;
}
export function Timestamps({ vod }: ITimestampsProps): React.JSX.Element {
const { tvrs, setTvrs } = useContext(VideoContext);
const [timestamps, setTimestamps] = useState<IRawTimestamp[]>([])
const router = useRouter();
const throttle = pThrottle({
limit: 1,
interval: 1000
});
const throttledTimestampFetch = throttle(getRawTimestampsForVod)
const [timestamps, setTimestamps] = useState<IRawTimestamp[]>([]);
const authContext = useContext(AuthContext);
useEffect(() => {
getRawTimestampsForVod(vod.id).then((data) => {
setTimestamps(data.data)
})
}, [vod])
const fetchData = async (page: number) => {
try {
const data = await throttledTimestampFetch(vod.id, page, 25)
setTimestamps((prevTimestamps) => [...prevTimestamps, ...data.data.filter((timestamp) => !prevTimestamps.map((t) => t.id).includes(timestamp.id))]);
// Fetch the next page if needed
if (page < data.meta.pagination.pageCount) {
fetchData(page + 1);
}
} catch (e) {
console.error(e)
}
};
fetchData(1)
}, [vod]);
return (
<div className='timestamps mb-5'>
<h4 className='subtitle is-4'>
<div className="timestamps mb-5">
<h4 className="subtitle is-4">
<a id="timestamps"></a>
<Link href="#timestamps">
<FontAwesomeIcon
icon={faLink}
className="fab fa-link mr-2"
></FontAwesomeIcon>
<FontAwesomeIcon
icon={faLink}
className="fab fa-link mr-2"
></FontAwesomeIcon>
</Link>
<FontAwesomeIcon
icon={faClock}
@ -43,13 +80,37 @@ export function Timestamps({ vod }: ITimestampsProps): React.JSX.Element {
></FontAwesomeIcon>
<span>Timestamps</span>
</h4>
{(!!timestamps) ? (
{(timestamps.length > 0) && (
timestamps.map((ts: IRawTimestamp) => {
return <p key={ts.id}><Link href={`?t=${formatUrlTimestamp(ts.attributes.time)}`}>{formatTimestamp(ts.attributes.time)}</Link> {ts.attributes.tag.data.attributes.name}</p>
return (
// <p>{JSON.stringify(ts, null, 2)}</p>
<p key={ts.id}>
<Link
href={`?t=${formatUrlTimestamp(ts.attributes.time)}`}
className="mr-2"
>
{formatTimestamp(ts.attributes.time)}
</Link>
<span className="mr-2">{ts.attributes.tag.data.attributes.name}</span>
{(authContext?.authData && isCreatedByMeRecently(authContext.authData, ts)) && (
<button
onClick={() => {
if (!authContext?.authData) return;
deleteTimestamp(authContext.authData, ts.id);
setTimestamps((prevTimestamps) => prevTimestamps.filter((timestamp) => timestamp.id !== ts.id));
}}
className={`button is-danger icon`}
>
<FontAwesomeIcon icon={faTrash}></FontAwesomeIcon>
</button>
)}
</p>
)
})
) : (
<p>This VOD has no timestamps</p>
)}
{(timestamps.length < 1) && <p><i>This VOD has no timestamps</i></p>}
</div>
)
}
}

View File

@ -15,8 +15,10 @@ import { parseUrlTimestamp } from "@/lib/dates";
import { TagButton } from '@/components/tag-button';
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTags, faLink } from "@fortawesome/free-solid-svg-icons";
import { Tag } from './tag';
import Link from 'next/link';
export interface IVideoInteractiveProps {
vod: IVod;
safeDate: string;
@ -60,10 +62,10 @@ export function VideoInteractive({ vod, safeDate, localizedDate }: IVideoInterac
const [timeStamp, setTimeStamp] = useState(0);
const [tvrs, setTvrs] = useState([]);
const [isPlayerReady, setIsPlayerReady] = useState(false);
const [selectedTag, setSelectedTag] = useState<string>('');
const ref = useRef(null);
const searchParams = useSearchParams();
const t = searchParams.get('t');
useEffect(() => {
if (!t) return;
@ -118,13 +120,15 @@ export function VideoInteractive({ vod, safeDate, localizedDate }: IVideoInterac
></FontAwesomeIcon>
<span>Tags</span>
</h4>
<div className='tags'>
{vod.tagVodRelations.map((tvr: ITagVodRelation) => (
<span className="tag is-small" key={tvr.id}>{tvr.tag.name}</span>
))}
<Tagger vod={vod}></Tagger>
<div>
<div className="tags has-addons mb-5">
{vod.tagVodRelations.map((tvr: ITagVodRelation) => (
<Tag key={tvr.id} tvr={tvr}></Tag>
))}
<Tagger vod={vod}></Tagger>
</div>
</div>
<Timestamps vod={vod} selectedTag={selectedTag}></Timestamps>
<Timestamps vod={vod}></Timestamps>
</div>
</VideoContext.Provider>

View File

@ -3,12 +3,13 @@ import { strapiUrl } from './constants'
import { unmarshallTag, ITag, IToyTag } from './tags';
import { IVod } from './vods';
import { IAuthData } from '@/components/auth';
import slugify from 'slugify';
export interface ITagVodRelation {
id: number;
tag: ITag | IToyTag;
vod: IVod;
creatorId: number;
createdAt: string;
}
@ -26,27 +27,12 @@ export function unmarshallTagVodRelation(d: any): ITagVodRelation {
return {
id: d.id,
tag: unmarshallTag(d.attributes.tag.data),
vod: d.attributes.vod
vod: d.attributes.vod,
creatorId: d.attributes.creatorId,
createdAt: d.attributes.createdAt,
}
}
// export async function createTagVodRelationAndTag (authData: IAuthData, tagName: string, vodId: number) {
// const res = await fetch(`${strapiUrl}/api/tag-vod-relations/tag`, {
// method: 'POST',
// headers: {
// 'Authorization': `Bearer ${authData.accessToken}`,
// 'Content-Type': 'application/json'
// },
// body: JSON.stringify({
// data: {
// tagName: slugify(tagName),
// vodId: vodId
// }
// })
// })
// const json = await res.json();
// return json;
// }
export async function createTagAndTvr(setError: Function, authData: IAuthData, tagName: string, vodId: number) {
if (!authData) throw new Error('Must be logged in');
@ -73,31 +59,6 @@ export async function createTagAndTvr(setError: Function, authData: IAuthData, t
}
// async createTimestamp() {
// const ts = {
// time: parseInt(Alpine.store('player').seconds),
// tag: this.selectedTag.tagId,
// vod: this.vodId
// }
// return fetch(`${window.backend}/api/timestamps?populate=*`, {
// method: 'POST',
// body: JSON.stringify({
// data: ts
// }),
// headers: {
// 'Content-Type': 'application/json',
// 'Authorization': `Bearer ${Alpine.store('auth').jwt}`
// },
// })
// .then(res => res.json())
// .then(data => {
// if (data?.error) {
// throw new Error(data.error.message || 'Problem while creating timestamp. Please try again later')
// }
// else return data.data
// })
// },
export async function getTagVodRelationsForVtuber(vtuberId: number, page: number = 1, pageSize: number = 25): Promise<ITagVodRelations> {
// get the tag-vod-relations where the vtuber is the vtuber we are interested in.
const query = qs.stringify(

View File

@ -31,16 +31,20 @@ export interface IRawTimestamp {
tag: IRawTag;
tagId: number;
vodId: number;
createdAt: string;
creatorId: number;
}
}
export interface ITimestampsResponse {
data: IRawTimestamp[];
pagination: {
page: number;
pageSize: number;
pageCount: number;
total: number;
meta: {
pagination: {
page: number;
pageSize: number;
pageCount: number;
total: number;
}
}
}
@ -51,6 +55,24 @@ function truncateString(str: string, maxLength: number) {
return str.substring(0, maxLength - 1) + '…';
}
export function deleteTimestamp(authData: IAuthData, tsId: number) {
return fetch(`${strapiUrl}/api/timestamps/deleteMine/${tsId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${authData.accessToken}`,
'Content-Type': 'application/json'
}
})
.then((res) => {
if (!res.ok) throw new Error(res.statusText);
else return res.json();
})
.catch((e) => {
console.error(e);
// setError('root.serverError', { message: e.message })
})
}
export function unmarshallTimestamps(ts: any) {
if (!ts?.attributes?.tag?.data) throw new Error(`cannot format ts id ${ts.id} because ts.attributes.tag.data is undefined`)
const id = ts.id
@ -62,7 +84,7 @@ export function unmarshallTimestamps(ts: any) {
return { id, time, vodId, tagName, tagId, tnShort }
}
export function assertTimestamp(setError: Function, authData: IAuthData, tagId: number, vodId: number, time: number) {
export function createTimestamp(setError: Function, authData: IAuthData, tagId: number, vodId: number, time: number) {
return fetch(`${strapiUrl}/api/timestamps/assert`, {
method: 'POST',
headers: {

View File

@ -180,7 +180,7 @@ export async function getVodForDate(date: Date): Promise<IVod|null> {
fields: ['cdnUrl', 'url']
},
tagVodRelations: {
fields: ['tag'],
fields: ['tag', 'createdAt', 'creatorId'],
populate: ['tag']
},
videoSrcB2: {

View File

@ -7,4 +7,8 @@
height: 2em;
margin-bottom: 0.5rem;
border-radius: 4px;
}
.isTiny {
height: 1.5em;
}

View File

@ -2,7 +2,7 @@
const path = require("path");
const nextConfig = {
output: 'standalone',
reactStrictMode: true,
reactStrictMode: false,
sassOptions: {
includePaths: [path.join(__dirname, "assets", "styles")],
},

View File

@ -45,6 +45,7 @@
"next-goatcounter": "^1.0.3",
"next-react-svg": "^1.2.0",
"nextjs-toploader": "^1.4.2",
"p-throttle": "^5.1.0",
"plyr": "^3.7.8",
"plyr-react": "^5.3.0",
"qs": "^6.11.2",

View File

@ -113,6 +113,9 @@ dependencies:
nextjs-toploader:
specifier: ^1.4.2
version: 1.4.2(next@13.5.1)(react-dom@18.2.0)(react@18.2.0)
p-throttle:
specifier: ^5.1.0
version: 5.1.0
plyr:
specifier: ^3.7.8
version: 3.7.8
@ -2722,6 +2725,11 @@ packages:
p-timeout: 5.1.0
dev: false
/p-throttle@5.1.0:
resolution: {integrity: sha512-+N+s2g01w1Zch4D0K3OpnPDqLOKmLcQ4BvIFq3JC0K29R28vUOjWpO+OJZBNt8X9i3pFCksZJZ0YXkUGjaFE6g==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
dev: false
/p-timeout@5.1.0:
resolution: {integrity: sha512-auFDyzzzGZZZdHz3BtET9VEz0SE/uMEAx7uWfGPucfzEwwe/xH0iVeZibQmANYE/hp9T2+UUZT5m+BKyrDp3Ew==}
engines: {node: '>=12'}