feat: added api

Signed-off-by: Hanif Dwy Putra S <hanifdwyputrasembiring@gmail.com>
This commit is contained in:
Hanif Dwy Putra S
2025-08-30 08:10:26 +08:00
parent b64eb30abd
commit 5d292838f3
8 changed files with 368 additions and 171 deletions

View File

@@ -15,9 +15,9 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"ioredis": "^5.7.0",
"ky": "^1.9.1",
"lucide-react": "^0.542.0",
"next": "15.5.2",
"ow": "^2.0.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.62.0",

9
apps/web/src/api/api.ts Normal file
View File

@@ -0,0 +1,9 @@
import ky from "ky";
export const apiClient = ky.extend({
referrerPolicy: 'same-origin',
credentials: 'same-origin',
priority: 'high',
throwHttpErrors: false,
cache: 'force-cache',
});

View File

@@ -0,0 +1,8 @@
import { ExtractedInfo } from "tiktok-dl-core"
export type DownloadResponse = {
data: ExtractedInfo & {
provider: string;
};
message?: string;
}

View File

@@ -1,4 +1,4 @@
import { downloadValidator } from "@/app/validators/download.validator";
import { downloadValidator } from "@/validators/download.validator";
import { rotateProvider } from "@/services/rotator";
import { NextRequest } from "next/server";
import { getProvider } from "tiktok-dl-core";

View File

