diff --git a/apps/web/.eslintrc.js b/apps/web/.eslintrc.js deleted file mode 100644 index d7c27ab..0000000 --- a/apps/web/.eslintrc.js +++ /dev/null @@ -1,6 +0,0 @@ -const eslintConfigs = require('tiktok-dl-config/eslint.typescript'); - -module.exports = { - ...eslintConfigs, - extends: eslintConfigs.extends.concat(['plugin:@next/next/recommended']), -}; diff --git a/apps/web/.gitignore b/apps/web/.gitignore index fbf4300..5ef6a52 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -1,2 +1,41 @@ -.next -*.rdb +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/apps/web/.prettierignore b/apps/web/.prettierignore deleted file mode 100644 index 338e2b7..0000000 --- a/apps/web/.prettierignore +++ /dev/null @@ -1,3 +0,0 @@ -*.json -.next - diff --git a/apps/web/.prettierrc.js b/apps/web/.prettierrc.js deleted file mode 100644 index 0ae5e0e..0000000 --- a/apps/web/.prettierrc.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = require('tiktok-dl-config/prettier'); diff --git a/apps/web/components.json b/apps/web/components.json new file mode 100644 index 0000000..952559d --- /dev/null +++ b/apps/web/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} diff --git a/apps/web/components/Body.tsx b/apps/web/components/Body.tsx deleted file mode 100644 index ff9a41b..0000000 --- a/apps/web/components/Body.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import React from 'react'; - -/** - * Body Component. - * @param {{children: JSX.Element}} param0 BodyComponent props. - * @return {JSX.Element} - */ -export const BodyComponent = ({ - children, -}: { - children: JSX.Element; -}): JSX.Element => { - return ( - -
-
{children}
-
-
- ); -}; diff --git a/apps/web/components/Footer.tsx b/apps/web/components/Footer.tsx deleted file mode 100644 index 788ae67..0000000 --- a/apps/web/components/Footer.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react'; - -export const Footer = () => ( - -

- © {new Date().getFullYear()}{' '} - - TikTok-DL Project - -

-
-); diff --git a/apps/web/components/FormInput.tsx b/apps/web/components/FormInput.tsx deleted file mode 100644 index a532a90..0000000 --- a/apps/web/components/FormInput.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import React from 'react'; -import useSWR, {Fetcher} from 'swr'; -import {ExtractedInfo} from 'tiktok-dl-core'; -import {getTikTokURL} from '../lib/url'; -import {VideoComponent} from './Video'; - -// // ERRORS /// -/** - * @class InvalidUrlError - */ -class InvalidUrlError extends Error { - /** - * @param {string} msg error message. - */ - constructor(msg?: string) { - super(msg); - this.name = 'INVALID_URL'; - } -} - -export type ExtractedInfoWithProvider = ExtractedInfo & { - provider: string; - _url: string; -}; - -interface StateData { - submitted: boolean; - error?: string | Error; - url: string; -} - -const fetcher: Fetcher = (...args) => - fetch(...args).then((r) => r.json()); - -/** - * FormInput Component. - * @return {JSX.Element} - */ -export const FormInputComponent = (): JSX.Element => { - const [state, setState] = React.useState({ - submitted: false, - error: undefined, - url: '', - }); - const {data, mutate} = useSWR( - (!state.error || !(state.error as string).length) && - /^http(s?)(:\/\/)([a-z]+\.)*tiktok\.com\/(.+)$/gi.test(state.url) - ? [ - '/api/download', - { - method: 'POST', - body: JSON.stringify({url: state.url}), - }, - ] - : null, - fetcher, - { - loadingTimeout: 5_000, - refreshInterval: 60_000, - revalidateOnMount: false, - onSuccess: () => - setState({ - ...state, - submitted: false, - }), - }, - ); - - React.useEffect(() => { - if ( - !/^http(s?)(:\/\/)([a-z]+\.)*tiktok\.com\/(.+)$/gi.test( - state.url, - ) && - state.url.length - ) { - setState({ - ...state, - error: new InvalidUrlError('Invalid TikTok URL'), - }); - } else { - // submit event trigger. - if (state.submitted && !state.error) { - mutate(); - } - - try { - const u = getTikTokURL(state.url); - if (!u) { - setState({ - ...state, - error: new InvalidUrlError('Invalid TikTok URL'), - }); - return; - } - - setState({ - ...state, - url: u, - }); - } catch { - setState({ - ...state, - error: new InvalidUrlError('Invalid TikTok URL'), - }); - } - - setState({ - ...state, - error: undefined, - }); - } - }, [state.submitted, state.url]); - - return ( - -
-

- Fill TikTok's Video URL below: -

-

- {state.error instanceof Error - ? state.error.name.concat( - ': '.concat(state.error.message), - ) - : state.error - ? state.error - : ''} -

-
{ - event.preventDefault(); - if (!state.url.length) { - setState({ - ...state, - error: 'Please fill the URL!', - }); - return; - } - !state.error && - setState({ - ...state, - submitted: true, - }); - }} - > -
- - setState({ - ...state, - url: event.target.value, - }) - } - value={state.url} - placeholder="e.g: " - className="p-3 border border-gray-300 font-sans h-auto w-auto outline-solid-blue-500" - /> -
- -
- -
-
- -
- {state.submitted && !data && ( -

- Wait a minute -

- )} - {data && data.video && data.video.urls.length && ( - - )} -
-
-
- ); -}; - -export default FormInputComponent; diff --git a/apps/web/components/Header.tsx b/apps/web/components/Header.tsx deleted file mode 100644 index 2243235..0000000 --- a/apps/web/components/Header.tsx +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Next Head Component. - * @param {{title: string, children: JSX.Element }} param0 NextHeadComponent args. - * @return {JSX.Element} - */ -export const NextHeadComponent = ({ - title, - children, -}: { - title?: string; - children?: JSX.Element; -}): JSX.Element => { - return ( - <> - {title ?? 'TikTok Downloader'} - - - - - - {children} - - ); -}; diff --git a/apps/web/components/Video.tsx b/apps/web/components/Video.tsx deleted file mode 100644 index 5cc00ae..0000000 --- a/apps/web/components/Video.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import React from 'react'; -import type {ExtractedInfoWithProvider} from './FormInput'; - -export const VideoComponent = ({data}: {data: ExtractedInfoWithProvider}) => { - const copyUrl = (url: string) => { - navigator.clipboard.writeText(url); - if (typeof window !== 'undefined') { - window.alert('URL Copied'); - } - }; - return ( - - This video is downloaded from{' '} - {data.provider}. - {data.caption &&
{data.caption}
} -
- -
- {data.video?.urls.map((url, index) => ( - - ))} -
-
-
- ); -}; diff --git a/apps/web/errors/httpError.ts b/apps/web/errors/httpError.ts deleted file mode 100644 index 567fd93..0000000 --- a/apps/web/errors/httpError.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * @class HttpError - */ -export class HttpError extends Error { - public statusCode = 400; - /** - * @constructor - * @param {string} message HttpError message, - */ - constructor(message: string) { - super(message); - } - - /** - * Set http status code. - * @param {number} code HttpError status code. - * @return {HttpError} - */ - setCode(code: number): HttpError { - this.statusCode = code; - return this; - } -} diff --git a/apps/web/errors/index.ts b/apps/web/errors/index.ts deleted file mode 100644 index b435d30..0000000 --- a/apps/web/errors/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './httpError'; diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs new file mode 100644 index 0000000..719cea2 --- /dev/null +++ b/apps/web/eslint.config.mjs @@ -0,0 +1,25 @@ +import { dirname } from "path"; +import { fileURLToPath } from "url"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +const eslintConfig = [ + ...compat.extends("next/core-web-vitals", "next/typescript"), + { + ignores: [ + "node_modules/**", + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ], + }, +]; + +export default eslintConfig; diff --git a/apps/web/lib/index.ts b/apps/web/lib/index.ts deleted file mode 100644 index d41839e..0000000 --- a/apps/web/lib/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './redis'; -export * from './rotator'; -export * from './url'; diff --git a/apps/web/middleware/apply.ts b/apps/web/middleware/apply.ts deleted file mode 100644 index 04e23bb..0000000 --- a/apps/web/middleware/apply.ts +++ /dev/null @@ -1,31 +0,0 @@ -import {NextApiRequest, NextApiResponse} from 'next'; - -type Middleware = ( - request: NextApiRequest, - response: NextApiResponse, -) => Promise; - -export const applyRoute = ( - route: (req: NextApiRequest, res: NextApiResponse) => Promise, - middlewares: Middleware[], -) => { - return async (req: NextApiRequest, res: NextApiResponse) => { - const middleware: - | { - message: string; - status: number; - } - | undefined = await Promise.all(middlewares.map((m) => m(req, res))) - .catch((e) => ({ - message: e.message, - status: 500, - })) - .then(() => undefined); - if (middleware) - return res.status(middleware.status).json({ - message: middleware.message, - statusCode: middleware.status, - }); - else return route(req, res); - }; -}; diff --git a/apps/web/middleware/ratelimit.ts b/apps/web/middleware/ratelimit.ts deleted file mode 100644 index dd8edcd..0000000 --- a/apps/web/middleware/ratelimit.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type {NextApiRequest, NextApiResponse} from 'next'; -import {rateLimitConfig} from '../config'; -import {HttpError} from '../errors'; -import {client} from '../lib'; - -export const ratelimitMiddleware = async ( - req: NextApiRequest, - _res: NextApiResponse, -) => { - const ip = req.headers['x-real-ip'] || req.headers['x-forwarded-for']; - if (!rateLimitConfig.enable || process.env.NODE_ENV === 'development') - return undefined; - else if (!ip) - throw new HttpError("Couldn't find your real ip address!").setCode(401); - const result = await client.get('rate-'.concat(ip.toString())); - if (result) { - if (parseInt(result) > rateLimitConfig.maxRatelimitPerXSeconds) { - throw new HttpError( - 'Please try again, you are getting ratelimit!', - ).setCode(429); - } - client.incr('rate-'.concat(ip.toString())); - return undefined; - } else { - client.set( - 'rate-'.concat(ip.toString()), - '1', - 'EX', - rateLimitConfig.ratelimitTime, - ); - return undefined; - } -}; diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index 4f11a03..830fb59 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +/// // NOTE: This file should not be edited -// see https://nextjs.org/docs/basic-features/typescript for more information. +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/next.config.js b/apps/web/next.config.js deleted file mode 100644 index 2fbc1fe..0000000 --- a/apps/web/next.config.js +++ /dev/null @@ -1,20 +0,0 @@ -const webpack = require('webpack'); -const withTM = require('next-transpile-modules')(['tiktok-dl-core']); -const WindiCSSWebpackPlugin = require('windicss-webpack-plugin'); - -const {parsed: cusEnv} = require('dotenv').config({ - path: require('path').resolve(__dirname, '..', '..', '.env'), -}); - -module.exports = withTM({ - reactStrictMode: true, - experimental: {esmExternals: true}, - webpack(config) { - // adding windicss plugin - config.plugins.push(new WindiCSSWebpackPlugin()); - if (typeof cusEnv !== 'undefined') { - config.plugins.push(new webpack.EnvironmentPlugin(cusEnv)); - } - return config; - }, -}); diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts new file mode 100644 index 0000000..ba04dfc --- /dev/null +++ b/apps/web/next.config.ts @@ -0,0 +1,8 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + transpilePackages: ['tiktok-dl-core'], + serverExternalPackages: ['vm2'], +}; + +export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json index 6b16801..9652bd9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,35 +1,39 @@ { - "name": "tiktok-dl-web", - "description": "TikTok-DL Project Website", - "version": "1.4.1", - "license": "MIT", - "dependencies": { - "dotenv": "16.6.1", - "ioredis": "5.7.0", - "next": "12.2.4", - "ow": "0.28.1", - "react": "18.2.0", - "react-dom": "18.2.0", - "swr": "1.3.0", - "tiktok-dl-config": "*", - "tiktok-dl-core": "*" - }, - "scripts": { - "build": "next build", - "dev": "next dev", - "lint": "next lint", - "format": "prettier . --write", - "start": "next start --port process.env.PORT" - }, - "devDependencies": { - "@next/eslint-plugin-next": "12.2.4", - "@types/node": "17.0.35", - "@types/react": "18.0.17", - "@types/react-dom": "18.0.6", - "next-transpile-modules": "9.0.0", - "prettier": "2.7.1", - "tiktok-dl-config": "*", - "typescript": "^5.9.2", - "windicss-webpack-plugin": "1.7.5" - } + "name": "web", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --turbopack", + "build": "next build --turbopack", + "start": "next start", + "lint": "eslint" + }, + "dependencies": { + "@hookform/resolvers": "^5.2.1", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-slot": "^1.2.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "ioredis": "^5.7.0", + "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", + "tailwind-merge": "^3.3.1", + "zod": "^4.1.5" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "15.5.2", + "tailwindcss": "^4", + "tw-animate-css": "^1.3.7", + "typescript": "^5" + } } diff --git a/apps/web/pages/_app.tsx b/apps/web/pages/_app.tsx deleted file mode 100644 index 71c6d3f..0000000 --- a/apps/web/pages/_app.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import 'windi.css'; -import type {AppProps} from 'next/app'; - -/** - * TikTokApp - * @param {AppProps} arg0 App properties. - * @return {JSX.Element} - */ -export default function TikTokApp({Component, pageProps}: AppProps) { - return ; -} diff --git a/apps/web/pages/_document.tsx b/apps/web/pages/_document.tsx deleted file mode 100644 index 9870228..0000000 --- a/apps/web/pages/_document.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import Document, {Head, Html, Main, NextScript} from 'next/document'; -import {BodyComponent} from '../components/Body'; -import {NextHeadComponent} from '../components/Header'; - -/** - * @class TikTokDocument - */ -export default class TikTokDocument extends Document { - /** - * Render TikTokDocument - * @return {JSX.Element} - */ - render(): JSX.Element { - return ( - - - - - - -
- - - - - ); - } -} diff --git a/apps/web/pages/about.tsx b/apps/web/pages/about.tsx deleted file mode 100644 index 9ae394e..0000000 --- a/apps/web/pages/about.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import useSWR from 'swr'; -import Head from 'next/head'; - -export default () => { - const {data, error} = useSWR( - 'https://api.github.com/repos/hansputera/tiktok-dl', - (...args) => fetch(...args).then((r) => r.json()), - ); - return ( - <> - - About - -
-

About TikTok-DL

- -
- {error ? ( -

{error}

- ) : ( - data && ( - <> -

- {data.description} -

-
    -
  • - This project is based on{' '} - - {data.license.name} - -
  • -
  • - Currently, we have{' '} - {data.stargazers_count.toLocaleString()}{' '} - stars, {data.forks.toLocaleString()}{' '} - forks, and {data.open_issues} opened - issues. -
  • -
  • - Also, this project is created at{' '} - - {new Date( - data.created_at, - ).toLocaleDateString('en-US', { - day: '2-digit', - month: 'long', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - timeZone: 'Asia/Jakarta', - })} - {' '} - by{' '} - - {data.owner.login} - -
  • -
  • - Last update:{' '} - - {new Date( - data.updated_at, - ).toLocaleDateString('en-US', { - day: '2-digit', - month: 'long', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - timeZone: 'Asia/Jakarta', - })} - -
  • -
{' '} - If you want see the source code,{' '} - - click here - - . And feel free to open an{' '} - - issue - {' '} - if you have a problem or find a bug. Thank you! - - ) - )} -
-
- - ); -}; diff --git a/apps/web/pages/api/download.ts b/apps/web/pages/api/download.ts deleted file mode 100644 index 64a42b4..0000000 --- a/apps/web/pages/api/download.ts +++ /dev/null @@ -1,87 +0,0 @@ -import type {NextApiRequest, NextApiResponse} from 'next'; -import ow from 'ow'; -import {getProvider, Providers, BaseProvider} from 'tiktok-dl-core'; -import {getTikTokURL} from '../../lib'; -import {rotateProvider} from '../../lib/rotator'; -import {applyRoute} from '../../middleware/apply'; -import {ratelimitMiddleware} from '../../middleware/ratelimit'; - -export default applyRoute( - async (req: NextApiRequest, res: NextApiResponse) => { - try { - if (req.method === 'POST' && typeof req.body === 'string') { - req.body = JSON.parse(req.body); - } - const providersType = Providers.map((p) => p.resourceName()); - ow( - req.body || req.query, - ow.object.partialShape({ - url: ow.string.url.validate((v) => ({ - validator: !!getTikTokURL(v), - message: 'Expected (.*).tiktok.com', - })), - type: ow.optional.string.validate((v) => ({ - validator: - typeof v === 'string' && - providersType.includes(v.toLowerCase()), - message: - 'Invalid Provider, available provider is: ' + - Providers.map((x) => x.resourceName()).join(', '), - })), - rotateOnError: - req.method === 'POST' - ? ow.optional.boolean - : ow.optional.string, - }), - ); - - let provider = getProvider( - (req.query.type || req.body.type) ?? 'random', - ); - - if (!provider) { - return res.status(400).json({ - error: 'Invalid provider', - providers: providersType, - }); - } - - const params = provider.getParams(); - if ( - params && - provider.resourceName() === - (req.query.type?.toString() || req.body.type)?.toLowerCase() - ) { - ow(req.query || req.body, ow.object.partialShape(params)); - } else if (params) { - provider = getProvider('random'); - } - - const url = getTikTokURL(req.query.url || req.body.url); - - const result = await rotateProvider( - provider as BaseProvider, - url!, - req.method === 'POST' - ? req.body.rotateOnError - : !!req.query.rotateOnError, - params - ? Object.fromEntries( - Object.keys(params!).map((p) => [ - p, - (req.query[p] as string) ?? req.body[p], - ]), - ) - : {}, - ); - return res.status(200).json(result); - } catch (e) { - return res - .status((e as Error).name === 'ArgumentError' ? 400 : 500) - .json({ - error: (e as Error).name + '|' + (e as Error).message, - }); - } - }, - [ratelimitMiddleware], -); diff --git a/apps/web/pages/api/index.ts b/apps/web/pages/api/index.ts deleted file mode 100644 index 775e3fb..0000000 --- a/apps/web/pages/api/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type {NextApiRequest, NextApiResponse} from 'next'; - -export default async (_: NextApiRequest, res: NextApiResponse) => { - return res.redirect('https://github.com/hansputera/tiktok-dl.git'); -}; diff --git a/apps/web/pages/api/providers.ts b/apps/web/pages/api/providers.ts deleted file mode 100644 index 4273743..0000000 --- a/apps/web/pages/api/providers.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type {NextApiRequest, NextApiResponse} from 'next'; -import {Providers} from 'tiktok-dl-core'; -import {ratelimitMiddleware} from '../../middleware/ratelimit'; -import type {Shape} from 'ow'; -import {applyRoute} from '../../middleware/apply'; - -export default applyRoute( - async (req: NextApiRequest, res: NextApiResponse) => { - const providers = Providers.map((p) => ({ - name: p.resourceName(), - url: p.client?.defaults.options.prefixUrl, - maintenance: p.maintenance, - params: p.getParams() - ? Object.keys(p.getParams()!).map((x) => ({ - name: x, - type: (p.getParams()![x] as Shape).type, - })) - : {}, - })); - - return res.send(providers); - }, - [ratelimitMiddleware], -); diff --git a/apps/web/pages/api/stored-links.ts b/apps/web/pages/api/stored-links.ts deleted file mode 100644 index 0a4028d..0000000 --- a/apps/web/pages/api/stored-links.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type {NextApiRequest, NextApiResponse} from 'next'; -import {client} from '../../lib'; -import {ratelimitMiddleware} from '../../middleware/ratelimit'; -import {matchLink} from 'tiktok-dl-core'; -import {applyRoute} from '../../middleware/apply'; - -export default applyRoute( - async (req: NextApiRequest, res: NextApiResponse) => { - await ratelimitMiddleware(req, res); - const keys = await client.keys('*'); - return res.status(200).json(keys.filter((x) => matchLink(x))); - }, - [ratelimitMiddleware], -); diff --git a/apps/web/pages/index.tsx b/apps/web/pages/index.tsx deleted file mode 100644 index 1e35b2b..0000000 --- a/apps/web/pages/index.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import dynamic from 'next/dynamic'; -import {Footer} from '../components/Footer'; - -const FormInputComponentDynamic = dynamic( - () => import('../components/FormInput'), - { - ssr: false, - }, -); - -export default () => { - return ( -
-

- TikTok-DL{' '} - - Download TikTok Video without watermark and free ads. - -

- - -
-
- ); -}; diff --git a/apps/web/postcss.config.mjs b/apps/web/postcss.config.mjs new file mode 100644 index 0000000..c7bcb4b --- /dev/null +++ b/apps/web/postcss.config.mjs @@ -0,0 +1,5 @@ +const config = { + plugins: ["@tailwindcss/postcss"], +}; + +export default config; diff --git a/apps/web/src/app/api/download/route.ts b/apps/web/src/app/api/download/route.ts new file mode 100644 index 0000000..73a550a --- /dev/null +++ b/apps/web/src/app/api/download/route.ts @@ -0,0 +1,57 @@ +import { downloadValidator } from "@/app/validators/download.validator"; +import { rotateProvider } from "@/services/rotator"; +import { NextRequest } from "next/server"; +import { getProvider } from "tiktok-dl-core"; + +export async function POST(request: NextRequest) { + try { + const json = await request.json(); + const safeData = await downloadValidator.safeParseAsync(json); + + if (safeData.error || !safeData.success) { + return Response.json({ + errors: safeData.error, + message: 'Validation errors', + }, { + status: 400, + }); + } + + const provider = getProvider(safeData.data.type); + if (!provider) { + return Response.json({ + message: 'Provider not found', + }, { status: 404 }); + } + + const params = provider?.getParams(); + if (params) { + const safeDataParams = await params.safeParseAsync(safeData.data.params); + if (safeDataParams.error || !safeDataParams.success) { + return Response.json({ + errors: safeDataParams.error, + message: 'Validation errors in params!', + }, { + status: 400, + }); + } + } + + const result = await rotateProvider(provider, safeData.data.url, safeData.data.rotateOnError, safeData.data.params); + return Response.json({ + data: result, + }); + } catch (e) { + return Response.json({ + message: (e as Error).message, + }, { + status: 500, + }); + } +} + +export async function GET() { + return Response.json({ + message: 'Currently we moved to POST method only.', + }); +} \ No newline at end of file diff --git a/apps/web/src/app/api/providers/route.ts b/apps/web/src/app/api/providers/route.ts new file mode 100644 index 0000000..6a6f719 --- /dev/null +++ b/apps/web/src/app/api/providers/route.ts @@ -0,0 +1,22 @@ +import { Shape } from "ow"; +import { BaseProvider, Providers } from "tiktok-dl-core"; + +const transformProvider = (provider: BaseProvider) => { + const params = provider.getParams(); + + return { + name: provider.resourceName(), + url: provider.client?.defaults.options.prefixUrl, + maintenance: provider.maintenance, + params: params ? Object.keys(params).map(key => ({ + name: key, + type: (params?.[key] as Shape | undefined)?.type, + })) : {}, + } +} + +export async function GET() { + return Response.json({ + data: Providers.map(transformProvider), + }); +} \ No newline at end of file diff --git a/apps/web/src/app/api/stored-links/route.ts b/apps/web/src/app/api/stored-links/route.ts new file mode 100644 index 0000000..4b57d20 --- /dev/null +++ b/apps/web/src/app/api/stored-links/route.ts @@ -0,0 +1,9 @@ +import { redisClient } from "@/lib/redis"; +import { matchLink } from "tiktok-dl-core"; + +export async function GET() { + const keys = await redisClient.keys('*'); + return Response.json({ + data: keys.filter(key => matchLink(key)), + }); +} \ No newline at end of file diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css new file mode 100644 index 0000000..5fa1992 --- /dev/null +++ b/apps/web/src/app/globals.css @@ -0,0 +1,74 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --background: oklch(94.27% 0.0268 242.57); + --secondary-background: oklch(100% 0 0); + --foreground: oklch(0% 0 0); + --main-foreground: oklch(0% 0 0); + --main: oklch(66.9% 0.18368 248.8066); + --border: oklch(0% 0 0); + --ring: oklch(0% 0 0); + --overlay: oklch(0% 0 0 / 0.8); + --shadow: 4px 4px 0px 0px var(--border); + --chart-1: #0099FF; + --chart-2: #FF4D50; + --chart-3: #FACC00; + --chart-4: #05E17A; + --chart-5: #7A83FF; + --chart-active-dot: #000; +} + +.dark { + --background: oklch(27.08% 0.0336 240.69); + --secondary-background: oklch(23.93% 0 0); + --foreground: oklch(92.49% 0 0); + --main-foreground: oklch(0% 0 0); + --main: oklch(61.9% 0.16907 248.5982); + --border: oklch(0% 0 0); + --ring: oklch(100% 0 0); + --shadow: 4px 4px 0px 0px var(--border); + --chart-1: #008AE5; + --chart-2: #FF6669; + --chart-3: #E0B700; + --chart-4: #04C86D; + --chart-5: #7A83FF; + --chart-active-dot: #fff; +} + +@theme inline { + --color-main: var(--main); + --color-background: var(--background); + --color-secondary-background: var(--secondary-background); + --color-foreground: var(--foreground); + --color-main-foreground: var(--main-foreground); + --color-border: var(--border); + --color-overlay: var(--overlay); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + + --spacing-boxShadowX: 4px; + --spacing-boxShadowY: 4px; + --spacing-reverseBoxShadowX: -4px; + --spacing-reverseBoxShadowY: -4px; + --radius-base: 10px; + --shadow-shadow: var(--shadow); + --font-weight-base: 500; + --font-weight-heading: 800; +} + +@layer base { + body { + @apply text-foreground font-base bg-background; + } + + h1, h2, h3, h4, h5, h6{ + @apply font-heading; + } +} \ No newline at end of file diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx new file mode 100644 index 0000000..50e185c --- /dev/null +++ b/apps/web/src/app/layout.tsx @@ -0,0 +1,26 @@ +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "TikTok-DL", + description: "An Open-Source Project where it could download TikTok's Video without annoying ads!", + keywords: 'tiktok-downloader, tiktokdl, tiktok, download video tiktok, tiktok no watermark', + authors: { + name: 'Hanif Dwy Putra S.', + url: 'https://hanifu.id', + }, +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx new file mode 100644 index 0000000..7d60b14 --- /dev/null +++ b/apps/web/src/app/page.tsx @@ -0,0 +1,102 @@ +'use client'; + +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 z from "zod"; + +export default function Home() { + const formSchema = z.object({ + url: z.url().refine((val) => getTikTokURL(val), { + error: 'Invalid VT Tiktok URL', + }), + }); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + url: '', + }, + }); + + const onSubmit = (values: z.infer) => { + + } + + return ( +
+
+ {/* Mobile Layout */} +
+

