One Calendar is a privacy-first calendar web app built with Next.js. It has modern security features, including e2ee, password-protected sharing, and self-destructing share links ๐Ÿ“… calendar.xyehr.cn
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #236 from EvanTechDev/codex/fix-import-card-tabs-display-bug

Introduce APP_CONFIG and dynamic OAuth provider support; centralize contact/status links and tidy auth flows

authored by

Evan Huang and committed by
GitHub
d92d6fbe 0fa350b8

+295 -224
+27 -12
app/(auth)/sign-in/page.tsx
··· 1 - import { LoginForm } from "@/components/auth/login-form" 2 - import { AuthBrand } from "@/components/auth/auth-brand" 1 + import { LoginForm } from "@/components/auth/login-form"; 2 + import { AuthBrand } from "@/components/auth/auth-brand"; 3 + import { currentUser } from "@clerk/nextjs/server"; 4 + import { redirect } from "next/navigation"; 3 5 4 - export default function LoginPage() { 6 + export default async function LoginPage() { 7 + const user = await currentUser(); 8 + if (user) { 9 + redirect("/app"); 10 + } 11 + 5 12 return ( 6 13 <div className="relative flex min-h-svh flex-col items-center justify-center overflow-hidden from-blue-500 via-indigo-500 to-purple-500 p-6 md:p-10"> 7 14 <div className="fixed -z-10 inset-0"> 8 15 <div className="absolute inset-0 bg-white dark:bg-black"> 9 - <div className="absolute inset-0" style={{ 10 - backgroundImage: `radial-gradient(circle at 1px 1px, rgba(0, 0, 0, 0.1) 1px, transparent 0)`, 11 - backgroundSize: '24px 24px' 12 - }} /> 13 - <div className="absolute inset-0 dark:block hidden" style={{ 14 - backgroundImage: `radial-gradient(circle at 1px 1px, rgba(255, 255, 255, 0.15) 1px, transparent 0)`, 15 - backgroundSize: '24px 24px' 16 - }} /> 16 + <div 17 + className="absolute inset-0" 18 + style={{ 19 + backgroundImage: 20 + "radial-gradient(circle at 1px 1px, rgba(0, 0, 0, 0.1) 1px, transparent 0)", 21 + backgroundSize: "24px 24px", 22 + }} 23 + /> 24 + <div 25 + className="absolute inset-0 hidden dark:block" 26 + style={{ 27 + backgroundImage: 28 + "radial-gradient(circle at 1px 1px, rgba(255, 255, 255, 0.15) 1px, transparent 0)", 29 + backgroundSize: "24px 24px", 30 + }} 31 + /> 17 32 </div> 18 33 </div> 19 34 <div className="relative z-10 flex w-full max-w-sm flex-col gap-6"> ··· 21 36 <LoginForm /> 22 37 </div> 23 38 </div> 24 - ) 39 + ); 25 40 }
+27 -12
app/(auth)/sign-up/page.tsx
··· 1 - import { SignUpForm } from "@/components/auth/sign-up-form" 2 - import { AuthBrand } from "@/components/auth/auth-brand" 1 + import { SignUpForm } from "@/components/auth/sign-up-form"; 2 + import { AuthBrand } from "@/components/auth/auth-brand"; 3 + import { currentUser } from "@clerk/nextjs/server"; 4 + import { redirect } from "next/navigation"; 3 5 4 - export default function SignUpPage() { 6 + export default async function SignUpPage() { 7 + const user = await currentUser(); 8 + if (user) { 9 + redirect("/app"); 10 + } 11 + 5 12 return ( 6 13 <div className="flex min-h-svh flex-col items-center justify-center from-blue-500 via-indigo-500 to-purple-500 gap-6 p-6 md:p-10"> 7 14 <div className="fixed -z-10 inset-0"> 8 15 <div className="absolute inset-0 bg-white dark:bg-black"> 9 - <div className="absolute inset-0" style={{ 10 - backgroundImage: `radial-gradient(circle at 1px 1px, rgba(0, 0, 0, 0.1) 1px, transparent 0)`, 11 - backgroundSize: '24px 24px' 12 - }} /> 13 - <div className="absolute inset-0 dark:block hidden" style={{ 14 - backgroundImage: `radial-gradient(circle at 1px 1px, rgba(255, 255, 255, 0.15) 1px, transparent 0)`, 15 - backgroundSize: '24px 24px' 16 - }} /> 16 + <div 17 + className="absolute inset-0" 18 + style={{ 19 + backgroundImage: 20 + "radial-gradient(circle at 1px 1px, rgba(0, 0, 0, 0.1) 1px, transparent 0)", 21 + backgroundSize: "24px 24px", 22 + }} 23 + /> 24 + <div 25 + className="absolute inset-0 hidden dark:block" 26 + style={{ 27 + backgroundImage: 28 + "radial-gradient(circle at 1px 1px, rgba(255, 255, 255, 0.15) 1px, transparent 0)", 29 + backgroundSize: "24px 24px", 30 + }} 31 + /> 17 32 </div> 18 33 </div> 19 34 <div className="flex w-full max-w-sm flex-col gap-6"> ··· 21 36 <SignUpForm /> 22 37 </div> 23 38 </div> 24 - ) 39 + ); 25 40 }
-2
app/layout.tsx
··· 5 5 import { Toaster } from "@/components/ui/sonner" 6 6 import { CalendarProvider } from "@/components/providers/calendar-context" 7 7 import { ClerkProvider } from '@clerk/nextjs' 8 - import { enUS } from '@clerk/localizations' 9 8 import { GeistSans } from "geist/font/sans" 10 9 import { ThemeProvider } from "@/components/providers/theme-provider" 11 10 import { PwaProvider } from "@/components/providers/pwa-provider" ··· 65 64 > 66 65 <ClerkProvider 67 66 publishableKey={clerkPublishableKey} 68 - localization={enUS} 69 67 signInFallbackRedirectUrl="/app" 70 68 signUpFallbackRedirectUrl="/app" 71 69 signInForceRedirectUrl="/"
+16 -1
components/app/analytics/import-export.tsx
··· 729 729 URL.revokeObjectURL(url); 730 730 }; 731 731 732 + const handleImportDialogOpenChange = (open: boolean) => { 733 + setImportDialogOpen(open); 734 + if (open) { 735 + setImportTab("file"); 736 + setSelectedFile(null); 737 + setImportUrl(""); 738 + setDebugInfo(""); 739 + setJsonImportEncrypted(false); 740 + setJsonImportPassword(""); 741 + } 742 + }; 743 + 732 744 return ( 733 745 <div className="w-full rounded-lg border p-4 space-y-6"> 734 746 <div> ··· 774 786 </div> 775 787 776 788 {} 777 - <Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}> 789 + <Dialog 790 + open={importDialogOpen} 791 + onOpenChange={handleImportDialogOpenChange} 792 + > 778 793 <DialogContent className="max-w-md max-h-[90vh] overflow-y-auto"> 779 794 <DialogHeader> 780 795 <DialogTitle>{t.importCalendar}</DialogTitle>
+16 -3
components/app/analytics/share-management.tsx
··· 16 16 import { format } from "date-fns"; 17 17 import { toast } from "sonner"; 18 18 import { fetchJson } from "@/lib/fetch-json"; 19 + import { 20 + Empty, 21 + EmptyDescription, 22 + EmptyHeader, 23 + EmptyMedia, 24 + EmptyTitle, 25 + } from "@/components/ui/empty"; 19 26 20 27 interface SharedEvent { 21 28 id: string; ··· 153 160 154 161 <div> 155 162 {sharedEvents.length === 0 ? ( 156 - <div className="text-center py-8 text-muted-foreground"> 157 - {t.noShares} 158 - </div> 163 + <Empty className="border-0 py-8"> 164 + <EmptyHeader> 165 + <EmptyMedia variant="icon"> 166 + <ExternalLink className="h-4 w-4" /> 167 + </EmptyMedia> 168 + <EmptyTitle>{t.shareManagementTitle}</EmptyTitle> 169 + <EmptyDescription>{t.noShares}</EmptyDescription> 170 + </EmptyHeader> 171 + </Empty> 159 172 ) : ( 160 173 <div className="space-y-4"> 161 174 {sharedEvents.map((share) => (
+3 -2
components/app/calendar.tsx
··· 50 50 import { Button } from "@/components/ui/button"; 51 51 import { Input } from "@/components/ui/input"; 52 52 import { cn } from "@/lib/utils"; 53 + import { APP_CONFIG } from "@/lib/config"; 53 54 import { toast } from "sonner"; 54 55 import { 55 56 DropdownMenu, ··· 879 880 <DropdownMenuItem 880 881 onClick={() => 881 882 window.open( 882 - "https://calendarstatus.xyehr.cn", 883 + APP_CONFIG.contact.statusPageUrl, 883 884 "_blank", 884 885 "noopener,noreferrer", 885 886 ) ··· 890 891 </DropdownMenuItem> 891 892 <DropdownMenuItem 892 893 onClick={() => { 893 - window.location.href = "mailto:evan.huang000@proton.me"; 894 + window.location.href = `mailto:${APP_CONFIG.contact.feedbackEmail}`; 894 895 }} 895 896 > 896 897 <MessageSquare className="mr-2 h-4 w-4" />
+1 -2
components/app/sidebar/countdown.tsx
··· 510 510 </Button> 511 511 <Button 512 512 variant="destructive" 513 - className="flex-1 text-destructive-foreground hover:text-destructive-foreground" 513 + className="flex-1" 514 514 onClick={() => deleteCountdown(selectedCountdown.id)} 515 515 > 516 516 <Trash2 className="mr-2 h-4 w-4" /> ··· 714 714 {selectedCountdown && ( 715 715 <Button 716 716 variant="destructive" 717 - className="text-destructive-foreground hover:text-destructive-foreground" 718 717 onClick={() => deleteCountdown(selectedCountdown.id)} 719 718 > 720 719 {t.countdownDelete}
+36 -52
components/auth/login-form.tsx
··· 15 15 import { useState, useRef } from "react"; 16 16 import { useRouter } from "next/navigation"; 17 17 import { Turnstile } from "@marsidev/react-turnstile"; 18 + import { 19 + getEnabledOAuthProviderKeys, 20 + OAUTH_PROVIDER_CONFIG, 21 + type OAuthStrategy, 22 + } from "@/lib/clerk-oauth"; 18 23 19 24 export function LoginForm({ 20 25 className, ··· 111 116 } 112 117 }; 113 118 114 - const handleOAuthLogin = (strategy: "oauth_google" | "oauth_microsoft" | "oauth_github") => { 119 + const handleOAuthLogin = (strategy: OAuthStrategy) => { 115 120 const siteKey = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY; 116 121 if (siteKey && !isCaptchaCompleted) { 117 122 setError("Please complete the CAPTCHA verification."); ··· 126 131 127 132 const siteKey = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY; 128 133 const hasCaptcha = Boolean(siteKey); 134 + const enabledOAuthProviders = getEnabledOAuthProviderKeys(); 135 + const hasOAuthProviders = enabledOAuthProviders.length > 0; 129 136 130 137 return ( 131 138 <div className={cn("flex flex-col gap-6", className)} {...props}> ··· 133 140 <CardHeader className="text-center"> 134 141 <CardTitle className="text-xl">Welcome back</CardTitle> 135 142 <CardDescription> 136 - Login with your Microsoft, Google or GitHub account 143 + {hasOAuthProviders 144 + ? "Login with your OAuth provider account" 145 + : "Login with your email and password"} 137 146 </CardDescription> 138 147 </CardHeader> 139 148 <CardContent> 140 149 <form onSubmit={handleEmailLogin}> 141 150 <div className="grid gap-6"> 142 - <div className="flex flex-col gap-4"> 143 - <Button 144 - variant="outline" 145 - className="w-full" 146 - type="button" 147 - onClick={() => handleOAuthLogin("oauth_microsoft")} 148 - > 149 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23" width="20" height="20"> 150 - <path fill="#f25022" d="M1 1h10v10H1z"/> 151 - <path fill="#00a4ef" d="M12 1h10v10H12z"/> 152 - <path fill="#7fba00" d="M1 12h10v10H1z"/> 153 - <path fill="#ffb900" d="M12 12h10v10H12z"/> 154 - </svg> 155 - <span className="ml-2">Login with Microsoft</span> 156 - </Button> 157 - <Button 158 - variant="outline" 159 - className="w-full" 160 - type="button" 161 - onClick={() => handleOAuthLogin("oauth_google")} 162 - > 163 - <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"> 164 - <path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/> 165 - <path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/> 166 - <path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/> 167 - <path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/> 168 - <path d="M1 1h22v22H1z" fill="none"/> 169 - </svg> 170 - <span className="ml-2">Login with Google</span> 171 - </Button> 172 - <Button 173 - variant="outline" 174 - className="w-full" 175 - type="button" 176 - onClick={() => handleOAuthLogin("oauth_github")} 177 - > 178 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="20" height="20"> 179 - <path 180 - d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" 181 - fill="currentColor" 182 - /> 183 - </svg> 184 - <span className="ml-2">Login with GitHub</span> 185 - </Button> 186 - </div> 187 - <div className="relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t after:border-border"> 188 - <span className="relative z-10 bg-background px-2 text-muted-foreground"> 189 - Or continue with 190 - </span> 191 - </div> 151 + {hasOAuthProviders && ( 152 + <> 153 + <div className="flex flex-col gap-4"> 154 + {enabledOAuthProviders.map((providerKey) => { 155 + const provider = OAUTH_PROVIDER_CONFIG[providerKey]; 156 + return ( 157 + <Button 158 + key={provider.strategy} 159 + variant="outline" 160 + className="w-full" 161 + type="button" 162 + onClick={() => handleOAuthLogin(provider.strategy)} 163 + > 164 + <span>Login with {provider.label}</span> 165 + </Button> 166 + ); 167 + })} 168 + </div> 169 + <div className="relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t after:border-border"> 170 + <span className="relative z-10 bg-background px-2 text-muted-foreground"> 171 + Or continue with 172 + </span> 173 + </div> 174 + </> 175 + )} 192 176 <div className="grid gap-6"> 193 177 <div className="grid gap-2"> 194 178 <Label htmlFor="email">Email</Label>
+33 -80
components/auth/sign-up-form.tsx
··· 9 9 import { useRouter } from "next/navigation"; 10 10 import { useState, useRef } from "react"; 11 11 import { Turnstile } from "@marsidev/react-turnstile"; 12 + import { 13 + getEnabledOAuthProviderKeys, 14 + OAUTH_PROVIDER_CONFIG, 15 + type OAuthStrategy, 16 + } from "@/lib/clerk-oauth"; 12 17 13 18 export function SignUpForm({ 14 19 className, ··· 127 132 ); 128 133 }; 129 134 130 - const handleOAuthSignUp = ( 131 - strategy: "oauth_google" | "oauth_microsoft" | "oauth_github", 132 - ) => { 135 + const handleOAuthSignUp = (strategy: OAuthStrategy) => { 133 136 const siteKey = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY; 134 137 if (siteKey && !isCaptchaCompleted) { 135 138 setError("Please complete the CAPTCHA verification."); ··· 290 293 291 294 const siteKey = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY; 292 295 const hasCaptcha = Boolean(siteKey); 296 + const enabledOAuthProviders = getEnabledOAuthProviderKeys(); 297 + const hasOAuthProviders = enabledOAuthProviders.length > 0; 293 298 294 299 return ( 295 300 <div className={cn("flex flex-col gap-6", className)} {...props}> ··· 300 305 <CardContent> 301 306 <form onSubmit={handleSubmit}> 302 307 <div className="grid gap-6"> 303 - <div className="flex flex-col gap-4"> 304 - <Button 305 - variant="outline" 306 - className="w-full" 307 - type="button" 308 - onClick={() => handleOAuthSignUp("oauth_microsoft")} 309 - > 310 - <svg 311 - xmlns="http://www.w3.org/2000/svg" 312 - viewBox="0 0 23 23" 313 - width="20" 314 - height="20" 315 - > 316 - <path fill="#f25022" d="M1 1h10v10H1z" /> 317 - <path fill="#00a4ef" d="M12 1h10v10H12z" /> 318 - <path fill="#7fba00" d="M1 12h10v10H1z" /> 319 - <path fill="#ffb900" d="M12 12h10v10H12z" /> 320 - </svg> 321 - <span className="ml-2">Continue with Microsoft</span> 322 - </Button> 323 - <Button 324 - variant="outline" 325 - className="w-full" 326 - type="button" 327 - onClick={() => handleOAuthSignUp("oauth_google")} 328 - > 329 - <svg 330 - xmlns="http://www.w3.org/2000/svg" 331 - height="24" 332 - viewBox="0 0 24 24" 333 - width="24" 334 - > 335 - <path 336 - d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" 337 - fill="#4285F4" 338 - /> 339 - <path 340 - d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" 341 - fill="#34A853" 342 - /> 343 - <path 344 - d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" 345 - fill="#FBBC05" 346 - /> 347 - <path 348 - d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" 349 - fill="#EA4335" 350 - /> 351 - <path d="M1 1h22v22H1z" fill="none" /> 352 - </svg> 353 - <span className="ml-2">Continue with Google</span> 354 - </Button> 355 - <Button 356 - variant="outline" 357 - className="w-full" 358 - type="button" 359 - onClick={() => handleOAuthSignUp("oauth_github")} 360 - > 361 - <svg 362 - xmlns="http://www.w3.org/2000/svg" 363 - viewBox="0 0 24 24" 364 - width="20" 365 - height="20" 366 - > 367 - <path 368 - d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" 369 - fill="currentColor" 370 - /> 371 - </svg> 372 - <span className="ml-2">Continue with GitHub</span> 373 - </Button> 374 - </div> 308 + {hasOAuthProviders && ( 309 + <> 310 + <div className="flex flex-col gap-4"> 311 + {enabledOAuthProviders.map((providerKey) => { 312 + const provider = OAUTH_PROVIDER_CONFIG[providerKey]; 313 + return ( 314 + <Button 315 + key={provider.strategy} 316 + variant="outline" 317 + className="w-full" 318 + type="button" 319 + onClick={() => handleOAuthSignUp(provider.strategy)} 320 + > 321 + <span>Continue with {provider.label}</span> 322 + </Button> 323 + ); 324 + })} 325 + </div> 375 326 376 - <div className="relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t after:border-border"> 377 - <span className="relative z-10 bg-background px-2 text-muted-foreground"> 378 - Or continue with 379 - </span> 380 - </div> 327 + <div className="relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t after:border-border"> 328 + <span className="relative z-10 bg-background px-2 text-muted-foreground"> 329 + Or continue with 330 + </span> 331 + </div> 332 + </> 333 + )} 381 334 382 335 <div className="grid gap-6"> 383 336 <div className="grid grid-cols-2 gap-4">
+6 -30
components/landing/footer-section.tsx
··· 2 2 3 3 import { ArrowUpRight } from "lucide-react"; 4 4 import { AnimatedWave } from "./animated-wave"; 5 - 6 - const footerSections = [ 7 - { 8 - title: "Product", 9 - links: [ 10 - { label: "Overview", href: "#features" }, 11 - { label: "Privacy", href: "/privacy" }, 12 - { label: "Terms", href: "/terms" }, 13 - ], 14 - }, 15 - { 16 - title: "Resources", 17 - links: [ 18 - { label: "Documentation", href: "https://docs.xyehr.cn/docs/one-calendar" }, 19 - { label: "Status", href: "https://calendarstatus.xyehr.cn" }, 20 - { label: "Support", href: "mailto:evan.huang000@proton.me" }, 21 - ], 22 - }, 23 - { 24 - title: "Connect", 25 - links: [ 26 - { label: "Contact", href: "mailto:evan.huang000@proton.me" }, 27 - { label: "Bluesky", href: "https://bsky.app/profile/calendar.xyehr.cn" }, 28 - { label: "Tangled", href: "https://tangled.org/e.xyehr.cn/One-Calendar" }, 29 - { label: "GitHub", href: "https://github.com/EvanTechDev/One-Calendar" }, 30 - ], 31 - }, 32 - ]; 5 + import { APP_CONFIG } from "@/lib/config"; 33 6 34 7 export function FooterSection() { 35 8 const currentYear = new Date().getFullYear(); ··· 53 26 </p> 54 27 </div> 55 28 56 - {footerSections.map((section) => ( 29 + {APP_CONFIG.landing.footerSections.map((section) => ( 57 30 <div key={section.title}> 58 31 <h3 className="text-sm font-medium mb-6">{section.title}</h3> 59 32 <ul className="space-y-4"> ··· 79 52 {currentYear} One Calendar. All rights reserved. 80 53 </p> 81 54 82 - <a className="text-sm text-muted-foreground hover:text-foreground" href="https://calendarstatus.xyehr.cn"> 55 + <a 56 + className="text-sm text-muted-foreground hover:text-foreground" 57 + href={APP_CONFIG.contact.statusPageUrl} 58 + > 83 59 Status page available 84 60 </a> 85 61 </div>
+50 -23
components/landing/navigation.tsx
··· 4 4 import { useState, useEffect } from "react"; 5 5 import { Button } from "@/components/ui/button"; 6 6 import { Menu, X } from "lucide-react"; 7 + import { useUser } from "@clerk/nextjs"; 7 8 8 9 const navLinks = [ 9 10 { name: "Features", href: "#features" }, ··· 13 14 ]; 14 15 15 16 export function Navigation() { 17 + const { isSignedIn } = useUser(); 16 18 const [isScrolled, setIsScrolled] = useState(false); 17 19 const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); 18 20 ··· 62 64 </div> 63 65 64 66 <div className="hidden md:flex items-center gap-4"> 65 - <Link href="/sign-in" className={`text-foreground/70 hover:text-foreground transition-all duration-500 ${isScrolled ? "text-xs" : "text-sm"}`}> 66 - Sign in 67 - </Link> 68 - <Button 69 - size="sm" 70 - asChild 71 - className={`bg-foreground hover:bg-foreground/90 text-background rounded-full transition-all duration-500 ${isScrolled ? "px-4 h-8 text-xs" : "px-6"}`} 72 - > 73 - <Link href="/sign-up">Start free</Link> 74 - </Button> 67 + {isSignedIn ? ( 68 + <Button 69 + size="sm" 70 + asChild 71 + className={`bg-foreground hover:bg-foreground/90 text-background rounded-full transition-all duration-500 ${isScrolled ? "px-4 h-8 text-xs" : "px-6"}`} 72 + > 73 + <Link href="/app">Calendar App</Link> 74 + </Button> 75 + ) : ( 76 + <> 77 + <Link href="/sign-in" className={`text-foreground/70 hover:text-foreground transition-all duration-500 ${isScrolled ? "text-xs" : "text-sm"}`}> 78 + Sign in 79 + </Link> 80 + <Button 81 + size="sm" 82 + asChild 83 + className={`bg-foreground hover:bg-foreground/90 text-background rounded-full transition-all duration-500 ${isScrolled ? "px-4 h-8 text-xs" : "px-6"}`} 84 + > 85 + <Link href="/sign-up">Start free</Link> 86 + </Button> 87 + </> 88 + )} 75 89 </div> 76 90 77 91 <button ··· 123 137 }`} 124 138 style={{ transitionDelay: isMobileMenuOpen ? "300ms" : "0ms" }} 125 139 > 126 - <Button 127 - variant="outline" 128 - asChild 129 - className="flex-1 rounded-full h-14 text-base" 130 - > 131 - <Link href="/sign-in" onClick={() => setIsMobileMenuOpen(false)}>Sign in</Link> 132 - </Button> 133 - <Button 134 - asChild 135 - className="flex-1 bg-foreground text-background rounded-full h-14 text-base" 136 - > 137 - <Link href="/sign-up" onClick={() => setIsMobileMenuOpen(false)}>Start free</Link> 138 - </Button> 140 + {isSignedIn ? ( 141 + <Button 142 + asChild 143 + className="flex-1 bg-foreground text-background rounded-full h-14 text-base" 144 + > 145 + <Link href="/app" onClick={() => setIsMobileMenuOpen(false)}> 146 + Calendar App 147 + </Link> 148 + </Button> 149 + ) : ( 150 + <> 151 + <Button 152 + variant="outline" 153 + asChild 154 + className="flex-1 rounded-full h-14 text-base" 155 + > 156 + <Link href="/sign-in" onClick={() => setIsMobileMenuOpen(false)}>Sign in</Link> 157 + </Button> 158 + <Button 159 + asChild 160 + className="flex-1 bg-foreground text-background rounded-full h-14 text-base" 161 + > 162 + <Link href="/sign-up" onClick={() => setIsMobileMenuOpen(false)}>Start free</Link> 163 + </Button> 164 + </> 165 + )} 139 166 </div> 140 167 </div> 141 168 </div>
+29
lib/clerk-oauth.ts
··· 1 + import { APP_CONFIG } from "@/lib/config"; 2 + 3 + export const OAUTH_PROVIDER_CONFIG = { 4 + microsoft: { 5 + strategy: "oauth_microsoft", 6 + label: "Microsoft", 7 + }, 8 + google: { 9 + strategy: "oauth_google", 10 + label: "Google", 11 + }, 12 + github: { 13 + strategy: "oauth_github", 14 + label: "GitHub", 15 + }, 16 + } as const; 17 + 18 + export type OAuthProviderKey = keyof typeof OAUTH_PROVIDER_CONFIG; 19 + export type OAuthStrategy = 20 + (typeof OAUTH_PROVIDER_CONFIG)[OAuthProviderKey]["strategy"]; 21 + 22 + export function getEnabledOAuthProviderKeys(): OAuthProviderKey[] { 23 + const parsed = APP_CONFIG.auth.enabledOAuthProviders 24 + .map((provider) => provider.trim().toLowerCase()) 25 + .filter((provider): provider is OAuthProviderKey => 26 + provider in OAUTH_PROVIDER_CONFIG, 27 + ); 28 + return [...new Set(parsed)]; 29 + }
+51
lib/config.ts
··· 1 + type ConfigurableOAuthProvider = "microsoft" | "google" | "github"; 2 + 3 + const FEEDBACK_EMAIL = "evan.huang000@proton.me"; 4 + const STATUS_PAGE_URL = "https://calendarstatus.xyehr.cn"; 5 + const GITHUB_URL = "https://github.com/EvanTechDev/One-Calendar"; 6 + 7 + export const APP_CONFIG = { 8 + contact: { 9 + feedbackEmail: FEEDBACK_EMAIL, 10 + statusPageUrl: STATUS_PAGE_URL, 11 + }, 12 + auth: { 13 + enabledOAuthProviders: [ 14 + "microsoft", 15 + "google", 16 + "github", 17 + ] as ConfigurableOAuthProvider[], 18 + }, 19 + landing: { 20 + footerSections: [ 21 + { 22 + title: "Product", 23 + links: [ 24 + { label: "Overview", href: "#features" }, 25 + { label: "Privacy", href: "/privacy" }, 26 + { label: "Terms", href: "/terms" }, 27 + ], 28 + }, 29 + { 30 + title: "Resources", 31 + links: [ 32 + { 33 + label: "Documentation", 34 + href: "https://docs.xyehr.cn/docs/one-calendar", 35 + }, 36 + { label: "Status", href: STATUS_PAGE_URL }, 37 + { label: "Support", href: `mailto:${FEEDBACK_EMAIL}` }, 38 + ], 39 + }, 40 + { 41 + title: "Connect", 42 + links: [ 43 + { label: "Contact", href: `mailto:${FEEDBACK_EMAIL}` }, 44 + { label: "Bluesky", href: "https://bsky.app/profile/calendar.xyehr.cn" }, 45 + { label: "Tangled", href: "https://tangled.org/e.xyehr.cn/One-Calendar" }, 46 + { label: "GitHub", href: GITHUB_URL }, 47 + ], 48 + }, 49 + ], 50 + }, 51 + } as const;
-5
package.json
··· 11 11 "generate:oauth-metadata": "bun lib/gen-oauth-metadata.mjs" 12 12 }, 13 13 "dependencies": { 14 - "@clerk/localizations": "4.3.0", 15 14 "@clerk/nextjs": "^7.0.0", 16 15 "@marsidev/react-turnstile": "latest", 17 16 "@radix-ui/react-avatar": "^1.1.2", 18 - "@radix-ui/react-collapsible": "^1.1.2", 19 - "@radix-ui/react-context-menu": "^2.2.4", 20 17 "@radix-ui/react-dialog": "latest", 21 - "@radix-ui/react-separator": "^1.1.1", 22 18 "@radix-ui/react-slot": "^1.2.0", 23 - "@radix-ui/react-tabs": "^1.1.2", 24 19 "@radix-ui/react-toast": "latest", 25 20 "class-variance-authority": "^0.7.1", 26 21 "clsx": "^2.1.1",