diff --git a/.example.env b/.example.env new file mode 100644 index 0000000..cf9f076 --- /dev/null +++ b/.example.env @@ -0,0 +1,38 @@ +NEXT_PUBLIC_DEFAULT_AVATAR_URL=https://avatars.githubusercontent.com/u/1024025?v=4 +NEXT_PUBLIC_UPLOAD_LIMIT_BYTES=2147483648 +NEXT_PUBLIC_GITHUB_AUTH_ENABLED=TRUE +NEXT_PUBLIC_DISCORD_AUTH_ENABLED=TRUE +NEXT_PUBLIC_GOOGLE_AUTH_ENABLED=TRUE +NEXT_PUBLIC_EMAIL_AUTH_ENABLED=TRUE +NEXT_PUBLIC_PAYMENTS_ENABLED=TRUE +TIERS_JSON='[ + {"name":"Free","max_storage_bytes":2147483648,"stripe_price_id":null,"display_price": "$0.00", "description": "Perfect for trying out cloud sharing", "display_features": ["2GB cloud storage", "Unlimited local recordings", "Basic sharing links"]}, +]' +NEXT_PUBLIC_APP_DESC="An open-source instant replay tool for modern Linux desktops. Built with ❤️ for the Linux community." +NEXT_PUBLIC_API_URL=https://wayclip.com +NEXT_PUBLIC_FOOTER_LINKS='{ + "Product": [ + { "text": "Features", "href": "https://wayclip.com#Features" }, + { "text": "Pricing", "href": "https://wayclip.com#Pricing" }, + { "text": "Privacy Policy", "href": "https://wayclip.com/privacy" }, + { "text": "Terms Of Service", "href": "https://wayclip.com/terms" }, + { "text": "Return Policy", "href": "https://wayclip.com/return-policy" }, + { "text": "Support", "href": "https://wayclip.com/support" } + ], + "Community": [ + { "text": "GitHub", "href": "https://github.com/wayclip", "external": true }, + { "text": "Contributing", "href": "https://wayclip.com/docs/contributing" }, + { "text": "Discord", "href": "https://discord.gg/BrXAHknFE6", "external": true }, + { "text": "Status", "href": "https://status.wayclip.com", "external": true }, + { "text": "Documentation", "href": "https://wayclip.com/docs" }, + { "text": "Download", "href": "https://wayclip.com/download" } + ] +}' +NEXT_PUBLIC_NAVBAR_LINKS='[ + { "text": "Front Page", "href": "https://wayclip.com" }, + { "text": "Dashboard", "href": "https://dash.wayclip.com/dash" }, + { "text": "Download", "href": "https://wayclip.com/download" }, + { "text": "Docs", "href": "https://wayclip.com/docs" } +]' +NEXT_PUBLIC_FRONTEND_URL=https://dash.wayclip.com +NEXT_PUBLIC_APP_NAME=Wayclip diff --git a/.github/workflows/dash.yml b/.github/workflows/dash.yml index 51c2485..b551053 100644 --- a/.github/workflows/dash.yml +++ b/.github/workflows/dash.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - selfhosting jobs: build-and-push: @@ -20,6 +21,19 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Get short SHA + id: vars + run: echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV + + - name: Determine tags + id: tags + run: | + if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then + echo "TAGS=wayclip/dash:latest" >> $GITHUB_ENV + elif [[ "${GITHUB_REF}" == "refs/heads/selfhosting" ]]; then + echo "TAGS=wayclip/dash:selfhosting-${SHORT_SHA}" >> $GITHUB_ENV + fi + - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -32,5 +46,5 @@ jobs: context: . file: ./Dockerfile push: true - tags: wayclip/dash:latest platforms: linux/amd64,linux/arm64 + tags: ${{ env.TAGS }} diff --git a/Dockerfile b/Dockerfile index 1c6205e..0ff1479 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,8 @@ FROM oven/bun:1-slim AS builder WORKDIR /app -COPY package.json bun.lock ./ -COPY next.config.ts components.json eslint.config.mjs postcss.config.mjs tsconfig.json ./ - -RUN bun install --immutable +COPY package.json bun.lock* ./ +RUN bun install --frozen-lockfile COPY . . @@ -16,11 +14,8 @@ WORKDIR /app ENV NODE_ENV=production ENV PORT=0330 ENV HOST=0.0.0.0 - -COPY --from=builder /app/public ./public COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static -EXPOSE 3003 - +EXPOSE 0330 CMD ["node", "server.js"] diff --git a/app/api/config/route.ts b/app/api/config/route.ts new file mode 100644 index 0000000..3f6f070 --- /dev/null +++ b/app/api/config/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from 'next/server'; +import { getServerConfig } from '@/lib/config'; + +export interface Tier { + name: string; + max_storage_bytes: number; + stripe_price_id: string | null; + display_price: string; + display_frequency?: string; + description: string; + display_features: string[]; + is_popular?: boolean; +} + +export interface AppConfig { + apiUrl: string; + appName: string; + appDesc: string; + defaultAvatarUrl: string; + uploadLimitBytes: number; + paymentsEnabled: boolean; + activeTiers: Tier[]; + discordAuthEnabled: boolean; + githubAuthEnabled: boolean; + googleAuthEnabled: boolean; + emailAuthEnabled: boolean; + footerLinks: Record; + navbarLinks: { text: string; href: string }[]; +} + +export async function GET() { + const config = getServerConfig(); + return NextResponse.json(config); +} diff --git a/app/dash/page.tsx b/app/dash/page.tsx index 2c848d7..af88839 100644 --- a/app/dash/page.tsx +++ b/app/dash/page.tsx @@ -1,17 +1,17 @@ 'use client'; -import { Copy, Trash2, ExternalLink, Check, LogOut, Unplug, Shield, ShieldCheck, Key } from 'lucide-react'; +import { Copy, Trash2, ExternalLink, Check, LogOut, Unplug, Shield, ShieldCheck, Key, RefreshCcw } from 'lucide-react'; +import { ShieldAlert } from 'lucide-react'; import { InputOTP, InputOTPSlot, InputOTPGroup, InputOTPSeparator } from '@/components/ui/input-otp'; import AdminPanel from '@/components/panel'; import { toast } from 'sonner'; -import { FormEvent } from 'react'; -import { useEffect, useState } from 'react'; +import { FormEvent, useEffect, useState } from 'react'; import Image from 'next/image'; -import { RefreshCcw } from 'lucide-react'; import { Clip } from '@/contexts/authContext'; import { useRouter } from 'next/navigation'; -import { isAxiosError } from 'axios'; +import axios, { isAxiosError } from 'axios'; import { useAuth } from '@/contexts/authContext'; +import { useConfig } from '@/contexts/configContext'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -29,7 +29,7 @@ import { DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; -import { cn } from '@/lib/utils'; +import { cn, formatBytes } from '@/lib/utils'; import { AlertDialog, AlertDialogCancel, @@ -40,7 +40,6 @@ import { AlertDialogTitle, AlertDialogTrigger, } from '@/components/ui/alert-dialog'; -import axios from 'axios'; const LoadingScreen = () => (
@@ -55,42 +54,103 @@ const providerIcons: { [key: string]: React.ReactNode } = { email: @, }; -const formatBytes = (bytes: number, decimals = 2) => { - if (!+bytes) return '0 Bytes'; - const k = 1024; - const dm = decimals < 0 ? 0 : decimals; - const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`; +interface Session { + id: string; + device: string; + last_seen_at: string; + created_at: string; +} + +const SessionsTable = ({ sessions, onRevoke }: { sessions: Session[]; onRevoke: (id: string) => void }) => { + if (!sessions || sessions.length === 0) { + return

No active sessions found.

; + } + return ( + + + + Device + Last Active + Signed In + Action + + + + {sessions.map((session) => ( + + {session.device} + {new Date(session.last_seen_at).toLocaleString()} + {new Date(session.created_at).toLocaleDateString()} + + + + + + + + Revoke Session? + + This will sign this device out of your account. You will need to log in + again on that device. Are you sure? + + + + + + + + + + + + + ))} + +
+ ); }; const ClipsTable = ({ clips, + apiUrl, onDelete, onCopy, }: { clips: Clip[]; + apiUrl: string; onDelete: (id: string) => void; onCopy: (url: string) => void; }) => { - const api_url = process.env.NEXT_PUBLIC_API_URL; if (!clips || clips.length === 0) { return

You haven't uploaded any clips yet.

; } - return ( File Name - {'file_size' in clips[0] && Size} - {'created_at' in clips[0] && Uploaded} + Size + Uploaded Actions {clips.map((clip) => { - const clipUrl = `${api_url}/clip/${clip.id}`; + const clipUrl = `${apiUrl}/clip/${clip.id}`; return ( @@ -104,10 +164,8 @@ const ClipsTable = ({ - {'file_size' in clip && {formatBytes(clip.file_size)}} - {'created_at' in clip && ( - {new Date(clip.created_at).toLocaleDateString()} - )} + {formatBytes(clip.file_size)} + {new Date(clip.created_at).toLocaleDateString()} - Sign out everywhere else? + Are you sure? - This will sign you out of all other active sessions on other browsers - and devices. Your current session will remain active. + This will sign you out of every other session on all of your devices, + including mobile and desktop clients. You will remain logged in here. - Cancel - + + + + - */} + -
@@ -795,7 +879,6 @@ const DashboardPage = () => {
- Your Clips @@ -807,24 +890,29 @@ const DashboardPage = () => {
) : ( - + )}
-

Billing & Subscriptions

- {userData.tier !== 'free' ? ( + {userData.tier.toLowerCase() !== 'free' ? ( Your Subscription You are currently on the{' '} - {userTierPlan.name} plan. + {userTierPlan?.name ?? 'Error'}{' '} + plan. @@ -849,34 +937,34 @@ const DashboardPage = () => {

Manage Subscription

You are currently on the{' '} - {userTierPlan.name} plan. + {userTierPlan?.name} plan.

)}
- {pricingPlans.map((plan) => ( + {config.activeTiers.map((plan) => (
{plan.name} - {plan.isPopular && Most Popular} + {plan.is_popular && Most Popular}
{plan.description}
- {plan.price} - {plan.priceFrequency} + {plan.display_price} + {plan.display_frequency}
    - {plan.features.map((feature) => ( + {plan.display_features.map((feature) => (
  • {feature} @@ -885,27 +973,31 @@ const DashboardPage = () => {
- {userData.tier === 'free' ? ( + {userData.tier.toLowerCase() === 'free' ? ( ) : ( )} diff --git a/app/layout.tsx b/app/layout.tsx index 8da36b9..a478b42 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,114 +1,46 @@ import { ThemeProvider } from '@/components/theme-provider'; import { Metadata } from 'next'; -import { AppInfo, getAppInfo } from '@/lib/utils'; import { AuthProvider } from '@/contexts/authContext'; +import { ConfigProvider } from '@/contexts/configContext'; import { Toaster } from 'sonner'; -import Link from 'next/link'; import { Inter } from 'next/font/google'; +import { AppShell } from '@/components/appShell'; +import { getServerConfig } from '@/lib/config'; import './globals.css'; -import { Navbar } from '@/components/nav'; const inter = Inter({ subsets: ['latin'], }); -export async function generateMetadata(): Promise { - let appInfo: AppInfo = { - backend_url: '', - frontend_url: '', - app_name: 'MyApp', - default_avatar_url: '', - upload_limit_bytes: 0, - }; - - try { - appInfo = await getAppInfo(); - } catch (e) { - console.error('Failed to fetch app info for metadata', e); - } +export function generateMetadata(): Metadata { + const config = getServerConfig(); + const appName = config?.appName || 'Wayclip'; + const description = config?.appDesc || `Welcome to ${appName}`; return { - title: appInfo.app_name, - description: `Welcome to ${appInfo.app_name}`, - openGraph: { - title: appInfo.app_name, - url: appInfo.frontend_url, - }, - twitter: { - card: 'summary_large_image', - title: appInfo.app_name, - description: `Welcome to ${appInfo.app_name}`, - }, + title: `${appName} | Dashboard`, + description, + openGraph: { title: `${appName} | Dashboard`, description }, + twitter: { card: 'summary_large_image', title: `${appName} | Dashboard`, description }, }; } -export type LinkItem = { - text: string; - href: string; - external?: boolean; -}; - -export type LinkCategories = { - [key: string]: LinkItem[]; -}; -export default async function RootLayout({ +export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { - const appInfo = await getAppInfo(); - const app_desc = process.env.NEXT_PUBLIC_APP_DESC; - - const footerLinks: LinkCategories = JSON.parse(process.env.NEXT_PUBLIC_FOOTER_LINKS || '{}'); - return ( - - - - -
{children}
- -
-
-
-
-
- {appInfo.app_name} -
-

{app_desc}

-
- {Object.entries(footerLinks).map(([category, links]) => ( -
-

{category}

-
    - {links.map((link) => ( -
  • - - {link.text} - -
  • - ))} -
-
- ))} -
-
-

- © {new Date().getFullYear()} {appInfo.app_name}. Open source software - licensed under MIT. -

-
-
-
- -
-
+ + + + + {children} + + + + ); diff --git a/app/login/page.tsx b/app/login/page.tsx index 5c6e051..ca372f8 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -10,10 +10,10 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { useAuth } from '@/contexts/authContext'; -import { useRouter, useSearchParams } from 'next/navigation'; +import { useConfig } from '@/contexts/configContext'; +import { useRouter } from 'next/navigation'; import axios, { isAxiosError } from 'axios'; import { toast } from 'sonner'; -import { getAuthInfo, AuthInfo } from '@/lib/utils'; const LoadingScreen = () => (
@@ -22,15 +22,9 @@ const LoadingScreen = () => ( ); const LoginClientComponent = () => { - const api_url = process.env.NEXT_PUBLIC_API_URL; - const { isAuthenticated, isLoading, refreshUser } = useAuth(); + const { config, isLoading: isConfigLoading } = useConfig(); + const { isAuthenticated, isLoading: isAuthLoading, refreshUser } = useAuth(); const router = useRouter(); - const searchParams = useSearchParams(); - - // State for auth provider settings - const [authInfo, setAuthInfo] = useState(null); - const [isAuthInfoLoading, setIsAuthInfoLoading] = useState(true); - const [activeTab, setActiveTab] = useState('login'); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); @@ -46,101 +40,65 @@ const LoginClientComponent = () => { const [is2FASubmitting, setIs2FASubmitting] = useState(false); useEffect(() => { - const fetchAuthInfo = async () => { - try { - const info = await getAuthInfo(); - setAuthInfo(info); - } catch (error) { - console.error('Failed to fetch auth info:', error); - toast.error('Could not load login options. Please refresh the page.'); - setAuthInfo({ - discord_auth_enabled: false, - github_auth_enabled: false, - google_auth_enabled: false, - email_auth_enabled: false, - }); - } finally { - setIsAuthInfoLoading(false); - } - }; - fetchAuthInfo(); - }, []); - - useEffect(() => { - if (!isLoading && isAuthenticated) { + if (!isAuthLoading && isAuthenticated) { router.replace('/dash'); } - }, [isLoading, isAuthenticated, router]); + }, [isAuthLoading, isAuthenticated, router]); useEffect(() => { - const verified = searchParams.get('verified'); - if (verified === 'true') { + const params = new URLSearchParams(window.location.search); + if (params.get('verified') === 'true') { toast.success('Email verified successfully! You can now log in.'); router.replace('/login'); } - const reset = searchParams.get('reset_success'); - if (reset === 'true') { + if (params.get('reset_success') === 'true') { toast.success('Password has been reset successfully. Please log in.'); router.replace('/login'); } - const error = searchParams.get('error'); + const error = params.get('error'); if (error) { - toast.error(error); + toast.error(decodeURIComponent(error)); router.replace('/login'); } - - const is2FARequired = searchParams.get('2fa_required'); - const token = searchParams.get('token'); - if (is2FARequired === 'true' && token) { - setTwoFAToken(token); + if (params.get('2fa_required') === 'true' && params.get('token')) { + setTwoFAToken(params.get('token')!); setShow2FA(true); toast.info('Please enter your 2FA code to complete login.'); router.replace('/login'); } - }, [searchParams, router]); + }, [router]); const handleOAuthLogin = (provider: 'github' | 'google' | 'discord') => { - if (!api_url) { + if (!config?.apiUrl) { toast.error('Configuration error: The API URL is not set.'); return; } - const finalRedirectUri = window.location.origin + '/dash'; - const loginUrl = `${api_url}/auth/${provider}?redirect_uri=${encodeURIComponent(finalRedirectUri)}`; - window.location.href = loginUrl; + const finalRedirectUri = `${window.location.origin}/dash`; + window.location.href = `${config.apiUrl}/auth/${provider}?redirect_uri=${encodeURIComponent(finalRedirectUri)}`; }; const handleErrorToast = (error: unknown, defaultMessage: string) => { + let message = defaultMessage; if (isAxiosError(error) && error.response?.data) { - const serverMessage = error.response.data; + const serverMessage = error.response.data.message || error.response.data; if (typeof serverMessage === 'string' && serverMessage.length > 0) { - toast.error(serverMessage); - } else if ( - serverMessage.message && - typeof serverMessage.message === 'string' && - serverMessage.message.length > 0 - ) { - toast.error(serverMessage.message); - } else { - toast.error(defaultMessage); + message = serverMessage; } - } else { - toast.error(defaultMessage); } + toast.error(message); }; const handlePasswordLogin = async (e: FormEvent) => { e.preventDefault(); + if (!config?.apiUrl) return; setIsSubmitting(true); try { const response = await axios.post( - `${api_url}/auth/login`, + `${config.apiUrl}/auth/login`, { email, password }, - { - withCredentials: true, - }, + { withCredentials: true }, ); - - if (response.status === 200 && response.data.success) { + if (response.data.success) { await refreshUser(); router.push('/dash'); } else if (response.data['2fa_required']) { @@ -151,16 +109,12 @@ const LoginClientComponent = () => { } catch (error) { if (isAxiosError(error) && error.response?.data?.error_code === 'EMAIL_NOT_VERIFIED') { toast.error(error.response.data.message, { - action: { - label: 'Resend Email', - onClick: () => handleResendVerification(email), - }, + action: { label: 'Resend Email', onClick: () => handleResendVerification(email) }, duration: 10000, }); } else { handleErrorToast(error, 'Login failed. Please check your credentials.'); } - console.error('Password login failed:', error); } finally { setIsSubmitting(false); } @@ -168,26 +122,20 @@ const LoginClientComponent = () => { const handle2FALogin = async (e: FormEvent) => { e.preventDefault(); + if (!config?.apiUrl) return; setIs2FASubmitting(true); try { const response = await axios.post( - `${api_url}/auth/2fa/authenticate`, - { - '2fa_token': twoFAToken, - code: twoFACode, - }, - { - withCredentials: true, - }, + `${config.apiUrl}/auth/2fa/authenticate`, + { '2fa_token': twoFAToken, code: twoFACode }, + { withCredentials: true }, ); - if (response.data.success) { await refreshUser(); router.push('/dash'); } } catch (error) { handleErrorToast(error, 'Invalid 2FA code. Please try again.'); - console.error('2FA login failed:', error); } finally { setIs2FASubmitting(false); } @@ -195,25 +143,22 @@ const LoginClientComponent = () => { const handleRegister = async (e: FormEvent) => { e.preventDefault(); - if (registerPassword !== registerConfirmPassword) { toast.error('Passwords do not match.'); return; } - if (registerPassword.length < 8) { toast.error('Password must be at least 8 characters long.'); return; } - + if (!config?.apiUrl) return; setIsRegistering(true); try { - const response = await axios.post(`${api_url}/auth/register`, { + const response = await axios.post(`${config.apiUrl}/auth/register`, { username: registerUsername, email: registerEmail, password: registerPassword, }); - toast.success( response.data.message || 'Registration successful! Please check your email to verify your account.', ); @@ -224,7 +169,6 @@ const LoginClientComponent = () => { setRegisterConfirmPassword(''); } catch (error) { handleErrorToast(error, 'Registration failed. Please try again.'); - console.error('Registration failed:', error); } finally { setIsRegistering(false); } @@ -235,21 +179,16 @@ const LoginClientComponent = () => { toast.error('Please enter your email address in the login form first.'); return; } + if (!config?.apiUrl) return; try { - const response = await axios.post(`${api_url}/auth/resend-verification`, { email }); + const response = await axios.post(`${config.apiUrl}/auth/resend-verification`, { email }); toast.success(response.data.message); } catch (error) { handleErrorToast(error, 'Failed to send verification email.'); - console.error(error); } }; - const handleForgotPassword = (e: FormEvent) => { - e.preventDefault(); - router.push('/reset-password'); - }; - - if (isLoading || isAuthenticated || isAuthInfoLoading) { + if (isAuthLoading || isConfigLoading || isAuthenticated) { return ; } @@ -311,15 +250,14 @@ const LoginClientComponent = () => { ); } - const anyOAuthEnabled = - authInfo?.github_auth_enabled || authInfo?.google_auth_enabled || authInfo?.discord_auth_enabled; - const showSeparator = anyOAuthEnabled && authInfo?.email_auth_enabled; + const anyOAuthEnabled = config?.githubAuthEnabled || config?.googleAuthEnabled || config?.discordAuthEnabled; + const showSeparator = anyOAuthEnabled && config?.emailAuthEnabled; return (
- Welcome to Wayclip + Welcome to {config?.appName || 'Wayclip'} Sign in to your account or create a new one. @@ -328,39 +266,37 @@ const LoginClientComponent = () => { Login Register -
- {authInfo?.github_auth_enabled && ( + {config?.githubAuthEnabled && ( )} - {authInfo?.google_auth_enabled && ( + {config?.googleAuthEnabled && ( )} - {authInfo?.discord_auth_enabled && ( + {config?.discordAuthEnabled && ( )} - {showSeparator && (
@@ -371,8 +307,7 @@ const LoginClientComponent = () => {
)} - - {authInfo?.email_auth_enabled && ( + {config?.emailAuthEnabled && (
@@ -392,7 +327,7 @@ const LoginClientComponent = () => { type='button' variant='link' className='ml-auto h-auto p-0 text-sm' - onClick={handleForgotPassword} + onClick={() => router.push('/reset-password')} > Forgot password? @@ -412,39 +347,37 @@ const LoginClientComponent = () => { )}
-
- {authInfo?.github_auth_enabled && ( + {config?.githubAuthEnabled && ( )} - {authInfo?.google_auth_enabled && ( + {config?.googleAuthEnabled && ( )} - {authInfo?.discord_auth_enabled && ( + {config?.discordAuthEnabled && ( )} - {showSeparator && (
@@ -455,8 +388,7 @@ const LoginClientComponent = () => {
)} - - {authInfo?.email_auth_enabled && ( + {config?.emailAuthEnabled && (
@@ -517,12 +449,10 @@ const LoginClientComponent = () => { ); }; -const LoginPage = () => { - return ( - }> - - - ); -}; +const LoginPage = () => ( + }> + + +); export default LoginPage; diff --git a/app/reset-password/page.tsx b/app/reset-password/page.tsx index 8176920..3af813f 100644 --- a/app/reset-password/page.tsx +++ b/app/reset-password/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Suspense, useState, FormEvent, useEffect } from 'react'; +import { Suspense, useState, FormEvent } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; @@ -10,7 +10,7 @@ import { toast } from 'sonner'; import axios, { isAxiosError } from 'axios'; import { Loader2 } from 'lucide-react'; import Link from 'next/link'; -import { getAuthInfo, AuthInfo } from '@/lib/utils'; +import { useConfig } from '@/contexts/configContext'; const InfoCard = ({ title, @@ -37,47 +37,21 @@ const InfoCard = ({ ); const ResetPasswordClientComponent = () => { - const api_url = process.env.NEXT_PUBLIC_API_URL; + const { config, isLoading: isConfigLoading } = useConfig(); const router = useRouter(); const searchParams = useSearchParams(); const token = searchParams.get('token'); - - const [authInfo, setAuthInfo] = useState(null); - const [isLoading, setIsLoading] = useState(true); const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); - useEffect(() => { - const fetchAuthSettings = async () => { - try { - const info = await getAuthInfo(); - setAuthInfo(info); - } catch (error) { - console.error('Failed to fetch auth settings:', error); - toast.error('Could not load page settings. Please try again later.'); - setAuthInfo(null); - } finally { - setIsLoading(false); - } - }; - - fetchAuthSettings(); - }, []); - const handleErrorToast = (error: unknown, defaultMessage: string) => { + let message = defaultMessage; if (isAxiosError(error) && error.response?.data) { - const serverMessage = error.response.data; - if (typeof serverMessage === 'string' && serverMessage.length > 0) { - toast.error(serverMessage); - } else if (serverMessage.message && typeof serverMessage.message === 'string') { - toast.error(serverMessage.message); - } else { - toast.error(defaultMessage); - } - } else { - toast.error(defaultMessage); + const serverMessage = error.response.data.message || error.response.data; + if (typeof serverMessage === 'string' && serverMessage.length > 0) message = serverMessage; } + toast.error(message); }; const handleSubmit = async (e: FormEvent) => { @@ -90,32 +64,28 @@ const ResetPasswordClientComponent = () => { toast.error('Passwords do not match.'); return; } - if (!token) { - toast.error('Missing password reset token.'); + if (!token || !config?.apiUrl) { + toast.error('Invalid request. Please try again.'); return; } setIsSubmitting(true); try { - const response = await axios.post(`${api_url}/auth/reset-password`, { - token, - password, - }); + const response = await axios.post(`${config.apiUrl}/auth/reset-password`, { token, password }); toast.success(response.data.message || 'Your password has been reset successfully.'); router.push('/login?reset_success=true'); } catch (err) { handleErrorToast(err, 'Failed to reset password. The link may have expired.'); - console.error('Password reset failed:', err); } finally { setIsSubmitting(false); } }; - if (isLoading) { - return null; + if (isConfigLoading) { + return ; } - if (!authInfo || !authInfo.email_auth_enabled) { + if (!config || !config.emailAuthEnabled) { return ( { ); }; -const ResetPasswordPage = () => { - return ( -
- }> - - -
- ); -}; +const ResetPasswordPage = () => ( +
+ }> + + +
+); export default ResetPasswordPage; diff --git a/bun.lock b/bun.lock index 71d561f..b447a15 100644 --- a/bun.lock +++ b/bun.lock @@ -30,6 +30,7 @@ "react-dom": "19.1.0", "sonner": "^2.0.7", "stripe": "^18.5.0", + "swr": "^2.3.6", "tailwind-merge": "^3.3.1", }, "devDependencies": { @@ -473,6 +474,8 @@ "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + "detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="], "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], @@ -911,6 +914,8 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "swr": ["swr@2.3.6", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw=="], + "tabbable": ["tabbable@6.2.0", "", {}, "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="], "tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="], diff --git a/components/appShell.tsx b/components/appShell.tsx new file mode 100644 index 0000000..a13c5e1 --- /dev/null +++ b/components/appShell.tsx @@ -0,0 +1,18 @@ +'use client'; + +import { useConfig } from '@/contexts/configContext'; +import { Navbar } from '@/components/nav'; +import { Footer } from '@/components/footer'; + +export function AppShell({ children }: { children: React.ReactNode }) { + const { config, isLoading } = useConfig(); + if (isLoading || !config) return null; + + return ( + <> + +
{children}
+
+ + ); +} diff --git a/components/detailView.tsx b/components/detailView.tsx index 1b4db8b..eedf971 100644 --- a/components/detailView.tsx +++ b/components/detailView.tsx @@ -21,7 +21,8 @@ import { Badge } from '@/components/ui/badge'; import { Label } from '@/components/ui/label'; import { Loader2, Ban, Trash2, Video, ExternalLink, Copy } from 'lucide-react'; import { useAuth } from '@/contexts/authContext'; -import { getPaymentInfo, ParsedPaymentInfo } from '@/lib/utils'; +import { useConfig } from '@/contexts/configContext'; +import { formatBytes } from '@/lib/utils'; type UserRole = 'user' | 'admin'; @@ -41,7 +42,6 @@ interface FullUserDetails { currentPeriodEnd: string | null; connectedProviders: string[]; } - interface UserClip { id: string; file_name: string; @@ -56,38 +56,29 @@ const DetailItem = ({ label, value }: { label: string; value: React.ReactNode })
); -const formatBytes = (bytes: number, decimals = 2) => { - if (!+bytes) return '0 Bytes'; - const k = 1024; - const dm = decimals < 0 ? 0 : decimals; - const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`; -}; - export const UserDetailView = ({ userId, onDataChange }: { userId: string; onDataChange: () => void }) => { - const api_url = process.env.NEXT_PUBLIC_API_URL; + const { config } = useConfig(); const { user: adminUser } = useAuth(); const [details, setDetails] = useState(null); const [isLoading, setIsLoading] = useState(true); const [userClips, setUserClips] = useState(null); const [clipsLoading, setClipsLoading] = useState(false); - const [paymentInfo, setPaymentInfo] = useState(null); const fetchDetails = useCallback(async () => { + if (!config?.apiUrl) return; + setIsLoading(true); try { - const response = await axios.get(`${api_url}/admin/users/${userId}`, { + const response = await axios.get(`${config.apiUrl}/admin/users/${userId}`, { withCredentials: true, }); - const paymentReponse = await getPaymentInfo(); setDetails(response.data); - setPaymentInfo(paymentReponse); } catch (error) { - toast.error(`Failed to fetch user details, ${error}`); + console.error(error); + toast.error(`Failed to fetch user details.`); } finally { setIsLoading(false); } - }, [userId, api_url]); + }, [userId, config?.apiUrl]); useEffect(() => { fetchDetails(); @@ -111,61 +102,70 @@ export const UserDetailView = ({ userId, onDataChange }: { userId: string; onDat toast.error('You cannot change your own role.'); return; } + if (!config?.apiUrl) return; handleAction( - () => axios.post(`${api_url}/admin/users/${userId}/role`, { role: role }, { withCredentials: true }), + () => axios.post(`${config.apiUrl}/admin/users/${userId}/role`, { role }, { withCredentials: true }), 'User role updated.', ); }; const handleUpdateTier = (tier: string) => { + if (!config?.apiUrl) return; handleAction( - () => axios.post(`${api_url}/admin/users/${userId}/tier`, { tier }, { withCredentials: true }), + () => axios.post(`${config.apiUrl}/admin/users/${userId}/tier`, { tier }, { withCredentials: true }), 'User tier manually updated.', ); }; const handleUnban = () => { + if (!config?.apiUrl) return; handleAction( - () => axios.post(`${api_url}/admin/users/${userId}/unban`, {}, { withCredentials: true }), + () => axios.post(`${config.apiUrl}/admin/users/${userId}/unban`, {}, { withCredentials: true }), 'User has been unbanned.', ); }; const handleBanUser = () => { + if (!config?.apiUrl) return; handleAction( - () => axios.post(`${api_url}/admin/users/${userId}/ban`, {}, { withCredentials: true }), + () => axios.post(`${config.apiUrl}/admin/users/${userId}/ban`, {}, { withCredentials: true }), 'User has been banned.', ); }; const handleDeleteUser = () => { + if (!config?.apiUrl) return; handleAction( - () => axios.delete(`${api_url}/admin/users/${userId}`, { withCredentials: true }), + () => axios.delete(`${config.apiUrl}/admin/users/${userId}`, { withCredentials: true }), 'User has been permanently deleted.', ); }; const fetchUserClips = async () => { + if (!config?.apiUrl) return; setClipsLoading(true); try { - const response = await axios.get(`${api_url}/admin/users/${userId}/clips`, { + const response = await axios.get(`${config.apiUrl}/admin/users/${userId}/clips`, { withCredentials: true, }); setUserClips(response.data); } catch (error) { - toast.error(`Failed to fetch user clips, ${error}`); + console.error(error); + toast.error(`Failed to fetch user clips.`); } finally { setClipsLoading(false); } }; const handleDeleteClip = async (clipId: string) => { + if (!config?.apiUrl) return; try { - await axios.delete(`${api_url}/admin/clips/${clipId}`, { withCredentials: true }); + await axios.delete(`${config.apiUrl}/admin/clips/${clipId}`, { withCredentials: true }); toast.success('Clip deleted successfully.'); await fetchUserClips(); } catch (error) { - toast.error(`Failed to delete clip, ${error}`); + console.error(error); + toast.error(`Failed to delete clip.`); } }; @@ -175,7 +175,7 @@ export const UserDetailView = ({ userId, onDataChange }: { userId: string; onDat
); - if (!details) return

Could not load user details.

; + if (!details || !config) return

Could not load user details.

; return (
@@ -267,7 +267,7 @@ export const UserDetailView = ({ userId, onDataChange }: { userId: string; onDat - {paymentInfo?.active_tiers.map((v, i) => ( + {config.activeTiers.map((v, i) => ( {v.name} @@ -275,12 +275,10 @@ export const UserDetailView = ({ userId, onDataChange }: { userId: string; onDat
- - {details.isBanned ? ( @@ -322,7 +320,6 @@ export const UserDetailView = ({ userId, onDataChange }: { userId: string; onDat )} -
- {clipsLoading && (
@@ -375,7 +371,7 @@ export const UserDetailView = ({ userId, onDataChange }: { userId: string; onDat - navigator.clipboard.writeText(`${api_url}/clip/${clip.id}`) + navigator.clipboard.writeText(`${config.apiUrl}/clip/${clip.id}`) } > diff --git a/components/footer.tsx b/components/footer.tsx new file mode 100644 index 0000000..76afeba --- /dev/null +++ b/components/footer.tsx @@ -0,0 +1,47 @@ +import Link from 'next/link'; +import type { AppConfig } from '@/app/api/config/route'; + +interface FooterProps { + appName?: string; + appDesc?: string; + footerLinks?: AppConfig['footerLinks']; +} + +export const Footer = ({ appName = 'Wayclip', appDesc = '', footerLinks = {} }: FooterProps) => { + return ( +
+
+
+
+ {appName} +

{appDesc}

+
+ {Object.entries(footerLinks).map(([category, links]) => ( +
+

{category}

+
    + {links.map((link) => ( +
  • + + {link.text} + +
  • + ))} +
+
+ ))} +
+
+

+ © {new Date().getFullYear()} {appName}. Open source software licensed under MIT. +

+
+
+
+ ); +}; diff --git a/components/nav.tsx b/components/nav.tsx index 8431343..8d319f3 100644 --- a/components/nav.tsx +++ b/components/nav.tsx @@ -3,12 +3,14 @@ import Link from 'next/link'; import { Button } from './ui/button'; import { ThemeToggle } from './toggle'; import { BsGithub } from '@vertisanpro/react-icons/bs'; -import { getAppInfo } from '@/lib/utils'; -import { LinkItem } from '@/app/layout'; +import type { AppConfig } from '@/app/api/config/route'; -export const Navbar = async () => { - const appInfo = await getAppInfo(); - const navLinks: LinkItem[] = JSON.parse(process.env.NEXT_PUBLIC_NAVBAR_LINKS || '[]'); +interface NavbarProps { + appName?: string; + navbarLinks?: AppConfig['navbarLinks']; +} + +export const Navbar = ({ appName = 'Wayclip', navbarLinks = [] }: NavbarProps) => { return (
{ >
); }; + +export default PaymentVerificationClient; diff --git a/contexts/authContext.tsx b/contexts/authContext.tsx index ddc2de1..b5b766a 100644 --- a/contexts/authContext.tsx +++ b/contexts/authContext.tsx @@ -2,6 +2,7 @@ import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react'; import axios from 'axios'; +import { useConfig } from './configContext'; type CredentialProvider = 'email' | 'github' | 'google' | 'discord'; @@ -42,34 +43,32 @@ export interface Clip { const AuthContext = createContext(undefined); export const AuthProvider = ({ children }: { children: ReactNode }) => { - const api_url = process.env.NEXT_PUBLIC_API_URL; + const { config, isLoading: isConfigLoading } = useConfig(); const [user, setUser] = useState(null); const [isLoading, setIsLoading] = useState(true); const doLogout = useCallback(async () => { - try { - await axios.post(`${api_url}/api/logout`, {}, { withCredentials: true }); - } catch (error) { - console.error('Logout request failed, proceeding with client-side logout:', error); - } finally { - setUser(null); - if (window.location.pathname !== '/login') { - window.location.href = '/login'; + if (config?.apiUrl) { + try { + await axios.post(`${config.apiUrl}/api/logout`, {}, { withCredentials: true }); + } catch (error) { + console.error('Logout request failed, proceeding client-side.', error); } } - }, [api_url]); + setUser(null); + if (window.location.pathname !== '/login') { + window.location.href = '/login'; + } + }, [config?.apiUrl]); const fetchUser = useCallback(async () => { - if (!api_url) { - console.error('Error: NEXT_PUBLIC_API_URL is not defined. Please check your .env.local file.'); - setIsLoading(false); + if (isConfigLoading || !config?.apiUrl) { return; } try { - const response = await axios.get(`${api_url}/api/me`, { + const response = await axios.get(`${config.apiUrl}/api/me`, { withCredentials: true, }); - if (response.data) { if (response.data.is_banned) { await doLogout(); @@ -80,12 +79,11 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { setUser(null); } } catch (error) { - console.error(error); setUser(null); } finally { setIsLoading(false); } - }, [api_url, doLogout]); + }, [config?.apiUrl, doLogout, isConfigLoading]); useEffect(() => { fetchUser(); @@ -99,7 +97,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { const value = { user, isAuthenticated: !!user, - isLoading, + isLoading: isLoading || isConfigLoading, logout: doLogout, refreshUser, }; diff --git a/contexts/configContext.tsx b/contexts/configContext.tsx new file mode 100644 index 0000000..c9b63d5 --- /dev/null +++ b/contexts/configContext.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { createContext, useContext, useState, useEffect, ReactNode } from 'react'; +import type { AppConfig } from '@/app/api/config/route'; + +interface ConfigContextType { + config: AppConfig | null; + isLoading: boolean; +} + +const ConfigContext = createContext(undefined); + +export const ConfigProvider = ({ children }: { children: ReactNode }) => { + const [config, setConfig] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const fetchConfig = async () => { + try { + const response = await fetch('/api/config'); + if (!response.ok) { + throw new Error('Failed to fetch application configuration.'); + } + const data: AppConfig = await response.json(); + setConfig(data); + } catch (err) { + console.error(err); + setConfig(null); + } finally { + setIsLoading(false); + } + }; + + fetchConfig(); + }, []); + + const value = { config, isLoading }; + + return {children}; +}; + +export const useConfig = () => { + const context = useContext(ConfigContext); + if (context === undefined) { + throw new Error('useConfig must be used within a ConfigProvider'); + } + return context; +}; diff --git a/lib/config.ts b/lib/config.ts new file mode 100644 index 0000000..dd397cc --- /dev/null +++ b/lib/config.ts @@ -0,0 +1,48 @@ +import type { AppConfig, Tier } from '@/app/api/config/route'; +export function getServerConfig(): AppConfig { + let activeTiers: Tier[] = []; + try { + if (process.env.TIERS_JSON) { + activeTiers = JSON.parse(process.env.TIERS_JSON); + } + } catch (error) { + console.error('Failed to parse TIERS_JSON:', error); + activeTiers = []; + } + + let footerLinks = {}; + try { + if (process.env.NEXT_PUBLIC_FOOTER_LINKS) { + footerLinks = JSON.parse(process.env.NEXT_PUBLIC_FOOTER_LINKS); + } + } catch (error) { + console.error('Failed to parse NEXT_PUBLIC_FOOTER_LINKS:', error); + footerLinks = {}; + } + + let navbarLinks = []; + try { + if (process.env.NEXT_PUBLIC_NAVBAR_LINKS) { + navbarLinks = JSON.parse(process.env.NEXT_PUBLIC_NAVBAR_LINKS); + } + } catch (error) { + console.error('Failed to parse NEXT_PUBLIC_NAVBAR_LINKS:', error); + navbarLinks = []; + } + + return { + apiUrl: process.env.NEXT_PUBLIC_API_URL || '', + appName: process.env.NEXT_PUBLIC_APP_NAME || 'Wayclip', + appDesc: process.env.NEXT_PUBLIC_APP_DESC || '', + defaultAvatarUrl: process.env.NEXT_PUBLIC_DEFAULT_AVATAR_URL || '', + uploadLimitBytes: Number(process.env.NEXT_PUBLIC_UPLOAD_LIMIT_BYTES) || 0, + paymentsEnabled: process.env.NEXT_PUBLIC_PAYMENTS_ENABLED === 'TRUE', + discordAuthEnabled: process.env.NEXT_PUBLIC_DISCORD_AUTH_ENABLED === 'TRUE', + githubAuthEnabled: process.env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED === 'TRUE', + googleAuthEnabled: process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED === 'TRUE', + emailAuthEnabled: process.env.NEXT_PUBLIC_EMAIL_AUTH_ENABLED === 'TRUE', + activeTiers, + footerLinks, + navbarLinks, + }; +} diff --git a/lib/utils.ts b/lib/utils.ts index 5281801..8f4f283 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,75 +1,15 @@ -import { clsx, type ClassValue } from 'clsx'; +import { type ClassValue, clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } -export type AppInfo = { - backend_url: string; - frontend_url: string; - app_name: string; - default_avatar_url: string; - upload_limit_bytes: number; +export const formatBytes = (bytes: number, decimals = 2): string => { + if (!+bytes) return '0 Bytes'; + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`; }; - -export async function getAppInfo(): Promise { - const res = await fetch(`${process.env.BACKEND_URL || 'http://localhost:8000'}/get-app-info`); - if (!res.ok) throw new Error('Failed to fetch app info'); - return res.json(); -} - -export type Tier = { - name: string; - max_storage_bytes: number; - stripe_price_id: string | null; -}; - -type RawPaymentInfo = { - payments_enabled: boolean; - active_tiers: string; -}; - -export type ParsedPaymentInfo = { - payments_enabled: boolean; - active_tiers: Tier[]; -}; - -export type AuthInfo = { - discord_auth_enabled: boolean; - github_auth_enabled: boolean; - google_auth_enabled: boolean; - email_auth_enabled: boolean; -}; - -export async function getPaymentInfo(): Promise { - const api_url = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'; - const res = await fetch(`${api_url}/get-payment-info`); - - if (!res.ok) { - throw new Error('Failed to fetch payment info'); - } - - const rawData: RawPaymentInfo = await res.json(); - - try { - const parsedTiers: Tier[] = JSON.parse(rawData.active_tiers); - - return { - payments_enabled: rawData.payments_enabled, - active_tiers: parsedTiers, - }; - } catch (error) { - console.error('Failed to parse active_tiers JSON:', error); - throw new Error('Failed to parse active_tiers from payment info'); - } -} - -export async function getAuthInfo(): Promise { - const api_url = process.env.NEXT_PUBLIC_API_URL; - const res = await fetch(`${api_url}/get-auth-info`); - if (!res.ok) { - throw new Error('Failed to fetch auth info'); - } - return res.json(); -} diff --git a/package.json b/package.json index 132eb45..23b0deb 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "react-dom": "19.1.0", "sonner": "^2.0.7", "stripe": "^18.5.0", + "swr": "^2.3.6", "tailwind-merge": "^3.3.1" }, "devDependencies": { diff --git a/public/file.svg b/public/file.svg deleted file mode 100644 index 004145c..0000000 --- a/public/file.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/globe.svg b/public/globe.svg deleted file mode 100644 index 567f17b..0000000 --- a/public/globe.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/next.svg b/public/next.svg deleted file mode 100644 index 5174b28..0000000 --- a/public/next.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/vercel.svg b/public/vercel.svg deleted file mode 100644 index 7705396..0000000 --- a/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/window.svg b/public/window.svg deleted file mode 100644 index b2b2a44..0000000 --- a/public/window.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file