feat: initial commit, draft frontend
This commit is contained in:
13
src/app.d.ts
vendored
Normal file
13
src/app.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
15
src/app.html
Normal file
15
src/app.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
8
src/hooks.server.ts
Normal file
8
src/hooks.server.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { auth } from '$lib/auth';
|
||||
import { svelteKitHandler } from 'better-auth/svelte-kit';
|
||||
import { building } from '$app/environment';
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
|
||||
export const handle: Handle = ({ event, resolve }) => {
|
||||
return svelteKitHandler({ event, resolve, auth, building });
|
||||
};
|
||||
42
src/lib/Header.svelte
Normal file
42
src/lib/Header.svelte
Normal file
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { Avatar, Button, DropdownMenu } from 'bits-ui';
|
||||
import { authClient } from './auth-client';
|
||||
import Image from './Image.svelte';
|
||||
import { User } from '@lucide/svelte';
|
||||
|
||||
const session = authClient.useSession();
|
||||
const user = $derived($session.data?.user);
|
||||
|
||||
const signIn = async () => {
|
||||
await authClient.signIn.social({
|
||||
provider: 'github'
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="h-14 w-full bg-[#292e42]">
|
||||
<div class="mx-auto flex h-full max-w-[78rem] items-center justify-between">
|
||||
<h1 class="header-title font-light">Godot Host</h1>
|
||||
<div>
|
||||
{#if !user}
|
||||
<Button.Root class="rounded-md p-2 transition-colors hover:bg-gray-800" onclick={signIn}>
|
||||
Sign In
|
||||
</Button.Root>
|
||||
{:else}
|
||||
<DropdownMenu.Root open={true}>
|
||||
<DropdownMenu.Trigger>
|
||||
<Image class="shadow-4xl h-10 w-10 rounded-full" src={user.image} />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content class="w-42 rounded-md bg-black shadow-xl shadow-gray-800/5">
|
||||
<DropdownMenu.Item class="mt-1 flex h-10 items-center rounded-md px-2 py-1.5">
|
||||
<User />
|
||||
<span>Sign Out</span>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
21
src/lib/Image.svelte
Normal file
21
src/lib/Image.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
type ImageStatus = 'loading' | 'loaded' | 'error';
|
||||
import type { HTMLImgAttributes } from 'svelte/elements';
|
||||
|
||||
let { src, ...props }: HTMLImgAttributes = $props();
|
||||
let status: ImageStatus = $state('loading');
|
||||
</script>
|
||||
|
||||
{#if status === 'loading'}
|
||||
<div class="h-full w-full animate-pulse bg-gray-800"></div>
|
||||
{/if}
|
||||
|
||||
<img
|
||||
{src}
|
||||
hidden={status === 'loading'}
|
||||
onload={() => {
|
||||
status = 'loaded';
|
||||
}}
|
||||
onerror={() => (status = 'error')}
|
||||
{...props}
|
||||
/>
|
||||
83
src/lib/Projects.svelte
Normal file
83
src/lib/Projects.svelte
Normal file
@@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
import { Play } from '@lucide/svelte';
|
||||
import { Button } from 'bits-ui';
|
||||
import type { Project } from './types/project';
|
||||
import { goto } from '$app/navigation';
|
||||
import Image from './Image.svelte';
|
||||
|
||||
const mockProjects: Project[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Uno',
|
||||
description: 'Recreation of the classic card game',
|
||||
thumbnail: 'https://picsum.photos/seed/uno/400/225',
|
||||
url: '/game/index.html'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Space Invaders',
|
||||
description: 'Retro arcade shooter',
|
||||
thumbnail: 'https://picsum.photos/seed/space/400/225'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Tetris',
|
||||
description: 'Block stacking puzzle game',
|
||||
thumbnail: 'https://picsum.photos/seed/tetris/400/225'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Snake',
|
||||
description: 'Classic snake game with power-ups',
|
||||
thumbnail: 'https://picsum.photos/seed/snake/400/225'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Pong',
|
||||
description: 'Two-player paddle game',
|
||||
thumbnail: 'https://picsum.photos/seed/pong/400/225'
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'Breakout',
|
||||
description: 'Brick-breaking arcade game',
|
||||
thumbnail: 'https://picsum.photos/seed/breakout/400/225'
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each mockProjects as project (project.id)}
|
||||
<div
|
||||
class="group relative overflow-hidden rounded-lg bg-gray-900 shadow-xl ring-1 ring-gray-800 transition-transform hover:scale-[1.02]"
|
||||
>
|
||||
<div class="relative aspect-video overflow-hidden">
|
||||
<Image
|
||||
src={project.thumbnail}
|
||||
alt={project.name}
|
||||
class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity duration-200 group-hover:opacity-100"
|
||||
>
|
||||
<Button.Root
|
||||
class="flex h-10 w-14 items-center justify-center rounded-md bg-white/90 text-gray-900 shadow-lg transition-transform hover:scale-110 hover:bg-white"
|
||||
onclick={() => {
|
||||
goto(`/p/${project.id}`).catch((e) => {
|
||||
console.warn(e);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Play />
|
||||
</Button.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<h3 class="text-lg font-semibold text-white">{project.name}</h3>
|
||||
<p class="mt-1 text-sm text-gray-400">{project.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
5
src/lib/auth-client.ts
Normal file
5
src/lib/auth-client.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createAuthClient } from 'better-auth/svelte';
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: 'http://localhost:5173'
|
||||
});
|
||||
17
src/lib/auth.ts
Normal file
17
src/lib/auth.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { betterAuth } from 'better-auth';
|
||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
||||
import { db } from './db/drizzle';
|
||||
import * as authSchema from './db/auth-schema';
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
provider: 'pg',
|
||||
schema: { ...authSchema }
|
||||
}),
|
||||
socialProviders: {
|
||||
github: {
|
||||
clientId: process.env.GH_CLIENT_ID!,
|
||||
clientSecret: process.env.GH_CLIENT_SECRET!
|
||||
}
|
||||
}
|
||||
});
|
||||
93
src/lib/db/auth-schema.ts
Normal file
93
src/lib/db/auth-schema.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import { pgTable, text, timestamp, boolean, index } from "drizzle-orm/pg-core";
|
||||
|
||||
export const user = pgTable("user", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull().unique(),
|
||||
emailVerified: boolean("email_verified").default(false).notNull(),
|
||||
image: text("image"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
});
|
||||
|
||||
export const session = pgTable(
|
||||
"session",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
token: text("token").notNull().unique(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
},
|
||||
(table) => [index("session_userId_idx").on(table.userId)],
|
||||
);
|
||||
|
||||
export const account = pgTable(
|
||||
"account",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
accountId: text("account_id").notNull(),
|
||||
providerId: text("provider_id").notNull(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
accessToken: text("access_token"),
|
||||
refreshToken: text("refresh_token"),
|
||||
idToken: text("id_token"),
|
||||
accessTokenExpiresAt: timestamp("access_token_expires_at"),
|
||||
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
|
||||
scope: text("scope"),
|
||||
password: text("password"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [index("account_userId_idx").on(table.userId)],
|
||||
);
|
||||
|
||||
export const verification = pgTable(
|
||||
"verification",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
identifier: text("identifier").notNull(),
|
||||
value: text("value").notNull(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [index("verification_identifier_idx").on(table.identifier)],
|
||||
);
|
||||
|
||||
export const userRelations = relations(user, ({ many }) => ({
|
||||
sessions: many(session),
|
||||
accounts: many(account),
|
||||
}));
|
||||
|
||||
export const sessionRelations = relations(session, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [session.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const accountRelations = relations(account, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [account.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
3
src/lib/db/drizzle.ts
Normal file
3
src/lib/db/drizzle.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { drizzle } from 'drizzle-orm/neon-http';
|
||||
|
||||
export const db = drizzle(process.env.DATABASE_URL!);
|
||||
7
src/lib/types/project.ts
Normal file
7
src/lib/types/project.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type Project = {
|
||||
id: number;
|
||||
name: string;
|
||||
thumbnail?: string;
|
||||
description: string;
|
||||
url?: string;
|
||||
};
|
||||
9
src/routes/+layout.svelte
Normal file
9
src/routes/+layout.svelte
Normal file
@@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
import Header from '$lib/Header.svelte';
|
||||
import './layout.css';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<Header />
|
||||
{@render children()}
|
||||
1
src/routes/+layout.ts
Normal file
1
src/routes/+layout.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const ssr = false;
|
||||
11
src/routes/+page.svelte
Normal file
11
src/routes/+page.svelte
Normal file
@@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
import Projects from '$lib/Projects.svelte';
|
||||
</script>
|
||||
|
||||
<main class="mt-8 flex flex-col items-center justify-center">
|
||||
<h1 class="flex justify-center text-2xl font-semibold">Projects</h1>
|
||||
|
||||
<section class="m-4 mt-4 max-w-4xl border-t-4 border-gray-700/80 p-4 px-12 pt-6">
|
||||
<Projects />
|
||||
</section>
|
||||
</main>
|
||||
128
src/routes/layout.css
Normal file
128
src/routes/layout.css
Normal file
@@ -0,0 +1,128 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
/* background-color: #242424; */
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* a { */
|
||||
/* font-weight: 500; */
|
||||
/* color: #646cff; */
|
||||
/* text-decoration: inherit; */
|
||||
/* } */
|
||||
/* a:hover { */
|
||||
/* color: #535bf2; */
|
||||
/* } */
|
||||
|
||||
/* body { */
|
||||
/* margin: 0; */
|
||||
/* display: flex; */
|
||||
/* place-items: center; */
|
||||
/* min-width: 320px; */
|
||||
/* min-height: 100vh; */
|
||||
/* } */
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
/* #app { */
|
||||
/* max-width: 1280px; */
|
||||
/* margin: 0 auto; */
|
||||
/* padding: 2rem; */
|
||||
/* text-align: center; */
|
||||
/* } */
|
||||
|
||||
.header-title {
|
||||
font-family: 'Times New Roman';
|
||||
}
|
||||
|
||||
button:default {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
/* transition: border-color 0.25s, background-color 0.2s; */
|
||||
color: #1a1b26
|
||||
}
|
||||
|
||||
button:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: default;
|
||||
@apply bg-zinc-700
|
||||
}
|
||||
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@keyframes grow-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes grow-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
[data-dropdown-menu-content][data-state="open"] {
|
||||
animation: grow-in 150ms ease-out;
|
||||
transform-origin: top;
|
||||
}
|
||||
|
||||
[data-dropdown-menu-content][data-state="closed"] {
|
||||
animation: grow-out 150ms ease-out;
|
||||
transform-origin: top;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
a:default:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
|
||||
button:default {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
90
src/routes/p/[id]/+page.svelte
Normal file
90
src/routes/p/[id]/+page.svelte
Normal file
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { Button } from 'bits-ui';
|
||||
import { Maximize, Minimize, ArrowLeft } from '@lucide/svelte';
|
||||
|
||||
const id = $derived(page.params.id);
|
||||
const src = $derived(`/projects/${id}/index.html`);
|
||||
|
||||
let isFullscreen = $state(false);
|
||||
let containerRef = $state<HTMLDivElement | null>(null);
|
||||
let iframeRef = $state<HTMLIFrameElement | null>(null);
|
||||
|
||||
const toggleFullscreen = async () => {
|
||||
if (!containerRef) return;
|
||||
|
||||
if (!document.fullscreenElement) {
|
||||
await containerRef.requestFullscreen();
|
||||
isFullscreen = true;
|
||||
iframeRef?.focus();
|
||||
} else {
|
||||
await document.exitFullscreen();
|
||||
isFullscreen = false;
|
||||
}
|
||||
};
|
||||
|
||||
const minimize = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (isFullscreen) {
|
||||
isFullscreen = false;
|
||||
}
|
||||
iframeRef?.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFullscreenChange = () => {
|
||||
isFullscreen = !!document.fullscreenElement;
|
||||
if (!isFullscreen) {
|
||||
iframeRef?.blur();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={minimize} />
|
||||
<svelte:document onfullscreenchange={handleFullscreenChange} />
|
||||
|
||||
<main class="flex min-h-screen flex-col items-center px-4 py-8">
|
||||
<div class="relative mb-6 flex w-full max-w-4xl items-center justify-center">
|
||||
<Button.Root
|
||||
class="absolute left-0 flex items-center gap-2 rounded-md px-3 py-2 text-gray-400 transition-colors hover:bg-gray-800 hover:text-white"
|
||||
onclick={() => goto('/')}
|
||||
>
|
||||
<ArrowLeft class="h-4 w-4" />
|
||||
<span>Back</span>
|
||||
</Button.Root>
|
||||
|
||||
<h1 class="text-xl font-semibold text-white">Project {id}</h1>
|
||||
</div>
|
||||
|
||||
<div
|
||||
bind:this={containerRef}
|
||||
class="relative w-full max-w-4xl overflow-hidden rounded-md bg-black shadow-2xl ring-1 ring-gray-700/80"
|
||||
class:max-w-none={isFullscreen}
|
||||
class:h-screen={isFullscreen}
|
||||
class:rounded-none={isFullscreen}
|
||||
>
|
||||
<div class="aspect-video" class:aspect-auto={isFullscreen} class:h-full={isFullscreen}>
|
||||
<iframe
|
||||
bind:this={iframeRef}
|
||||
{src}
|
||||
title="Project: {id}"
|
||||
class="h-full w-full border-0"
|
||||
allow="fullscreen"
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-0 bottom-0 flex items-center justify-between p-4"
|
||||
>
|
||||
<span class="text-sm text-white/50">Press ESC to exit</span>
|
||||
<Button.Root class="pointer-events-auto" onclick={toggleFullscreen}>
|
||||
{#if isFullscreen}
|
||||
<Minimize class="h-6 w-6" />
|
||||
{:else}
|
||||
<Maximize class="h-6 w-6" />
|
||||
{/if}
|
||||
</Button.Root>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
Reference in New Issue
Block a user