add supabase

This commit is contained in:
Jason Laster
2025-02-27 14:17:43 -08:00
parent 3b9e859fc8
commit 58f2cb0f58
23 changed files with 10625 additions and 7569 deletions

View File

@@ -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
View File

@@ -7,6 +7,8 @@ yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
.cursor
supabase/.temp/
node_modules
dist

View 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>
);
}

View 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>
);
}

View 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>
)}
</>
);
}

View File

@@ -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
View 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 {};

View File

@@ -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
View 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
View 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);
};

View 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;
}
}

View File

@@ -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 />
</>
);
}

View File

@@ -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]);

View File

@@ -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({

View File

@@ -11,3 +11,10 @@ interface Performance {
usedJSHeapSize: number;
};
}
// Add browser property to Process interface
declare namespace NodeJS {
interface Process {
browser?: boolean;
}
}

View File

@@ -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

File diff suppressed because it is too large Load Diff

View 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;

View File

@@ -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;

View File

@@ -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;

View 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]';

View File

@@ -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();
});

View File

@@ -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();