shitty ui (arnav will make actually good)

This commit is contained in:
Charmunks 2025-11-02 11:26:05 -05:00
parent c0333f3e74
commit ee4abde45a
6 changed files with 1042 additions and 52 deletions

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Svelte</title>
<title>Hack Club Spaces</title>
</head>
<body>
<div id="app"></div>

View file

@ -1,63 +1,105 @@
<script>
import { onMount } from 'svelte'
import { onMount } from 'svelte';
import Auth from './lib/Auth.svelte';
import Dashboard from './lib/Dashboard.svelte';
import { API_BASE } from './config.js';
import svelteLogo from './assets/svelte.svg'
import Emojis from './lib/Emojis.svelte'
let isAuthenticated = false;
let user = null;
let spaces = [];
let emojisList;
onMount(async () => {
const response = await fetch('http://localhost:5678/api/v1/emojis');
const { emojis } = await response.json();
emojisList = emojis;
console.log(emojisList)
onMount(() => {
const storedAuth = localStorage.getItem('auth_token');
const storedUser = localStorage.getItem('user_data');
if (storedAuth && storedUser) {
try {
isAuthenticated = true;
user = JSON.parse(storedUser);
loadSpaces();
} catch (err) {
localStorage.removeItem('auth_token');
localStorage.removeItem('user_data');
}
}
});
function handleAuthenticated(event) {
const { authorization, username, email } = event.detail;
user = {
authorization,
username,
email
};
localStorage.setItem('auth_token', authorization);
localStorage.setItem('user_data', JSON.stringify(user));
isAuthenticated = true;
loadSpaces();
}
async function loadSpaces() {
if (!user) return;
try {
const response = await fetch(`${API_BASE}/spaces/list`, {
headers: {
'Authorization': user.authorization,
},
});
const data = await response.json();
if (response.ok) {
spaces = data.spaces;
}
} catch (err) {
console.error('Failed to load spaces:', err);
}
}
async function handleSignOut() {
if (user) {
try {
await fetch(`${API_BASE}/users/signout`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ authorization: user.authorization }),
});
} catch (err) {
console.error('Sign out error:', err);
}
}
isAuthenticated = false;
user = null;
spaces = [];
localStorage.removeItem('auth_token');
localStorage.removeItem('user_data');
}
</script>
<main>
<div>
<a href="https://vitejs.dev" target="_blank" rel="noreferrer">
<img src="/vite.svg" class="logo" alt="Vite Logo" />
</a>
<a href="https://svelte.dev" target="_blank" rel="noreferrer">
<img src={svelteLogo} class="logo svelte" alt="Svelte Logo" />
</a>
</div>
<h1>Vite + Svelte</h1>
{#if emojisList}
<div class="card">
<Emojis emojis={emojisList} />
</div>
{#if isAuthenticated && user}
<Dashboard
bind:spaces={spaces}
authorization={user.authorization}
username={user.username}
on:signout={handleSignOut}
/>
{:else}
<Auth on:authenticated={handleAuthenticated} />
{/if}
<p>
Check out <a href="https://github.com/sveltejs/kit#readme" target="_blank" rel="noreferrer">SvelteKit</a>, the official Svelte app framework powered by Vite!
</p>
<p class="read-the-docs">
Click on the Vite and Svelte logos to learn more
</p>
</main>
<style>
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.svelte:hover {
filter: drop-shadow(0 0 2em #ff3e00aa);
}
.read-the-docs {
color: #888;
main {
width: 100%;
min-height: 100vh;
}
</style>

12
client/src/config.js Normal file
View file

@ -0,0 +1,12 @@
export const API_BASE = import.meta.env.API_URL || 'http://localhost:3000/api/v1';
export const ERROR_MESSAGES = {
NETWORK_ERROR: 'Network error. Please try again.',
AUTH_FAILED: 'Authentication failed',
INVALID_EMAIL: 'Invalid email format',
INVALID_CODE: 'Invalid or expired verification code',
CREATE_FAILED: 'Failed to create space',
START_FAILED: 'Failed to start space',
STOP_FAILED: 'Failed to stop space',
STATUS_FAILED: 'Failed to get status',
};

333
client/src/lib/Auth.svelte Normal file
View file

@ -0,0 +1,333 @@
<script>
import { createEventDispatcher } from 'svelte';
import { API_BASE, ERROR_MESSAGES } from '../config.js';
const dispatch = createEventDispatcher();
let mode = 'login'; // 'login', 'signup', or 'verify'
let email = '';
let username = '';
let verificationCode = '';
let error = '';
let loading = false;
let message = '';
async function sendVerificationCode() {
error = '';
message = '';
loading = true;
try {
const response = await fetch(`${API_BASE}/users/send`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
});
const data = await response.json();
if (response.ok) {
message = 'Verification code sent to your email!';
mode = 'verify';
} else {
error = data.message || 'Failed to send verification code';
}
} catch (err) {
error = ERROR_MESSAGES.NETWORK_ERROR;
} finally {
loading = false;
}
}
async function handleLogin() {
error = '';
loading = true;
try {
const response = await fetch(`${API_BASE}/users/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, verificationCode: parseInt(verificationCode) }),
});
const data = await response.json();
if (response.ok) {
dispatch('authenticated', {
authorization: data.data.authorization,
username: data.data.username,
email: data.data.email
});
} else {
error = data.message || 'Login failed';
}
} catch (err) {
error = ERROR_MESSAGES.NETWORK_ERROR;
} finally {
loading = false;
}
}
async function handleSignup() {
error = '';
loading = true;
try {
const response = await fetch(`${API_BASE}/users/signup`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, username, verificationCode: parseInt(verificationCode) }),
});
const data = await response.json();
if (response.ok) {
dispatch('authenticated', {
authorization: data.data.authorization,
username: data.data.username,
email: data.data.email
});
} else {
error = data.message || 'Signup failed';
}
} catch (err) {
error = ERROR_MESSAGES.NETWORK_ERROR;
} finally {
loading = false;
}
}
function switchMode(newMode) {
mode = newMode;
error = '';
message = '';
verificationCode = '';
}
</script>
<div class="auth-container">
<div class="auth-card">
<h2>Hack Club Spaces</h2>
{#if mode === 'login' || mode === 'signup'}
<div class="tabs">
<button
class:active={mode === 'login'}
on:click={() => switchMode('login')}
>
Login
</button>
<button
class:active={mode === 'signup'}
on:click={() => switchMode('signup')}
>
Sign Up
</button>
</div>
<form on:submit|preventDefault={sendVerificationCode}>
<div class="form-group">
<label for="email">Email</label>
<input
id="email"
type="email"
bind:value={email}
required
placeholder="your@email.com"
/>
</div>
{#if mode === 'signup'}
<div class="form-group">
<label for="username">Username</label>
<input
id="username"
type="text"
bind:value={username}
required
maxlength="100"
placeholder="Choose a username"
/>
</div>
{/if}
{#if error}
<div class="error">{error}</div>
{/if}
{#if message}
<div class="success">{message}</div>
{/if}
<button type="submit" disabled={loading || !email}>
{loading ? 'Sending...' : 'Send Verification Code'}
</button>
</form>
{:else if mode === 'verify'}
<p class="info">Check your email for the verification code</p>
<form on:submit|preventDefault={mode === 'login' ? handleLogin : handleSignup}>
<div class="form-group">
<label for="code">Verification Code</label>
<input
id="code"
type="text"
bind:value={verificationCode}
required
placeholder="Enter code from email"
/>
</div>
{#if error}
<div class="error">{error}</div>
{/if}
<button type="submit" disabled={loading || !verificationCode}>
{loading ? 'Verifying...' : mode === 'signup' ? 'Complete Sign Up' : 'Login'}
</button>
<button type="button" class="secondary" on:click={() => switchMode(mode)}>
Resend Code
</button>
</form>
{/if}
</div>
</div>
<style>
.auth-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 2rem;
}
.auth-card {
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 2rem;
width: 100%;
max-width: 400px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
h2 {
text-align: center;
margin-bottom: 2rem;
color: #ec3750;
}
.tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.tabs button {
flex: 1;
padding: 0.75rem;
background: transparent;
border: 1px solid #646cff;
color: #646cff;
cursor: pointer;
transition: all 0.3s;
}
.tabs button.active {
background: #646cff;
color: white;
}
.form-group {
margin-bottom: 1.5rem;
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
input {
width: 100%;
padding: 0.75rem;
border: 1px solid #444;
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
color: inherit;
font-size: 1rem;
box-sizing: border-box;
}
input:focus {
outline: none;
border-color: #646cff;
}
button[type="submit"], .secondary {
width: 100%;
padding: 0.75rem;
margin-top: 0.5rem;
border-radius: 8px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
}
button[type="submit"] {
background: #646cff;
border: none;
color: white;
}
button[type="submit"]:hover:not(:disabled) {
background: #535bf2;
}
button[type="submit"]:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.secondary {
background: transparent;
border: 1px solid #646cff;
color: #646cff;
}
.secondary:hover {
background: rgba(100, 108, 255, 0.1);
}
.error {
padding: 0.75rem;
margin-bottom: 1rem;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 8px;
color: #ef4444;
}
.success {
padding: 0.75rem;
margin-bottom: 1rem;
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
border-radius: 8px;
color: #22c55e;
}
.info {
text-align: center;
margin-bottom: 1.5rem;
color: #888;
}
</style>

View file

@ -0,0 +1,601 @@
<script>
import { createEventDispatcher } from 'svelte';
import { API_BASE, ERROR_MESSAGES } from '../config.js';
export let spaces = [];
export let authorization = '';
export let username = '';
const dispatch = createEventDispatcher();
let showCreateForm = false;
let newSpaceType = 'code-server';
let newSpacePassword = '';
let error = '';
let loading = false;
let actionLoading = {};
let actionError = {};
const spaceTypes = [
{ value: 'code-server', label: 'VS Code Server', description: 'Web-based code editor' },
{ value: 'blender', label: 'Blender 3D', description: '3D modeling and animation' },
{ value: 'kicad', label: 'KiCad', description: 'PCB design software' }
];
async function createSpace() {
error = '';
loading = true;
try {
const response = await fetch(`${API_BASE}/spaces/create`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': authorization,
},
body: JSON.stringify({
password: newSpacePassword,
type: newSpaceType
}),
});
const data = await response.json();
if (response.ok) {
showCreateForm = false;
newSpacePassword = '';
await loadSpaces();
} else {
error = data.error || ERROR_MESSAGES.CREATE_FAILED;
}
} catch (err) {
error = ERROR_MESSAGES.NETWORK_ERROR;
} finally {
loading = false;
}
}
async function loadSpaces() {
try {
const response = await fetch(`${API_BASE}/spaces/list`, {
headers: {
'Authorization': authorization,
},
});
const data = await response.json();
if (response.ok) {
spaces = data.spaces;
}
} catch (err) {
console.error('Failed to load spaces:', err);
}
}
async function startSpace(spaceId) {
actionLoading[spaceId] = 'starting';
actionError[spaceId] = '';
actionLoading = actionLoading;
actionError = actionError;
// Find the space to get its access_url
const space = spaces.find(s => s.id === spaceId);
try {
const response = await fetch(`${API_BASE}/spaces/start/${spaceId}`, {
method: 'POST',
headers: {
'Authorization': authorization,
},
});
const data = await response.json();
if (response.ok) {
await loadSpaces();
} else {
actionError[spaceId] = data.error || ERROR_MESSAGES.START_FAILED;
actionError = actionError;
}
} catch (err) {
actionError[spaceId] = ERROR_MESSAGES.NETWORK_ERROR;
actionError = actionError;
} finally {
delete actionLoading[spaceId];
actionLoading = actionLoading;
if (space && space.access_url) {
window.location.href = space.access_url;
}
}
}
async function stopSpace(spaceId) {
actionLoading[spaceId] = 'stopping';
actionError[spaceId] = '';
actionLoading = actionLoading;
actionError = actionError;
try {
const response = await fetch(`${API_BASE}/spaces/stop/${spaceId}`, {
method: 'POST',
headers: {
'Authorization': authorization,
},
});
const data = await response.json();
if (response.ok) {
await loadSpaces();
} else {
actionError[spaceId] = data.error || ERROR_MESSAGES.STOP_FAILED;
actionError = actionError;
}
} catch (err) {
actionError[spaceId] = ERROR_MESSAGES.NETWORK_ERROR;
actionError = actionError;
} finally {
delete actionLoading[spaceId];
actionLoading = actionLoading;
}
}
async function refreshStatus(spaceId) {
actionLoading[spaceId] = 'refreshing';
actionError[spaceId] = '';
actionLoading = actionLoading;
actionError = actionError;
try {
const response = await fetch(`${API_BASE}/spaces/status/${spaceId}`, {
headers: {
'Authorization': authorization,
},
});
const data = await response.json();
if (response.ok) {
await loadSpaces();
} else {
actionError[spaceId] = data.error || ERROR_MESSAGES.STATUS_FAILED;
actionError = actionError;
}
} catch (err) {
actionError[spaceId] = ERROR_MESSAGES.NETWORK_ERROR;
actionError = actionError;
} finally {
delete actionLoading[spaceId];
actionLoading = actionLoading;
}
}
function handleSignOut() {
dispatch('signout');
}
function getStatusColor(status) {
switch(status?.toLowerCase()) {
case 'running': return '#22c55e';
case 'stopped': return '#ef4444';
case 'created': return '#eab308';
default: return '#888';
}
}
</script>
<div class="dashboard">
<header>
<div>
<h1>Hack Club Spaces</h1>
<p class="welcome">Welcome, {username}!</p>
</div>
<button class="signout" on:click={handleSignOut}>Sign Out</button>
</header>
<div class="content">
<div class="actions-bar">
<button class="primary" on:click={() => showCreateForm = !showCreateForm}>
{showCreateForm ? 'Cancel' : '+ Create New Space'}
</button>
<button class="secondary" on:click={loadSpaces}>
Refresh
</button>
</div>
{#if showCreateForm}
<div class="create-form">
<h3>Create New Space</h3>
<div class="form-group">
<label for="type">Space Type</label>
<select id="type" bind:value={newSpaceType}>
{#each spaceTypes as spaceType}
<option value={spaceType.value}>
{spaceType.label} - {spaceType.description}
</option>
{/each}
</select>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
id="password"
type="password"
bind:value={newSpacePassword}
required
placeholder="Set a password for this space"
/>
</div>
{#if error}
<div class="error">{error}</div>
{/if}
<button
class="primary"
on:click={createSpace}
disabled={loading || !newSpacePassword}
>
{loading ? 'Creating...' : 'Create Space'}
</button>
</div>
{/if}
<div class="spaces-list">
<h3>Your Spaces</h3>
{#if spaces.length === 0}
<div class="empty-state">
<p>No spaces yet. Create your first space to get started!</p>
</div>
{:else}
<div class="spaces-grid">
{#each spaces as space}
<div class="space-card">
<div class="space-header">
<h4>{space.type}</h4>
<span
class="status-badge"
style="background-color: {getStatusColor(space.status)}"
>
{space.status || 'Unknown'}
</span>
</div>
<div class="space-info">
<p><strong>Space ID:</strong> {space.id}</p>
{#if space.url}
<p><strong>URL:</strong>
<a href={space.url} target="_blank" rel="noopener noreferrer">
{space.url}
</a>
</p>
{/if}
<p><strong>Created:</strong> {new Date(space.created_at).toLocaleString()}</p>
</div>
{#if actionError[space.id]}
<div class="error small">{actionError[space.id]}</div>
{/if}
<div class="space-actions">
{#if actionLoading[space.id]}
<button disabled class="action-btn">
{actionLoading[space.id]}...
</button>
{:else}
{#if space.status?.toLowerCase() === 'running'}
<button
class="action-btn stop"
on:click={() => stopSpace(space.id)}
>
Stop
</button>
{#if space.url}
<a
href={space.url}
target="_blank"
rel="noopener noreferrer"
class="action-btn open"
>
Open
</a>
{/if}
{:else}
<button
class="action-btn start"
on:click={() => startSpace(space.id)}
>
Start
</button>
{/if}
<button
class="action-btn refresh"
on:click={() => refreshStatus(space.id)}
>
</button>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
<style>
.dashboard {
min-height: 100vh;
padding: 2rem;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 2px solid #333;
}
h1 {
margin: 0;
color: #ec3750;
}
.welcome {
margin: 0.5rem 0 0 0;
color: #888;
}
.signout {
padding: 0.5rem 1rem;
background: transparent;
border: 1px solid #ef4444;
color: #ef4444;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
}
.signout:hover {
background: rgba(239, 68, 68, 0.1);
}
.content {
max-width: 1200px;
margin: 0 auto;
}
.actions-bar {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
}
.primary {
background: #646cff;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 8px;
cursor: pointer;
font-weight: 500;
transition: all 0.3s;
}
.primary:hover:not(:disabled) {
background: #535bf2;
}
.primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.secondary {
background: transparent;
border: 1px solid #646cff;
color: #646cff;
padding: 0.75rem 1.5rem;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
}
.secondary:hover {
background: rgba(100, 108, 255, 0.1);
}
.create-form {
background: rgba(255, 255, 255, 0.05);
padding: 2rem;
border-radius: 12px;
margin-bottom: 2rem;
}
.create-form h3 {
margin-top: 0;
}
.form-group {
margin-bottom: 1.5rem;
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
input, select {
width: 100%;
padding: 0.75rem;
border: 1px solid #444;
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
color: inherit;
font-size: 1rem;
box-sizing: border-box;
}
input:focus, select:focus {
outline: none;
border-color: #646cff;
}
.error {
padding: 0.75rem;
margin-bottom: 1rem;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 8px;
color: #ef4444;
}
.error.small {
padding: 0.5rem;
font-size: 0.875rem;
}
.spaces-list h3 {
margin-bottom: 1.5rem;
}
.empty-state {
text-align: center;
padding: 3rem;
color: #888;
}
.spaces-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
}
.space-card {
background: rgba(255, 255, 255, 0.05);
padding: 1.5rem;
border-radius: 12px;
border: 1px solid #333;
transition: all 0.3s;
}
.space-card:hover {
border-color: #646cff;
box-shadow: 0 4px 12px rgba(100, 108, 255, 0.1);
}
.space-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.space-header h4 {
margin: 0;
text-transform: capitalize;
}
.status-badge {
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.875rem;
font-weight: 500;
color: white;
}
.space-info {
margin-bottom: 1rem;
font-size: 0.875rem;
}
.space-info p {
margin: 0.5rem 0;
color: #888;
}
.space-info strong {
color: #ccc;
}
.space-info a {
color: #646cff;
text-decoration: none;
}
.space-info a:hover {
text-decoration: underline;
}
.space-actions {
display: flex;
gap: 0.5rem;
}
.action-btn {
flex: 1;
padding: 0.5rem;
border-radius: 8px;
border: none;
cursor: pointer;
font-weight: 500;
transition: all 0.3s;
text-decoration: none;
display: flex;
align-items: center;
justify-content: center;
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.action-btn.start {
background: #22c55e;
color: white;
}
.action-btn.start:hover:not(:disabled) {
background: #16a34a;
}
.action-btn.stop {
background: #ef4444;
color: white;
}
.action-btn.stop:hover:not(:disabled) {
background: #dc2626;
}
.action-btn.open {
background: #646cff;
color: white;
}
.action-btn.open:hover {
background: #535bf2;
}
.action-btn.refresh {
background: transparent;
border: 1px solid #646cff;
color: #646cff;
flex: 0 0 auto;
min-width: 40px;
}
.action-btn.refresh:hover:not(:disabled) {
background: rgba(100, 108, 255, 0.1);
}
</style>

View file

@ -3,3 +3,5 @@ AIRTABLE_API_KEY=your-airtable-pat
AIRTABLE_BASE_ID=your=airtable-baseid
PORT=3000
DOCKER=false
API_URL=http://localhost:3000/api/v1
SERVER_URL=http://localhost