Keep track of API usage in user accounts, improve approval mechanism (#95)

This commit is contained in:
Brian Hackett
2025-04-04 14:40:10 -07:00
committed by GitHub
parent 4f5051dee5
commit 9389fb2afc
11 changed files with 190 additions and 120 deletions

View File

@@ -11,6 +11,7 @@ export function ClientAuth() {
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [isSigningIn, setIsSigningIn] = useState(false); const [isSigningIn, setIsSigningIn] = useState(false);
const [showDropdown, setShowDropdown] = useState(false); const [showDropdown, setShowDropdown] = useState(false);
const [usageData, setUsageData] = useState<{ peanuts_used: number; peanuts_refunded: number } | null>(null);
useEffect(() => { useEffect(() => {
async function getUser() { async function getUser() {
@@ -37,6 +38,30 @@ export function ClientAuth() {
}; };
}, []); }, []);
useEffect(() => {
async function updateUsageData() {
try {
const { data, error } = await getSupabase()
.from('profiles')
.select('peanuts_used, peanuts_refunded')
.eq('id', user?.id)
.single();
if (error) {
throw error;
}
setUsageData(data);
} catch (error) {
console.error('Error fetching usage data:', error);
}
}
if (showDropdown) {
updateUsageData();
}
}, [showDropdown]);
const handleSignIn = async (e: React.FormEvent) => { const handleSignIn = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setIsSigningIn(true); setIsSigningIn(true);
@@ -119,10 +144,16 @@ export function ClientAuth() {
</button> </button>
{showDropdown && ( {showDropdown && (
<div className="absolute right-0 mt-2 py-2 bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor rounded shadow-lg z-10"> <div className="absolute right-0 mt-2 py-2 whitespace-nowrap 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"> <div className="px-4 py-2 text-bolt-elements-textPrimary border-b border-bolt-elements-borderColor">
{user.email} {user.email}
</div> </div>
<div className="px-4 py-2 text-bolt-elements-textPrimary border-b border-bolt-elements-borderColor">
{`Peanuts used: ${usageData?.peanuts_used ?? '...'}`}
</div>
<div className="px-4 py-2 text-bolt-elements-textPrimary border-b border-bolt-elements-borderColor">
{`Peanuts refunded: ${usageData?.peanuts_refunded ?? '...'}`}
</div>
<button <button
onClick={handleSignOut} onClick={handleSignOut}
className="block w-full text-left px-4 py-2 text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-2" className="block w-full text-left px-4 py-2 text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-2"

View File

@@ -9,7 +9,7 @@ import { IconButton } from '~/components/ui/IconButton';
import { Workbench } from '~/components/workbench/Workbench.client'; import { Workbench } from '~/components/workbench/Workbench.client';
import { classNames } from '~/utils/classNames'; import { classNames } from '~/utils/classNames';
import { Messages } from './Messages.client'; import { Messages } from './Messages.client';
import { getPreviousRepositoryId, type Message } from '~/lib/persistence/message'; import { type Message } from '~/lib/persistence/message';
import { SendButton } from './SendButton.client'; import { SendButton } from './SendButton.client';
import * as Tooltip from '@radix-ui/react-tooltip'; import * as Tooltip from '@radix-ui/react-tooltip';
@@ -20,7 +20,6 @@ import FilePreview from './FilePreview';
import { SpeechRecognitionButton } from '~/components/chat/SpeechRecognition'; import { SpeechRecognitionButton } from '~/components/chat/SpeechRecognition';
import { ScreenshotStateManager } from './ScreenshotStateManager'; import { ScreenshotStateManager } from './ScreenshotStateManager';
import type { RejectChangeData } from './ApproveChange'; import type { RejectChangeData } from './ApproveChange';
import { assert } from '~/lib/replay/ReplayProtocolClient';
import ApproveChange from './ApproveChange'; import ApproveChange from './ApproveChange';
export const TEXTAREA_MIN_HEIGHT = 76; export const TEXTAREA_MIN_HEIGHT = 76;
@@ -46,10 +45,8 @@ interface BaseChatProps {
setUploadedFiles?: (files: File[]) => void; setUploadedFiles?: (files: File[]) => void;
imageDataList?: string[]; imageDataList?: string[];
setImageDataList?: (dataList: string[]) => void; setImageDataList?: (dataList: string[]) => void;
onRewind?: (messageId: string) => void;
approveChangesMessageId?: string;
onApproveChange?: (messageId: string) => void; onApproveChange?: (messageId: string) => void;
onRejectChange?: (lastMessageId: string, data: RejectChangeData) => void; onRejectChange?: (messageId: string, data: RejectChangeData) => void;
} }
export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>( export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
@@ -72,8 +69,6 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
imageDataList = [], imageDataList = [],
setImageDataList, setImageDataList,
messages, messages,
onRewind,
approveChangesMessageId,
onApproveChange, onApproveChange,
onRejectChange, onRejectChange,
}, },
@@ -209,30 +204,21 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
} }
}; };
const showApproveChange = (() => { const approveChangeMessageId = (() => {
if (hasPendingMessage) { if (hasPendingMessage || !messages) {
return false; return undefined;
} }
if (!messages?.length) { for (let i = messages.length - 1; i >= 0; i--) {
return false; const message = messages[i];
if (message.repositoryId && message.peanuts) {
return message.approved ? undefined : message.id;
}
if (message.role == 'user') {
return undefined;
}
} }
return undefined;
const lastMessage = messages[messages.length - 1];
if (!lastMessage.repositoryId) {
return false;
}
if (!getPreviousRepositoryId(messages, messages.length - 1)) {
return false;
}
if (lastMessage.id != approveChangesMessageId) {
return false;
}
return true;
})(); })();
let messageInput; let messageInput;
@@ -369,7 +355,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
Get what you want Get what you want
</h1> </h1>
<p className="text-md lg:text-xl mb-8 text-bolt-elements-textSecondary animate-fade-in animation-delay-200"> <p className="text-md lg:text-xl mb-8 text-bolt-elements-textSecondary animate-fade-in animation-delay-200">
Build, test, and fix your app all from one prompt Write, test, and fix your app all from one prompt
</p> </p>
</div> </div>
)} )}
@@ -387,7 +373,6 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
messages={messages} messages={messages}
hasPendingMessage={hasPendingMessage} hasPendingMessage={hasPendingMessage}
pendingMessageStatus={pendingMessageStatus} pendingMessageStatus={pendingMessageStatus}
onRewind={onRewind}
/> />
) : null; ) : null;
}} }}
@@ -444,24 +429,12 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
/> />
)} )}
</ClientOnly> </ClientOnly>
{showApproveChange && ( {approveChangeMessageId && (
<ApproveChange <ApproveChange
rejectFormOpen={rejectFormOpen} rejectFormOpen={rejectFormOpen}
setRejectFormOpen={setRejectFormOpen} setRejectFormOpen={setRejectFormOpen}
onApprove={() => { onApprove={() => onApproveChange?.(approveChangeMessageId)}
if (onApproveChange && messages) { onReject={(data) => onRejectChange?.(approveChangeMessageId, data)}
const lastMessage = messages[messages.length - 1];
assert(lastMessage);
onApproveChange(lastMessage.id);
}
}}
onReject={(data) => {
if (onRejectChange && messages) {
const lastMessage = messages[messages.length - 1];
assert(lastMessage);
onRejectChange(lastMessage.id, data);
}
}}
/> />
)} )}
{!rejectFormOpen && messageInput} {!rejectFormOpen && messageInput}

