🪻 distributed transcription service thistle.dunkirk.sh

feat: add a better header implementation

dunkirk.sh d24ac67e b4bc2798

verified
Changed files
+244 -204
src
+171 -150
src/components/auth.ts
···
static override styles = css`
:host {
display: block;
-
position: fixed;
-
top: 2rem;
-
right: 2rem;
-
z-index: 1000;
}
.auth-button {
···
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
}
-
.modal h2 {
margin-top: 0;
color: var(--text);
-
font-size: 1.777rem;
}
-
.modal form {
-
display: flex;
-
flex-direction: column;
-
gap: 1rem;
}
-
.field {
-
display: flex;
-
flex-direction: column;
-
gap: 0.5rem;
-
}
-
-
.field label {
font-weight: 500;
color: var(--text);
}
-
.field input {
padding: 0.75rem;
border: 2px solid var(--secondary);
border-radius: 6px;
···
font-family: inherit;
background: var(--background);
color: var(--text);
}
-
.field input:focus {
outline: none;
border-color: var(--primary);
}
-
.error {
color: var(--accent);
font-size: 0.875rem;
-
margin: 0;
}
-
.btn {
padding: 0.75rem 1.5rem;
border-radius: 6px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
-
border: 2px solid;
}
-
.btn:disabled {
-
opacity: 0.5;
cursor: not-allowed;
}
-
.btn-affirmative {
background: var(--primary);
color: white;
-
border-color: var(--primary);
}
-
.btn-affirmative:hover:not(:disabled) {
background: transparent;
color: var(--primary);
}
···
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.user-menu a,
···
private closeModal() {
this.showModal = false;
this.email = "";
this.password = "";
this.name = "";
this.error = "";
-
this.needsRegistration = false;
}
private async handleSubmit(e: Event) {
···
if (this.needsRegistration) {
const response = await fetch("/api/auth/register", {
method: "POST",
-
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: this.email,
password: this.password,
-
name: this.name,
}),
});
···
} else {
const response = await fetch("/api/auth/login", {
method: "POST",
-
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: this.email,
password: this.password,
···
}
}
-
async handleLogout() {
try {
await fetch("/api/auth/logout", { method: "POST" });
this.user = null;
···
}
}
-
private toggleUserMenu() {
this.showModal = !this.showModal;
}
override render() {
if (this.loading) {
return html`<div class="loading">Loading...</div>`;
}
-
if (this.user) {
-
return html`
-
<div>
-
<button class="auth-button" @click=${this.toggleUserMenu}>
-
<img
-
src="https://hostedboringavatars.vercel.app/api/marble?size=24&name=${this.user.avatar}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff"
-
alt="Avatar"
-
style="border-radius: 50%; width: 24px; height: 24px;"
-
/>
-
<span class="email">${this.user.name ?? this.user.email}</span>
-
<span>▼</span>
-
</button>
-
${
-
this.showModal
-
? html`
-
<div class="user-menu">
-
<a href="/transcribe" @click=${this.closeModal}>Transcribe</a>
-
<a href="/settings" @click=${this.closeModal}>Settings</a>
-
<button @click=${this.handleLogout}>Logout</button>
-
</div>
-
`
-
: ""
-
}
-
</div>
-
`;
-
}
-
return html`
-
<div>
-
<button class="auth-button" @click=${this.openModal}>Login</button>
${
-
this.showModal
? html`
-
<div class="modal-overlay" @click=${this.closeModal}>
-
<div class="modal" @click=${(e: Event) => e.stopPropagation()}>
-
<h2>${this.needsRegistration ? "Complete Registration" : "Login"}</h2>
${
this.needsRegistration
? html`
-
<p class="info-text">
-
Welcome! We'll create an account for <strong>${this.email}</strong>
-
</p>
-
`
: ""
}
-
<form @submit=${this.handleSubmit}>
-
<div class="field">
-
<label for="email">Email</label>
-
<input
-
type="email"
-
id="email"
-
.value=${this.email}
-
@input=${(e: InputEvent) => {
-
this.email = (e.target as HTMLInputElement).value;
-
}}
-
required
-
?disabled=${this.needsRegistration}
-
/>
-
</div>
-
${
-
this.needsRegistration
-
? html`
-
<div class="field">
-
<label for="name">Name</label>
-
<input
-
type="text"
-
id="name"
-
.value=${this.name}
-
@input=${(e: InputEvent) => {
-
this.name = (
-
e.target as HTMLInputElement
-
).value;
-
}}
-
required
-
placeholder="What should we call you?"
-
/>
-
</div>
-
`
-
: ""
-
}
-
-
<div class="field">
-
<label for="password">${this.needsRegistration ? "Create Password" : "Password"}</label>
-
<input
-
type="password"
-
id="password"
-
.value=${this.password}
-
@input=${(e: InputEvent) => {
-
this.password = (e.target as HTMLInputElement).value;
-
}}
-
required
-
minlength="8"
-
placeholder=${this.needsRegistration ? "At least 8 characters plz" : ""}
-
/>
-
</div>
-
${this.error ? html`<p class="error">${this.error}</p>` : ""}
-
<div class="modal-actions">
-
<button
-
type="submit"
-
class="btn btn-affirmative"
-
?disabled=${this.isSubmitting}
-
>
-
${
-
this.isSubmitting
-
? "Loading..."
-
: this.needsRegistration
-
? "Create Account"
-
: "Login"
-
}
-
</button>
-
<button
-
type="button"
-
class="btn btn-neutral"
-
@click=${this.closeModal}
-
>
-
Cancel
-
</button>
-
</div>
-
</form>
-
</div>
</div>
-
`
-
: ""
-
}
-
</div>
`;
}
}
···
static override styles = css`
:host {
display: block;
+
}
+
+
.auth-container {
+
position: relative;
}
.auth-button {
···
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
}
+
.modal-title {
margin-top: 0;
+
margin-bottom: 1rem;
color: var(--text);
}
+
.form-group {
+
margin-bottom: 1rem;
}
+
label {
+
display: block;
+
margin-bottom: 0.25rem;
font-weight: 500;
color: var(--text);
+
font-size: 0.875rem;
}
+
input {
+
width: 100%;
padding: 0.75rem;
border: 2px solid var(--secondary);
border-radius: 6px;
···
font-family: inherit;
background: var(--background);
color: var(--text);
+
transition: all 0.2s;
+
box-sizing: border-box;
}
+
input:focus {
outline: none;
border-color: var(--primary);
}
+
.error-message {
color: var(--accent);
font-size: 0.875rem;
+
margin-top: 1rem;
}
+
button {
padding: 0.75rem 1.5rem;
+
border: 2px solid var(--primary);
border-radius: 6px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
}
+
button:disabled {
+
opacity: 0.6;
cursor: not-allowed;
}
+
.btn-primary {
background: var(--primary);
color: white;
+
flex: 1;
}
+
.btn-primary:hover:not(:disabled) {
background: transparent;
color: var(--primary);
}
···
display: flex;
flex-direction: column;
gap: 0.5rem;
+
z-index: 100;
}
.user-menu a,
···
private closeModal() {
this.showModal = false;
+
this.needsRegistration = false;
this.email = "";
this.password = "";
this.name = "";
this.error = "";
}
private async handleSubmit(e: Event) {
···
if (this.needsRegistration) {
const response = await fetch("/api/auth/register", {
method: "POST",
+
headers: {
+
"Content-Type": "application/json",
+
},
body: JSON.stringify({
email: this.email,
password: this.password,
+
name: this.name || null,
}),
});
···
} else {
const response = await fetch("/api/auth/login", {
method: "POST",
+
headers: {
+
"Content-Type": "application/json",
+
},
body: JSON.stringify({
email: this.email,
password: this.password,
···
}
}
+
private async handleLogout() {
try {
await fetch("/api/auth/logout", { method: "POST" });
this.user = null;
···
}
}
+
private toggleMenu() {
this.showModal = !this.showModal;
}
+
private handleEmailInput(e: Event) {
+
this.email = (e.target as HTMLInputElement).value;
+
}
+
+
private handleNameInput(e: Event) {
+
this.name = (e.target as HTMLInputElement).value;
+
}
+
+
private handlePasswordInput(e: Event) {
+
this.password = (e.target as HTMLInputElement).value;
+
}
+
override render() {
if (this.loading) {
return html`<div class="loading">Loading...</div>`;
}
return html`
+
<div class="auth-container">
${
+
this.user
? html`
+
<button class="auth-button" @click=${this.toggleMenu}>
+
<div class="user-info">
+
<img
+
src="https://hostedboringavatars.vercel.app/api/marble?size=32&name=${this.user.avatar}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff"
+
alt="Avatar"
+
width="32"
+
height="32"
+
style="border-radius: 50%"
+
/>
+
<span class="email">${this.user.name ?? this.user.email}</span>
+
</div>
+
</button>
+
${
+
this.showModal
+
? html`
+
<div class="user-menu">
+
<a href="/transcribe" @click=${this.closeModal}>Transcribe</a>
+
<a href="/settings" @click=${this.closeModal}>Settings</a>
+
<button @click=${this.handleLogout}>Logout</button>
+
</div>
+
`
+
: ""
+
}
+
`
+
: html`
+
<button class="auth-button" @click=${this.openModal}>
+
Sign In
+
</button>
+
`
+
}
+
</div>
+
+
${
+
this.showModal && !this.user
+
? html`
+
<div class="modal-overlay" @click=${this.closeModal}>
+
<div class="modal" @click=${(e: Event) => e.stopPropagation()}>
+
<h2 class="modal-title">
+
${this.needsRegistration ? "Create Account" : "Sign In"}
+
</h2>
+
+
${
+
this.needsRegistration
+
? html`
+
<p class="info-text">
+
That email isn't registered yet. Let's create an
+
account!
+
</p>
+
`
+
: ""
+
}
+
+
<form @submit=${this.handleSubmit}>
+
<div class="form-group">
+
<label for="email">Email</label>
+
<input
+
type="email"
+
id="email"
+
.value=${this.email}
+
@input=${this.handleEmailInput}
+
required
+
?disabled=${this.isSubmitting}
+
/>
+
</div>
+
${
this.needsRegistration
? html`
+
<div class="form-group">
+
<label for="name">Name (optional)</label>
+
<input
+
type="text"
+
id="name"
+
.value=${this.name}
+
@input=${this.handleNameInput}
+
?disabled=${this.isSubmitting}
+
/>
+
</div>
+
`
: ""
}
+
<div class="form-group">
+
<label for="password">Password</label>
+
<input
+
type="password"
+
id="password"
+
.value=${this.password}
+
@input=${this.handlePasswordInput}
+
required
+
?disabled=${this.isSubmitting}
+
/>
+
</div>
+
${
+
this.error
+
? html`<div class="error-message">${this.error}</div>`
+
: ""
+
}
+
<div class="modal-actions">
+
<button
+
type="submit"
+
class="btn-primary"
+
?disabled=${this.isSubmitting}
+
>
+
${
+
this.isSubmitting
+
? "Loading..."
+
: this.needsRegistration
+
? "Create Account"
+
: "Sign In"
+
}
+
</button>
+
<button
+
type="button"
+
class="btn-neutral"
+
@click=${this.closeModal}
+
?disabled=${this.isSubmitting}
+
>
+
Cancel
+
</button>
+
</div>
+
</form>
</div>
+
</div>
+
`
+
: ""
+
}
`;
}
}
+1
src/index.ts
···
email: user.email,
name: user.name,
avatar: user.avatar,
});
},
},
···
email: user.email,
name: user.name,
avatar: user.avatar,
+
created_at: user.created_at,
});
},
},
+14 -7
src/pages/index.html
···
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>🪻</text></svg>">
<link rel="stylesheet" href="../styles/main.css">
<style>
-
main {
-
max-width: 48rem;
-
text-align: center;
-
padding: 4rem 2rem;
-
}
-
.hero-title {
font-size: 3rem;
font-weight: 700;
···
color: var(--text);
opacity: 0.8;
margin-bottom: 2rem;
}
.cta-buttons {
···
</head>
<body>
-
<auth-component></auth-component>
<main>
<h1 class="hero-title">Thistle</h1>
···
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>🪻</text></svg>">
<link rel="stylesheet" href="../styles/main.css">
<style>
.hero-title {
font-size: 3rem;
font-weight: 700;
···
color: var(--text);
opacity: 0.8;
margin-bottom: 2rem;
+
}
+
+
main {
+
text-align: center;
+
padding: 4rem 2rem;
}
.cta-buttons {
···
</head>
<body>
+
<header>
+
<div class="header-content">
+
<a href="/" class="site-title">
+
<span>🪻</span>
+
<span>Thistle</span>
+
</a>
+
<auth-component></auth-component>
+
</div>
+
</header>
<main>
<h1 class="hero-title">Thistle</h1>
+13 -21
src/pages/settings.html
···
<link rel="icon"
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>🪻</text></svg>">
<link rel="stylesheet" href="../styles/main.css">
<style>
-
.back-link {
-
display: inline-flex;
-
align-items: center;
-
gap: 0.5rem;
-
color: var(--text);
-
opacity: 0.7;
-
text-decoration: none;
-
font-size: 0.875rem;
-
margin-bottom: 2rem;
-
transition: opacity 0.2s;
-
}
-
-
.back-link:hover {
-
opacity: 1;
}
</style>
</head>
<body>
-
<auth-component></auth-component>
<main>
-
<a href="/" class="back-link">
-
← Back to Home
-
</a>
-
-
<h1>Settings</h1>
<user-settings></user-settings>
</main>
···
<script type="module" src="../components/user-settings.ts"></script>
</body>
-
</html>
···
<link rel="icon"
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>🪻</text></svg>">
<link rel="stylesheet" href="../styles/main.css">
+
<style>
+
main {
+
max-width: 64rem !important;
}
</style>
</head>
<body>
+
<header>
+
<div class="header-content">
+
<a href="/" class="site-title">
+
<span>🪻</span>
+
<span>Thistle - Settings</span>
+
</a>
+
<auth-component></auth-component>
+
</div>
+
</header>
<main>
<user-settings></user-settings>
</main>
···
<script type="module" src="../components/user-settings.ts"></script>
</body>
+
</html>
+10 -26
src/pages/transcribe.html
···
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>🪻</text></svg>">
<link rel="stylesheet" href="../styles/main.css">
<style>
-
main {
-
max-width: 48rem;
-
}
-
.page-header {
text-align: center;
margin-bottom: 3rem;
···
color: var(--text);
opacity: 0.8;
}
-
-
.back-link {
-
display: inline-flex;
-
align-items: center;
-
gap: 0.5rem;
-
color: var(--text);
-
opacity: 0.7;
-
text-decoration: none;
-
font-size: 0.875rem;
-
margin-bottom: 2rem;
-
transition: opacity 0.2s;
-
}
-
-
.back-link:hover {
-
opacity: 1;
-
}
</style>
</head>
<body>
-
<auth-component></auth-component>
<main>
-
<a href="/" class="back-link">
-
← Back to Home
-
</a>
-
<div class="page-header">
<h1 class="page-title">Audio Transcription</h1>
<p class="page-subtitle">Upload your audio files and get accurate transcripts powered by AI</p>
···
<script type="module" src="../components/transcription.ts"></script>
</body>
-
</html>
···
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>🪻</text></svg>">
<link rel="stylesheet" href="../styles/main.css">
<style>
.page-header {
text-align: center;
margin-bottom: 3rem;
···
color: var(--text);
opacity: 0.8;
}
</style>
</head>
<body>
+
<header>
+
<div class="header-content">
+
<a href="/" class="site-title">
+
<span>🪻</span>
+
<span>Thistle - Transcription</span>
+
</a>
+
<auth-component></auth-component>
+
</div>
+
</header>
<main>
<div class="page-header">
<h1 class="page-title">Audio Transcription</h1>
<p class="page-subtitle">Upload your audio files and get accurate transcripts powered by AI</p>
···
<script type="module" src="../components/transcription.ts"></script>
</body>
+
</html>
+33
src/styles/header.css
···
···
+
/* Header styles shared across all pages */
+
+
header {
+
position: sticky;
+
top: 0;
+
z-index: 1000;
+
background: var(--background);
+
border-bottom: 2px solid var(--secondary);
+
padding: 1rem 2rem;
+
margin: -2rem -2rem 2rem -2rem;
+
}
+
+
.header-content {
+
max-width: 1200px;
+
margin: 0 auto;
+
display: flex;
+
justify-content: space-between;
+
align-items: center;
+
}
+
+
.site-title {
+
font-size: 1.5rem;
+
font-weight: 600;
+
color: var(--text);
+
text-decoration: none;
+
display: flex;
+
align-items: center;
+
gap: 0.5rem;
+
}
+
+
.site-title:hover {
+
color: var(--primary);
+
}
+2
src/styles/main.css
···
@import url('./buttons.css');
:root {
/* Color palette */
···
main {
margin: 0 auto;
}
···
@import url('./buttons.css');
+
@import url('./header.css');
:root {
/* Color palette */
···
main {
margin: 0 auto;
+
max-width: 48rem;
}