page.tsx 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. 'use client';
  2. import { useState, useEffect, Suspense } from 'react';
  3. import { useRouter, useSearchParams } from 'next/navigation';
  4. import { useAuth } from '@/lib/auth-context';
  5. import { invitationsApi } from '@/lib/api';
  6. import { Button } from '@/components/ui/button';
  7. function LoginForm() {
  8. const router = useRouter();
  9. const searchParams = useSearchParams();
  10. const inviteToken = searchParams.get('invite_token');
  11. const { login, acceptedProjects, clearAcceptedProjects } = useAuth();
  12. const [email, setEmail] = useState('');
  13. const [password, setPassword] = useState('');
  14. const [error, setError] = useState('');
  15. const [loading, setLoading] = useState(false);
  16. const [justJoined, setJustJoined] = useState(false);
  17. // Pre-fill email if coming from invite link
  18. useEffect(() => {
  19. if (inviteToken) {
  20. invitationsApi.verify(inviteToken)
  21. .then(({ invitation }) => {
  22. if (!email) setEmail(invitation.email);
  23. })
  24. .catch(() => {});
  25. }
  26. }, [inviteToken]); // eslint-disable-line react-hooks/exhaustive-deps
  27. useEffect(() => {
  28. if (acceptedProjects.length > 0 && !justJoined) {
  29. setJustJoined(true);
  30. }
  31. }, [acceptedProjects, justJoined]);
  32. const handleSubmit = async (e: React.FormEvent) => {
  33. e.preventDefault();
  34. setError('');
  35. setLoading(true);
  36. try {
  37. await login(email, password);
  38. if (inviteToken) {
  39. router.push(`/invite/${inviteToken}`);
  40. } else {
  41. router.push('/projects');
  42. }
  43. } catch (err: unknown) {
  44. setError(err instanceof Error ? err.message : 'Invalid email or password');
  45. } finally {
  46. setLoading(false);
  47. }
  48. };
  49. const handleGoToProject = (projectId: string) => {
  50. clearAcceptedProjects();
  51. router.push(`/projects/${projectId}`);
  52. };
  53. return (
  54. <div className="min-h-screen flex items-center justify-center relative overflow-hidden"
  55. style={{ background: 'var(--bg)' }}>
  56. {/* Ambient background blobs */}
  57. <div className="absolute inset-0 overflow-hidden pointer-events-none">
  58. <div className="absolute -top-40 -left-40 w-[600px] h-[600px] rounded-full opacity-[0.07]"
  59. style={{ background: 'radial-gradient(circle, #6366F1 0%, transparent 70%)' }} />
  60. <div className="absolute top-1/2 -right-40 w-[500px] h-[500px] rounded-full opacity-[0.05]"
  61. style={{ background: 'radial-gradient(circle, #818CF8 0%, transparent 70%)' }} />
  62. <div className="absolute -bottom-40 left-1/3 w-[400px] h-[400px] rounded-full opacity-[0.04]"
  63. style={{ background: 'radial-gradient(circle, #22C55E 0%, transparent 70%)' }} />
  64. {/* Grid pattern */}
  65. <div className="absolute inset-0 opacity-[0.025]"
  66. style={{
  67. backgroundImage: 'linear-gradient(rgba(255,255,255,0.5) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.5) 1px, transparent 1px)',
  68. backgroundSize: '48px 48px',
  69. }} />
  70. </div>
  71. {/* Logo */}
  72. <div className="absolute top-8 left-1/2 -translate-x-1/2 flex items-center gap-2.5 animate-fade-in">
  73. <div className="w-9 h-9 rounded-xl flex items-center justify-center"
  74. style={{ background: '#6366F1', boxShadow: '0 0 24px rgba(99,102,241,0.5)' }}>
  75. <svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  76. <path strokeLinecap="round" strokeLinejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
  77. <path strokeLinecap="round" strokeLinejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
  78. </svg>
  79. </div>
  80. <span className="text-lg font-semibold tracking-tight" style={{ color: 'var(--text)' }}>VidReview</span>
  81. </div>
  82. {/* Card */}
  83. <div className="relative w-full max-w-[400px] mx-6 animate-scale-in">
  84. <div className="rounded-2xl p-8"
  85. style={{
  86. background: 'rgba(22,24,34,0.90)',
  87. backdropFilter: 'blur(20px)',
  88. border: '1px solid rgba(255,255,255,0.08)',
  89. boxShadow: 'var(--shadow-modal)',
  90. }}>
  91. {/* Accepted projects notification */}
  92. {justJoined && acceptedProjects.length > 0 && (
  93. <div className="mb-5 rounded-lg px-4 py-3 text-sm animate-scale-in"
  94. style={{ background: 'rgba(34,197,94,0.12)', border: '1px solid rgba(34,197,94,0.25)', color: '#86EFAC' }}>
  95. <p className="font-medium mb-2">You're now a member of:</p>
  96. {acceptedProjects.map(p => (
  97. <button
  98. key={p.projectId}
  99. onClick={() => handleGoToProject(p.projectId)}
  100. className="block text-left hover:underline"
  101. style={{ color: '#86EFAC' }}
  102. >
  103. → {p.projectName}
  104. </button>
  105. ))}
  106. </div>
  107. )}
  108. {/* Heading */}
  109. <div className="mb-7">
  110. <h1 className="text-xl font-semibold tracking-tight" style={{ color: 'var(--text)' }}>
  111. Welcome back
  112. </h1>
  113. <p className="text-sm mt-1" style={{ color: 'var(--text-muted)' }}>
  114. Sign in to your workspace
  115. </p>
  116. </div>
  117. {/* Error alert */}
  118. {error && (
  119. <div className="mb-5 rounded-lg px-4 py-3 text-sm flex items-start gap-2.5 animate-scale-in"
  120. style={{ background: 'rgba(239,68,68,0.12)', border: '1px solid rgba(239,68,68,0.25)', color: '#FCA5A5' }}>
  121. <svg className="w-4 h-4 mt-0.5 shrink-0 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  122. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
  123. d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
  124. </svg>
  125. {error}
  126. </div>
  127. )}
  128. <form onSubmit={handleSubmit} className="space-y-4">
  129. <div>
  130. <label htmlFor="email" className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text)' }}>
  131. Email address
  132. </label>
  133. <input
  134. id="email"
  135. type="email"
  136. className="input"
  137. value={email}
  138. onChange={e => setEmail(e.target.value)}
  139. placeholder="you@company.com"
  140. required
  141. autoComplete="email"
  142. autoFocus
  143. />
  144. </div>
  145. <div>
  146. <div className="flex items-center justify-between mb-1.5">
  147. <label htmlFor="password" className="text-sm font-medium" style={{ color: 'var(--text)' }}>
  148. Password
  149. </label>
  150. <a href="#"
  151. className="text-xs transition-colors hover:underline"
  152. style={{ color: '#818CF8' }}>
  153. Forgot password?
  154. </a>
  155. </div>
  156. <input
  157. id="password"
  158. type="password"
  159. className="input"
  160. value={password}
  161. onChange={e => setPassword(e.target.value)}
  162. placeholder="••••••••"
  163. required
  164. autoComplete="current-password"
  165. />
  166. </div>
  167. <Button
  168. type="submit"
  169. loading={loading}
  170. className="btn btn-primary btn-lg w-full mt-2"
  171. >
  172. {loading ? 'Signing in…' : 'Sign in'}
  173. </Button>
  174. </form>
  175. {/* Divider */}
  176. <div className="flex items-center gap-3 my-6">
  177. <hr className="flex-1" style={{ borderColor: 'rgba(255,255,255,0.07)' }} />
  178. <span className="text-xs px-2" style={{ color: 'var(--text-subtle)' }}>or</span>
  179. <hr className="flex-1" style={{ borderColor: 'rgba(255,255,255,0.07)' }} />
  180. </div>
  181. {/* Register link */}
  182. <p className="text-center text-sm" style={{ color: 'var(--text-muted)' }}>
  183. No account yet?{' '}
  184. <a
  185. href={inviteToken ? `/register?invite_token=${inviteToken}` : '/register'}
  186. className="font-medium transition-colors hover:underline"
  187. style={{ color: '#818CF8' }}
  188. >
  189. Create workspace
  190. </a>
  191. </p>
  192. </div>
  193. {/* Demo hint */}
  194. <p className="text-center text-xs mt-5" style={{ color: 'var(--text-subtle)' }}>
  195. Demo: <span className="font-mono" style={{ color: 'var(--text-muted)' }}>admin@vidreview.local</span>
  196. {' / '}
  197. <span className="font-mono" style={{ color: 'var(--text-muted)' }}>admin123</span>
  198. </p>
  199. </div>
  200. {/* Footer */}
  201. <p className="absolute bottom-6 text-center text-xs" style={{ color: 'var(--text-subtle)' }}>
  202. © 2026 VidReview — Video collaboration for creative teams
  203. </p>
  204. </div>
  205. );
  206. }
  207. export default function LoginPage() {
  208. return (
  209. <Suspense fallback={
  210. <div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
  211. <div className="flex items-center gap-3" style={{ color: 'var(--text-muted)' }}>
  212. <div className="w-5 h-5 rounded-full animate-spin"
  213. style={{ borderColor: '#6366F1', borderTopColor: 'transparent' }} />
  214. <span className="text-sm">Loading…</span>
  215. </div>
  216. </div>
  217. }>
  218. <LoginForm />
  219. </Suspense>
  220. );
  221. }