@@ -1,14 +1,191 @@
'use client';
import { apiClient } from "@/api/api";
import { DownloadResponse } from "@/api/types";
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { getTikTokURL } from "@/lib/utils";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useState, useRef, useEffect } from "react";
import { Download, ExternalLink, Loader2 } from "lucide-react";
import z from "zod";
const VideoPlayer = ({
videoData,
currentTime,
isPlaying,
onTimeUpdate,
onPlayStateChange,
isActive = true
}: {
videoData: DownloadResponse['data'];
currentTime: number;
isPlaying: boolean;
onTimeUpdate: (time: number) => void;
onPlayStateChange: (playing: boolean) => void;
isActive?: boolean;
}) => {
const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
const video = videoRef.current;
if (video && isActive) {
video.currentTime = currentTime;
if (isPlaying) {
video.play().catch(() => {
onPlayStateChange(false);
});
}
}
}, [videoData.video?.urls[0], isActive]);
useEffect(() => {
const video = videoRef.current;
if (video && isActive && Math.abs(video.currentTime - currentTime) > 1) {
video.currentTime = currentTime;
}
}, [currentTime, isActive]);
useEffect(() => {
const video = videoRef.current;
if (video) {
if (!isActive) {
if (!video.paused) {
video.pause();
}
} else {
if (isPlaying && video.paused) {
video.currentTime = currentTime;
video.play().catch(() => onPlayStateChange(false));
} else if (!isPlaying && !video.paused) {
video.pause();
}
}
}
}, [isPlaying, isActive, currentTime]);
const handleTimeUpdate = () => {
if (videoRef.current && isActive) {
onTimeUpdate(videoRef.current.currentTime);
}
};
const handlePlay = () => {
if (isActive) {
onPlayStateChange(true);
}
};
const handlePause = () => {
if (isActive) {
onPlayStateChange(false);
}
};
return (
<div className="bg-white rounded-xl p-6 shadow-lg border">
<h3 className="text-xl lg:text-2xl font-semibold mb-4">Video Preview</h3>
<div className="relative">
<video
ref={videoRef}
controls
className="w-full aspect-video rounded-lg shadow-md bg-black"
src={videoData.video?.urls[0]}
poster={videoData.video?.thumb}
preload="metadata"
onTimeUpdate={handleTimeUpdate}
onPlay={handlePlay}
onPause={handlePause}
onLoadedMetadata={handleTimeUpdate}
>
Your browser does not support the video tag.
</video>
</div>
<div className="mt-4 text-sm text-gray-600">
<p><strong>Provider:</strong> {videoData.provider}</p>
<p><strong>Available formats:</strong> {videoData.video?.urls.length}</p>
</div>
</div>
);
};
const DownloadOptions = ({ videoData, onDownload }: {
videoData: DownloadResponse['data'],
onDownload: (url: string, index: number) => void
}) => {
return (
<div className="bg-white rounded-xl p-6 shadow-lg border">
<h3 className="text-xl lg:text-2xl font-semibold mb-4">
Download Options
<span className="text-base font-normal text-gray-500 ml-2">
({videoData.video?.urls.length} available)
</span>
</h3>
<div className="space-y-3">
{videoData.video?.urls.map((url, index) => (
<Button
key={index}
variant="neutral"
onClick={() => onDownload(url, index)}
className="w-full justify-start p-4 h-auto hover:bg-gray-50 transition-colors"
size="lg"
>
<Download className="w-5 h-5 mr-3 flex-shrink-0" />
<div className="text-left flex-1">
<div className="font-medium text-base">
Quality {index + 1} {index === 0 ? "(HD)" : index === 1 ? "(Standard)" : "(Alternative)"}
</div>
<div className="text-sm text-gray-500 mt-1">
Click to download MP4 file
</div>
</div>
</Button>
))}
<div className="pt-3 border-t mt-4">
<Button
variant="noShadow"
onClick={() => window.open(videoData.video?.urls[0], '_blank')}
className="w-full justify-start p-4 h-auto hover:bg-gray-50 transition-colors"
size="lg"
>
<ExternalLink className="w-5 h-5 mr-3 flex-shrink-0" />
<div className="text-left">
<div className="font-medium">View in new tab</div>
<div className="text-sm text-gray-500 mt-1">
Open video directly in browser
</div>
</div>
</Button>
</div>
</div>
</div>
);
};
export default function Home() {
const [videoData, setVideoData] = useState<DownloadResponse | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [videoCurrentTime, setVideoCurrentTime] = useState(0);
const [videoIsPlaying, setVideoIsPlaying] = useState(false);
const [activePlayer, setActivePlayer] = useState<'mobile' | 'desktop'>('desktop');
useEffect(() => {
const checkScreenSize = () => {
setActivePlayer(window.innerWidth >= 1024 ? 'desktop' : 'mobile');
};
checkScreenSize();
window.addEventListener('resize', checkScreenSize);
return () => window.removeEventListener('resize', checkScreenSize);
}, []);
const formSchema = z.object({
url: z.url().refine((val) => getTikTokURL(val), {
error: 'Invalid VT Tiktok URL',
@@ -22,26 +199,82 @@ export default function Home() {
},
});
const onSubmit = (values: z.infer<typeof formSchema>) => {
const onSubmit = async (values: z.infer<typeof formSchema>) => {
setIsLoading(true);
try {
const response = await apiClient.post('./api/download', {
json: {
url: values.url,
},
}).json<DownloadResponse>();
}
if (response.message) {
form.setError('url', {
message: response.message,
});
setVideoData(null);
return;
}
setVideoData(response);
setVideoCurrentTime(0);
setVideoIsPlaying(false);
} catch (error) {
console.error('API Error:', error);
form.setError('url', {
message: 'Failed to process video. Please try again.',
});
} finally {
setIsLoading(false);
}
};
const handleDownload = (url: string, index: number) => {
const link = document.createElement('a');
link.href = url;
link.download = `tiktok_video_${index + 1}.mp4`;
link.target = '_blank';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const resetForm = () => {
setVideoData(null);
setVideoCurrentTime(0);
setVideoIsPlaying(false);
form.reset();
};
const handleTimeUpdate = (time: number) => {
setVideoCurrentTime(time);
};
const handlePlayStateChange = (playing: boolean) => {
setVideoIsPlaying(playing);
};
return (
<div className="min-h-screen flex items-center justify-center lg:justify-center">
<div className="w-full max-w-6xl mx-auto px-4">
{/* Mobile Layout */}
<div className="lg:hidden">
<h1 className="text-4xl font-sans text-black mb-6">
<div className="min-h-screen">
{/* Mobile/Tablet Layout - Stack Vertically */}
<div className="lg:hidden p-4">
<div className="max-w-2xl mx-auto">
<h1 className="text-4xl font-sans text-black mb-6 text-center">
Download TikTok Videos!
</h1>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 mb-6">
<FormField control={form.control} name="url" render={({ field }) => (
<FormItem>
<FormLabel className="text-xl">VT URL</FormLabel>
<FormControl>
<Input placeholder="Tiktok video URL (e.g. https://vt.tiktok.com/XXXXXX)" {...field} />
<Input
placeholder="Tiktok video URL (e.g. https://vt.tiktok.com/XXXXXX)"
className="text-lg py-3"
{...field}
/>
</FormControl>
<FormDescription>
Please provide the tiktok video URL to download
@@ -50,50 +283,100 @@ export default function Home() {
</FormItem>
)} />
<Button type="submit">
Download
</Button>
<div className="flex gap-2">
<Button type="submit" disabled={isLoading} className="flex-1">
{isLoading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
{isLoading ? "Processing..." : "Download"}
</Button>
{videoData && (
<Button type="button" variant="neutral" onClick={resetForm}>
New Video
</Button>
)}
</div>
</form>
</Form>
{/* Video Results - Stack Vertically on Mobile */}
{videoData?.data && (
<div className="space-y-6">
<VideoPlayer
videoData={videoData.data}
currentTime={videoCurrentTime}
isPlaying={videoIsPlaying}
onTimeUpdate={handleTimeUpdate}
onPlayStateChange={handlePlayStateChange}
isActive={activePlayer === 'mobile'}
/>
<DownloadOptions videoData={videoData.data} onDownload={handleDownload} />
</div>
)}
</div>
</div>
{/* Large Screen Hero Layout */}
<div className="hidden lg:flex lg:items-center lg:gap-12">
{/* Hero Title */}
<div className="flex-shrink-0">
<h1 className="text-6xl font-sans text-black leading-tight">
Download<br />
TikTok<br />
Videos!
</h1>
</div>
{/* Large Screen Hero Layout - Side by Side */}
<div className="hidden lg:flex lg:min-h-screen lg:items-center lg:justify-center">
<div className="w-full max-w-7xl mx-auto px-6">
<div className="flex items-start gap-12">
{/* Hero Title */}
<div className="flex-shrink-0">
<h1 className="text-6xl font-sans text-black leading-tight">
Download<br />
TikTok<br />
Videos!
</h1>
</div>
{/* Form Section */}
<div className="flex-1 max-w-md">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField control={form.control} name="url" render={({ field }) => (
<FormItem>
<FormLabel className="text-xl">VT URL</FormLabel>
<FormControl>
<Input
placeholder="Tiktok video URL (e.g. https://vt.tiktok.com/XXXXXX)"
className="text-lg py-3"
{...field}
/>
</FormControl>
<FormDescription className="text-base">
Please provide the tiktok video URL to download
</FormDescription>
<FormMessage />
</FormItem>
)} />
{/* Form and Video Section */}
<div className="flex-1 max-w-5xl">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 mb-8">
<FormField control={form.control} name="url" render={({ field }) => (
<FormItem>
<FormLabel className="text-xl">VT URL</FormLabel>
<FormControl>
<Input
placeholder="Tiktok video URL (e.g. https://vt.tiktok.com/XXXXXX)"
className="text-lg py-3"
{...field}
/>
</FormControl>
<FormDescription className="text-base">
Please provide the tiktok video URL to download
</FormDescription>
<FormMessage />
</FormItem>
)} />
<Button type="submit" size="lg" className="w-full">
Download
</Button>
</form>
</Form>
<div className="flex gap-3">
<Button type="submit" size="lg" disabled={isLoading} className="flex-1 max-w-xs">
{isLoading && <Loader2 className="w-5 h-5 mr-2 animate-spin" />}
{isLoading ? "Processing..." : "Download"}
</Button>
{videoData && (
<Button type="button" variant="neutral" size="lg" onClick={resetForm}>
New Video
</Button>
)}
</div>
</form>
</Form>
{/* Video Results - Side by Side on Desktop */}
{videoData?.data && (
<div className="grid grid-cols-1 xl:grid-cols-2 gap-8">
<VideoPlayer
videoData={videoData.data}
currentTime={videoCurrentTime}
isPlaying={videoIsPlaying}
onTimeUpdate={handleTimeUpdate}
onPlayStateChange={handlePlayStateChange}
isActive={activePlayer === 'desktop'}
/>
<DownloadOptions videoData={videoData.data} onDownload={handleDownload} />
</div>
)}
</div>
</div>
</div>
</div>

View File

@@ -6,8 +6,8 @@ export const downloadValidator = z.object({
url: z.url().refine((url) => getTikTokURL(url), {
error: 'Invalid VT URL',
}),
type: z.enum(Providers.map(provider => provider.resourceName()).concat('random')),
type: z.enum(Providers.map(provider => provider.resourceName()).concat('random')).default('random'),
rotateOnError: z.boolean().default(true),
nocache: z.boolean().default(false),
params: z.object().optional(),
params: z.object().optional().default({}),
});

View File

@@ -30,8 +30,15 @@ export const Providers: BaseProvider[] = [
// new GetVidTikProvider(),
];
export const getRandomProvider = () =>
Providers[Math.floor(Math.random() * Providers.length)];
export const getRandomProvider = (): BaseProvider => {
const provider = Providers[Math.floor(Math.random() * Providers.length)]
while(provider.resourceName() === 'native')
{
return getRandomProvider();
}
return provider;
};
export const getProvider = (name: string) =>
name.toLowerCase() !== 'random'

126
yarn.lock
View File

@@ -682,13 +682,6 @@ __metadata:
languageName: node
linkType: hard
"@sindresorhus/is@npm:^6.3.0":
version: 6.3.1
resolution: "@sindresorhus/is@npm:6.3.1"
checksum: 10/d28893760d7cb347a28164d1ceb55150b6bb66c5771d0c4dbefd88db63b9e4dac945d9afde2af2e0e113b1ab498985dc58d0c7e6c2ae9cc94aebc5b24d74d92c
languageName: node
linkType: hard
"@standard-schema/utils@npm:^0.3.0":
version: 0.3.0
resolution: "@standard-schema/utils@npm:0.3.0"
@@ -1681,13 +1674,6 @@ __metadata:
languageName: node
linkType: hard
"callsites@npm:^4.1.0":
version: 4.2.0
resolution: "callsites@npm:4.2.0"
checksum: 10/9a740675712076a38208967d7f80b525c9c7f4524c2af5d3936c5e278a601af0423a07e91f79679fec0546f3a52514d56969c6fe65f84d794e64a36b1f5eda8a
languageName: node
linkType: hard
"caniuse-lite@npm:^1.0.30001579":
version: 1.0.30001737
resolution: "caniuse-lite@npm:1.0.30001737"
@@ -1785,13 +1771,6 @@ __metadata:
languageName: node
linkType: hard
"convert-hrtime@npm:^5.0.0":
version: 5.0.0
resolution: "convert-hrtime@npm:5.0.0"
checksum: 10/5245ad1ac6dd57b2d87624ae0eeac1d2a74812a6631208c09368bef787a28e7dbfa736cddaa9c8a0c425cb240437ea506afec7b9684ff617004d06a551f26c87
languageName: node
linkType: hard
"cross-spawn@npm:^7.0.2":
version: 7.0.3
resolution: "cross-spawn@npm:7.0.3"
@@ -1980,15 +1959,6 @@ __metadata:
languageName: node
linkType: hard
"dot-prop@npm:^8.0.2":
version: 8.0.2
resolution: "dot-prop@npm:8.0.2"
dependencies:
type-fest: "npm:^3.8.0"
checksum: 10/b321e43393c6efba35875c493ebfc6115d8a56c251431e88f055b82224e104c8a6eeb567877339715fb81cdbb67009bfa9cffb57cc423a560756874989dabb45
languageName: node
linkType: hard
"dunder-proto@npm:^1.0.0, dunder-proto@npm:^1.0.1":
version: 1.0.1
resolution: "dunder-proto@npm:1.0.1"
@@ -2017,13 +1987,6 @@ __metadata:
languageName: node
linkType: hard
"environment@npm:^1.0.0":
version: 1.1.0
resolution: "environment@npm:1.1.0"
checksum: 10/dd3c1b9825e7f71f1e72b03c2344799ac73f2e9ef81b78ea8b373e55db021786c6b9f3858ea43a436a2c4611052670ec0afe85bc029c384cc71165feee2f4ba6
languageName: node
linkType: hard
"es-abstract@npm:^1.17.5, es-abstract@npm:^1.23.2, es-abstract@npm:^1.23.3, es-abstract@npm:^1.23.5, es-abstract@npm:^1.23.6, es-abstract@npm:^1.23.9, es-abstract@npm:^1.24.0":
version: 1.24.0
resolution: "es-abstract@npm:1.24.0"
@@ -2610,13 +2573,6 @@ __metadata:
languageName: node
linkType: hard
"fast-equals@npm:^5.0.1":
version: 5.2.2
resolution: "fast-equals@npm:5.2.2"
checksum: 10/87939dc01c6634f844369c2d774c9bf82b6c5935eb45c698fdfd2e708439c6c94a67a41c67c7e063759394e319850ee563e717e65776c8f5997566b0cbb17c7a
languageName: node
linkType: hard
"fast-glob@npm:3.3.1":
version: 3.3.1
resolution: "fast-glob@npm:3.3.1"
@@ -2801,13 +2757,6 @@ __metadata:
languageName: node
linkType: hard
"function-timeout@npm:^1.0.1":
version: 1.0.2
resolution: "function-timeout@npm:1.0.2"
checksum: 10/3afedebacaaf237ba9aaef925886fcf5abd434ca12a18c1c7cecb001e57bf9b30434278edcc977a127baeb5b6361f7c278243c1dbf8bf349aa8b30500c57a699
languageName: node
linkType: hard
"function.prototype.name@npm:^1.1.6, function.prototype.name@npm:^1.1.8":
version: 1.1.8
resolution: "function.prototype.name@npm:1.1.8"
@@ -3093,15 +3042,6 @@ __metadata:
languageName: node
linkType: hard
"identifier-regex@npm:^1.0.0":
version: 1.0.0
resolution: "identifier-regex@npm:1.0.0"
dependencies:
reserved-identifiers: "npm:^1.0.0"
checksum: 10/4c18d94de9c3bd48c6f8e810084a8003d216ef4be88a7f37714ada2bb3cdd6a21e0fd918eb9d6b8417b3bbc36876cc2984627a99715a97375d1a31fc2b9f04bb
languageName: node
linkType: hard
"ignore@npm:^5.2.0":
version: 5.2.0
resolution: "ignore@npm:5.2.0"
@@ -3311,16 +3251,6 @@ __metadata:
languageName: node
linkType: hard
"is-identifier@npm:^1.0.0":
version: 1.0.1
resolution: "is-identifier@npm:1.0.1"
dependencies:
identifier-regex: "npm:^1.0.0"
super-regex: "npm:^1.0.0"
checksum: 10/c882f78ce47c04bbbc2cd6ce410a854c236162a03633b3dc9450347d1c60094f3a6b2a433913b6a40692d53901b3269eed42fbc5971c1c5ebed14cde718ba26c
languageName: node
linkType: hard
"is-map@npm:^2.0.3":
version: 2.0.3
resolution: "is-map@npm:2.0.3"
@@ -3544,6 +3474,13 @@ __metadata:
languageName: node
linkType: hard
"ky@npm:^1.9.1":
version: 1.9.1
resolution: "ky@npm:1.9.1"
checksum: 10/61b4f3d4614d26583be2d48b8977bcaaa8332cb24fc02a0205c132fa92eefe9c0a424326aa6820c404465dd47303e299798339a0237e527d479f84aa5db13e6b
languageName: node
linkType: hard
"language-subtag-registry@npm:^0.3.20":
version: 0.3.23
resolution: "language-subtag-registry@npm:0.3.23"
@@ -4077,20 +4014,6 @@ __metadata:
languageName: node
linkType: hard
"ow@npm:^2.0.0":
version: 2.0.0
resolution: "ow@npm:2.0.0"
dependencies:
"@sindresorhus/is": "npm:^6.3.0"
callsites: "npm:^4.1.0"
dot-prop: "npm:^8.0.2"
environment: "npm:^1.0.0"
fast-equals: "npm:^5.0.1"
is-identifier: "npm:^1.0.0"
checksum: 10/549c2db4efdb93c1adf4b73a35b2930c4361a184ca0728db19111fa61efbbde813165e3c82690b7268075daa42325a98d9ae56c7f37a96974e9b7c9bffbe6ac1
languageName: node
linkType: hard
"own-keys@npm:^1.0.1":
version: 1.0.1
resolution: "own-keys@npm:1.0.1"
@@ -4363,13 +4286,6 @@ __metadata:
languageName: node
linkType: hard
"reserved-identifiers@npm:^1.0.0":
version: 1.0.0
resolution: "reserved-identifiers@npm:1.0.0"
checksum: 10/95b4cdedd57a2589e76012c66f9f4414ae5d25178a79a8b3d11a16b080616af7f790f8f55dc7825813d1a83a383de19492f021998484f834dca0f5a3b015a536
languageName: node
linkType: hard
"resolve-alpn@npm:^1.2.0":
version: 1.2.1
resolution: "resolve-alpn@npm:1.2.1"
@@ -4901,16 +4817,6 @@ __metadata:
languageName: node
linkType: hard
"super-regex@npm:^1.0.0":
version: 1.0.0
resolution: "super-regex@npm:1.0.0"
dependencies:
function-timeout: "npm:^1.0.1"
time-span: "npm:^5.1.0"
checksum: 10/d99e90ee0950356b86b01ad327605080e72ee0712c7e5c66335e7e4e3bd2919206caea929fa2d5ca97c2afc1d1ab91466d09eadcf1101196edcfb94bebfea388
languageName: node
linkType: hard
"supports-color@npm:^7.1.0":
version: 7.2.0
resolution: "supports-color@npm:7.2.0"
@@ -4995,15 +4901,6 @@ __metadata:
languageName: unknown
linkType: soft
"time-span@npm:^5.1.0":
version: 5.1.0
resolution: "time-span@npm:5.1.0"
dependencies:
convert-hrtime: "npm:^5.0.0"
checksum: 10/949c45fcb873f2d26fda3db1b7f7161ce65206f6e94a7c6c9bf3a5a07a373570dba57ca5c1f816efa6326adbc3f9e93bb6ef19a7a220f4259a917e1192d49418
languageName: node
linkType: hard
"tinyglobby@npm:^0.2.13":
version: 0.2.14
resolution: "tinyglobby@npm:0.2.14"
@@ -5179,13 +5076,6 @@ __metadata:
languageName: node
linkType: hard
"type-fest@npm:^3.8.0":
version: 3.13.1
resolution: "type-fest@npm:3.13.1"
checksum: 10/9a8a2359ada34c9b3affcaf3a8f73ee14c52779e89950db337ce66fb74c3399776c697c99f2532e9b16e10e61cfdba3b1c19daffb93b338b742f0acd0117ce12
languageName: node
linkType: hard
"typed-array-buffer@npm:^1.0.3":
version: 1.0.3
resolution: "typed-array-buffer@npm:1.0.3"
@@ -5390,9 +5280,9 @@ __metadata:
eslint: "npm:^9"
eslint-config-next: "npm:15.5.2"
ioredis: "npm:^5.7.0"
ky: "npm:^1.9.1"
lucide-react: "npm:^0.542.0"
next: "npm:15.5.2"
ow: "npm:^2.0.0"
react: "npm:19.1.0"
react-dom: "npm:19.1.0"
react-hook-form: "npm:^7.62.0"