Initial commit with working prototype. Successfully allows uploading to the audio books stream and creates a podcast that antennapod can load
This commit is contained in:
parent
ae5a2a076d
commit
91fcddabb5
1
.gitignore
vendored
1
.gitignore
vendored
@ -19,6 +19,7 @@
|
|||||||
|
|
||||||
# production
|
# production
|
||||||
/build
|
/build
|
||||||
|
/public/uploads/
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
19
.vscode/launch.json
vendored
Normal file
19
.vscode/launch.json
vendored
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "Next.js Debug with Turbopack",
|
||||||
|
"program": "${workspaceFolder}/node_modules/next/dist/bin/next",
|
||||||
|
"args": [
|
||||||
|
"dev",
|
||||||
|
"--turbopack"
|
||||||
|
],
|
||||||
|
"cwd": "${workspaceFolder}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
26
db.json
Normal file
26
db.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"streams": [
|
||||||
|
{
|
||||||
|
"id": "f60236c3-81dd-47ba-b0ae-afd9229ac6f2",
|
||||||
|
"title": "Audio Books",
|
||||||
|
"description": "Personal repo of audio books",
|
||||||
|
"author": "anonymous",
|
||||||
|
"language": "en",
|
||||||
|
"categories": [
|
||||||
|
"audiobooks"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"podcasts": [
|
||||||
|
{
|
||||||
|
"podcastId": "0be8bd5f-4a42-4a2f-9540-3a63595855fe",
|
||||||
|
"streamId": "f60236c3-81dd-47ba-b0ae-afd9229ac6f2",
|
||||||
|
"title": "House of the Spirits",
|
||||||
|
"description": "The House of the Spirits\" is Isabel Allende's debut novel, first published in 1982. It's a multi-generational family saga that blends magical realism with political history, following the Trueba family through decades of love, power, and political upheaval in an unnamed Latin American country (heavily based on Chile).",
|
||||||
|
"uploadDate": "1746935468253",
|
||||||
|
"author": "Isabel Allende",
|
||||||
|
"imageUrl": "1006e3b7-f5db-49db-bdd6-4e3967abadc2.jpg",
|
||||||
|
"url": "21f0ca38-4a3b-486b-919d-4b6c78b189c0.m4a"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -1,7 +1,9 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
images: {
|
||||||
|
remotePatterns: [new URL('https://placehold.co/**')],
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
17352
package-lock.json
generated
17352
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
67
package.json
67
package.json
@ -1,27 +1,44 @@
|
|||||||
{
|
{
|
||||||
"name": "ever-givin-pod",
|
"name": "ever-givin-pod",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"type": "module",
|
||||||
"dev": "next dev --turbopack",
|
"scripts": {
|
||||||
"build": "next build",
|
"dev": "next dev --turbopack",
|
||||||
"start": "next start",
|
"debug": "node --inspect-brk node_modules/next/dist/bin/next dev --turbopack",
|
||||||
"lint": "next lint"
|
"server": "node server/server.js",
|
||||||
},
|
"build": "next build",
|
||||||
"dependencies": {
|
"start": "next start",
|
||||||
"react": "^19.0.0",
|
"lint": "next lint"
|
||||||
"react-dom": "^19.0.0",
|
},
|
||||||
"next": "15.3.2"
|
"dependencies": {
|
||||||
},
|
"@types/lowdb": "^1.0.15",
|
||||||
"devDependencies": {
|
"formidable": "^3.5.4",
|
||||||
"typescript": "^5",
|
"lowdb": "^7.0.1",
|
||||||
"@types/node": "^20",
|
"mime-types": "^3.0.1",
|
||||||
"@types/react": "^19",
|
"multer": "^1.4.5-lts.2",
|
||||||
"@types/react-dom": "^19",
|
"multiparty": "^4.2.3",
|
||||||
"@tailwindcss/postcss": "^4",
|
"next": "15.3.2",
|
||||||
"tailwindcss": "^4",
|
"podcast": "^2.0.1",
|
||||||
"eslint": "^9",
|
"react": "^19.0.0",
|
||||||
"eslint-config-next": "15.3.2",
|
"react-dom": "^19.0.0",
|
||||||
"@eslint/eslintrc": "^3"
|
"react-hook-form": "^7.56.3",
|
||||||
}
|
"uuid": "^11.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/eslintrc": "^3",
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/express": "^5.0.1",
|
||||||
|
"@types/formidable": "^3.4.5",
|
||||||
|
"@types/mime-types": "^2.1.4",
|
||||||
|
"@types/multer": "^1.4.12",
|
||||||
|
"@types/multiparty": "^4.2.1",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "15.3.2",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
18
src/app/[stream]/podcasts/addPodcastCard.tsx
Normal file
18
src/app/[stream]/podcasts/addPodcastCard.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function AddPodcastCard({ stream }: { stream: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
router.push(`${process.env.NEXT_PUBLIC_API_BASE_URL}/${stream}/publish`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={handleClick}
|
||||||
|
className="w-[150px] h-[150px] shadow-md rounded-lg overflow-hidden flex items-center justify-center m-4 mb-1 bg-purple-200 cursor-pointer hover:bg-purple-300 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-4xl font-bold text-gray-800">+</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
src/app/[stream]/podcasts/page.tsx
Normal file
17
src/app/[stream]/podcasts/page.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import Main from '../../layout/main';
|
||||||
|
import PodcastList from './podcastList';
|
||||||
|
import PodcastSummary from './podcastSummary';
|
||||||
|
|
||||||
|
export default function StreamPodcasts() {
|
||||||
|
const params = useParams();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Main>
|
||||||
|
<PodcastSummary stream={params?.stream} />
|
||||||
|
<PodcastList stream={params?.stream} />
|
||||||
|
</Main>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
src/app/[stream]/podcasts/podcastCard.tsx
Normal file
31
src/app/[stream]/podcasts/podcastCard.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import Image from 'next/image';
|
||||||
|
import { PodcastDto } from '../common/dtos/podcastDto';
|
||||||
|
|
||||||
|
export default function PodcastCard({
|
||||||
|
imageUrl = "https://placehold.co/400",
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
uploadDate,
|
||||||
|
url,
|
||||||
|
author,
|
||||||
|
}: PodcastDto) {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-[150px] shadow-md rounded-sm overflow-hidden flex m-4 mb-1">
|
||||||
|
<div className="w-[150px] h-full bg-purple-100 flex-shrink-0 relative">
|
||||||
|
<Image unoptimized src={imageUrl} alt={title} layout="fill" objectFit="cover" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 flex-grow flex flex-col bg-purple-200">
|
||||||
|
<h2 className="text-lg font-bold text-gray-800">{title}</h2>
|
||||||
|
<p className="text-sm text-gray-600 flex-grow">{description}</p>
|
||||||
|
<div className="mt-2 text-sm text-gray-500">
|
||||||
|
<p>By: {author}</p>
|
||||||
|
<p>Uploaded: {uploadDate}</p>
|
||||||
|
</div>
|
||||||
|
<a href={url} target="_blank" rel="noopener noreferrer" className="mt-4 text-blue-500 hover:underline text-sm">
|
||||||
|
Listen Now
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
src/app/[stream]/podcasts/podcastList.tsx
Normal file
39
src/app/[stream]/podcasts/podcastList.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import PodcastCard from './podcastCard';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { PodcastDto } from '../../../common/dtos/podcastDto';
|
||||||
|
import AddPodcastCard from './addPodcastCard';
|
||||||
|
|
||||||
|
type PodcastListProps = {
|
||||||
|
stream: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PodcastList({ stream }: PodcastListProps) {
|
||||||
|
const [podcastData, setPodcastData] = useState<PodcastDto[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/${stream}/podcasts`)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => setPodcastData(data));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-[800px] mx-auto w-full px-4">
|
||||||
|
<AddPodcastCard stream={stream}/>
|
||||||
|
{
|
||||||
|
podcastData.map((podcast, index) => (
|
||||||
|
<div key={index} className="flex justify-center">
|
||||||
|
<PodcastCard
|
||||||
|
title={podcast.title}
|
||||||
|
description={podcast.description}
|
||||||
|
uploadDate={podcast.uploadDate}
|
||||||
|
url={podcast.url}
|
||||||
|
author={podcast.author}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
src/app/[stream]/podcasts/podcastSummary.tsx
Normal file
75
src/app/[stream]/podcasts/podcastSummary.tsx
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { StreamDto } from '../common/dtos/steamDto';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
type PodcastSummaryType = {
|
||||||
|
stream: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PodcastSummary({ stream }: PodcastSummaryType) {
|
||||||
|
const [summaryData, setSummaryData] = useState<StreamDto>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/${stream}`)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => setSummaryData(data));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!summaryData) {
|
||||||
|
return <p>Loading...</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-auto shadow-md overflow-hidden flex flex-col bg-purple-200">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-start">
|
||||||
|
<div className="relative w-full h-[150px] md:w-1/3 md:h-[376] bg-purple-100 flex-shrink-0">
|
||||||
|
<Image unoptimized src={summaryData.imageUrl} alt={summaryData.title} fill className="rounded-md object-cover" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 flex-grow flex flex-col md:ml-4">
|
||||||
|
<h2 className="text-lg font-bold text-gray-800">{summaryData.title}</h2>
|
||||||
|
<p className="text-sm text-gray-600">{summaryData.description}</p>
|
||||||
|
|
||||||
|
<div className="mt-4 text-sm text-gray-500">
|
||||||
|
<p><strong>Author:</strong> {summaryData.author}</p>
|
||||||
|
<p><strong>Managing Editor:</strong> {summaryData.managingEditor}</p>
|
||||||
|
<p><strong>Web Master:</strong> {summaryData.webMaster}</p>
|
||||||
|
<p><strong>Language:</strong> {summaryData.language}</p>
|
||||||
|
<p><strong>Published Date:</strong> {summaryData.pubDate}</p>
|
||||||
|
<p><strong>TTL:</strong> {summaryData.ttl} minutes</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<p className="text-sm font-bold text-gray-700">Categories:</p>
|
||||||
|
<ul className="list-disc list-inside text-sm text-gray-600">
|
||||||
|
{summaryData.categories?.map((category, index) => (
|
||||||
|
<li key={index}>{category}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<a
|
||||||
|
href={summaryData.feedUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-500 hover:underline text-sm block"
|
||||||
|
>
|
||||||
|
View Feed
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={summaryData.siteUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-500 hover:underline text-sm block mt-2"
|
||||||
|
>
|
||||||
|
Visit Site
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
src/app/[stream]/publish/page.tsx
Normal file
15
src/app/[stream]/publish/page.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import Main from '../../layout/main';
|
||||||
|
import StreamPublishForm from './publishForm';
|
||||||
|
|
||||||
|
export default function StreamPublish() {
|
||||||
|
const params = useParams();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Main>
|
||||||
|
<StreamPublishForm stream={params?.stream} />
|
||||||
|
</Main>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
src/app/[stream]/publish/publishForm.tsx
Normal file
111
src/app/[stream]/publish/publishForm.tsx
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useForm, SubmitHandler } from "react-hook-form";
|
||||||
|
|
||||||
|
type PodcastDto = {
|
||||||
|
imageUrl?: FileList;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
uploadDate: string;
|
||||||
|
url: FileList;
|
||||||
|
author: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function StreamPublishForm({ stream }: { stream?: string }) {
|
||||||
|
const { register, handleSubmit, formState: { errors } } = useForm<PodcastDto>();
|
||||||
|
|
||||||
|
const onSubmit: SubmitHandler<PodcastDto> = (data) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("image", data.imageUrl?.[0] as File);
|
||||||
|
formData.append("title", data.title);
|
||||||
|
formData.append("description", data.description);
|
||||||
|
formData.append("uploadDate", data.uploadDate);
|
||||||
|
formData.append("file", data.url?.[0] as File);
|
||||||
|
formData.append("author", data.author);
|
||||||
|
|
||||||
|
|
||||||
|
fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/${stream}/podcasts`, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((result) => {
|
||||||
|
console.log("Podcast created:", result);
|
||||||
|
})
|
||||||
|
.catch((err) => console.error("Error creating podcast:", err));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-[800px] mx-auto w-full px-4 bg-purple-200 rounded-sm pt-4 pb-4 mt-4">
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="title" className="block text-sm font-medium text-gray-700">
|
||||||
|
Title
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="title"
|
||||||
|
{...register("title", { required: "Title is required" })}
|
||||||
|
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-purple-500 focus:border-purple-500 sm:text-sm p-3"
|
||||||
|
/>
|
||||||
|
{errors.title && <p className="text-red-500 text-sm">{errors.title.message}</p>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="description" className="block text-sm font-medium text-gray-700">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
{...register("description", { required: "Description is required" })}
|
||||||
|
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-purple-500 focus:border-purple-500 sm:text-sm p-3"
|
||||||
|
/>
|
||||||
|
{errors.description && <p className="text-red-500 text-sm">{errors.description.message}</p>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="author" className="block text-sm font-medium text-gray-700">
|
||||||
|
Author
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="author"
|
||||||
|
{...register("author", { required: "Author is required" })}
|
||||||
|
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-purple-500 focus:border-purple-500 sm:text-sm p-3"
|
||||||
|
/>
|
||||||
|
{errors.author && <p className="text-red-500 text-sm">{errors.author.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="imageFile" className="block text-sm font-medium text-gray-700">
|
||||||
|
Upload Image
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="imageFile"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
{...register("imageUrl", { required: "Image is required" })}
|
||||||
|
className="mt-1 block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border file:border-gray-300 file:text-sm file:font-semibold file:bg-gray-50 hover:file:bg-gray-100"
|
||||||
|
/>
|
||||||
|
{errors.imageUrl && <p className="text-red-500 text-sm">{errors.imageUrl.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="podcastFile" className="block text-sm font-medium text-gray-700">
|
||||||
|
Upload Podcast File
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="podcastFile"
|
||||||
|
type="file"
|
||||||
|
accept="audio/*"
|
||||||
|
{...register("url", { required: "Podcast file is required" })}
|
||||||
|
className="mt-1 block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border file:border-gray-300 file:text-sm file:font-semibold file:bg-gray-50 hover:file:bg-gray-100"
|
||||||
|
/>
|
||||||
|
{errors.url && <p className="text-red-500 text-sm">{errors.url.message}</p>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full bg-purple-600 text-purple-200 py-2 px-4 rounded-md hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2"
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,26 +1,26 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--background: #ffffff;
|
||||||
--foreground: #171717;
|
--foreground: #171717;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: var(--font-geist-sans);
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
--background: #0a0a0a;
|
--background: #09001e;
|
||||||
--foreground: #ededed;
|
--foreground: #ededed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
}
|
color: #09001e;
|
||||||
|
}
|
||||||
@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "Ever Givin Pod",
|
||||||
description: "Generated by create next app",
|
description: "Basic podcast management app",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@ -24,9 +24,7 @@ export default function RootLayout({
|
|||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body
|
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
26
src/app/layout/main.tsx
Normal file
26
src/app/layout/main.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
type MainProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Main({ children }: MainProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleHeaderClick = () => {
|
||||||
|
router.push(`${process.env.NEXT_PUBLIC_API_BASE_URL}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col">
|
||||||
|
<header className="bg-purple-300 py-4 sticky top-0 z-10">
|
||||||
|
<h1 className="text-center text-2xl font-bold cursor-pointer" onClick={handleHeaderClick}>Ever Givin Pod</h1>
|
||||||
|
</header>
|
||||||
|
<main className="flex-grow">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
103
src/app/page.tsx
103
src/app/page.tsx
@ -1,103 +1,10 @@
|
|||||||
import Image from "next/image";
|
import Main from './layout/main';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
|
<Main>
|
||||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
<h1 className='text-purple-200'>Welcome</h1>
|
||||||
<Image
|
<a className='text-purple-200' href={`${process.env.NEXT_PUBLIC_API_BASE_URL}/f60236c3-81dd-47ba-b0ae-afd9229ac6f2/podcasts`}>Go to podcast list.</a>
|
||||||
className="dark:invert"
|
</Main>
|
||||||
src="/next.svg"
|
|
||||||
alt="Next.js logo"
|
|
||||||
width={180}
|
|
||||||
height={38}
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<ol className="list-inside list-decimal text-sm/6 text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
|
|
||||||
<li className="mb-2 tracking-[-.01em]">
|
|
||||||
Get started by editing{" "}
|
|
||||||
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-[family-name:var(--font-geist-mono)] font-semibold">
|
|
||||||
src/app/page.tsx
|
|
||||||
</code>
|
|
||||||
.
|
|
||||||
</li>
|
|
||||||
<li className="tracking-[-.01em]">
|
|
||||||
Save and see your changes instantly.
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
|
||||||
<a
|
|
||||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
/>
|
|
||||||
Deploy now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Read our docs
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/file.svg"
|
|
||||||
alt="File icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Learn
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/window.svg"
|
|
||||||
alt="Window icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Examples
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/globe.svg"
|
|
||||||
alt="Globe icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Go to nextjs.org →
|
|
||||||
</a>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
78
src/common/data/db.ts
Normal file
78
src/common/data/db.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import path from 'path';
|
||||||
|
import { JSONFilePreset } from 'lowdb/node';
|
||||||
|
import { PodcastDto } from '../dtos/podcastDto';
|
||||||
|
import { StreamDto } from '../dtos/streamDto';
|
||||||
|
|
||||||
|
export type DatabaseSchema = {
|
||||||
|
streams: StreamDto[];
|
||||||
|
podcasts: PodcastDto[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultData: DatabaseSchema = {
|
||||||
|
streams: [
|
||||||
|
{
|
||||||
|
id: 'f60236c3-81dd-47ba-b0ae-afd9229ac6f2',
|
||||||
|
title: 'Audio Books',
|
||||||
|
description: 'Personal repo of audio books',
|
||||||
|
author: 'anonymous',
|
||||||
|
language: 'en',
|
||||||
|
categories: ['audiobooks'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
podcasts: [],
|
||||||
|
};
|
||||||
|
const db = await JSONFilePreset(path.join(process.cwd(), 'db.json'), defaultData)
|
||||||
|
const writeQueue: (() => Promise<void>)[] = [];
|
||||||
|
let isWriting = false;
|
||||||
|
|
||||||
|
const processQueue = async () => {
|
||||||
|
if (isWriting || writeQueue.length === 0) return;
|
||||||
|
|
||||||
|
isWriting = true;
|
||||||
|
const task = writeQueue.shift();
|
||||||
|
|
||||||
|
if (task) {
|
||||||
|
await task();
|
||||||
|
}
|
||||||
|
|
||||||
|
isWriting = false;
|
||||||
|
|
||||||
|
processQueue();
|
||||||
|
};
|
||||||
|
|
||||||
|
const queueWrite = async (task: () => Promise<void>) => {
|
||||||
|
writeQueue.push(task);
|
||||||
|
processQueue();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const initDb = async () => {
|
||||||
|
await db.read();
|
||||||
|
db.data ??= defaultData;
|
||||||
|
await db.write();
|
||||||
|
};
|
||||||
|
|
||||||
|
await queueWrite(async () => {
|
||||||
|
await initDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ----------------------
|
||||||
|
// ----- Operations -----
|
||||||
|
// ----------------------
|
||||||
|
export const getPodcasts = async (stream: string) => {
|
||||||
|
return db.data?.podcasts.filter((podcast) => podcast.streamId === stream) ?? [];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const publishPodcast = async (podcast: PodcastDto) => {
|
||||||
|
await queueWrite(async () => {
|
||||||
|
db.data?.podcasts.push(podcast);
|
||||||
|
await db.write();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStreams = async () => {
|
||||||
|
return db.data?.streams ?? [];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStream = async (id: string) => {
|
||||||
|
return db.data?.streams.find((stream) => stream.id === id);
|
||||||
|
};
|
||||||
10
src/common/dtos/podcastDto.ts
Normal file
10
src/common/dtos/podcastDto.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export type PodcastDto = {
|
||||||
|
podcastId: string;
|
||||||
|
streamId: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
uploadDate: string;
|
||||||
|
url?: string;
|
||||||
|
author?: string;
|
||||||
|
};
|
||||||
15
src/common/dtos/streamDto.ts
Normal file
15
src/common/dtos/streamDto.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
export type StreamDto = {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
feedUrl?: string;
|
||||||
|
siteUrl?: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
author?: string;
|
||||||
|
managingEditor?: string;
|
||||||
|
webMaster?: string;
|
||||||
|
language?: string;
|
||||||
|
categories?: string[];
|
||||||
|
pubDate?: string;
|
||||||
|
ttl?: number;
|
||||||
|
};
|
||||||
53
src/common/helpers/data.ts
Normal file
53
src/common/helpers/data.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
import mime from 'mime-types';
|
||||||
|
import { PodcastDto } from '../dtos/podcastDto';
|
||||||
|
|
||||||
|
export const getFileSize = (filePath: string): number => {
|
||||||
|
const stats = fs.statSync(filePath);
|
||||||
|
return stats.size;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFileMimeType = (filePath: string): string => {
|
||||||
|
const ext = path.extname(filePath);
|
||||||
|
const mimeType = mime.lookup(ext);
|
||||||
|
return mimeType || 'application/octet-stream';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const convertUrlToPublic = (url?: string) => {
|
||||||
|
return url ? `${process.env.NEXT_PUBLIC_API_BASE_URL}/uploads/${url}` : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const preparePodcastItem = (podcast: PodcastDto) => {
|
||||||
|
const fileSize = getFileSize(path.join(process.cwd(), `public/uploads/${podcast.url}`)),
|
||||||
|
fileUrl = convertUrlToPublic(podcast.url);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...podcast,
|
||||||
|
imageUrl: convertUrlToPublic(podcast.imageUrl),
|
||||||
|
url: fileUrl,
|
||||||
|
enclosure: {
|
||||||
|
url: fileUrl,
|
||||||
|
type: getFileMimeType(podcast.url ?? ''),
|
||||||
|
size: fileSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getLocalIpAddress = (): string => {
|
||||||
|
const networkInterfaces = os.networkInterfaces();
|
||||||
|
|
||||||
|
for (const interfaceName in networkInterfaces) {
|
||||||
|
const interfaces = networkInterfaces[interfaceName];
|
||||||
|
if (!interfaces) continue;
|
||||||
|
|
||||||
|
for (const iface of interfaces) {
|
||||||
|
if (iface.family === 'IPv4' && !iface.internal) {
|
||||||
|
return iface.address;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'localhost';
|
||||||
|
};
|
||||||
35
src/pages/api/[stream].ts
Normal file
35
src/pages/api/[stream].ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import { StreamDto } from '../../common/dtos/streamDto';
|
||||||
|
import { getStream } from '../../common/data/db';
|
||||||
|
|
||||||
|
export default async function handler(
|
||||||
|
req: NextApiRequest,
|
||||||
|
res: NextApiResponse<StreamDto | undefined>
|
||||||
|
) {
|
||||||
|
const { stream } = req.query;
|
||||||
|
|
||||||
|
const data = await getStream(stream as string);
|
||||||
|
|
||||||
|
res.status(200).json(data)
|
||||||
|
|
||||||
|
// if(stream == 'nothing') {
|
||||||
|
// res.status(200).json(null);
|
||||||
|
// } else {
|
||||||
|
// res.status(200).json(
|
||||||
|
// {
|
||||||
|
// title: "Stream: stream1",
|
||||||
|
// description: "This is the description for stream: stream1",
|
||||||
|
// feedUrl: "http://localhost:3000/api/stream1/feed",
|
||||||
|
// siteUrl: "http://localhost:3000/stream1/podcasts",
|
||||||
|
// imageUrl: "https://placehold.co/400",
|
||||||
|
// author: "John Doe",
|
||||||
|
// managingEditor: "Jane Doe",
|
||||||
|
// webMaster: "John Smith",
|
||||||
|
// language: "en",
|
||||||
|
// categories: ["Technology", "Education", "Entertainment"],
|
||||||
|
// pubDate: "2023-10-01",
|
||||||
|
// ttl: 60,
|
||||||
|
// }
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
}
|
||||||
18
src/pages/api/[stream]/feed.ts
Normal file
18
src/pages/api/[stream]/feed.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import { Podcast } from 'podcast';
|
||||||
|
import { getPodcasts, getStream } from '../../../common/data/db';
|
||||||
|
import { preparePodcastItem } from '../../../common/helpers/data';
|
||||||
|
|
||||||
|
export default async function handler(
|
||||||
|
req: NextApiRequest,
|
||||||
|
res: NextApiResponse
|
||||||
|
) {
|
||||||
|
const { stream } = req.query,
|
||||||
|
data = await getStream(stream as string),
|
||||||
|
items = (await getPodcasts(stream as string)).map(preparePodcastItem),
|
||||||
|
feed = new Podcast(data);
|
||||||
|
|
||||||
|
items.forEach(item => feed.addItem(item));
|
||||||
|
res.setHeader('Content-Type', 'application/xml');
|
||||||
|
res.status(200).send(feed.buildXml());
|
||||||
|
}
|
||||||
127
src/pages/api/[stream]/podcasts.ts
Normal file
127
src/pages/api/[stream]/podcasts.ts
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import multiparty from 'multiparty';
|
||||||
|
import { getPodcasts, publishPodcast } from '../../../common/data/db';
|
||||||
|
import { PodcastDto } from '../../../common/dtos/podcastDto';
|
||||||
|
|
||||||
|
const get = async (req: NextApiRequest, res: NextApiResponse<PodcastDto[]>) => {
|
||||||
|
const stream = req.query.stream as string,
|
||||||
|
podcasts = await getPodcasts(stream);
|
||||||
|
|
||||||
|
res.status(200).json(podcasts);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
api: {
|
||||||
|
bodyParser: false
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const post = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
|
const stream = req.query.stream as string;
|
||||||
|
|
||||||
|
if (!stream || typeof stream !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'Invalid stream parameter' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadDir = path.join(process.cwd(), 'public/uploads');
|
||||||
|
await fs.mkdir(uploadDir, { recursive: true });
|
||||||
|
const form = new multiparty.Form({
|
||||||
|
uploadDir
|
||||||
|
});
|
||||||
|
|
||||||
|
form.parse(req, async (err, fields, files) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error parsing form:', err);
|
||||||
|
return res.status(500).json({ error: 'Error processing form data' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const title = fields.title?.[0];
|
||||||
|
const description = fields.description?.[0];
|
||||||
|
const author = fields.author?.[0];
|
||||||
|
|
||||||
|
console.log(fields);
|
||||||
|
const imageFile = files.image?.[0];
|
||||||
|
const podcastFile = files.file?.[0];
|
||||||
|
|
||||||
|
if (!imageFile || !podcastFile) {
|
||||||
|
return res.status(400).json({ error: 'Missing image or file' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageFileName = `${uuidv4()}${path.extname(imageFile.originalFilename)}`;
|
||||||
|
const podcastFileName = `${uuidv4()}${path.extname(podcastFile.originalFilename)}`;
|
||||||
|
|
||||||
|
const imageFilePath = path.join(uploadDir, imageFileName);
|
||||||
|
const podcastFilePath = path.join(uploadDir, podcastFileName);
|
||||||
|
|
||||||
|
await fs.rename(imageFile.path, imageFilePath);
|
||||||
|
await fs.rename(podcastFile.path, podcastFilePath);
|
||||||
|
|
||||||
|
const podcastData: PodcastDto = {
|
||||||
|
podcastId: uuidv4(),
|
||||||
|
streamId: stream,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
uploadDate: Date.now().toString(),
|
||||||
|
author,
|
||||||
|
imageUrl: imageFileName,
|
||||||
|
url: podcastFileName,
|
||||||
|
};
|
||||||
|
|
||||||
|
await publishPodcast(podcastData);
|
||||||
|
|
||||||
|
res.status(201).json({ message: 'Podcast created successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling request:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// try {
|
||||||
|
// const { title, description, author, image, file } = req.body;
|
||||||
|
|
||||||
|
// const uploadDir = path.join(process.cwd(), 'public/uploads');
|
||||||
|
// const imageFileName = `${uuidv4()}${path.extname(image.name)}`;
|
||||||
|
// const imageFilePath = path.join(uploadDir, imageFileName);
|
||||||
|
// await fs.writeFile(imageFilePath, Buffer.from(image.data, 'base64'));
|
||||||
|
|
||||||
|
// const podcastFileName = `${uuidv4()}${path.extname(file.name)}`;
|
||||||
|
// const podcastFilePath = path.join(uploadDir, podcastFileName);
|
||||||
|
// await fs.writeFile(podcastFilePath, Buffer.from(file.data, 'base64'));
|
||||||
|
|
||||||
|
// const podcastData: PodcastDto = {
|
||||||
|
// podcastId: uuidv4(),
|
||||||
|
// streamId: stream,
|
||||||
|
// title,
|
||||||
|
// description,
|
||||||
|
// uploadDate: Date.now().toString(),
|
||||||
|
// author,
|
||||||
|
// imageUrl: imageFileName,
|
||||||
|
// url: podcastFileName,
|
||||||
|
// };
|
||||||
|
|
||||||
|
// await publishPodcast(podcastData);
|
||||||
|
|
||||||
|
// res.status(201).json({ message: 'Podcast created successfully' });
|
||||||
|
// } catch (error) {
|
||||||
|
// console.error('Error handling request:', error);
|
||||||
|
// res.status(500).json({ error: 'Internal server error' });
|
||||||
|
// }
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function handler(
|
||||||
|
req: NextApiRequest,
|
||||||
|
res: NextApiResponse
|
||||||
|
) {
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
get(req, res);
|
||||||
|
} else if (req.method === 'POST') {
|
||||||
|
post(req, res);
|
||||||
|
} else {
|
||||||
|
res.setHeader('Allow', ['GET', 'POST']);
|
||||||
|
res.status(405).end(`Method ${req.method} Not Allowed`);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user