mirror of
https://github.com/System-End/spaces.git
synced 2026-04-19 16:38:24 +00:00
shitty ui (arnav will make actually good)
This commit is contained in:
parent
c0333f3e74
commit
ee4abde45a
6 changed files with 1042 additions and 52 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
12
client/src/config.js
Normal 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
333
client/src/lib/Auth.svelte
Normal 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>
|
||||
601
client/src/lib/Dashboard.svelte
Normal file
601
client/src/lib/Dashboard.svelte
Normal 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>
|
||||
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue