mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-26 18:26:38 +00:00
add supabase
This commit is contained in:
@@ -111,3 +111,10 @@ HONEYCOMB_DATASET=
|
||||
|
||||
# If you're using Sentry for error tracking, include your Sentry Auth token here to upload sourcemaps
|
||||
SENTRY_AUTH_TOKEN=
|
||||
|
||||
# Supabase Configuration
|
||||
# Get your Supabase URL and anon key from your project settings -> API
|
||||
# https://app.supabase.com/project/_/settings/api
|
||||
SUPABASE_URL=your_supabase_project_url
|
||||
SUPABASE_ANON_KEY=your_supabase_anon_key
|
||||
USE_SUPABASE=false
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -7,6 +7,8 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
.cursor
|
||||
supabase/.temp/
|
||||
|
||||
node_modules
|
||||
dist
|
||||
|
||||
84
app/components/auth/Auth.tsx
Normal file
84
app/components/auth/Auth.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { signInWithEmail, signUp } from '~/lib/stores/auth';
|
||||
|
||||
interface AuthProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function Auth({ onClose }: AuthProps) {
|
||||
const [isSignUp, setIsSignUp] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
if (isSignUp) {
|
||||
await signUp(email, password);
|
||||
toast.success('Check your email for the confirmation link');
|
||||
} else {
|
||||
await signInWithEmail(email, password);
|
||||
toast.success('Signed in successfully');
|
||||
onClose();
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Authentication failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-bolt-elements-background-depth-1 rounded-lg shadow-lg max-w-md w-full">
|
||||
<h2 className="text-2xl font-bold mb-6 text-center">{isSignUp ? 'Create Account' : 'Sign In'}</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full p-2 border rounded bg-bolt-elements-background-depth-2 text-bolt-elements-textPrimary"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full p-2 border rounded bg-bolt-elements-background-depth-2 text-bolt-elements-textPrimary"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2 px-4 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Loading...' : isSignUp ? 'Sign Up' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<button onClick={() => setIsSignUp(!isSignUp)} className="text-blue-500 hover:underline">
|
||||
{isSignUp ? 'Already have an account? Sign in' : "Don't have an account? Sign up"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
app/components/auth/AuthModal.tsx
Normal file
20
app/components/auth/AuthModal.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import ReactModal from 'react-modal';
|
||||
import { Auth } from './Auth';
|
||||
|
||||
interface AuthModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function AuthModal({ isOpen, onClose }: AuthModalProps) {
|
||||
return (
|
||||
<ReactModal
|
||||
isOpen={isOpen}
|
||||
onRequestClose={onClose}
|
||||
className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 outline-none"
|
||||
overlayClassName="fixed inset-0 bg-black bg-opacity-50 z-50"
|
||||
>
|
||||
<Auth onClose={onClose} />
|
||||
</ReactModal>
|
||||
);
|
||||
}
|
||||
194
app/components/auth/ClientAuth.tsx
Normal file
194
app/components/auth/ClientAuth.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { getSupabase } from '~/lib/supabase/client';
|
||||
import type { AuthError, Session, User, AuthChangeEvent } from '@supabase/supabase-js';
|
||||
|
||||
export function ClientAuth() {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showSignIn, setShowSignIn] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isSigningIn, setIsSigningIn] = useState(false);
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function getUser() {
|
||||
try {
|
||||
const { data } = await getSupabase().auth.getUser();
|
||||
setUser(data.user);
|
||||
} catch (error) {
|
||||
console.error('Error fetching user:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
getUser();
|
||||
|
||||
const {
|
||||
data: { subscription },
|
||||
} = getSupabase().auth.onAuthStateChange((event: AuthChangeEvent, session: Session | null) => {
|
||||
setUser(session?.user ?? null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSignIn = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSigningIn(true);
|
||||
|
||||
try {
|
||||
const { error } = await getSupabase().auth.signInWithPassword({ email, password });
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
setShowSignIn(false);
|
||||
toast.success('Successfully signed in!');
|
||||
} catch (error) {
|
||||
const authError = error as AuthError;
|
||||
toast.error(authError.message || 'Failed to sign in');
|
||||
} finally {
|
||||
setIsSigningIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignUp = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSigningIn(true);
|
||||
|
||||
try {
|
||||
const { error } = await getSupabase().auth.signUp({ email, password });
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
toast.success('Check your email for the confirmation link!');
|
||||
setShowSignIn(false);
|
||||
} catch (error) {
|
||||
const authError = error as AuthError;
|
||||
toast.error(authError.message || 'Failed to sign up');
|
||||
} finally {
|
||||
setIsSigningIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await getSupabase().auth.signOut();
|
||||
setShowDropdown(false);
|
||||
toast.success('Signed out successfully');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="w-8 h-8 rounded-full bg-gray-300 animate-pulse" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{user ? (
|
||||
<div className="relative">
|
||||
<button
|
||||
className="flex items-center justify-center w-8 h-8 rounded-full bg-blue-500 text-white"
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
>
|
||||
{user.user_metadata?.avatar_url ? (
|
||||
<img
|
||||
src={user.user_metadata.avatar_url}
|
||||
alt="User avatar"
|
||||
className="w-full h-full rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span>{user.email?.substring(0, 2).toUpperCase()}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showDropdown && (
|
||||
<div className="absolute right-0 mt-2 w-48 py-2 bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor rounded shadow-lg z-10">
|
||||
<div className="px-4 py-2 text-bolt-elements-textPrimary border-b border-bolt-elements-borderColor">
|
||||
{user.email}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="block w-full text-left px-4 py-2 text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-2"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowSignIn(true)}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 font-bold"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showSignIn && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50"
|
||||
onClick={() => setShowSignIn(false)}
|
||||
>
|
||||
<div
|
||||
className="bg-bolt-elements-background-depth-1 p-6 rounded-lg max-w-md mx-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-2xl font-bold mb-4 text-bolt-elements-textPrimary">Sign In</h2>
|
||||
<form onSubmit={handleSignIn}>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="email" className="block mb-1 text-bolt-elements-textPrimary">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full p-2 border rounded bg-bolt-elements-background-depth-2 text-bolt-elements-textPrimary border-bolt-elements-borderColor"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="password" className="block mb-1 text-bolt-elements-textPrimary">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full p-2 border rounded bg-bolt-elements-background-depth-2 text-bolt-elements-textPrimary border-bolt-elements-borderColor"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSigningIn}
|
||||
className="flex-1 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{isSigningIn ? 'Processing...' : 'Sign In'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSignUp}
|
||||
disabled={isSigningIn}
|
||||
className="flex-1 py-2 bg-green-500 text-white rounded hover:bg-green-600 disabled:opacity-50"
|
||||
>
|
||||
{isSigningIn ? 'Processing...' : 'Sign Up'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5,45 +5,56 @@ import { classNames } from '~/utils/classNames';
|
||||
import { HeaderActionButtons } from './HeaderActionButtons.client';
|
||||
import { ChatDescription } from '~/lib/persistence/ChatDescription.client';
|
||||
import { Feedback } from './Feedback';
|
||||
import { Suspense } from 'react';
|
||||
import { ClientAuth } from '~/components/auth/ClientAuth';
|
||||
import { shouldUseSupabase } from '~/lib/supabase/client';
|
||||
|
||||
export function Header() {
|
||||
const chat = useStore(chatStore);
|
||||
|
||||
return (
|
||||
<header
|
||||
className={classNames('flex items-center p-5 border-b h-[var(--header-height)]', {
|
||||
className={classNames('flex items-center justify-between p-5 border-b h-[var(--header-height)]', {
|
||||
'border-transparent': !chat.started,
|
||||
'border-bolt-elements-borderColor': chat.started,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center gap-2 z-logo text-bolt-elements-textPrimary cursor-pointer">
|
||||
<div className="flex flex-1 items-center gap-2 z-logo text-bolt-elements-textPrimary cursor-pointer">
|
||||
<div className="i-ph:sidebar-simple-duotone text-xl" />
|
||||
<a href="/" className="text-2xl font-semibold text-accent flex items-center">
|
||||
<img src="/logo-styled.svg" alt="logo" className="w-[40px] inline-block rotate-90" />
|
||||
</a>
|
||||
<Feedback />
|
||||
<a
|
||||
href="https://www.replay.io/discord"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-bolt-elements-textPrimary hover:text-accent"
|
||||
>
|
||||
<div className="i-ph:discord-logo-fill text-xl" />
|
||||
</a>
|
||||
</div>
|
||||
{chat.started && ( // Display ChatDescription and HeaderActionButtons only when the chat has started.
|
||||
<>
|
||||
|
||||
<div className="flex-1 flex items-center ">
|
||||
{chat.started && (
|
||||
<span className="flex-1 px-4 truncate text-center text-bolt-elements-textPrimary">
|
||||
<ClientOnly>{() => <ChatDescription />}</ClientOnly>
|
||||
</span>
|
||||
<ClientOnly>
|
||||
{() => (
|
||||
<div className="mr-1">
|
||||
<HeaderActionButtons />
|
||||
</div>
|
||||
)}
|
||||
</ClientOnly>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{chat.started && (
|
||||
<ClientOnly>
|
||||
{() => (
|
||||
<div className="mr-1">
|
||||
<HeaderActionButtons />
|
||||
</div>
|
||||
)}
|
||||
</ClientOnly>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{shouldUseSupabase() && (
|
||||
<ClientOnly>
|
||||
{() => (
|
||||
<Suspense fallback={<div className="w-8 h-8 rounded-full bg-gray-300 animate-pulse" />}>
|
||||
<ClientAuth />
|
||||
</Suspense>
|
||||
)}
|
||||
</ClientOnly>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
|
||||
19
app/env.d.ts
vendored
Normal file
19
app/env.d.ts
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* <reference types="@remix-run/cloudflare" />
|
||||
* <reference types="vite/client" />
|
||||
*/
|
||||
|
||||
interface WindowEnv {
|
||||
SUPABASE_URL: string;
|
||||
SUPABASE_ANON_KEY: string;
|
||||
USE_SUPABASE?: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
ENV: WindowEnv;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure this is treated as a module
|
||||
export {};
|
||||
@@ -6,6 +6,15 @@ import type { ProtocolMessage } from './SimulationPrompt';
|
||||
import Cookies from 'js-cookie';
|
||||
import JSZip from 'jszip';
|
||||
import type { FileArtifact } from '~/utils/folderImport';
|
||||
import { shouldUseSupabase } from '~/lib/supabase/client';
|
||||
import {
|
||||
supabaseListAllProblems,
|
||||
supabaseGetProblem,
|
||||
supabaseSubmitProblem,
|
||||
supabaseUpdateProblem,
|
||||
supabaseSubmitFeedback,
|
||||
supabaseDeleteProblem,
|
||||
} from '~/lib/supabase/problems';
|
||||
|
||||
export interface BoltProblemComment {
|
||||
username?: string;
|
||||
@@ -51,6 +60,10 @@ export interface BoltProblem extends BoltProblemDescription {
|
||||
export type BoltProblemInput = Omit<BoltProblem, 'problemId' | 'timestamp'>;
|
||||
|
||||
export async function listAllProblems(): Promise<BoltProblemDescription[]> {
|
||||
if (shouldUseSupabase()) {
|
||||
return supabaseListAllProblems();
|
||||
}
|
||||
|
||||
try {
|
||||
const rv = await sendCommandDedicatedClient({
|
||||
method: 'Recording.globalExperimentalCommand',
|
||||
@@ -70,7 +83,16 @@ export async function listAllProblems(): Promise<BoltProblemDescription[]> {
|
||||
}
|
||||
|
||||
export async function getProblem(problemId: string): Promise<BoltProblem | null> {
|
||||
if (shouldUseSupabase()) {
|
||||
return supabaseGetProblem(problemId);
|
||||
}
|
||||
|
||||
try {
|
||||
if (!problemId) {
|
||||
toast.error('Invalid problem ID');
|
||||
return null;
|
||||
}
|
||||
|
||||
const rv = await sendCommandDedicatedClient({
|
||||
method: 'Recording.globalExperimentalCommand',
|
||||
params: {
|
||||
@@ -78,10 +100,14 @@ export async function getProblem(problemId: string): Promise<BoltProblem | null>
|
||||
params: { problemId },
|
||||
},
|
||||
});
|
||||
console.log('FetchProblemRval', rv);
|
||||
|
||||
const problem = (rv as { rval: { problem: BoltProblem } }).rval.problem;
|
||||
|
||||
if (!problem) {
|
||||
toast.error('Problem not found');
|
||||
return null;
|
||||
}
|
||||
|
||||
if ('prompt' in problem) {
|
||||
// 2/11/2025: Update obsolete data format for older problems.
|
||||
problem.repositoryContents = (problem as any).prompt.content;
|
||||
@@ -91,12 +117,23 @@ export async function getProblem(problemId: string): Promise<BoltProblem | null>
|
||||
return problem;
|
||||
} catch (error) {
|
||||
console.error('Error fetching problem', error);
|
||||
toast.error('Failed to fetch problem');
|
||||
|
||||
// Check for specific protocol error
|
||||
if (error instanceof Error && error.message.includes('Unknown problem ID')) {
|
||||
toast.error('Problem not found');
|
||||
} else {
|
||||
toast.error('Failed to fetch problem');
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function submitProblem(problem: BoltProblemInput): Promise<string | null> {
|
||||
if (shouldUseSupabase()) {
|
||||
return supabaseSubmitProblem(problem);
|
||||
}
|
||||
|
||||
try {
|
||||
const rv = await sendCommandDedicatedClient({
|
||||
method: 'Recording.globalExperimentalCommand',
|
||||
@@ -116,11 +153,24 @@ export async function submitProblem(problem: BoltProblemInput): Promise<string |
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateProblem(problemId: string, problem: BoltProblemInput | undefined) {
|
||||
export async function deleteProblem(problemId: string): Promise<void | undefined> {
|
||||
if (shouldUseSupabase()) {
|
||||
return supabaseDeleteProblem(problemId);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function updateProblem(problemId: string, problem: BoltProblemInput): Promise<void | undefined> {
|
||||
if (shouldUseSupabase()) {
|
||||
return supabaseUpdateProblem(problemId, problem);
|
||||
}
|
||||
|
||||
try {
|
||||
if (!getNutIsAdmin()) {
|
||||
toast.error('Admin user required');
|
||||
return;
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const loginKey = Cookies.get(nutLoginKeyCookieName);
|
||||
@@ -131,10 +181,14 @@ export async function updateProblem(problemId: string, problem: BoltProblemInput
|
||||
params: { problemId, problem, loginKey },
|
||||
},
|
||||
});
|
||||
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
console.error('Error updating problem', error);
|
||||
toast.error('Failed to update problem');
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const nutLoginKeyCookieName = 'nutLoginKey';
|
||||
@@ -208,6 +262,10 @@ export async function extractFileArtifactsFromRepositoryContents(repositoryConte
|
||||
}
|
||||
|
||||
export async function submitFeedback(feedback: any) {
|
||||
if (shouldUseSupabase()) {
|
||||
return supabaseSubmitFeedback(feedback);
|
||||
}
|
||||
|
||||
try {
|
||||
const rv = await sendCommandDedicatedClient({
|
||||
method: 'Recording.globalExperimentalCommand',
|
||||
|
||||
145
app/lib/stores/auth.ts
Normal file
145
app/lib/stores/auth.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { atom } from 'nanostores';
|
||||
import { getSupabase } from '~/lib/supabase/client';
|
||||
import type { User, Session } from '@supabase/supabase-js';
|
||||
import { logStore } from './logs';
|
||||
|
||||
export const userStore = atom<User | null>(null);
|
||||
export const sessionStore = atom<Session | null>(null);
|
||||
export const isLoadingStore = atom<boolean>(true);
|
||||
|
||||
export async function initializeAuth() {
|
||||
try {
|
||||
isLoadingStore.set(true);
|
||||
|
||||
// Get initial session
|
||||
const {
|
||||
data: { session },
|
||||
error,
|
||||
} = await getSupabase().auth.getSession();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (session) {
|
||||
userStore.set(session.user);
|
||||
sessionStore.set(session);
|
||||
logStore.logSystem('Auth initialized with existing session', {
|
||||
userId: session.user.id,
|
||||
email: session.user.email,
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for auth changes
|
||||
const {
|
||||
data: { subscription },
|
||||
} = getSupabase().auth.onAuthStateChange(async (event, session) => {
|
||||
logStore.logSystem('Auth state changed', { event });
|
||||
|
||||
if (session) {
|
||||
userStore.set(session.user);
|
||||
sessionStore.set(session);
|
||||
logStore.logSystem('User authenticated', {
|
||||
userId: session.user.id,
|
||||
email: session.user.email,
|
||||
});
|
||||
} else {
|
||||
userStore.set(null);
|
||||
sessionStore.set(null);
|
||||
logStore.logSystem('User signed out');
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
} catch (error) {
|
||||
logStore.logError('Failed to initialize auth', error);
|
||||
throw error;
|
||||
} finally {
|
||||
isLoadingStore.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function signInWithEmail(email: string, password: string) {
|
||||
try {
|
||||
isLoadingStore.set(true);
|
||||
|
||||
const { data, error } = await getSupabase().auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
logStore.logError('Failed to sign in', error);
|
||||
throw error;
|
||||
} finally {
|
||||
isLoadingStore.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function signUp(email: string, password: string) {
|
||||
try {
|
||||
isLoadingStore.set(true);
|
||||
|
||||
const { data, error } = await getSupabase().auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
emailRedirectTo: typeof window !== 'undefined' ? `${window.location.origin}/auth/callback` : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
logStore.logError('Failed to sign up', error);
|
||||
throw error;
|
||||
} finally {
|
||||
isLoadingStore.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updatePassword(newPassword: string) {
|
||||
try {
|
||||
isLoadingStore.set(true);
|
||||
|
||||
const { error } = await getSupabase().auth.updateUser({
|
||||
password: newPassword,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
logStore.logError('Failed to update password', error);
|
||||
throw error;
|
||||
} finally {
|
||||
isLoadingStore.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function signOut() {
|
||||
try {
|
||||
isLoadingStore.set(true);
|
||||
|
||||
const { error } = await getSupabase().auth.signOut();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
logStore.logError('Failed to sign out', error);
|
||||
throw error;
|
||||
} finally {
|
||||
isLoadingStore.set(false);
|
||||
}
|
||||
}
|
||||
104
app/lib/supabase/client.ts
Normal file
104
app/lib/supabase/client.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[];
|
||||
|
||||
export interface Database {
|
||||
public: {
|
||||
Tables: {
|
||||
problems: {
|
||||
Row: {
|
||||
id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: 'Pending' | 'Solved' | 'Unsolved';
|
||||
keywords: string[];
|
||||
repository_contents: Json;
|
||||
user_id: string | null;
|
||||
problem_comments: Database['public']['Tables']['problem_comments']['Row'][];
|
||||
};
|
||||
Insert: Omit<Database['public']['Tables']['problems']['Row'], 'created_at' | 'updated_at' | 'problem_comments'>;
|
||||
Update: Partial<Database['public']['Tables']['problems']['Insert']>;
|
||||
};
|
||||
problem_comments: {
|
||||
Row: {
|
||||
id: string;
|
||||
created_at: string;
|
||||
problem_id: string;
|
||||
content: string;
|
||||
username: string;
|
||||
};
|
||||
Insert: Omit<Database['public']['Tables']['problem_comments']['Row'], 'id' | 'created_at'>;
|
||||
Update: Partial<Database['public']['Tables']['problem_comments']['Insert']>;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Get Supabase URL and key from environment variables
|
||||
let supabaseUrl = '';
|
||||
let supabaseAnonKey = '';
|
||||
|
||||
/**
|
||||
* Determines whether Supabase should be used based on URL parameters and environment variables.
|
||||
* URL parameters take precedence over environment variables.
|
||||
*/
|
||||
export function shouldUseSupabase(): boolean {
|
||||
// Check URL parameters (client-side only)
|
||||
const urlParams = typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : null;
|
||||
const useSupabaseFromUrl = urlParams ? urlParams.get('supabase') === 'true' : false;
|
||||
|
||||
// Check environment variables
|
||||
const useSupabaseFromEnv =
|
||||
typeof window !== 'undefined' ? window.ENV?.USE_SUPABASE === 'true' : process.env.USE_SUPABASE === 'true';
|
||||
|
||||
// URL param takes precedence over environment variable
|
||||
const shouldUse = useSupabaseFromUrl || useSupabaseFromEnv;
|
||||
|
||||
// Log for debugging
|
||||
if (typeof window !== 'undefined') {
|
||||
console.log('Supabase usage check:', {
|
||||
fromUrl: useSupabaseFromUrl,
|
||||
fromEnv: useSupabaseFromEnv,
|
||||
enabled: shouldUse,
|
||||
});
|
||||
}
|
||||
|
||||
return shouldUse;
|
||||
}
|
||||
|
||||
export function getSupabase() {
|
||||
// Determine execution environment and get appropriate variables
|
||||
if (process.browser) {
|
||||
supabaseUrl = window.ENV.SUPABASE_URL || '';
|
||||
supabaseAnonKey = window.ENV.SUPABASE_ANON_KEY || '';
|
||||
} else {
|
||||
// Node.js environment (development)
|
||||
supabaseUrl = process.env.SUPABASE_URL || '';
|
||||
supabaseAnonKey = process.env.SUPABASE_ANON_KEY || '';
|
||||
}
|
||||
|
||||
// If neither URL param nor environment variable is set to true, log a warning
|
||||
if (!shouldUseSupabase()) {
|
||||
console.log('Supabase is not enabled. Set USE_SUPABASE=true or use ?supabase=true query parameter.');
|
||||
}
|
||||
|
||||
// Log warning if environment variables are missing
|
||||
if (!supabaseUrl || !supabaseAnonKey) {
|
||||
console.warn('Missing Supabase environment variables. Some features may not work properly.');
|
||||
}
|
||||
|
||||
// Create and return the Supabase client
|
||||
return createClient<Database>(supabaseUrl, supabaseAnonKey, {
|
||||
auth: {
|
||||
persistSession: true,
|
||||
autoRefreshToken: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function to check if Supabase is properly initialized
|
||||
export const isSupabaseInitialized = (): boolean => {
|
||||
return Boolean(supabaseUrl && supabaseAnonKey);
|
||||
};
|
||||
245
app/lib/supabase/problems.ts
Normal file
245
app/lib/supabase/problems.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
// Supabase implementation of problem management functions
|
||||
|
||||
import { toast } from 'react-toastify';
|
||||
import { getSupabase, type Database } from './client';
|
||||
import type { BoltProblem, BoltProblemDescription, BoltProblemInput, BoltProblemStatus } from '~/lib/replay/Problems';
|
||||
import { getProblemsUsername, getNutIsAdmin } from '~/lib/replay/Problems';
|
||||
|
||||
export async function supabaseListAllProblems(): Promise<BoltProblemDescription[]> {
|
||||
try {
|
||||
const { data, error } = await getSupabase()
|
||||
.from('problems')
|
||||
.select('id, created_at, updated_at, title, description, status, keywords, user_id')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
console.log('ListAllProblemsData', data);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const problems: BoltProblemDescription[] = data.map((problem) => ({
|
||||
version: 1,
|
||||
problemId: problem.id,
|
||||
timestamp: new Date(problem.created_at).getTime(),
|
||||
title: problem.title,
|
||||
description: problem.description,
|
||||
status: problem.status,
|
||||
keywords: problem.keywords,
|
||||
}));
|
||||
|
||||
return problems;
|
||||
} catch (error) {
|
||||
console.error('Error fetching problems', error);
|
||||
toast.error('Failed to fetch problems');
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function supabaseGetProblem(problemId: string): Promise<BoltProblem | null> {
|
||||
try {
|
||||
if (!problemId) {
|
||||
toast.error('Invalid problem ID');
|
||||
return null;
|
||||
}
|
||||
|
||||
const { data, error } = await getSupabase()
|
||||
.from('problems')
|
||||
.select(
|
||||
`
|
||||
*,
|
||||
problem_comments (
|
||||
*
|
||||
)
|
||||
`,
|
||||
)
|
||||
.eq('id', problemId)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
// More specific error message based on error code
|
||||
if (error.code === 'PGRST116') {
|
||||
toast.error('Problem not found');
|
||||
} else {
|
||||
toast.error(`Failed to fetch problem: ${error.message}`);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
toast.error('Problem not found');
|
||||
return null;
|
||||
}
|
||||
|
||||
// If the problem has a user_id, fetch the profile information
|
||||
let username = null;
|
||||
|
||||
if (data.user_id) {
|
||||
const { data: profileData, error: profileError } = await getSupabase()
|
||||
.from('profiles')
|
||||
.select('username')
|
||||
.eq('id', data.user_id)
|
||||
.single();
|
||||
|
||||
if (!profileError && profileData) {
|
||||
username = profileData.username;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
problemId: data.id,
|
||||
version: 1,
|
||||
timestamp: new Date(data.created_at).getTime(),
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
status: data.status as BoltProblemStatus,
|
||||
keywords: data.keywords,
|
||||
repositoryContents: data.repository_contents,
|
||||
username,
|
||||
comments: data.problem_comments.map((comment: any) => ({
|
||||
id: comment.id,
|
||||
createdAt: comment.created_at,
|
||||
problemId: comment.problem_id,
|
||||
content: comment.content,
|
||||
username: comment.username,
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching problem:', error);
|
||||
|
||||
// Don't show duplicate toast if we already showed one above
|
||||
if (!(error as any)?.code) {
|
||||
toast.error('Failed to fetch problem');
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function supabaseSubmitProblem(problem: BoltProblemInput): Promise<string | null> {
|
||||
try {
|
||||
const supabaseProblem = {
|
||||
id: undefined as any, // This will be set by Supabase
|
||||
title: problem.title,
|
||||
description: problem.description,
|
||||
status: problem.status as BoltProblemStatus,
|
||||
keywords: problem.keywords || [],
|
||||
repository_contents: problem.repositoryContents,
|
||||
user_id: null,
|
||||
};
|
||||
|
||||
const { data, error } = await getSupabase().from('problems').insert(supabaseProblem).select().single();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data.id;
|
||||
} catch (error) {
|
||||
console.error('Error submitting problem', error);
|
||||
toast.error('Failed to submit problem');
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function supabaseDeleteProblem(problemId: string): Promise<void | undefined> {
|
||||
try {
|
||||
const { error: deleteError } = await getSupabase().from('problems').delete().eq('id', problemId);
|
||||
|
||||
if (deleteError) {
|
||||
throw deleteError;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
console.error('Error deleting problem', error);
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function supabaseUpdateProblem(problemId: string, problem: BoltProblemInput): Promise<void | undefined> {
|
||||
try {
|
||||
if (!getNutIsAdmin()) {
|
||||
toast.error('Admin user required');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Extract comments to add separately if needed
|
||||
const comments = problem.comments || [];
|
||||
delete (problem as any).comments;
|
||||
|
||||
// Convert to Supabase format
|
||||
const updates: Database['public']['Tables']['problems']['Update'] = {
|
||||
title: problem.title,
|
||||
description: problem.description,
|
||||
status: problem.status,
|
||||
keywords: problem.keywords || [],
|
||||
repository_contents: problem.repositoryContents,
|
||||
};
|
||||
|
||||
// Update the problem
|
||||
const { error: updateError } = await getSupabase().from('problems').update(updates).eq('id', problemId);
|
||||
|
||||
if (updateError) {
|
||||
throw updateError;
|
||||
}
|
||||
|
||||
// Handle comments if they exist
|
||||
if (comments.length > 0) {
|
||||
/**
|
||||
* Create a unique identifier for each comment based on content and timestamp.
|
||||
* This allows us to use upsert with onConflict to avoid duplicates.
|
||||
*/
|
||||
const commentInserts = comments.map((comment) => ({
|
||||
problem_id: problemId,
|
||||
content: comment.content,
|
||||
username: comment.username || getProblemsUsername() || 'Anonymous',
|
||||
|
||||
/**
|
||||
* Use timestamp as a unique identifier for the comment.
|
||||
* This assumes that comments with the same timestamp are the same comment.
|
||||
*/
|
||||
created_at: new Date(comment.timestamp).toISOString(),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Use upsert with onConflict to avoid duplicates.
|
||||
* This will insert new comments and ignore existing ones based on created_at.
|
||||
*/
|
||||
const { error: commentsError } = await getSupabase().from('problem_comments').upsert(commentInserts, {
|
||||
onConflict: 'created_at',
|
||||
ignoreDuplicates: true,
|
||||
});
|
||||
|
||||
if (commentsError) {
|
||||
throw commentsError;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating problem', error);
|
||||
toast.error('Failed to update problem');
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function supabaseSubmitFeedback(feedback: any) {
|
||||
try {
|
||||
/*
|
||||
* Store feedback in supabase if needed
|
||||
* For now, just return true
|
||||
*/
|
||||
console.log('Feedback submitted:', feedback);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error submitting feedback', error);
|
||||
toast.error('Failed to submit feedback');
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
108
app/root.tsx
108
app/root.tsx
@@ -1,12 +1,16 @@
|
||||
import { sentryHandleError } from '~/lib/sentry';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import type { LinksFunction } from '@remix-run/cloudflare';
|
||||
import { Links, Meta, Outlet, Scripts, ScrollRestoration, useRouteError } from '@remix-run/react';
|
||||
import type { LinksFunction, LoaderFunction } from '@remix-run/cloudflare';
|
||||
import { json } from '@remix-run/cloudflare';
|
||||
import { Links, Meta, Outlet, Scripts, ScrollRestoration, useRouteError, useLoaderData } from '@remix-run/react';
|
||||
import tailwindReset from '@unocss/reset/tailwind-compat.css?url';
|
||||
import { themeStore } from './lib/stores/theme';
|
||||
import { stripIndents } from './utils/stripIndent';
|
||||
import { createHead } from 'remix-island';
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { logStore } from './lib/stores/logs';
|
||||
import { initializeAuth, userStore, isLoadingStore } from './lib/stores/auth';
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
|
||||
import reactToastifyStyles from 'react-toastify/dist/ReactToastify.css?url';
|
||||
import globalStyles from './styles/index.scss?url';
|
||||
@@ -14,6 +18,14 @@ import xtermStyles from '@xterm/xterm/css/xterm.css?url';
|
||||
|
||||
import 'virtual:uno.css';
|
||||
|
||||
interface LoaderData {
|
||||
ENV: {
|
||||
SUPABASE_URL: string;
|
||||
SUPABASE_ANON_KEY: string;
|
||||
USE_SUPABASE?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const links: LinksFunction = () => [
|
||||
{
|
||||
rel: 'icon',
|
||||
@@ -39,6 +51,20 @@ export const links: LinksFunction = () => [
|
||||
},
|
||||
];
|
||||
|
||||
export const loader: LoaderFunction = async ({ context }) => {
|
||||
const supabaseUrl = (context.SUPABASE_URL || process.env.SUPABASE_URL || '') as string;
|
||||
const supabaseAnonKey = (context.SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY || '') as string;
|
||||
const useSupabase = (context.USE_SUPABASE || process.env.USE_SUPABASE || '') as string;
|
||||
|
||||
return json<LoaderData>({
|
||||
ENV: {
|
||||
SUPABASE_URL: supabaseUrl,
|
||||
SUPABASE_ANON_KEY: supabaseAnonKey,
|
||||
USE_SUPABASE: useSupabase,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const inlineThemeCode = stripIndents`
|
||||
setTutorialKitTheme();
|
||||
|
||||
@@ -63,46 +89,82 @@ export const Head = createHead(() => (
|
||||
</>
|
||||
));
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
function ClientOnly({ children }: { children: React.ReactNode }) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
return mounted ? <>{children}</> : null;
|
||||
}
|
||||
|
||||
function ThemeProvider() {
|
||||
const theme = useStore(themeStore);
|
||||
|
||||
useEffect(() => {
|
||||
document.querySelector('html')?.setAttribute('data-theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
import { logStore } from './lib/stores/logs';
|
||||
function AuthProvider({ data }: { data: LoaderData }) {
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.ENV = data.ENV;
|
||||
initializeAuth().catch((err: Error) => {
|
||||
logStore.logError('Failed to initialize auth', err);
|
||||
});
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const ErrorBoundary = () => {
|
||||
const error = useRouteError();
|
||||
sentryHandleError(error as Error);
|
||||
|
||||
// Using our conditional error handling instead of direct Sentry import
|
||||
sentryHandleError(error instanceof Error ? error : new Error(String(error)));
|
||||
|
||||
return <div>Something went wrong</div>;
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
const theme = useStore(themeStore);
|
||||
const data = useLoaderData<typeof loader>() as LoaderData;
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
logStore.logSystem('Application initialized', {
|
||||
theme,
|
||||
platform: navigator.platform,
|
||||
userAgent: navigator.userAgent,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
window.ENV = data.ENV;
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Only access stores on the client side
|
||||
const theme = useStore(themeStore);
|
||||
const user = useStore(userStore);
|
||||
const isLoading = useStore(isLoadingStore);
|
||||
|
||||
useEffect(() => {
|
||||
if (mounted) {
|
||||
logStore.logSystem('Application initialized', {
|
||||
theme,
|
||||
platform: navigator.platform,
|
||||
userAgent: navigator.userAgent,
|
||||
timestamp: new Date().toISOString(),
|
||||
isAuthenticated: !!user,
|
||||
});
|
||||
}
|
||||
}, [theme, user, mounted]);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Outlet />
|
||||
</Layout>
|
||||
<>
|
||||
<ClientOnly>
|
||||
<ThemeProvider />
|
||||
<AuthProvider data={data} />
|
||||
<main className="">{isLoading ? <div></div> : <Outlet />}</main>
|
||||
<ToastContainer position="bottom-right" theme={theme} />
|
||||
</ClientOnly>
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useParams } from '@remix-run/react';
|
||||
import {
|
||||
getProblem,
|
||||
updateProblem as backendUpdateProblem,
|
||||
deleteProblem as backendDeleteProblem,
|
||||
getProblemsUsername,
|
||||
BoltProblemStatus,
|
||||
getNutIsAdmin,
|
||||
@@ -222,7 +223,7 @@ function ViewProblemPage() {
|
||||
|
||||
const deleteProblem = useCallback(async () => {
|
||||
console.log('BackendDeleteProblem', problemId);
|
||||
await backendUpdateProblem(problemId, undefined);
|
||||
await backendDeleteProblem(problemId);
|
||||
toast.success('Problem deleted');
|
||||
}, [problemData]);
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import BackgroundRays from '~/components/ui/BackgroundRays';
|
||||
import { TooltipProvider } from '@radix-ui/react-tooltip';
|
||||
import { cssTransition, ToastContainer } from 'react-toastify';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
import { BoltProblemStatus, listAllProblems } from '~/lib/replay/Problems';
|
||||
import { listAllProblems, BoltProblemStatus } from '~/lib/replay/Problems';
|
||||
import type { BoltProblemDescription } from '~/lib/replay/Problems';
|
||||
|
||||
const toastAnimation = cssTransition({
|
||||
|
||||
7
app/types/global.d.ts
vendored
7
app/types/global.d.ts
vendored
@@ -11,3 +11,10 @@ interface Performance {
|
||||
usedJSHeapSize: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Add browser property to Process interface
|
||||
declare namespace NodeJS {
|
||||
interface Process {
|
||||
browser?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
"@sentry/cloudflare": "^8.55.0",
|
||||
"@sentry/remix": "^8",
|
||||
"@sentry/vite-plugin": "^3.1.2",
|
||||
"@supabase/supabase-js": "^2.49.1",
|
||||
"@uiw/codemirror-theme-vscode": "^4.23.6",
|
||||
"@unocss/reset": "^0.61.9",
|
||||
"@webcontainer/api": "1.5.1-internal.8",
|
||||
|
||||
16757
pnpm-lock.yaml
generated
16757
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
170
supabase/migrations/20240326000000_create_problems.sql
Normal file
170
supabase/migrations/20240326000000_create_problems.sql
Normal file
@@ -0,0 +1,170 @@
|
||||
-- Create problems table
|
||||
CREATE TABLE IF NOT EXISTS problems (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'solved', 'unsolved')),
|
||||
keywords TEXT[] NOT NULL DEFAULT '{}',
|
||||
repository_contents JSONB NOT NULL DEFAULT '{}',
|
||||
user_id UUID REFERENCES auth.users(id)
|
||||
);
|
||||
|
||||
-- Create problem_comments table
|
||||
CREATE TABLE IF NOT EXISTS problem_comments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
problem_id UUID NOT NULL REFERENCES problems(id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL,
|
||||
username TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Create updated_at trigger for problems table
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
CREATE TRIGGER update_problems_updated_at
|
||||
BEFORE UPDATE ON problems
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- Create RLS policies
|
||||
ALTER TABLE problems ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE problem_comments ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Allow public read access to problems and comments
|
||||
CREATE POLICY "Allow public read access to problems"
|
||||
ON problems FOR SELECT
|
||||
TO public
|
||||
USING (true);
|
||||
|
||||
CREATE POLICY "Allow public read access to problem comments"
|
||||
ON problem_comments FOR SELECT
|
||||
TO public
|
||||
USING (true);
|
||||
|
||||
-- Allow anyone to create problems (including anonymous users)
|
||||
DROP POLICY IF EXISTS "Allow authenticated users to create problems" ON problems;
|
||||
DROP POLICY IF EXISTS "Allow anyone to create problems" ON problems;
|
||||
CREATE POLICY "Allow anyone to create problems"
|
||||
ON problems FOR INSERT
|
||||
TO public
|
||||
WITH CHECK (true);
|
||||
|
||||
-- Allow anyone to update problems
|
||||
DROP POLICY IF EXISTS "Allow authenticated users to update problems" ON problems;
|
||||
DROP POLICY IF EXISTS "Allow anyone to update problems" ON problems;
|
||||
CREATE POLICY "Allow anyone to update problems"
|
||||
ON problems FOR UPDATE
|
||||
TO public
|
||||
USING (true)
|
||||
WITH CHECK (true);
|
||||
|
||||
-- Allow anyone to create comments
|
||||
DROP POLICY IF EXISTS "Allow authenticated users to create comments" ON problem_comments;
|
||||
DROP POLICY IF EXISTS "Allow anyone to create comments" ON problem_comments;
|
||||
CREATE POLICY "Allow anyone to create comments"
|
||||
ON problem_comments FOR INSERT
|
||||
TO public
|
||||
WITH CHECK (true);
|
||||
|
||||
-- Create function to get problem with comments
|
||||
CREATE OR REPLACE FUNCTION get_problem_with_comments(problem_id UUID)
|
||||
RETURNS TABLE (
|
||||
id UUID,
|
||||
created_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
status TEXT,
|
||||
keywords TEXT[],
|
||||
repository_contents JSONB,
|
||||
user_id UUID,
|
||||
problem_comments JSON
|
||||
) AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
p.id,
|
||||
p.created_at,
|
||||
p.updated_at,
|
||||
p.title,
|
||||
p.description,
|
||||
p.status,
|
||||
p.keywords,
|
||||
p.repository_contents,
|
||||
p.user_id,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'id', c.id,
|
||||
'created_at', c.created_at,
|
||||
'problem_id', c.problem_id,
|
||||
'content', c.content,
|
||||
'username', c.username
|
||||
)
|
||||
) FILTER (WHERE c.id IS NOT NULL),
|
||||
'[]'::json
|
||||
) as problem_comments
|
||||
FROM problems p
|
||||
LEFT JOIN problem_comments c ON c.problem_id = p.id
|
||||
WHERE p.id = problem_id
|
||||
GROUP BY p.id;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create profiles for existing users who don't have one
|
||||
DO $$
|
||||
DECLARE
|
||||
user_record RECORD;
|
||||
BEGIN
|
||||
-- Loop through all users in auth.users who don't have a profile
|
||||
FOR user_record IN
|
||||
SELECT au.id, au.email
|
||||
FROM auth.users au
|
||||
LEFT JOIN public.profiles p ON p.id = au.id
|
||||
WHERE p.id IS NULL
|
||||
LOOP
|
||||
-- Insert a new profile for each user
|
||||
INSERT INTO public.profiles (
|
||||
id,
|
||||
username,
|
||||
full_name,
|
||||
avatar_url,
|
||||
is_admin
|
||||
) VALUES (
|
||||
user_record.id,
|
||||
user_record.email,
|
||||
NULL,
|
||||
NULL,
|
||||
FALSE
|
||||
);
|
||||
|
||||
RAISE NOTICE 'Created profile for user %', user_record.email;
|
||||
END LOOP;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Output the number of profiles created
|
||||
SELECT 'Profiles created for existing users: ' || COUNT(*)::text as result
|
||||
FROM (
|
||||
SELECT au.id
|
||||
FROM auth.users au
|
||||
LEFT JOIN public.profiles p ON p.id = au.id
|
||||
WHERE p.id IS NULL
|
||||
) as missing_profiles;
|
||||
|
||||
-- List all profiles
|
||||
SELECT
|
||||
p.id,
|
||||
p.username,
|
||||
p.is_admin,
|
||||
p.created_at
|
||||
FROM public.profiles p
|
||||
ORDER BY p.created_at DESC;
|
||||
@@ -0,0 +1,49 @@
|
||||
-- Create profiles for existing users who don't have one
|
||||
DO $$
|
||||
DECLARE
|
||||
user_record RECORD;
|
||||
BEGIN
|
||||
-- Loop through all users in auth.users who don't have a profile
|
||||
FOR user_record IN
|
||||
SELECT au.id, au.email
|
||||
FROM auth.users au
|
||||
LEFT JOIN public.profiles p ON p.id = au.id
|
||||
WHERE p.id IS NULL
|
||||
LOOP
|
||||
-- Insert a new profile for each user
|
||||
INSERT INTO public.profiles (
|
||||
id,
|
||||
username,
|
||||
full_name,
|
||||
avatar_url,
|
||||
is_admin
|
||||
) VALUES (
|
||||
user_record.id,
|
||||
user_record.email,
|
||||
NULL,
|
||||
NULL,
|
||||
FALSE
|
||||
);
|
||||
|
||||
RAISE NOTICE 'Created profile for user %', user_record.email;
|
||||
END LOOP;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Output the number of profiles created
|
||||
SELECT 'Profiles created for existing users: ' || COUNT(*)::text as result
|
||||
FROM (
|
||||
SELECT au.id
|
||||
FROM auth.users au
|
||||
LEFT JOIN public.profiles p ON p.id = au.id
|
||||
WHERE p.id IS NULL
|
||||
) as missing_profiles;
|
||||
|
||||
-- List all profiles
|
||||
SELECT
|
||||
p.id,
|
||||
p.username,
|
||||
p.is_admin,
|
||||
p.created_at
|
||||
FROM public.profiles p
|
||||
ORDER BY p.created_at DESC;
|
||||
@@ -0,0 +1,49 @@
|
||||
-- Create profiles for existing users who don't have one
|
||||
DO $$
|
||||
DECLARE
|
||||
user_record RECORD;
|
||||
BEGIN
|
||||
-- Loop through all users in auth.users who don't have a profile
|
||||
FOR user_record IN
|
||||
SELECT au.id, au.email
|
||||
FROM auth.users au
|
||||
LEFT JOIN public.profiles p ON p.id = au.id
|
||||
WHERE p.id IS NULL
|
||||
LOOP
|
||||
-- Insert a new profile for each user
|
||||
INSERT INTO public.profiles (
|
||||
id,
|
||||
username,
|
||||
full_name,
|
||||
avatar_url,
|
||||
is_admin
|
||||
) VALUES (
|
||||
user_record.id,
|
||||
user_record.email,
|
||||
NULL,
|
||||
NULL,
|
||||
FALSE
|
||||
);
|
||||
|
||||
RAISE NOTICE 'Created profile for user %', user_record.email;
|
||||
END LOOP;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Output the number of profiles created
|
||||
SELECT 'Profiles created for existing users: ' || COUNT(*)::text as result
|
||||
FROM (
|
||||
SELECT au.id
|
||||
FROM auth.users au
|
||||
LEFT JOIN public.profiles p ON p.id = au.id
|
||||
WHERE p.id IS NULL
|
||||
) as missing_profiles;
|
||||
|
||||
-- List all profiles
|
||||
SELECT
|
||||
p.id,
|
||||
p.username,
|
||||
p.is_admin,
|
||||
p.created_at
|
||||
FROM public.profiles p
|
||||
ORDER BY p.created_at DESC;
|
||||
92
supabase/migrations/2025035000000_create_profiles_table.sql
Normal file
92
supabase/migrations/2025035000000_create_profiles_table.sql
Normal file
@@ -0,0 +1,92 @@
|
||||
-- Create profiles table
|
||||
CREATE TABLE IF NOT EXISTS public.profiles (
|
||||
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
|
||||
username TEXT UNIQUE,
|
||||
full_name TEXT,
|
||||
avatar_url TEXT,
|
||||
is_admin BOOLEAN DEFAULT FALSE NOT NULL
|
||||
);
|
||||
|
||||
-- Create a trigger to update the updated_at column
|
||||
CREATE OR REPLACE FUNCTION update_profiles_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER update_profiles_updated_at
|
||||
BEFORE UPDATE ON public.profiles
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_profiles_updated_at();
|
||||
|
||||
-- Create a policy to allow users to read all profiles
|
||||
CREATE POLICY "Profiles are viewable by everyone"
|
||||
ON public.profiles FOR SELECT
|
||||
USING (true);
|
||||
|
||||
-- Drop the existing policies if they exist
|
||||
DROP POLICY IF EXISTS "Users can update their own profile" ON public.profiles;
|
||||
DROP POLICY IF EXISTS "Only admins can update is_admin field" ON public.profiles;
|
||||
|
||||
-- Create a policy to allow users to update their own non-admin fields
|
||||
CREATE OR REPLACE FUNCTION check_is_admin_unchanged(is_admin_new boolean, user_id uuid)
|
||||
RETURNS boolean AS $$
|
||||
DECLARE
|
||||
is_admin_old boolean;
|
||||
BEGIN
|
||||
SELECT p.is_admin INTO is_admin_old FROM public.profiles p WHERE p.id = user_id;
|
||||
RETURN is_admin_new = is_admin_old;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- Create a policy to allow users to update their own profile EXCEPT the is_admin field
|
||||
CREATE POLICY "Users can update their own non-admin fields"
|
||||
ON public.profiles FOR UPDATE
|
||||
USING (auth.uid() = id)
|
||||
WITH CHECK (
|
||||
check_is_admin_unchanged(is_admin, id)
|
||||
);
|
||||
|
||||
-- Create a policy to allow only admins to update any profile including the is_admin field
|
||||
CREATE POLICY "Admins can update any profile"
|
||||
ON public.profiles FOR UPDATE
|
||||
USING (
|
||||
auth.uid() IN (SELECT id FROM public.profiles WHERE is_admin = true)
|
||||
);
|
||||
|
||||
-- Create a policy to allow users to insert their own profile
|
||||
CREATE POLICY "Users can insert their own profile"
|
||||
ON public.profiles FOR INSERT
|
||||
WITH CHECK (
|
||||
auth.uid() = id AND
|
||||
(is_admin = false OR auth.uid() IN (SELECT id FROM public.profiles WHERE is_admin = true))
|
||||
);
|
||||
|
||||
-- Enable RLS on the profiles table
|
||||
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Create a function to handle new user signups
|
||||
CREATE OR REPLACE FUNCTION public.handle_new_user()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Insert a row into public.profiles
|
||||
INSERT INTO public.profiles (id, username, is_admin)
|
||||
VALUES (NEW.id, NEW.email, FALSE);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- Create a trigger to automatically create a profile for new users
|
||||
CREATE OR REPLACE TRIGGER on_auth_user_created
|
||||
AFTER INSERT ON auth.users
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.handle_new_user();
|
||||
|
||||
-- Set the first user as an admin (optional - uncomment and modify as needed)
|
||||
-- UPDATE public.profiles
|
||||
-- SET is_admin = TRUE
|
||||
-- WHERE id = '[YOUR_USER_ID]';
|
||||
@@ -1,13 +1,20 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('should load the homepage', async ({ page }) => {
|
||||
// Navigate to the homepage
|
||||
await page.goto('/');
|
||||
|
||||
// Check that the page title is correct
|
||||
const title = await page.title();
|
||||
expect(title).toContain('Nut');
|
||||
|
||||
// Verify some key elements are visible
|
||||
await expect(page.locator('header')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Create a project from a preset', async ({ page }) => {
|
||||
await page.goto('http://localhost:5173/');
|
||||
await page.getByRole('button', { name: 'Build a todo app in React' }).click();
|
||||
await page
|
||||
.locator('div')
|
||||
.filter({ hasText: /^Build a todo app in React using Tailwind$/ })
|
||||
.first()
|
||||
.click();
|
||||
await page.getByRole('button', { name: 'Code', exact: true }).click();
|
||||
});
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('Should be able to load a problem', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('banner').locator('div').nth(1).click();
|
||||
await page.getByRole('link', { name: 'Problems' }).click();
|
||||
await page.goto('/problems');
|
||||
await page.getByRole('link', { name: 'Contact book tiny search icon' }).click();
|
||||
await page.getByRole('link', { name: 'Load Problem' }).click();
|
||||
await expect(page.getByText('Import the "problem" folder')).toBeVisible();
|
||||
|
||||
Reference in New Issue
Block a user