+ {projects.map((project) => {
+ const owner = !isLocalProject(project) ? project.owner : null
- {/* Info */}
-
-
{project.name}
-
- {!isLocalProject(project) && (
-
-
-
-
{project.views}
+ return (
+
onProjectClick(project.id)}
+ className="group text-left cursor-pointer"
+ >
+ {/* Thumbnail card */}
+
+ {!isLocalProject(project) && project.thumbnail_url ? (
+

+ ) : (
+
+ No preview
-
-
- )}
+ )}
+ {isLocalProject(project) && (
+
+ {isAuthenticated && onSaveToCloud ? (
+
+ ) : (
+
+ Local
+
+ )}
+
+ )}
+ {canEdit && !isLocalProject(project) && (
+
+ {onViewClick && (
+
+ )}
+
+
+ )}
+
- {isLocalProject(project) && (
-
- {new Date(project.updated_at).toLocaleDateString()}
+ {/* Info row below the card */}
+
+ {/* Avatar */}
+ {showOwner && owner ? (
+
e.stopPropagation()}
+ className="shrink-0"
+ >
+ {owner.image ? (
+

+ ) : (
+
+ {owner.name?.[0]?.toUpperCase() || '?'}
+
+ )}
+
+ ) : null}
+
+ {/* Name + stats */}
+
+
{project.name}
+
+ {showOwner && owner && (
+ <>
+
e.stopPropagation()}
+ className="hover:text-foreground transition-colors truncate"
+ >
+ {owner.username || owner.name}
+
+ {!isLocalProject(project) &&
·}
+ >
+ )}
+ {!isLocalProject(project) && (
+ <>
+
+
+ {project.views}
+
+
·
+
+ >
+ )}
+ {isLocalProject(project) && (
+
{new Date(project.updated_at).toLocaleDateString()}
+ )}
+
- )}
+
-
- ))}
+ )
+ })}
{/* Settings Dialog */}
diff --git a/apps/editor/features/community/components/public-profile-page.tsx b/apps/editor/features/community/components/public-profile-page.tsx
new file mode 100644
index 00000000..dd3c474a
--- /dev/null
+++ b/apps/editor/features/community/components/public-profile-page.tsx
@@ -0,0 +1,137 @@
+'use client'
+
+import Image from 'next/image'
+import Link from 'next/link'
+import { useRouter } from 'next/navigation'
+import { ProjectGrid } from './project-grid'
+import { HubFooter } from './hub-footer'
+import type { Project } from '../lib/projects/types'
+
+function GitHubIcon({ className }: { className?: string }) {
+ return (
+
+ )
+}
+
+function XIcon({ className }: { className?: string }) {
+ return (
+
+ )
+}
+
+interface PublicProfilePageProps {
+ profile: {
+ id: string
+ name: string
+ image: string | null
+ username: string
+ githubUrl: string | null
+ xUrl: string | null
+ }
+ projects: Project[]
+}
+
+export function PublicProfilePage({ profile, projects }: PublicProfilePageProps) {
+ const router = useRouter()
+
+ return (
+
+ {/* Header — same layout as the Hub */}
+
+
+
+ {/* Profile Header */}
+
+ {profile.image ? (
+
+ ) : (
+
+ {profile.name[0]?.toUpperCase() || '?'}
+
+ )}
+
+
{profile.name}
+
@{profile.username}
+ {(profile.githubUrl || profile.xUrl) && (
+
+ {profile.githubUrl && (
+
+
+
+ )}
+ {profile.xUrl && (
+
+
+
+ )}
+
+ )}
+
+
+
+ {/* Projects */}
+
+ Public Projects
+ {projects.length > 0 ? (
+ router.push(`/viewer/${id}`)}
+ showOwner={false}
+ />
+ ) : (
+
+ No public projects yet
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/apps/editor/features/community/components/settings-page.tsx b/apps/editor/features/community/components/settings-page.tsx
new file mode 100644
index 00000000..e746ed51
--- /dev/null
+++ b/apps/editor/features/community/components/settings-page.tsx
@@ -0,0 +1,242 @@
+'use client'
+
+import Image from 'next/image'
+import Link from 'next/link'
+import { ArrowLeft } from 'lucide-react'
+import { useState } from 'react'
+import { updateUsername, updateProfile } from '../lib/auth/actions'
+
+interface SettingsPageProps {
+ user: {
+ id: string
+ name?: string | null
+ email?: string | null
+ image?: string | null
+ }
+ currentUsername: string | null
+ currentGithubUrl: string | null
+ currentXUrl: string | null
+}
+
+export function SettingsPage({
+ user,
+ currentUsername,
+ currentGithubUrl,
+ currentXUrl,
+}: SettingsPageProps) {
+ const [username, setUsername] = useState(currentUsername ?? '')
+ const [githubUrl, setGithubUrl] = useState(currentGithubUrl ?? '')
+ const [xUrl, setXUrl] = useState(currentXUrl ?? '')
+ const [isSavingUsername, setIsSavingUsername] = useState(false)
+ const [isSavingSocial, setIsSavingSocial] = useState(false)
+ const [usernameMessage, setUsernameMessage] = useState<{
+ type: 'success' | 'error'
+ text: string
+ } | null>(null)
+ const [socialMessage, setSocialMessage] = useState<{
+ type: 'success' | 'error'
+ text: string
+ } | null>(null)
+
+ const handleSaveUsername = async (e: React.FormEvent) => {
+ e.preventDefault()
+ setUsernameMessage(null)
+ setIsSavingUsername(true)
+
+ const result = await updateUsername(username)
+
+ if (result.success) {
+ setUsernameMessage({ type: 'success', text: 'Username updated successfully' })
+ } else {
+ setUsernameMessage({ type: 'error', text: result.error ?? 'Failed to update username' })
+ }
+
+ setIsSavingUsername(false)
+ }
+
+ const handleSaveSocial = async (e: React.FormEvent) => {
+ e.preventDefault()
+ setSocialMessage(null)
+ setIsSavingSocial(true)
+
+ const result = await updateProfile({
+ githubUrl: githubUrl.trim() || null,
+ xUrl: xUrl.trim() || null,
+ })
+
+ if (result.success) {
+ setSocialMessage({ type: 'success', text: 'Social links updated successfully' })
+ } else {
+ setSocialMessage({ type: 'error', text: result.error ?? 'Failed to update social links' })
+ }
+
+ setIsSavingSocial(false)
+ }
+
+ const usernameChanged = username.trim() !== (currentUsername ?? '')
+ const socialChanged =
+ (githubUrl.trim() || '') !== (currentGithubUrl ?? '') ||
+ (xUrl.trim() || '') !== (currentXUrl ?? '')
+
+ return (
+
+
+
+
+
+
+
Back
+
+
Settings
+
+
+
+
+
+ {/* Profile Section */}
+
+ Profile
+
+
+ {user.image ? (
+
+ ) : (
+
+ {user.name?.[0]?.toUpperCase() || user.email?.[0]?.toUpperCase() || 'U'}
+
+ )}
+
+ {user.name &&
{user.name}
}
+ {user.email && (
+
{user.email}
+ )}
+
+
+
+
+
+
+
+ {/* Social Links Section */}
+
+
+
+ )
+}
diff --git a/apps/editor/features/community/components/sign-in-dialog.tsx b/apps/editor/features/community/components/sign-in-dialog.tsx
index efb6bc05..90616c4b 100644
--- a/apps/editor/features/community/components/sign-in-dialog.tsx
+++ b/apps/editor/features/community/components/sign-in-dialog.tsx
@@ -10,22 +10,59 @@ interface SignInDialogProps {
onOpenChange: (open: boolean) => void
}
+function GoogleIcon({ className }: { className?: string }) {
+ return (
+
+ )
+}
+
/**
- * SignInDialog - Magic link authentication dialog
+ * SignInDialog - Authentication dialog with Google OAuth and magic link
*/
export function SignInDialog({ open, onOpenChange }: SignInDialogProps) {
const [email, setEmail] = useState('')
const [isLoading, setIsLoading] = useState(false)
+ const [isGoogleLoading, setIsGoogleLoading] = useState(false)
const [error, setError] = useState
(null)
const [success, setSuccess] = useState(false)
+ const handleGoogleSignIn = async () => {
+ setError(null)
+ setIsGoogleLoading(true)
+ try {
+ await authClient.signIn.social({
+ provider: 'google',
+ callbackURL: window.location.origin,
+ })
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to sign in with Google')
+ setIsGoogleLoading(false)
+ }
+ }
+
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setIsLoading(true)
try {
- // Use better-auth's magic link sign in
const result = await authClient.signIn.magicLink({
email,
callbackURL: window.location.origin,
@@ -45,9 +82,8 @@ export function SignInDialog({ open, onOpenChange }: SignInDialogProps) {
}
const handleClose = () => {
- if (!isLoading) {
+ if (!isLoading && !isGoogleLoading) {
onOpenChange(false)
- // Reset state after a short delay to avoid flash
setTimeout(() => {
setEmail('')
setError(null)
@@ -56,6 +92,8 @@ export function SignInDialog({ open, onOpenChange }: SignInDialogProps) {
}
}
+ const anyLoading = isLoading || isGoogleLoading
+
return (