View File

@@ -34,6 +34,7 @@ import { getMessagesRepositoryId, getPreviousRepositoryId, type Message } from '
import { useAuthStatus } from '~/lib/stores/auth'; import { useAuthStatus } from '~/lib/stores/auth';
import { debounce } from '~/utils/debounce'; import { debounce } from '~/utils/debounce';
import { supabaseSubmitFeedback } from '~/lib/supabase/feedback'; import { supabaseSubmitFeedback } from '~/lib/supabase/feedback';
import { supabaseAddRefund } from '~/lib/supabase/peanuts';
const toastAnimation = cssTransition({ const toastAnimation = cssTransition({
enter: 'animated fadeInRight', enter: 'animated fadeInRight',
@@ -142,6 +143,22 @@ function mergeResponseMessage(msg: Message, messages: Message[]): Message[] {
return messages; return messages;
} }
// Get the index of the last message that will be present after rewinding.
// This is the last message which is either a user message or has a repository change.
function getRewindMessageIndexAfterReject(messages: Message[], messageId: string): number {
for (let i = messages.length - 1; i >= 0; i--) {
const { id, role, repositoryId } = messages[i];
if (role == 'user') {
return i;
}
if (repositoryId && id != messageId) {
return i;
}
}
console.error('No rewind message found', messages, messageId);
return -1;
}
export const ChatImpl = memo((props: ChatProps) => { export const ChatImpl = memo((props: ChatProps) => {
const { initialMessages, resumeChat: initialResumeChat, storeMessageHistory } = props; const { initialMessages, resumeChat: initialResumeChat, storeMessageHistory } = props;
@@ -150,7 +167,6 @@ export const ChatImpl = memo((props: ChatProps) => {
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]); // Move here const [uploadedFiles, setUploadedFiles] = useState<File[]>([]); // Move here
const [imageDataList, setImageDataList] = useState<string[]>([]); // Move here const [imageDataList, setImageDataList] = useState<string[]>([]); // Move here
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [approveChangesMessageId, setApproveChangesMessageId] = useState<string | undefined>(undefined);
const { isLoggedIn } = useAuthStatus(); const { isLoggedIn } = useAuthStatus();
// Input currently in the textarea. // Input currently in the textarea.
@@ -209,6 +225,7 @@ export const ChatImpl = memo((props: ChatProps) => {
gNumAborts++; gNumAborts++;
chatStore.aborted.set(true); chatStore.aborted.set(true);
setPendingMessageId(undefined); setPendingMessageId(undefined);
setPendingMessageStatus('');
setResumeChat(undefined); setResumeChat(undefined);
const chatId = chatStore.currentChat.get()?.id; const chatId = chatStore.currentChat.get()?.id;
@@ -390,10 +407,7 @@ export const ChatImpl = memo((props: ChatProps) => {
textareaRef.current?.blur(); textareaRef.current?.blur();
if (updatedRepository) { if (!updatedRepository) {
const lastMessage = newMessages[newMessages.length - 1];
setApproveChangesMessageId(lastMessage.id);
} else {
simulationReset(); simulationReset();
} }
}; };
@@ -482,33 +496,6 @@ export const ChatImpl = memo((props: ChatProps) => {
})(); })();
}, [initialResumeChat]); }, [initialResumeChat]);
// Rewind far enough to erase the specified message.
const onRewind = async (messageId: string) => {
console.log('Rewinding', messageId);
const messageIndex = messages.findIndex((message) => message.id === messageId);
if (messageIndex < 0) {
toast.error('Rewind message not found');
return;
}
const previousRepositoryId = getPreviousRepositoryId(messages, messageIndex);
if (!previousRepositoryId) {
toast.error('No repository ID found for rewind');
return;
}
setMessages(messages.slice(0, messageIndex));
simulationRepositoryUpdated(previousRepositoryId);
pingTelemetry('RewindChat', {
numMessages: messages.length,
rewindIndex: messageIndex,
});
};
const flashScreen = async () => { const flashScreen = async () => {
const flash = document.createElement('div'); const flash = document.createElement('div');
flash.style.position = 'fixed'; flash.style.position = 'fixed';
@@ -534,7 +521,17 @@ export const ChatImpl = memo((props: ChatProps) => {
const onApproveChange = async (messageId: string) => { const onApproveChange = async (messageId: string) => {
console.log('ApproveChange', messageId); console.log('ApproveChange', messageId);
setApproveChangesMessageId(undefined); setMessages(
messages.map((message) => {
if (message.id == messageId) {
return {
...message,
approved: true,
};
}
return message;
}),
);
await flashScreen(); await flashScreen();
@@ -546,23 +543,31 @@ export const ChatImpl = memo((props: ChatProps) => {
const onRejectChange = async (messageId: string, data: RejectChangeData) => { const onRejectChange = async (messageId: string, data: RejectChangeData) => {
console.log('RejectChange', messageId, data); console.log('RejectChange', messageId, data);
setApproveChangesMessageId(undefined); // Find the last message that will be present after rewinding. This is the
// last message which is either a user message or has a repository change.
const messageIndex = getRewindMessageIndexAfterReject(messages, messageId);
const message = messages.find((message) => message.id === messageId); if (messageIndex < 0) {
assert(message, 'Message not found'); toast.error('Rewind message not found');
assert(message == messages[messages.length - 1], 'Message must be the last message'); return;
// Erase all messages since the last user message.
let rewindMessageId = message.id;
for (let i = messages.length - 2; i >= 0; i--) {
if (messages[i].role == 'user') {
break;
}
rewindMessageId = messages[i].id;
} }
await onRewind(rewindMessageId);
const message = messages.find((m) => m.id == messageId);
if (!message) {
toast.error('Message not found');
return;
}
if (message.peanuts) {
await supabaseAddRefund(message.peanuts);
}
const previousRepositoryId = getPreviousRepositoryId(messages, messageIndex + 1);
setMessages(messages.slice(0, messageIndex + 1));
simulationRepositoryUpdated(previousRepositoryId);
let shareProjectSuccess = false; let shareProjectSuccess = false;
@@ -615,8 +620,6 @@ export const ChatImpl = memo((props: ChatProps) => {
setUploadedFiles={setUploadedFiles} setUploadedFiles={setUploadedFiles}
imageDataList={imageDataList} imageDataList={imageDataList}
setImageDataList={setImageDataList} setImageDataList={setImageDataList}
onRewind={onRewind}
approveChangesMessageId={approveChangesMessageId}
onApproveChange={onApproveChange} onApproveChange={onApproveChange}
onRejectChange={onRejectChange} onRejectChange={onRejectChange}
/> />

View File

@@ -1,7 +1,7 @@
import React, { Suspense } from 'react'; import React, { Suspense } from 'react';
import { classNames } from '~/utils/classNames'; import { classNames } from '~/utils/classNames';
import WithTooltip from '~/components/ui/Tooltip'; import WithTooltip from '~/components/ui/Tooltip';
import { getPreviousRepositoryId, type Message } from '~/lib/persistence/message'; import type { Message } from '~/lib/persistence/message';
import { MessageContents } from './MessageContents'; import { MessageContents } from './MessageContents';
interface MessagesProps { interface MessagesProps {
@@ -10,18 +10,16 @@ interface MessagesProps {
hasPendingMessage?: boolean; hasPendingMessage?: boolean;
pendingMessageStatus?: string; pendingMessageStatus?: string;
messages?: Message[]; messages?: Message[];
onRewind?: (messageId: string) => void;
} }
export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props: MessagesProps, ref) => { export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props: MessagesProps, ref) => {
const { id, hasPendingMessage = false, pendingMessageStatus = '', messages = [], onRewind } = props; const { id, hasPendingMessage = false, pendingMessageStatus = '', messages = [] } = props;
return ( return (
<div id={id} ref={ref} className={props.className}> <div id={id} ref={ref} className={props.className}>
{messages.length > 0 {messages.length > 0
? messages.map((message, index) => { ? messages.map((message, index) => {
const { role, id: messageId, repositoryId } = message; const { role, repositoryId } = message;
const previousRepositoryId = getPreviousRepositoryId(messages, index);
const isUserMessage = role === 'user'; const isUserMessage = role === 'user';
const isFirst = index === 0; const isFirst = index === 0;
const isLast = index === messages.length - 1; const isLast = index === messages.length - 1;
@@ -51,16 +49,15 @@ export const Messages = React.forwardRef<HTMLDivElement, MessagesProps>((props:
<div className="grid grid-col-1 w-full"> <div className="grid grid-col-1 w-full">
<MessageContents message={message} /> <MessageContents message={message} />
</div> </div>
{previousRepositoryId && repositoryId && onRewind && ( {repositoryId && (
<div className="flex gap-2 flex-col lg:flex-row"> <div className="flex gap-2 flex-col lg:flex-row">
<WithTooltip tooltip="Undo changes in this message"> <WithTooltip tooltip="Start new chat from here">
<button <button
onClick={() => { onClick={() => {
onRewind(messageId); window.open(`/repository/${repositoryId}`, '_blank');
}} }}
key="i-ph:arrow-u-up-left"
className={classNames( className={classNames(
'i-ph:arrow-u-up-left', 'i-ph:git-fork',
'text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors', 'text-xl text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary transition-colors',
)} )}
/> />

View File

@@ -25,7 +25,12 @@ export function Header() {
<img src="/logo-styled.svg" alt="logo" className="w-[40px] inline-block rotate-90" /> <img src="/logo-styled.svg" alt="logo" className="w-[40px] inline-block rotate-90" />
</a> </a>
<Feedback /> <Feedback />
<a href="https://www.replay.io/discord" className="text-bolt-elements-accent underline hover:no-underline" target="_blank" rel="noopener noreferrer"> <a
href="https://www.replay.io/discord"
className="text-bolt-elements-accent underline hover:no-underline"
target="_blank"
rel="noopener noreferrer"
>
<div className="i-ph:discord-logo-fill text-[1.3em]" /> <div className="i-ph:discord-logo-fill text-[1.3em]" />
</a> </a>
</div> </div>

View File

@@ -8,6 +8,11 @@ interface MessageBase {
id: string; id: string;
role: MessageRole; role: MessageRole;
repositoryId?: string; repositoryId?: string;
peanuts?: number;
// Not part of the protocol, indicates whether the user has explicitly approved
// the message. Once approved, the approve/reject UI is not shown again for the message.
approved?: boolean;
} }
interface MessageText extends MessageBase { interface MessageText extends MessageBase {

View File

@@ -56,20 +56,28 @@ class DevelopmentServerManager {
let gActiveDevelopmentServer: DevelopmentServerManager | undefined; let gActiveDevelopmentServer: DevelopmentServerManager | undefined;
export async function updateDevelopmentServer(repositoryId: string) { export async function updateDevelopmentServer(repositoryId: string | undefined) {
console.log('UpdateDevelopmentServer', new Date().toISOString(), repositoryId); console.log('UpdateDevelopmentServer', new Date().toISOString(), repositoryId);
workbenchStore.showWorkbench.set(true); workbenchStore.showWorkbench.set(repositoryId !== undefined);
workbenchStore.repositoryId.set(repositoryId); workbenchStore.repositoryId.set(repositoryId);
workbenchStore.previewURL.set(undefined); workbenchStore.previewURL.set(undefined);
workbenchStore.previewError.set(false); workbenchStore.previewError.set(false);
if (!repositoryId) {
return;
}
if (!gActiveDevelopmentServer) { if (!gActiveDevelopmentServer) {
gActiveDevelopmentServer = new DevelopmentServerManager(); gActiveDevelopmentServer = new DevelopmentServerManager();
} }
const url = await gActiveDevelopmentServer.setRepositoryContents(repositoryId); const url = await gActiveDevelopmentServer.setRepositoryContents(repositoryId);
if (workbenchStore.repositoryId.get() != repositoryId) {
return;
}
if (url) { if (url) {
workbenchStore.previewURL.set(url); workbenchStore.previewURL.set(url);
} else { } else {

View File

@@ -11,6 +11,7 @@ import type { Message } from '~/lib/persistence/message';
import { database } from '~/lib/persistence/db'; import { database } from '~/lib/persistence/db';
import { chatStore } from '~/lib/stores/chat'; import { chatStore } from '~/lib/stores/chat';
import { debounce } from '~/utils/debounce'; import { debounce } from '~/utils/debounce';
import { getSupabase } from '~/lib/supabase/client';
function createRepositoryIdPacket(repositoryId: string): SimulationPacket { function createRepositoryIdPacket(repositoryId: string): SimulationPacket {
return { return {
@@ -64,6 +65,15 @@ class ChatManager {
await this.client.initialize(); await this.client.initialize();
const {
data: { user },
} = await getSupabase().auth.getUser();
const userId = user?.id || null;
if (userId) {
await this.client.sendCommand({ method: 'Nut.setUserId', params: { userId } });
}
const { chatId } = (await this.client.sendCommand({ method: 'Nut.startChat', params: {} })) as { chatId: string }; const { chatId } = (await this.client.sendCommand({ method: 'Nut.startChat', params: {} })) as { chatId: string };
console.log('ChatStarted', new Date().toISOString(), chatId); console.log('ChatStarted', new Date().toISOString(), chatId);
@@ -235,7 +245,7 @@ function startChat(repositoryId: string | undefined, pageData: SimulationData) {
* Called when the repository has changed. We'll start a new chat * Called when the repository has changed. We'll start a new chat
* and update the remote development server. * and update the remote development server.
*/ */
export const simulationRepositoryUpdated = debounce((repositoryId: string) => { export const simulationRepositoryUpdated = debounce((repositoryId: string | undefined) => {
startChat(repositoryId, []); startChat(repositoryId, []);
updateDevelopmentServer(repositoryId); updateDevelopmentServer(repositoryId);
}, 500); }, 500);

View File

@@ -0,0 +1,37 @@
import { getSupabase } from './client';
export async function supabaseAddRefund(peanuts: number) {
const supabase = getSupabase();
// Get the current user ID if available
const {
data: { user },
} = await supabase.auth.getUser();
const userId = user?.id || null;
const { data, error } = await supabase.from('profiles').select('peanuts_refunded').eq('id', userId).single();
if (error) {
console.error('AddPeanutsRefund:ErrorFetchingData', { error });
return;
}
const currentPeanutsRefunded = data.peanuts_refunded;
if (typeof currentPeanutsRefunded !== 'number') {
console.error('AddPeanutsRefund:InvalidPeanutsRefunded', { currentPeanutsRefunded });
return;
}
const newPeanutsRefunded = Math.round(currentPeanutsRefunded + peanuts);
// Note: this is not atomic.
// https://linear.app/replay/issue/PRO-1122/update-api-usage-atomically
const { error: updateError } = await supabase
.from('profiles')
.update({ peanuts_refunded: newPeanutsRefunded })
.eq('id', userId);
if (updateError) {
console.error('AddPeanutsRefund:ErrorUpdatingData', { updateError });
}
}

View File

@@ -15,10 +15,9 @@ function AboutPage() {
<h1 className="text-4xl font-bold mb-8 text-gray-900 dark:text-gray-200">About Nut</h1> <h1 className="text-4xl font-bold mb-8 text-gray-900 dark:text-gray-200">About Nut</h1>
<p className="mb-6"> <p className="mb-6">
Nut is an agentic app builder for reliably developing full stack apps using AI. Nut is an agentic app builder for reliably developing full stack apps using AI. When you ask Nut to build or
When you ask Nut to build or change an app, it will do its best to get the code change an app, it will do its best to get the code changes right the first time. Afterwards it will check
changes right the first time. Afterwards it will check the app to make sure it's the app to make sure it's working as expected, writing tests and fixing problems those tests uncover.
working as expected, writing tests and fixing problems those tests uncover.
</p> </p>
<p className="mb-6"> <p className="mb-6">
@@ -45,9 +44,8 @@ function AboutPage() {
rel="noopener noreferrer" rel="noopener noreferrer"
> >
Replay.io Replay.io
</a>{' '} team. </a>{' '}
We'd love to hear from you! Leave us some feedback at the top of the page, team. We'd love to hear from you! Leave us some feedback at the top of the page, join our{' '}
join our{' '}
<a <a
href="https://www.replay.io/discord" href="https://www.replay.io/discord"
className="text-bolt-elements-accent underline hover:no-underline" className="text-bolt-elements-accent underline hover:no-underline"
@@ -56,10 +54,11 @@ function AboutPage() {
> >
Discord Discord
</a>{' '} </a>{' '}
or reach us at {' '} or reach us at{' '}
<a href="mailto:hi@replay.io" className="text-bolt-elements-accent underline hover:no-underline"> <a href="mailto:hi@replay.io" className="text-bolt-elements-accent underline hover:no-underline">
hi@replay.io hi@replay.io
</a>. </a>
.
</p> </p>
</div> </div>
</div> </div>

View File

@@ -6,7 +6,9 @@ CREATE TABLE IF NOT EXISTS public.profiles (
username TEXT UNIQUE, username TEXT UNIQUE,
full_name TEXT, full_name TEXT,
avatar_url TEXT, avatar_url TEXT,
is_admin BOOLEAN DEFAULT FALSE NOT NULL is_admin BOOLEAN DEFAULT FALSE NOT NULL,
peanuts_used INTEGER DEFAULT 0 NOT NULL,
peanuts_refunded INTEGER DEFAULT 0 NOT NULL
); );
-- Create a trigger to update the updated_at column -- Create a trigger to update the updated_at column