+ Download TikTok Videos! +

+ +
+ + ( + + VT URL + + + + + Please provide the tiktok video URL to download + + + + )} /> + + + + +
+ + {/* Large Screen Hero Layout */} +
+ {/* Hero Title */} +
+

+ Download
+ TikTok
+ Videos! +

+
+ + {/* Form Section */} +
+
+ + ( + + VT URL + + + + + Please provide the tiktok video URL to download + + + + )} /> + + + + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/app/validators/download.validator.ts b/apps/web/src/app/validators/download.validator.ts new file mode 100644 index 0000000..0c2df55 --- /dev/null +++ b/apps/web/src/app/validators/download.validator.ts @@ -0,0 +1,13 @@ +import { getTikTokURL } from "@/lib/utils"; +import { Providers } from "tiktok-dl-core"; +import z from "zod"; + +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')), + rotateOnError: z.boolean().default(true), + nocache: z.boolean().default(false), + params: z.object().optional(), +}); diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx new file mode 100644 index 0000000..f91affd --- /dev/null +++ b/apps/web/src/components/ui/button.tsx @@ -0,0 +1,56 @@ +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import * as React from "react" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-base text-sm font-base ring-offset-white transition-all gap-2 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: + "text-main-foreground bg-main border-2 border-border shadow-shadow hover:translate-x-boxShadowX hover:translate-y-boxShadowY hover:shadow-none", + noShadow: "text-main-foreground bg-main border-2 border-border", + neutral: + "bg-secondary-background text-foreground border-2 border-border shadow-shadow hover:translate-x-boxShadowX hover:translate-y-boxShadowY hover:shadow-none", + reverse: + "text-main-foreground bg-main border-2 border-border hover:translate-x-reverseBoxShadowX hover:translate-y-reverseBoxShadowY hover:shadow-shadow", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 px-3", + lg: "h-11 px-8", + icon: "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +) + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/apps/web/src/components/ui/form.tsx b/apps/web/src/components/ui/form.tsx new file mode 100644 index 0000000..e52f8d6 --- /dev/null +++ b/apps/web/src/components/ui/form.tsx @@ -0,0 +1,167 @@ +"use client" + +import * as LabelPrimitive from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" +import { + Controller, + FormProvider, + useFormContext, + useFormState, + type ControllerProps, + type FieldPath, + type FieldValues, +} from "react-hook-form" + +import * as React from "react" + +import { Label } from "@/components/ui/label" + +import { cn } from "@/lib/utils" + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue, +) + +function FormField< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ ...props }: ControllerProps) { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState } = useFormContext() + const formState = useFormState({ name: fieldContext.name }) + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within ") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext( + {} as FormItemContextValue, +) + +function FormItem({ className, ...props }: React.ComponentProps<"div">) { + const id = React.useId() + + return ( + +
+ + ) +} + +function FormLabel({ + className, + ...props +}: React.ComponentProps) { + const { error, formItemId } = useFormField() + + return ( +