Rewrite with React after AI got stuck in some obscure state errors on SolidJS

This commit is contained in:
2026-03-02 22:04:58 +02:00
parent 81c4c70c51
commit 154b9b66ce
38 changed files with 4131 additions and 1878 deletions

View File

@@ -65,6 +65,64 @@ public class ApiEndpointsTests(ApiTestFactory factory) : IClassFixture<ApiTestFa
var openHours = await getResponse.Content.ReadFromJsonAsync<List<LokOpenHoursDto>>(); var openHours = await getResponse.Content.ReadFromJsonAsync<List<LokOpenHoursDto>>();
Assert.NotNull(openHours); Assert.NotNull(openHours);
Assert.Contains(openHours, item => item.Id == created.Id); Assert.Contains(openHours, item => item.Id == created.Id);
Assert.True(openHours.Count(item => item.IsActive) <= 1);
var createSecondPayload = new
{
id = 0,
name = "test-version-2",
version = DateTime.UtcNow.ToString("O"),
paragraph1 = "p1b",
paragraph2 = "p2b",
paragraph3 = "p3b",
paragraph4 = "p4b",
kitchenNotice = "k1b"
};
var createSecondResponse = await _client.PostAsJsonAsync("/lok/open-hours", createSecondPayload);
Assert.Equal(HttpStatusCode.Created, createSecondResponse.StatusCode);
var createdSecond = await createSecondResponse.Content.ReadFromJsonAsync<LokOpenHoursDto>();
Assert.NotNull(createdSecond);
Assert.True(createdSecond.IsActive);
var setActiveResponse = await _client.PutAsync($"/lok/open-hours/{created.Id}/active", null);
Assert.Equal(HttpStatusCode.OK, setActiveResponse.StatusCode);
var updatePayload = new
{
id = created.Id,
name = "updated-version",
version = DateTime.UtcNow.ToString("O"),
paragraph1 = "updated-p1",
paragraph2 = "updated-p2",
paragraph3 = "updated-p3",
paragraph4 = "updated-p4",
kitchenNotice = "updated-k1"
};
var updateResponse = await _client.PutAsJsonAsync($"/lok/open-hours/{created.Id}", updatePayload);
Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode);
var updated = await updateResponse.Content.ReadFromJsonAsync<LokOpenHoursDto>();
Assert.NotNull(updated);
Assert.Equal(created.Id, updated.Id);
Assert.Equal(updatePayload.name, updated.Name);
Assert.Equal(updatePayload.paragraph1, updated.Paragraph1);
var getAfterUpdateResponse = await _client.GetAsync("/lok/open-hours");
Assert.Equal(HttpStatusCode.OK, getAfterUpdateResponse.StatusCode);
var openHoursAfterUpdate = await getAfterUpdateResponse.Content.ReadFromJsonAsync<List<LokOpenHoursDto>>();
Assert.NotNull(openHoursAfterUpdate);
var updatedInList = openHoursAfterUpdate.Single(item => item.Id == created.Id);
Assert.Equal(updatePayload.name, updatedInList.Name);
Assert.Equal(updatePayload.paragraph4, updatedInList.Paragraph4);
var activeCount = openHoursAfterUpdate.Count(item => item.IsActive);
Assert.Equal(1, activeCount);
Assert.False(openHoursAfterUpdate.Single(item => item.Id == createdSecond.Id).IsActive);
Assert.True(openHoursAfterUpdate.Single(item => item.Id == created.Id).IsActive);
var deleteResponse = await _client.DeleteAsync($"/lok/open-hours/{created.Id}"); var deleteResponse = await _client.DeleteAsync($"/lok/open-hours/{created.Id}");
Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode); Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode);
@@ -117,6 +175,8 @@ public class LokOpenHoursDto
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public bool IsActive { get; set; }
public DateTime Version { get; set; } public DateTime Version { get; set; }
public string Paragraph1 { get; set; } = string.Empty; public string Paragraph1 { get; set; } = string.Empty;

View File

@@ -71,5 +71,69 @@ public static class LokEndpoints
httpContext.Response.StatusCode = StatusCodes.Status204NoContent; httpContext.Response.StatusCode = StatusCodes.Status204NoContent;
}) })
.WithName("DeleteLokOpenHours"); .WithName("DeleteLokOpenHours");
app.MapPut("/lok/open-hours/{id:long}", async (HttpContext httpContext, long id) =>
{
var lokService = httpContext.RequestServices.GetRequiredService<LokService>();
var openHours = await httpContext.Request.ReadFromJsonAsync<LokOpenHours>();
if (openHours is null)
{
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
await httpContext.Response.WriteAsJsonAsync(new
{
Message = "Request body is required."
});
return;
}
if (string.IsNullOrWhiteSpace(openHours.Name))
{
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
await httpContext.Response.WriteAsJsonAsync(new
{
Message = "Open hours version name is required."
});
return;
}
var updatedOpenHours = await lokService.UpdateOpenHours(id, openHours);
if (updatedOpenHours is null)
{
httpContext.Response.StatusCode = StatusCodes.Status404NotFound;
await httpContext.Response.WriteAsJsonAsync(new
{
Message = "Open hours version not found."
});
return;
}
await httpContext.Response.WriteAsJsonAsync(updatedOpenHours);
})
.WithName("UpdateLokOpenHours");
app.MapPut("/lok/open-hours/{id:long}/active", async (HttpContext httpContext, long id) =>
{
var lokService = httpContext.RequestServices.GetRequiredService<LokService>();
var activated = await lokService.SetActiveOpenHours(id);
if (!activated)
{
httpContext.Response.StatusCode = StatusCodes.Status404NotFound;
await httpContext.Response.WriteAsJsonAsync(new
{
Message = "Open hours version not found."
});
return;
}
await httpContext.Response.WriteAsJsonAsync(new
{
Id = id,
IsActive = true
});
})
.WithName("SetActiveLokOpenHours");
} }
} }

View File

@@ -4,6 +4,8 @@ public class LokOpenHours
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public bool IsActive { get; set; }
public DateTime Version { get; set; } public DateTime Version { get; set; }
public string Paragraph1 { get; set; } = string.Empty; public string Paragraph1 { get; set; } = string.Empty;

View File

@@ -21,6 +21,20 @@ public class Program
builder.Services.AddScoped(_ => new SqliteConnection(resolvedConnectionString)); builder.Services.AddScoped(_ => new SqliteConnection(resolvedConnectionString));
builder.Services.AddScoped<LokService>(); builder.Services.AddScoped<LokService>();
builder.Services.AddCors(options =>
{
options.AddPolicy("UiCors", policy =>
{
policy
.WithOrigins(
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://localhost:4173",
"http://127.0.0.1:4173")
.AllowAnyHeader()
.AllowAnyMethod();
});
});
builder.Services.AddOpenApi(); builder.Services.AddOpenApi();
@@ -64,6 +78,57 @@ public class Program
command.CommandText = "ALTER TABLE LokOpenHours ADD COLUMN name TEXT NOT NULL DEFAULT '';"; command.CommandText = "ALTER TABLE LokOpenHours ADD COLUMN name TEXT NOT NULL DEFAULT '';";
command.ExecuteNonQuery(); command.ExecuteNonQuery();
} }
command.CommandText = "SELECT COUNT(*) FROM pragma_table_info('LokOpenHours') WHERE name = 'isActive';";
var hasIsActiveColumn = Convert.ToInt32(command.ExecuteScalar()) > 0;
if (!hasIsActiveColumn)
{
command.CommandText = "ALTER TABLE LokOpenHours ADD COLUMN isActive INTEGER NOT NULL DEFAULT 0;";
command.ExecuteNonQuery();
}
command.CommandText = "SELECT COUNT(*) FROM LokOpenHours WHERE isActive = 1;";
var activeCount = Convert.ToInt32(command.ExecuteScalar());
if (activeCount == 0)
{
command.CommandText = @"
UPDATE LokOpenHours
SET isActive = 1
WHERE id = (
SELECT id
FROM LokOpenHours
ORDER BY datetime(version) DESC, id DESC
LIMIT 1
);";
command.ExecuteNonQuery();
}
else if (activeCount > 1)
{
command.CommandText = @"
WITH selected_active AS (
SELECT id
FROM LokOpenHours
WHERE isActive = 1
ORDER BY datetime(version) DESC, id DESC
LIMIT 1
)
UPDATE LokOpenHours
SET isActive = CASE
WHEN id = (SELECT id FROM selected_active) THEN 1
ELSE 0
END
WHERE isActive = 1
OR id = (SELECT id FROM selected_active);";
command.ExecuteNonQuery();
}
command.CommandText = @"
CREATE UNIQUE INDEX IF NOT EXISTS IX_LokOpenHours_OneActive
ON LokOpenHours(isActive)
WHERE isActive = 1;";
command.ExecuteNonQuery();
} }
} }
@@ -72,7 +137,12 @@ public class Program
app.MapOpenApi(); app.MapOpenApi();
} }
app.UseCors("UiCors");
if (!app.Environment.IsDevelopment())
{
app.UseHttpsRedirection(); app.UseHttpsRedirection();
}
SystemEndpoints.MapSystemEndpoints(app); SystemEndpoints.MapSystemEndpoints(app);
LokEndpoints.MapLokEndpoints(app); LokEndpoints.MapLokEndpoints(app);

View File

@@ -19,7 +19,7 @@ public class LokService
await using var command = _connection.CreateCommand(); await using var command = _connection.CreateCommand();
command.CommandText = @" command.CommandText = @"
SELECT id, name, version, paragraph1, paragraph2, paragraph3, paragraph4, kitchenNotice SELECT id, name, isActive, version, paragraph1, paragraph2, paragraph3, paragraph4, kitchenNotice
FROM LokOpenHours FROM LokOpenHours
ORDER BY datetime(version) DESC, id DESC ORDER BY datetime(version) DESC, id DESC
LIMIT 5"; LIMIT 5";
@@ -34,6 +34,7 @@ public class LokService
{ {
Id = reader["id"] is long id ? id : Convert.ToInt64(reader["id"]), Id = reader["id"] is long id ? id : Convert.ToInt64(reader["id"]),
Name = reader["name"]?.ToString() ?? string.Empty, Name = reader["name"]?.ToString() ?? string.Empty,
IsActive = ParseBoolean(reader["isActive"]),
Version = ParseVersion(reader["version"]?.ToString()), Version = ParseVersion(reader["version"]?.ToString()),
Paragraph1 = reader["paragraph1"]?.ToString() ?? string.Empty, Paragraph1 = reader["paragraph1"]?.ToString() ?? string.Empty,
Paragraph2 = reader["paragraph2"]?.ToString() ?? string.Empty, Paragraph2 = reader["paragraph2"]?.ToString() ?? string.Empty,
@@ -55,13 +56,22 @@ public class LokService
var version = DateTime.UtcNow; var version = DateTime.UtcNow;
using var transaction = _connection.BeginTransaction();
await using var resetCommand = _connection.CreateCommand();
resetCommand.Transaction = transaction;
resetCommand.CommandText = "UPDATE LokOpenHours SET isActive = 0 WHERE isActive = 1;";
await resetCommand.ExecuteNonQueryAsync();
await using var command = _connection.CreateCommand(); await using var command = _connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = @" command.CommandText = @"
INSERT INTO LokOpenHours (name, version, paragraph1, paragraph2, paragraph3, paragraph4, kitchenNotice) INSERT INTO LokOpenHours (name, isActive, version, paragraph1, paragraph2, paragraph3, paragraph4, kitchenNotice)
VALUES (@name, @version, @paragraph1, @paragraph2, @paragraph3, @paragraph4, @kitchenNotice); VALUES (@name, @isActive, @version, @paragraph1, @paragraph2, @paragraph3, @paragraph4, @kitchenNotice);
SELECT last_insert_rowid();"; SELECT last_insert_rowid();";
command.Parameters.AddWithValue("@name", openHours.Name ?? string.Empty); command.Parameters.AddWithValue("@name", openHours.Name ?? string.Empty);
command.Parameters.AddWithValue("@isActive", 1);
command.Parameters.AddWithValue("@version", version.ToString("O")); command.Parameters.AddWithValue("@version", version.ToString("O"));
command.Parameters.AddWithValue("@paragraph1", openHours.Paragraph1 ?? string.Empty); command.Parameters.AddWithValue("@paragraph1", openHours.Paragraph1 ?? string.Empty);
command.Parameters.AddWithValue("@paragraph2", openHours.Paragraph2 ?? string.Empty); command.Parameters.AddWithValue("@paragraph2", openHours.Paragraph2 ?? string.Empty);
@@ -70,11 +80,15 @@ public class LokService
command.Parameters.AddWithValue("@kitchenNotice", openHours.KitchenNotice ?? string.Empty); command.Parameters.AddWithValue("@kitchenNotice", openHours.KitchenNotice ?? string.Empty);
var insertedId = await command.ExecuteScalarAsync(); var insertedId = await command.ExecuteScalarAsync();
var insertedIdValue = Convert.ToInt64(insertedId);
transaction.Commit();
return new LokOpenHours return new LokOpenHours
{ {
Id = Convert.ToInt64(insertedId), Id = insertedIdValue,
Name = openHours.Name ?? string.Empty, Name = openHours.Name ?? string.Empty,
IsActive = true,
Version = version, Version = version,
Paragraph1 = openHours.Paragraph1 ?? string.Empty, Paragraph1 = openHours.Paragraph1 ?? string.Empty,
Paragraph2 = openHours.Paragraph2 ?? string.Empty, Paragraph2 = openHours.Paragraph2 ?? string.Empty,
@@ -91,6 +105,16 @@ public class LokService
await _connection.OpenAsync(); await _connection.OpenAsync();
} }
await using var activeCommand = _connection.CreateCommand();
activeCommand.CommandText = "SELECT isActive FROM LokOpenHours WHERE id = @id;";
activeCommand.Parameters.AddWithValue("@id", id);
var activeValue = await activeCommand.ExecuteScalarAsync();
if (activeValue is null)
{
return false;
}
await using var command = _connection.CreateCommand(); await using var command = _connection.CreateCommand();
command.CommandText = @" command.CommandText = @"
DELETE FROM LokOpenHours DELETE FROM LokOpenHours
@@ -99,9 +123,115 @@ public class LokService
command.Parameters.AddWithValue("@id", id); command.Parameters.AddWithValue("@id", id);
var affectedRows = await command.ExecuteNonQueryAsync(); var affectedRows = await command.ExecuteNonQueryAsync();
if (affectedRows > 0)
{
await EnsureSingleActiveInvariant();
}
return affectedRows > 0; return affectedRows > 0;
} }
public async Task<LokOpenHours?> UpdateOpenHours(long id, LokOpenHours openHours)
{
if (_connection.State != ConnectionState.Open)
{
await _connection.OpenAsync();
}
var version = DateTime.UtcNow;
await using var activeCommand = _connection.CreateCommand();
activeCommand.CommandText = "SELECT isActive FROM LokOpenHours WHERE id = @id;";
activeCommand.Parameters.AddWithValue("@id", id);
var activeValue = await activeCommand.ExecuteScalarAsync();
if (activeValue is null)
{
return null;
}
var isActive = ParseBoolean(activeValue);
await using var command = _connection.CreateCommand();
command.CommandText = @"
UPDATE LokOpenHours
SET
name = @name,
version = @version,
paragraph1 = @paragraph1,
paragraph2 = @paragraph2,
paragraph3 = @paragraph3,
paragraph4 = @paragraph4,
kitchenNotice = @kitchenNotice
WHERE id = @id;";
command.Parameters.AddWithValue("@id", id);
command.Parameters.AddWithValue("@name", openHours.Name ?? string.Empty);
command.Parameters.AddWithValue("@version", version.ToString("O"));
command.Parameters.AddWithValue("@paragraph1", openHours.Paragraph1 ?? string.Empty);
command.Parameters.AddWithValue("@paragraph2", openHours.Paragraph2 ?? string.Empty);
command.Parameters.AddWithValue("@paragraph3", openHours.Paragraph3 ?? string.Empty);
command.Parameters.AddWithValue("@paragraph4", openHours.Paragraph4 ?? string.Empty);
command.Parameters.AddWithValue("@kitchenNotice", openHours.KitchenNotice ?? string.Empty);
var affectedRows = await command.ExecuteNonQueryAsync();
if (affectedRows == 0)
{
return null;
}
return new LokOpenHours
{
Id = id,
Name = openHours.Name ?? string.Empty,
IsActive = isActive,
Version = version,
Paragraph1 = openHours.Paragraph1 ?? string.Empty,
Paragraph2 = openHours.Paragraph2 ?? string.Empty,
Paragraph3 = openHours.Paragraph3 ?? string.Empty,
Paragraph4 = openHours.Paragraph4 ?? string.Empty,
KitchenNotice = openHours.KitchenNotice ?? string.Empty
};
}
public async Task<bool> SetActiveOpenHours(long id)
{
if (_connection.State != ConnectionState.Open)
{
await _connection.OpenAsync();
}
using var transaction = _connection.BeginTransaction();
await using var existsCommand = _connection.CreateCommand();
existsCommand.Transaction = transaction;
existsCommand.CommandText = "SELECT COUNT(*) FROM LokOpenHours WHERE id = @id;";
existsCommand.Parameters.AddWithValue("@id", id);
var exists = Convert.ToInt32(await existsCommand.ExecuteScalarAsync()) > 0;
if (!exists)
{
transaction.Rollback();
return false;
}
await using var resetCommand = _connection.CreateCommand();
resetCommand.Transaction = transaction;
resetCommand.CommandText = "UPDATE LokOpenHours SET isActive = 0 WHERE isActive = 1;";
await resetCommand.ExecuteNonQueryAsync();
await using var activateCommand = _connection.CreateCommand();
activateCommand.Transaction = transaction;
activateCommand.CommandText = "UPDATE LokOpenHours SET isActive = 1 WHERE id = @id;";
activateCommand.Parameters.AddWithValue("@id", id);
await activateCommand.ExecuteNonQueryAsync();
transaction.Commit();
return true;
}
private static DateTime ParseVersion(string? value) private static DateTime ParseVersion(string? value)
{ {
if (!string.IsNullOrWhiteSpace(value) && DateTime.TryParse(value, out var parsed)) if (!string.IsNullOrWhiteSpace(value) && DateTime.TryParse(value, out var parsed))
@@ -111,4 +241,61 @@ public class LokService
return DateTime.MinValue; return DateTime.MinValue;
} }
private static bool ParseBoolean(object? value)
{
return value switch
{
bool boolValue => boolValue,
long longValue => longValue == 1,
int intValue => intValue == 1,
string stringValue when int.TryParse(stringValue, out var parsedInt) => parsedInt == 1,
string stringValue when bool.TryParse(stringValue, out var parsedBool) => parsedBool,
_ => false
};
}
private async Task EnsureSingleActiveInvariant()
{
await using var countCommand = _connection.CreateCommand();
countCommand.CommandText = "SELECT COUNT(*) FROM LokOpenHours WHERE isActive = 1;";
var activeCount = Convert.ToInt32(await countCommand.ExecuteScalarAsync());
if (activeCount == 0)
{
await using var promoteCommand = _connection.CreateCommand();
promoteCommand.CommandText = @"
UPDATE LokOpenHours
SET isActive = 1
WHERE id = (
SELECT id
FROM LokOpenHours
ORDER BY datetime(version) DESC, id DESC
LIMIT 1
);";
await promoteCommand.ExecuteNonQueryAsync();
return;
}
if (activeCount > 1)
{
await using var normalizeCommand = _connection.CreateCommand();
normalizeCommand.CommandText = @"
WITH selected_active AS (
SELECT id
FROM LokOpenHours
WHERE isActive = 1
ORDER BY datetime(version) DESC, id DESC
LIMIT 1
)
UPDATE LokOpenHours
SET isActive = CASE
WHEN id = (SELECT id FROM selected_active) THEN 1
ELSE 0
END
WHERE isActive = 1
OR id = (SELECT id FROM selected_active);";
await normalizeCommand.ExecuteNonQueryAsync();
}
}
} }

View File

@@ -1,6 +1,7 @@
CREATE TABLE IF NOT EXISTS LokOpenHours ( CREATE TABLE IF NOT EXISTS LokOpenHours (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL DEFAULT '', name TEXT NOT NULL DEFAULT '',
isActive INTEGER NOT NULL DEFAULT 0,
version TEXT NOT NULL, version TEXT NOT NULL,
paragraph1 TEXT NOT NULL DEFAULT '', paragraph1 TEXT NOT NULL DEFAULT '',
paragraph2 TEXT NOT NULL DEFAULT '', paragraph2 TEXT NOT NULL DEFAULT '',

Binary file not shown.

View File

@@ -1,8 +0,0 @@
import { defineConfig } from "@solidjs/start/config";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
ssr: true, // false for client-side rendering only
server: { preset: "" }, // your deployment
vite: { plugins: [tailwindcss()] }
});

File diff suppressed because it is too large Load Diff

13
ui/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>KlAPI UI</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2792
ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,28 +2,29 @@
"name": "example-with-auth", "name": "example-with-auth",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vinxi dev", "dev": "vite",
"build": "vinxi build", "build": "vite build",
"start": "vinxi start", "start": "vite preview",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"lint": "biome lint src", "lint": "biome lint src",
"lint:fix": "biome check --write src" "lint:fix": "biome check --write src"
}, },
"dependencies": { "dependencies": {
"@solidjs/meta": "^0.29.4",
"@solidjs/router": "^0.15.3",
"@solidjs/start": "^1.1.7",
"@types/node": "^25.2.0", "@types/node": "^25.2.0",
"solid-js": "^1.9.9", "react": "^18.3.1",
"start-oauth": "^1.3.0", "react-dom": "^18.3.1",
"unstorage": "1.17.1", "react-router-dom": "^6.28.0",
"vinxi": "^0.5.8" "recoil": "^0.7.7"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"@biomejs/biome": "^1.9.4", "@biomejs/biome": "^1.9.4",
"@tailwindcss/vite": "^4.1.13", "@tailwindcss/vite": "^4.1.13",
"tailwindcss": "^4.1.13", "tailwindcss": "^4.1.13",
"vite": "^5.4.10",
"vitest": "^2.1.9" "vitest": "^2.1.9"
}, },
"engines": { "engines": {

View File

@@ -1,4 +1,3 @@
import { action, query } from "@solidjs/router";
import { buildApiUrl } from "./url"; import { buildApiUrl } from "./url";
async function fetchApi<T>(path: string, init?: RequestInit): Promise<T> { async function fetchApi<T>(path: string, init?: RequestInit): Promise<T> {
@@ -22,15 +21,10 @@ async function fetchApi<T>(path: string, init?: RequestInit): Promise<T> {
return (await response.json()) as T; return (await response.json()) as T;
} }
export const queryApiVersion = query(async () => {
"use server";
const data = await fetchApi<{ version: string }>("/");
return data.version;
}, "api-version");
export type LokOpenHours = { export type LokOpenHours = {
id: number; id: number;
name: string; name: string;
isActive: boolean;
version: string; version: string;
paragraph1: string; paragraph1: string;
paragraph2: string; paragraph2: string;
@@ -39,14 +33,28 @@ export type LokOpenHours = {
kitchenNotice: string; kitchenNotice: string;
}; };
export const queryLokOpenHours = query(async (_refreshKey = 0) => { export type LokOpenHoursInput = {
"use server"; name: string;
return await fetchApi<LokOpenHours[]>("/lok/open-hours"); paragraph1: string;
}, "lok-open-hours"); paragraph2: string;
paragraph3: string;
paragraph4: string;
kitchenNotice: string;
};
export const createLokOpenHours = action(async (formData: FormData) => { export async function queryApiVersion(): Promise<string> {
"use server"; const data = await fetchApi<{ version: string }>("/");
const name = String(formData.get("name") ?? "").trim(); return data.version;
}
export async function queryLokOpenHours(): Promise<LokOpenHours[]> {
return await fetchApi<LokOpenHours[]>("/lok/open-hours");
}
export async function createLokOpenHours(
input: LokOpenHoursInput,
): Promise<LokOpenHours> {
const name = input.name.trim();
if (!name) { if (!name) {
throw new Error("Open hours version name is required."); throw new Error("Open hours version name is required.");
@@ -55,25 +63,54 @@ export const createLokOpenHours = action(async (formData: FormData) => {
const payload = { const payload = {
id: 0, id: 0,
name, name,
isActive: false,
version: new Date().toISOString(), version: new Date().toISOString(),
paragraph1: String(formData.get("paragraph1") ?? ""), paragraph1: input.paragraph1,
paragraph2: String(formData.get("paragraph2") ?? ""), paragraph2: input.paragraph2,
paragraph3: String(formData.get("paragraph3") ?? ""), paragraph3: input.paragraph3,
paragraph4: String(formData.get("paragraph4") ?? ""), paragraph4: input.paragraph4,
kitchenNotice: String(formData.get("kitchenNotice") ?? ""), kitchenNotice: input.kitchenNotice,
} satisfies LokOpenHours; } satisfies LokOpenHours;
return await fetchApi<LokOpenHours>("/lok/open-hours", { return await fetchApi<LokOpenHours>("/lok/open-hours", {
method: "POST", method: "POST",
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
}); }
export const deleteLokOpenHours = action(async (formData: FormData) => { export async function updateLokOpenHours(
"use server"; id: number,
const idValue = String(formData.get("id") ?? "").trim(); input: LokOpenHoursInput,
const id = Number(idValue); ): Promise<LokOpenHours> {
if (!Number.isFinite(id) || id <= 0) {
throw new Error("Open hours id is required for update.");
}
const name = input.name.trim();
if (!name) {
throw new Error("Open hours version name is required.");
}
const payload = {
id,
name,
isActive: false,
version: new Date().toISOString(),
paragraph1: input.paragraph1,
paragraph2: input.paragraph2,
paragraph3: input.paragraph3,
paragraph4: input.paragraph4,
kitchenNotice: input.kitchenNotice,
} satisfies LokOpenHours;
return await fetchApi<LokOpenHours>(`/lok/open-hours/${id}`, {
method: "PUT",
body: JSON.stringify(payload),
});
}
export async function deleteLokOpenHours(id: number): Promise<void> {
if (!Number.isFinite(id) || id <= 0) { if (!Number.isFinite(id) || id <= 0) {
throw new Error("Open hours id is required for delete."); throw new Error("Open hours id is required for delete.");
} }
@@ -81,6 +118,19 @@ export const deleteLokOpenHours = action(async (formData: FormData) => {
await fetchApi<void>(`/lok/open-hours/${id}`, { await fetchApi<void>(`/lok/open-hours/${id}`, {
method: "DELETE", method: "DELETE",
}); });
}
return { deleted: true }; export async function setActiveLokOpenHours(
}); id: number,
): Promise<{ id: number; isActive: boolean }> {
if (!Number.isFinite(id) || id <= 0) {
throw new Error("Open hours id is required for setting active version.");
}
return await fetchApi<{ id: number; isActive: boolean }>(
`/lok/open-hours/${id}/active`,
{
method: "PUT",
},
);
}

View File

@@ -1,4 +1,7 @@
const API_BASE_URL = process.env.API_BASE_URL ?? "http://localhost:5013"; const API_BASE_URL =
(typeof process !== "undefined" ? process.env?.API_BASE_URL : undefined) ??
import.meta.env.VITE_API_BASE_URL ??
"/api";
export const buildApiUrl = (path: string) => export const buildApiUrl = (path: string) =>
`${API_BASE_URL.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`; `${API_BASE_URL.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;

View File

@@ -5,7 +5,7 @@
font-family: "Manrope", "Avenir Next", "Segoe UI", sans-serif; font-family: "Manrope", "Avenir Next", "Segoe UI", sans-serif;
} }
#app { #root {
user-select: none; user-select: none;
} }

View File

@@ -1,41 +1,57 @@
import { type RouteDefinition, Router } from "@solidjs/router"; import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { FileRoutes } from "@solidjs/start/router"; import { useEffect } from "react";
import { Meta, MetaProvider } from "@solidjs/meta"; import { useSetRecoilState } from "recoil";
import { createEffect, Suspense } from "solid-js"; import Nav from "~/components/Nav";
import { querySession } from "./auth"; import Home from "~/routes/index";
import Auth from "./components/Context"; import About from "~/routes/about";
import Nav from "./components/Nav"; import Login from "~/routes/login";
import ErrorNotification from "./components/Error"; import NotFound from "~/routes/[...404]";
import { language, t } from "~/i18n"; import Toasts from "~/components/Toasts";
import { initializeLanguage, useLanguage } from "~/i18n";
import { sessionAtom } from "~/state/appState";
import "./app.css"; import "./app.css";
export const route: RouteDefinition = { function AppShell() {
preload: ({ location }) => querySession(location.pathname) const { language, setLanguage } = useLanguage();
}; const setSession = useSetRecoilState(sessionAtom);
export default function App() { useEffect(() => {
createEffect(() => { initializeLanguage(setLanguage);
if (typeof document !== "undefined") { }, [setLanguage]);
document.documentElement.lang = language();
useEffect(() => {
const storedEmail = localStorage.getItem("session-email");
if (!storedEmail) {
setSession(null);
return;
} }
});
setSession({ email: storedEmail });
}, [setSession]);
useEffect(() => {
document.documentElement.lang = language;
}, [language]);
return ( return (
<Router <>
root={props => (
<MetaProvider>
<Meta name="description" content={t("meta.description")} />
<Auth>
<Suspense>
<Nav /> <Nav />
{props.children} <Routes>
<ErrorNotification /> <Route path="/" element={<Home />} />
</Suspense> <Route path="/about" element={<About />} />
</Auth> <Route path="/login" element={<Login />} />
</MetaProvider> <Route path="*" element={<NotFound />} />
)} <Route path="/index.html" element={<Navigate to="/" replace />} />
> </Routes>
<FileRoutes /> <Toasts />
</Router> </>
);
}
export default function App() {
return (
<BrowserRouter>
<AppShell />
</BrowserRouter>
); );
} }

View File

@@ -1,28 +0,0 @@
import { createStorage } from "unstorage";
import fsLiteDriver from "unstorage/drivers/fs-lite";
interface User {
id: number;
email: string;
password?: string;
}
const storage = createStorage({ driver: fsLiteDriver({ base: "./.data" }) });
export async function createUser(data: Pick<User, "email" | "password">) {
const users = (await storage.getItem<User[]>("users:data")) ?? [];
const counter = (await storage.getItem<number>("users:counter")) ?? 1;
const user: User = { id: counter, ...data };
await Promise.all([
storage.setItem("users:data", [...users, user]),
storage.setItem("users:counter", counter + 1)
]);
return user;
}
export async function findUser({ email, id }: { email?: string; id?: number }) {
const users = (await storage.getItem<User[]>("users:data")) ?? [];
if (id) return users.find(u => u.id === id);
if (email) return users.find(u => u.email === email);
return undefined;
}

View File

@@ -1,40 +0,0 @@
import { action, query, redirect } from "@solidjs/router";
import { getLanguageFromFormData, getTranslations } from "~/i18n";
import { getSession, passwordLogin } from "./server";
// Define routes that require being logged in
const PROTECTED_ROUTES = ["/"];
const isProtected = (path: string) =>
PROTECTED_ROUTES.some((route) =>
route.endsWith("/*")
? path.startsWith(route.slice(0, -2))
: path === route || path.startsWith(`${route}/`),
);
export const querySession = query(async (path: string) => {
"use server";
const { data } = await getSession();
if (path === "/login" && data.id) return redirect("/");
if (data.id) return data;
if (isProtected(path)) throw redirect(`/login?redirect=${path}`);
return null;
}, "session");
export const formLogin = action(async (formData: FormData) => {
"use server";
const lang = getLanguageFromFormData(formData);
const translations = getTranslations(lang);
const email = formData.get("email");
const password = formData.get("password");
if (typeof email !== "string" || typeof password !== "string")
return new Error(translations["errors.requiredEmailPassword"]);
return await passwordLogin(email.trim().toLowerCase(), password, lang);
});
export const logout = action(async () => {
"use server";
const session = await getSession();
await session.update({ id: undefined });
throw redirect("/login", { revalidate: "session" });
});

View File

@@ -1,102 +0,0 @@
import { redirect } from "@solidjs/router";
import { useSession } from "vinxi/http";
import { getRandomValues, subtle, timingSafeEqual } from "node:crypto";
import { createUser, findUser } from "./db";
import type { Language } from "~/i18n";
import { getTranslations } from "~/i18n";
export interface Session {
id: number;
email: string;
}
export const getSessionSecret = () => {
const secret = process.env.SESSION_SECRET;
if (!secret) {
throw new Error("SESSION_SECRET is required");
}
return secret;
};
export const getSession = () =>
useSession<Session>({
password: getSessionSecret(),
});
export async function createSession(user: Session, redirectTo?: string) {
const validDest = redirectTo?.[0] === "/" && redirectTo[1] !== "/";
const session = await getSession();
await session.update(user);
return redirect(validDest ? redirectTo : "/");
}
async function createHash(password: string) {
const salt = getRandomValues(new Uint8Array(16));
const saltHex = Buffer.from(salt).toString("hex");
const key = await subtle.deriveBits(
{
name: "PBKDF2",
salt,
iterations: 100_000,
hash: "SHA-512",
},
await subtle.importKey(
"raw",
new TextEncoder().encode(password),
"PBKDF2",
false,
["deriveBits"],
),
512,
);
const hash = Buffer.from(key).toString("hex");
return `${saltHex}:${hash}`;
}
async function checkPassword(
storedPassword: string,
providedPassword: string,
lang: Language,
) {
const translations = getTranslations(lang);
const [storedSalt, storedHash] = storedPassword.split(":");
if (!storedSalt || !storedHash)
throw new Error(translations["errors.invalidStoredPasswordFormat"]);
const key = await subtle.deriveBits(
{
name: "PBKDF2",
salt: Buffer.from(storedSalt, "hex"),
iterations: 100_000,
hash: "SHA-512",
},
await subtle.importKey(
"raw",
new TextEncoder().encode(providedPassword),
"PBKDF2",
false,
["deriveBits"],
),
512,
);
const hash = Buffer.from(key);
const stored = Buffer.from(storedHash, "hex");
if (stored.length !== hash.length || !timingSafeEqual(stored, hash))
throw new Error(translations["errors.invalidEmailOrPassword"]);
}
export async function passwordLogin(
email: string,
password: string,
lang: Language = "en",
) {
const translations = getTranslations(lang);
let user = await findUser({ email });
if (!user)
user = await createUser({
email,
password: await createHash(password),
});
else if (!user.password) throw new Error(translations["errors.oauthOnly"]);
else await checkPassword(user.password, password, lang);
return createSession(user);
}

View File

@@ -1,28 +0,0 @@
import { createAsync, useLocation, type AccessorWithLatest } from "@solidjs/router";
import { createContext, useContext, type ParentProps } from "solid-js";
import { logout, querySession } from "../auth";
import type { Session } from "../auth/server";
const Context = createContext<{
session: AccessorWithLatest<Session | null | undefined>;
signedIn: () => boolean;
logout: typeof logout;
}>();
export default function Auth(props: ParentProps) {
const location = useLocation();
const session = createAsync(() => querySession(location.pathname), {
deferStream: true
});
const signedIn = () => Boolean(session()?.id);
return (
<Context.Provider value={{ session, signedIn, logout }}>{props.children}</Context.Provider>
);
}
export function useAuth() {
const context = useContext(Context);
if (!context) throw new Error("useAuth must be used within Auth context");
return context;
}

View File

@@ -1,35 +0,0 @@
import { useSearchParams } from "@solidjs/router";
import { createEffect, onCleanup, Show } from "solid-js";
import { t } from "~/i18n";
import { X } from "./Icons";
export default function ErrorNotification() {
const [searchParams, setSearchParams] = useSearchParams();
createEffect(() => {
if (searchParams.error) {
const timer = setTimeout(() => setSearchParams({ error: "" }), 5000);
onCleanup(() => clearTimeout(timer));
}
});
return (
<Show when={typeof searchParams.error === "string" && searchParams.error} keyed>
{msg => (
<aside class="flex items-start gap-3 fixed bottom-4 left-4 max-w-sm bg-[#F5D1A9] border border-[#C99763] rounded-xl p-4 shadow-lg z-50 transition-all duration-300 text-sm">
<div>
<strong class="font-medium text-[#4C250E]">{t("error.title")}</strong>
<p class="text-[#70421E] mt-1 select-text">{msg}</p>
</div>
<button
type="button"
onclick={() => setSearchParams({ error: "" })}
class="text-[#A56C38] hover:text-[#4C250E] transition-colors"
>
<X class="w-4 h-4" />
</button>
</aside>
)}
</Show>
);
}

View File

@@ -1,17 +0,0 @@
type Icon = { class: string };
export const X = (props: Icon) => (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
class={props.class}
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
);

View File

@@ -1,69 +1,73 @@
import { useMatch } from "@solidjs/router"; import { Link, useLocation, useNavigate } from "react-router-dom";
import { Show } from "solid-js"; import { useRecoilState } from "recoil";
import { useAuth } from "~/components/Context"; import { sessionAtom } from "~/state/appState";
import { language, setLanguage, t } from "~/i18n"; import { useLanguage, useT } from "~/i18n";
export default function Nav() { export default function Nav() {
const { session, signedIn, logout } = useAuth(); const location = useLocation();
const isHome = useMatch(() => "/"); const navigate = useNavigate();
const isAbout = useMatch(() => "/about"); const t = useT();
const { language, setLanguage } = useLanguage();
const [session, setSession] = useRecoilState(sessionAtom);
const signOut = () => {
setSession(null);
localStorage.removeItem("session-email");
navigate("/login");
};
const linkClass = (path: string) =>
`px-3 py-2 text-[#F5D1A9] uppercase transition-colors duration-200 border-b-2 ${location.pathname === path
? "border-[#E3A977] text-[#FFF7EE]"
: "border-transparent hover:text-[#FFF7EE]"
}`;
return ( return (
<nav class="fixed top-0 left-0 w-full bg-[#70421E] shadow-sm z-50 flex items-center justify-between py-3 px-4 font-medium text-sm"> <nav className="fixed top-0 left-0 z-50 flex w-full items-center justify-between bg-[#70421E] px-4 py-3 text-sm font-medium shadow-sm">
<a <Link to="/" className={linkClass("/")}>{t("nav.home")}</Link>
href="/" <Link to="/about" className={linkClass("/about")}>{t("nav.about")}</Link>
class={`px-3 py-2 text-[#F5D1A9] uppercase transition-colors duration-200 border-b-2 ${isHome() ? "border-[#E3A977] text-[#FFF7EE]" : "border-transparent hover:text-[#FFF7EE]"
}`} <div className="ml-auto flex items-center gap-2">
> <b className="text-[#F5D1A9]">{session?.email ?? ""}</b>
{t("nav.home")} <div className="flex items-center gap-1 rounded-md border border-[#8E4F24] bg-[#8E4F24]/45 p-1">
</a>
<a
href="/about"
class={`px-3 py-2 text-[#F5D1A9] uppercase transition-colors duration-200 border-b-2 ${isAbout() ? "border-[#E3A977] text-[#FFF7EE]" : "border-transparent hover:text-[#FFF7EE]"
}`}
>
{t("nav.about")}
</a>
<div class="ml-auto flex items-center gap-2">
<b class="font-large text-[#F5D1A9]">{session()?.email}</b>
<div class="flex items-center gap-1 rounded-md border border-[#8E4F24] bg-[#8E4F24]/45 p-1">
<button <button
type="button" type="button"
onclick={() => setLanguage("fi")} onClick={() => setLanguage("fi")}
class={`px-2 py-1 text-xs rounded ${language() === "fi" ? "bg-[#E3A977] text-[#4C250E]" : "text-[#F5D1A9] hover:text-[#FFF7EE]" className={`rounded px-2 py-1 text-xs ${language === "fi"
? "bg-[#E3A977] text-[#4C250E]"
: "text-[#F5D1A9] hover:text-[#FFF7EE]"
}`} }`}
> >
{t("nav.language.fi")} {t("nav.language.fi")}
</button> </button>
<button <button
type="button" type="button"
onclick={() => setLanguage("en")} onClick={() => setLanguage("en")}
class={`px-2 py-1 text-xs rounded ${language() === "en" ? "bg-[#E3A977] text-[#4C250E]" : "text-[#F5D1A9] hover:text-[#FFF7EE]" className={`rounded px-2 py-1 text-xs ${language === "en"
? "bg-[#E3A977] text-[#4C250E]"
: "text-[#F5D1A9] hover:text-[#FFF7EE]"
}`} }`}
> >
{t("nav.language.en")} {t("nav.language.en")}
</button> </button>
</div> </div>
<Show
when={signedIn()} {session ? (
fallback={
<a
href="/login"
class="px-4 py-2 text-[#F5D1A9] bg-[#8E4F24] border border-[#A56C38] rounded-md hover:bg-[#A56C38] hover:text-[#FFF7EE] focus:outline-none transition-colors duration-200"
>
{t("nav.login")}
</a>
}
>
<form action={logout} method="post">
<button <button
type="submit" type="button"
class="px-4 py-2 text-[#F5D1A9] bg-[#8E4F24] border border-[#A56C38] rounded-md hover:bg-[#A56C38] hover:text-[#FFF7EE] focus:outline-none transition-colors duration-200" onClick={signOut}
className="rounded-md border border-[#A56C38] bg-[#8E4F24] px-4 py-2 text-[#F5D1A9] transition-colors duration-200 hover:bg-[#A56C38] hover:text-[#FFF7EE]"
> >
{t("nav.signOut")} {t("nav.signOut")}
</button> </button>
</form> ) : (
</Show> <Link
to="/login"
className="rounded-md border border-[#A56C38] bg-[#8E4F24] px-4 py-2 text-[#F5D1A9] transition-colors duration-200 hover:bg-[#A56C38] hover:text-[#FFF7EE]"
>
{t("nav.login")}
</Link>
)}
</div> </div>
</nav> </nav>
); );

View File

@@ -1,228 +1,416 @@
import { createAsync, useSubmission } from "@solidjs/router"; import { useEffect, useMemo, useRef, useState } from "react";
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"; import { useRecoilState } from "recoil";
import { createLokOpenHours, deleteLokOpenHours, queryLokOpenHours } from "~/api"; import {
import { t } from "~/i18n"; createLokOpenHours,
deleteLokOpenHours,
queryLokOpenHours,
setActiveLokOpenHours,
updateLokOpenHours,
type LokOpenHours,
} from "~/api";
import { useT } from "~/i18n";
import { openHoursAtom, toastsAtom, type Toast } from "~/state/appState";
const NEW_VERSION_OPTION = "__new__"; type FormState = {
name: string;
paragraph1: string;
paragraph2: string;
paragraph3: string;
paragraph4: string;
kitchenNotice: string;
};
const EMPTY_FORM: FormState = {
name: "",
paragraph1: "",
paragraph2: "",
paragraph3: "",
paragraph4: "",
kitchenNotice: "",
};
export default function OpenHoursForm() { export default function OpenHoursForm() {
const [refreshKey, setRefreshKey] = createSignal(0); const t = useT();
const openHours = createAsync(() => queryLokOpenHours(refreshKey()).catch(() => [])); const [versions, setVersions] = useRecoilState(openHoursAtom);
const createSubmission = useSubmission(createLokOpenHours); const [, setToasts] = useRecoilState(toastsAtom);
const deleteSubmission = useSubmission(deleteLokOpenHours);
const [selectedVersion, setSelectedVersion] = createSignal("");
const [name, setName] = createSignal("");
const [paragraph1, setParagraph1] = createSignal("");
const [paragraph2, setParagraph2] = createSignal("");
const [paragraph3, setParagraph3] = createSignal("");
const [paragraph4, setParagraph4] = createSignal("");
const [kitchenNotice, setKitchenNotice] = createSignal("");
const latestFive = createMemo(() => openHours() ?? []); const [selectedVersion, setSelectedVersion] = useState("");
const [isEditing, setIsEditing] = useState(false);
const [editingVersionId, setEditingVersionId] = useState("");
const [deletingId, setDeletingId] = useState("");
const [activatingId, setActivatingId] = useState("");
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const selectedOpenHours = createMemo(() => const initializedRef = useRef(false);
latestFive().find(version => String(version.id) === selectedVersion()) const toastGuardRef = useRef<Record<string, number>>({});
);
createEffect(() => { const isUpdateMode = editingVersionId.length > 0;
if (!createSubmission.result) return;
setRefreshKey(previous => previous + 1);
setSelectedVersion("");
});
createEffect(() => { const pushToast = (message: string, kind: Toast["kind"], dedupeKey: string) => {
if (!deleteSubmission.result) return; const now = Date.now();
setRefreshKey(previous => previous + 1); const lastShown = toastGuardRef.current[dedupeKey] ?? 0;
setSelectedVersion(""); if (now - lastShown < 800) return;
});
createEffect(() => { toastGuardRef.current[dedupeKey] = now;
const versions = latestFive();
const current = selectedVersion();
if (versions.length === 0) { setToasts((previous) => [
if (current !== NEW_VERSION_OPTION) { ...previous,
setSelectedVersion(NEW_VERSION_OPTION); {
} id: now + Math.floor(Math.random() * 1000),
return; message,
} kind,
leaving: false,
if (current === NEW_VERSION_OPTION) { },
return; ]);
}
const hasCurrent = versions.some(version => String(version.id) === current);
if (!current || !hasCurrent) {
setSelectedVersion(String(versions[0].id));
}
});
const reuseSelected = () => {
const selected = selectedOpenHours();
if (!selected) {
setName("");
setParagraph1("");
setParagraph2("");
setParagraph3("");
setParagraph4("");
setKitchenNotice("");
return;
}
setName(selected.name);
setParagraph1(selected.paragraph1);
setParagraph2(selected.paragraph2);
setParagraph3(selected.paragraph3);
setParagraph4(selected.paragraph4);
setKitchenNotice(selected.kitchenNotice);
}; };
createEffect(() => { const clearForm = () => {
reuseSelected(); setForm(EMPTY_FORM);
};
const hydrate = async () => {
const loaded = await queryLokOpenHours().catch(() => []);
setVersions(loaded);
initializedRef.current = true;
};
useEffect(() => {
if (initializedRef.current) return;
void hydrate();
}, []);
useEffect(() => {
if (versions.length === 0) {
if (selectedVersion) setSelectedVersion("");
return;
}
const activeVersion = versions.find((version) => version.isActive);
if (activeVersion) {
const activeId = String(activeVersion.id);
if (activeId !== selectedVersion) {
setSelectedVersion(activeId);
}
return;
}
if (!versions.some((version) => String(version.id) === selectedVersion)) {
setSelectedVersion(String(versions[0].id));
}
}, [versions, selectedVersion]);
useEffect(() => {
if (!isEditing || !isUpdateMode) return;
if (versions.some((version) => String(version.id) === editingVersionId)) return;
setEditingVersionId("");
setIsEditing(false);
clearForm();
}, [versions, isEditing, isUpdateMode, editingVersionId]);
useEffect(() => {
if (!isEditing || isUpdateMode) return;
clearForm();
}, [isEditing, isUpdateMode]);
const populateForm = (versionId: string) => {
const selected = versions.find((version) => String(version.id) === versionId);
if (!selected) {
clearForm();
return;
}
setForm({
name: selected.name,
paragraph1: selected.paragraph1,
paragraph2: selected.paragraph2,
paragraph3: selected.paragraph3,
paragraph4: selected.paragraph4,
kitchenNotice: selected.kitchenNotice,
}); });
};
const startCreate = () => {
setEditingVersionId("");
clearForm();
setIsEditing(true);
};
const startEdit = (versionId: string) => {
setEditingVersionId(versionId);
populateForm(versionId);
setIsEditing(true);
};
const cancelEdit = () => {
setEditingVersionId("");
clearForm();
setIsEditing(false);
};
const submitForm = async (event: React.FormEvent) => {
event.preventDefault();
setSaving(true);
try {
if (isUpdateMode) {
const updated = await updateLokOpenHours(Number(editingVersionId), form);
setVersions((previous) =>
previous.map((version) =>
version.id === updated.id ? updated : version,
),
);
pushToast(
t("home.openHours.updated"),
"success",
`update:${updated.id}:${updated.version}`,
);
} else {
const created = await createLokOpenHours(form);
setVersions((previous) => {
const next = created.isActive
? [created, ...previous.map((version) => ({ ...version, isActive: false }))]
: [created, ...previous];
return next.slice(0, 5);
});
setSelectedVersion(String(created.id));
pushToast(
t("home.openHours.saved"),
"success",
`create:${created.id}:${created.version}`,
);
}
setEditingVersionId("");
clearForm();
setIsEditing(false);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
pushToast(message, "error", `error:save:${message}`);
} finally {
setSaving(false);
}
};
const onDelete = async (version: LokOpenHours) => {
setDeletingId(String(version.id));
try {
await deleteLokOpenHours(version.id);
setVersions((previous) =>
previous.filter((item) => item.id !== version.id),
);
if (editingVersionId === String(version.id)) {
setEditingVersionId("");
clearForm();
setIsEditing(false);
}
pushToast(t("home.openHours.deleted"), "success", `delete:${version.id}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
pushToast(message, "error", `error:delete:${message}`);
} finally {
setDeletingId("");
}
};
const onSetActive = async (version: LokOpenHours) => {
if (version.isActive) return;
setActivatingId(String(version.id));
try {
const result = await setActiveLokOpenHours(version.id);
setVersions((previous) =>
previous.map((item) => ({
...item,
isActive: item.id === result.id,
})),
);
setSelectedVersion(String(result.id));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
pushToast(message, "error", `error:active:${message}`);
} finally {
setActivatingId("");
}
};
const tooltipText = (version: LokOpenHours) =>
[
version.name,
version.paragraph1,
version.paragraph2,
version.paragraph3,
version.paragraph4,
version.kitchenNotice,
]
.filter(Boolean)
.join("\n");
const nameRequired = useMemo(() => form.name.trim().length === 0, [form.name]);
return ( return (
<section class="w-full max-w-3xl rounded-2xl border border-[#C99763] bg-[#F5D1A9] p-6 shadow-md"> <section className="w-full max-w-3xl rounded-2xl border border-[#C99763] bg-[#F5D1A9] p-4 shadow-md sm:p-6">
<h2 class="text-2xl font-semibold text-[#4C250E]">{t("home.openHours.heading")}</h2> <h2 className="text-2xl font-semibold text-[#4C250E]">{t("home.openHours.heading")}</h2>
<Show when={latestFive().length > 0} fallback={<p class="mt-3 text-[#70421E]">{t("home.openHours.empty")}</p>}> <div className="mt-4">
<div class="mt-2 flex gap-2"> <div
<select className={`transition-all duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] ${!isEditing
id="open-hours-version" ? "max-h-[1200px] translate-y-0 opacity-100"
class="min-w-0 flex-1 rounded-md border border-[#C99763] bg-[#FFF7EE] px-3 py-2 text-[#4C250E] focus:outline-none focus:ring-2 focus:ring-[#A56C38]" : "pointer-events-none max-h-0 -translate-y-3 overflow-hidden opacity-0"
value={selectedVersion()} }`}
onInput={event => setSelectedVersion(event.currentTarget.value)}
> >
<For each={latestFive()}> <div className="mb-3 flex justify-end">
{version => ( <button
<option value={String(version.id)}> type="button"
{version.name || t("home.openHours.latest")} · {new Date(version.version).toLocaleString()} onClick={startCreate}
</option> className="w-full rounded-md border border-[#70421E] bg-[#8E4F24] px-4 py-2 text-[#FFF7EE] hover:bg-[#70421E] sm:w-auto"
)} >
</For> {t("home.openHours.new")}
<option value={NEW_VERSION_OPTION}>{t("home.openHours.new")}</option> </button>
</select>
</div> </div>
<form action={deleteLokOpenHours} method="post" class="mt-3"> {versions.length === 0 ? (
<input type="hidden" name="id" value={selectedOpenHours()?.id ?? ""} /> <p className="text-[#70421E]">{t("home.openHours.empty")}</p>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{versions.map((version) => {
const versionId = String(version.id);
const active = version.isActive;
const deleting = deletingId === versionId;
const settingActive = activatingId === versionId;
return (
<article
key={version.id}
onClick={() => {
if (active || settingActive) return;
void onSetActive(version);
}}
className={`rounded-xl border p-3 transition-all duration-400 ease-[cubic-bezier(0.22,1,0.36,1)] ${active ? "border-[#8E4F24] bg-[#D6A06B]" : "border-[#C99763] bg-[#EED5B8]"
} ${deleting ? "-translate-y-1 scale-90 opacity-0" : ""} ${settingActive && !active ? "opacity-80" : ""
}`}
>
<div className="mb-2 flex items-center justify-between gap-2" title={tooltipText(version)}>
<p className="truncate font-medium text-[#4C250E]">
{version.name || t("home.openHours.latest")}
</p>
{active && (
<span className="rounded-md bg-[#8E4F24] px-2 py-0.5 text-xs font-medium text-[#FFF7EE]">
{t("home.openHours.active")}
</span>
)}
</div>
<div className="flex flex-wrap gap-2">
<button <button
type="submit" type="button"
disabled={!selectedOpenHours() || deleteSubmission.pending} onClick={(event) => {
class="rounded-md border border-[#8E4F24] bg-[#EED5B8] px-4 py-2 text-[#70421E] hover:bg-[#E3A977] disabled:opacity-50 disabled:cursor-not-allowed" event.stopPropagation();
startEdit(versionId);
}}
className="rounded-md border border-[#A56C38] bg-[#FFF7EE] px-3 py-1.5 text-sm text-[#70421E] hover:bg-[#E3A977]"
>
{t("home.openHours.edit")}
</button>
<button
type="button"
disabled={active || deleting}
onClick={(event) => {
event.stopPropagation();
void onDelete(version);
}}
className={`rounded-md border border-[#8E4F24] bg-[#EED5B8] px-3 py-1.5 text-sm text-[#70421E] disabled:cursor-not-allowed disabled:opacity-50 ${!active ? "hover:bg-[#E3A977]" : ""
}`}
> >
{t("home.openHours.delete")} {t("home.openHours.delete")}
</button> </button>
</form> </div>
</Show> </article>
);
})}
</div>
)}
</div>
<form
onSubmit={submitForm}
className={`space-y-3 transition-all duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] ${isEditing
? "max-h-[1600px] translate-y-0 opacity-100"
: "pointer-events-none max-h-0 translate-y-3 overflow-hidden opacity-0"
}`}
>
<div className="flex flex-wrap items-center justify-between gap-2">
{isUpdateMode && (
<p className="text-sm font-medium text-[#4C250E]">{t("home.openHours.editing")}</p>
)}
<button
type="button"
onClick={cancelEdit}
className="rounded-md border border-[#A56C38] bg-[#EED5B8] px-3 py-1.5 text-sm text-[#70421E] hover:bg-[#E3A977]"
>
{t("home.openHours.cancel")}
</button>
</div>
<form action={createLokOpenHours} method="post" class="mt-5 space-y-3">
<div> <div>
<label for="name" class="block text-sm font-medium text-[#4C250E]">{t("home.openHours.name")}</label> <label htmlFor="name" className="block text-sm font-medium text-[#4C250E]">{t("home.openHours.name")}</label>
<input <input
id="name" id="name"
name="name" name="name"
required required
value={name()} value={form.name}
onInput={event => setName(event.currentTarget.value)} onChange={(event) => setForm((previous) => ({ ...previous, name: event.target.value }))}
class="mt-1 w-full rounded-md border border-[#C99763] bg-[#FFF7EE] px-3 py-2 text-[#4C250E] focus:outline-none focus:ring-2 focus:ring-[#A56C38]" className="mt-1 w-full rounded-md border border-[#C99763] bg-[#FFF7EE] px-3 py-2 text-[#4C250E] focus:outline-none focus:ring-2 focus:ring-[#A56C38]"
/> />
<Show when={!name().trim()}> {nameRequired && (
<p class="mt-1 text-xs text-[#8E4F24]">{t("home.openHours.nameRequired")}</p> <p className="mt-1 text-xs text-[#8E4F24]">{t("home.openHours.nameRequired")}</p>
</Show> )}
</div> </div>
<div> {(["paragraph1", "paragraph2", "paragraph3", "paragraph4", "kitchenNotice"] as const).map((field) => (
<label for="paragraph1" class="block text-sm font-medium text-[#4C250E]">{t("home.openHours.paragraph1")}</label> <div key={field}>
<label htmlFor={field} className="block text-sm font-medium text-[#4C250E]">
{t(`home.openHours.${field}`)}
</label>
<textarea <textarea
id="paragraph1" id={field}
name="paragraph1" name={field}
rows={2} rows={2}
value={paragraph1()} value={form[field]}
onInput={event => setParagraph1(event.currentTarget.value)} onChange={(event) =>
class="mt-1 w-full rounded-md border border-[#C99763] bg-[#FFF7EE] px-3 py-2 text-[#4C250E] focus:outline-none focus:ring-2 focus:ring-[#A56C38]" setForm((previous) => ({
...previous,
[field]: event.target.value,
}))
}
className="mt-1 w-full rounded-md border border-[#C99763] bg-[#FFF7EE] px-3 py-2 text-[#4C250E] focus:outline-none focus:ring-2 focus:ring-[#A56C38]"
/> />
</div> </div>
))}
<div> <div className="flex flex-wrap gap-3 pt-1">
<label for="paragraph2" class="block text-sm font-medium text-[#4C250E]">{t("home.openHours.paragraph2")}</label>
<textarea
id="paragraph2"
name="paragraph2"
rows={2}
value={paragraph2()}
onInput={event => setParagraph2(event.currentTarget.value)}
class="mt-1 w-full rounded-md border border-[#C99763] bg-[#FFF7EE] px-3 py-2 text-[#4C250E] focus:outline-none focus:ring-2 focus:ring-[#A56C38]"
/>
</div>
<div>
<label for="paragraph3" class="block text-sm font-medium text-[#4C250E]">{t("home.openHours.paragraph3")}</label>
<textarea
id="paragraph3"
name="paragraph3"
rows={2}
value={paragraph3()}
onInput={event => setParagraph3(event.currentTarget.value)}
class="mt-1 w-full rounded-md border border-[#C99763] bg-[#FFF7EE] px-3 py-2 text-[#4C250E] focus:outline-none focus:ring-2 focus:ring-[#A56C38]"
/>
</div>
<div>
<label for="paragraph4" class="block text-sm font-medium text-[#4C250E]">{t("home.openHours.paragraph4")}</label>
<textarea
id="paragraph4"
name="paragraph4"
rows={2}
value={paragraph4()}
onInput={event => setParagraph4(event.currentTarget.value)}
class="mt-1 w-full rounded-md border border-[#C99763] bg-[#FFF7EE] px-3 py-2 text-[#4C250E] focus:outline-none focus:ring-2 focus:ring-[#A56C38]"
/>
</div>
<div>
<label for="kitchenNotice" class="block text-sm font-medium text-[#4C250E]">{t("home.openHours.kitchenNotice")}</label>
<textarea
id="kitchenNotice"
name="kitchenNotice"
rows={2}
value={kitchenNotice()}
onInput={event => setKitchenNotice(event.currentTarget.value)}
class="mt-1 w-full rounded-md border border-[#C99763] bg-[#FFF7EE] px-3 py-2 text-[#4C250E] focus:outline-none focus:ring-2 focus:ring-[#A56C38]"
/>
</div>
<div class="flex flex-wrap gap-3 pt-1">
<button
type="button"
disabled={!selectedOpenHours()}
onClick={reuseSelected}
class="rounded-md border border-[#A56C38] bg-[#EED5B8] px-4 py-2 text-[#70421E] hover:bg-[#E3A977] disabled:opacity-50 disabled:cursor-not-allowed"
>
{t("home.openHours.reuse")}
</button>
<button <button
type="submit" type="submit"
disabled={createSubmission.pending} disabled={saving}
class="rounded-md border border-[#70421E] bg-[#8E4F24] px-4 py-2 text-[#FFF7EE] hover:bg-[#70421E] disabled:opacity-50 disabled:cursor-not-allowed" className="w-full rounded-md border border-[#70421E] bg-[#8E4F24] px-4 py-2 text-[#FFF7EE] hover:bg-[#70421E] disabled:cursor-not-allowed disabled:opacity-50 sm:w-auto"
> >
{createSubmission.pending ? t("home.openHours.saving") : t("home.openHours.submit")} {isUpdateMode
? saving
? t("home.openHours.updating")
: t("home.openHours.update")
: saving
? t("home.openHours.saving")
: t("home.openHours.submit")}
</button> </button>
</div> </div>
<Show when={createSubmission.result}>
<p class="text-sm text-[#4C250E]">{t("home.openHours.saved")}</p>
</Show>
<Show when={deleteSubmission.result}>
<p class="text-sm text-[#4C250E]">{t("home.openHours.deleted")}</p>
</Show>
<Show when={createSubmission.error} keyed>
{error => <p class="text-sm text-[#8E4F24]">{error.message}</p>}
</Show>
<Show when={deleteSubmission.error} keyed>
{error => <p class="text-sm text-[#8E4F24]">{error.message}</p>}
</Show>
</form> </form>
</div>
</section> </section>
); );
} }

View File

@@ -0,0 +1,64 @@
import { useEffect } from "react";
import { useRecoilState } from "recoil";
import { toastsAtom } from "~/state/appState";
export default function Toasts() {
const [toasts, setToasts] = useRecoilState(toastsAtom);
useEffect(() => {
if (toasts.length === 0) return;
const timers = toasts.map((toast) =>
window.setTimeout(() => {
setToasts((previous) =>
previous.map((item) =>
item.id === toast.id ? { ...item, leaving: true } : item,
),
);
window.setTimeout(() => {
setToasts((previous) => previous.filter((item) => item.id !== toast.id));
}, 220);
}, 3200),
);
return () => timers.forEach((timer) => window.clearTimeout(timer));
}, [toasts, setToasts]);
const dismiss = (id: number) => {
setToasts((previous) =>
previous.map((toast) =>
toast.id === id ? { ...toast, leaving: true } : toast,
),
);
window.setTimeout(() => {
setToasts((previous) => previous.filter((toast) => toast.id !== id));
}, 220);
};
return (
<div className="pointer-events-none fixed bottom-3 left-3 right-3 z-50 flex flex-col gap-2 sm:bottom-5 sm:left-auto sm:right-5 sm:w-full sm:max-w-sm">
{toasts.map((toast) => (
<div
key={toast.id}
className={`pointer-events-auto rounded-lg border px-4 py-3 text-sm shadow-lg transition-all duration-200 ease-out ${toast.kind === "success"
? "border-[#8E4F24] bg-[#F5E2CB] text-[#4C250E]"
: "border-[#8E4F24] bg-[#F7D3B7] text-[#4C250E]"
} ${toast.leaving ? "translate-y-2 opacity-0" : "translate-y-0 opacity-100"}`}
>
<div className="flex items-start justify-between gap-3">
<p className="leading-snug">{toast.message}</p>
<button
type="button"
onClick={() => dismiss(toast.id)}
className="rounded px-1 text-base leading-none text-[#70421E] hover:bg-[#EED5B8]"
aria-label="Dismiss"
>
×
</button>
</div>
</div>
))}
</div>
);
}

View File

@@ -1,9 +0,0 @@
import { mount, StartClient } from "@solidjs/start/client";
const appRoot = document.getElementById("app");
if (!appRoot) {
throw new Error("App root element '#app' not found");
}
mount(() => <StartClient />, appRoot);

View File

@@ -1,21 +0,0 @@
import { createHandler, StartServer } from "@solidjs/start/server";
export default createHandler(() => (
<StartServer
document={({ assets, children, scripts }) => (
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="description" content="SolidStart with-auth example" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/svg" href="favicon.svg" />
{assets}
</head>
<body>
<div id="app">{children}</div>
{scripts}
</body>
</html>
)}
/>
));

2
ui/src/global.d.ts vendored
View File

@@ -1 +1 @@
/// <reference types="@solidjs/start/env" /> /// <reference types="vite/client" />

View File

@@ -1,7 +1,6 @@
import { createSignal } from "solid-js"; import { useMemo } from "react";
import { isServer } from "solid-js/web"; import { useRecoilState, useRecoilValue } from "recoil";
import { languageAtom, type Language } from "~/state/appState";
export type Language = "fi" | "en";
const STORAGE_KEY = "ui-language"; const STORAGE_KEY = "ui-language";
@@ -13,7 +12,7 @@ const translations = {
"nav.signOut": "Sign Out", "nav.signOut": "Sign Out",
"nav.language.fi": "FI", "nav.language.fi": "FI",
"nav.language.en": "EN", "nav.language.en": "EN",
"meta.description": "SolidStart with-auth example", "meta.description": "React + Recoil example",
"home.title": "Home", "home.title": "Home",
"home.heading": "KlAPI", "home.heading": "KlAPI",
"home.signedInAs": "You are signed in as", "home.signedInAs": "You are signed in as",
@@ -21,8 +20,12 @@ const translations = {
"home.openHours.heading": "Open hours versions", "home.openHours.heading": "Open hours versions",
"home.openHours.latest": "Latest", "home.openHours.latest": "Latest",
"home.openHours.new": "New version", "home.openHours.new": "New version",
"home.openHours.edit": "Edit",
"home.openHours.cancel": "Cancel",
"home.openHours.editing": "Editing version",
"home.openHours.active": "Active",
"home.openHours.reuse": "Reuse selected", "home.openHours.reuse": "Reuse selected",
"home.openHours.delete": "Delete selected", "home.openHours.delete": "Delete",
"home.openHours.empty": "No open-hours versions found yet", "home.openHours.empty": "No open-hours versions found yet",
"home.openHours.name": "Version name", "home.openHours.name": "Version name",
"home.openHours.nameRequired": "Version name is required", "home.openHours.nameRequired": "Version name is required",
@@ -32,8 +35,11 @@ const translations = {
"home.openHours.paragraph4": "Paragraph 4", "home.openHours.paragraph4": "Paragraph 4",
"home.openHours.kitchenNotice": "Kitchen notice", "home.openHours.kitchenNotice": "Kitchen notice",
"home.openHours.submit": "Add new version", "home.openHours.submit": "Add new version",
"home.openHours.update": "Save changes",
"home.openHours.saving": "Saving...", "home.openHours.saving": "Saving...",
"home.openHours.updating": "Saving changes...",
"home.openHours.saved": "New version saved", "home.openHours.saved": "New version saved",
"home.openHours.updated": "Version updated",
"home.openHours.deleted": "Version deleted", "home.openHours.deleted": "Version deleted",
"about.title": "About", "about.title": "About",
"about.apiVersion": "API version", "about.apiVersion": "API version",
@@ -48,12 +54,7 @@ const translations = {
"notFound.message": "Sorry, the page youre looking for doesn't exist", "notFound.message": "Sorry, the page youre looking for doesn't exist",
"notFound.goHome": "Go Home", "notFound.goHome": "Go Home",
"error.title": "Error", "error.title": "Error",
"counter.clicks": "Clicks",
"errors.requiredEmailPassword": "Email and password are required", "errors.requiredEmailPassword": "Email and password are required",
"errors.invalidStoredPasswordFormat": "Invalid stored password format",
"errors.invalidEmailOrPassword": "Invalid email or password",
"errors.oauthOnly":
"Account exists via OAuth. Sign in with your OAuth provider",
}, },
fi: { fi: {
"nav.home": "Etusivu", "nav.home": "Etusivu",
@@ -62,7 +63,7 @@ const translations = {
"nav.signOut": "Kirjaudu ulos", "nav.signOut": "Kirjaudu ulos",
"nav.language.fi": "FI", "nav.language.fi": "FI",
"nav.language.en": "EN", "nav.language.en": "EN",
"meta.description": "SolidStart with-auth -esimerkki", "meta.description": "React + Recoil -esimerkki",
"home.title": "Etusivu", "home.title": "Etusivu",
"home.heading": "KlAPI", "home.heading": "KlAPI",
"home.signedInAs": "Olet kirjautunut käyttäjänä", "home.signedInAs": "Olet kirjautunut käyttäjänä",
@@ -70,8 +71,12 @@ const translations = {
"home.openHours.heading": "Aukioloaikaversiot", "home.openHours.heading": "Aukioloaikaversiot",
"home.openHours.latest": "Viimeisin", "home.openHours.latest": "Viimeisin",
"home.openHours.new": "Uusi versio", "home.openHours.new": "Uusi versio",
"home.openHours.edit": "Muokkaa",
"home.openHours.cancel": "Peruuta",
"home.openHours.editing": "Muokataan versiota",
"home.openHours.active": "Aktiivinen",
"home.openHours.reuse": "Käytä valittua uudelleen", "home.openHours.reuse": "Käytä valittua uudelleen",
"home.openHours.delete": "Poista valittu", "home.openHours.delete": "Poista",
"home.openHours.empty": "Aukioloaikaversioita ei vielä löytynyt", "home.openHours.empty": "Aukioloaikaversioita ei vielä löytynyt",
"home.openHours.name": "Version nimi", "home.openHours.name": "Version nimi",
"home.openHours.nameRequired": "Version nimi on pakollinen", "home.openHours.nameRequired": "Version nimi on pakollinen",
@@ -81,8 +86,11 @@ const translations = {
"home.openHours.paragraph4": "Kappale 4", "home.openHours.paragraph4": "Kappale 4",
"home.openHours.kitchenNotice": "Keittiöhuomio", "home.openHours.kitchenNotice": "Keittiöhuomio",
"home.openHours.submit": "Lisää uusi versio", "home.openHours.submit": "Lisää uusi versio",
"home.openHours.update": "Tallenna muutokset",
"home.openHours.saving": "Tallennetaan...", "home.openHours.saving": "Tallennetaan...",
"home.openHours.updating": "Tallennetaan muutoksia...",
"home.openHours.saved": "Uusi versio tallennettu", "home.openHours.saved": "Uusi versio tallennettu",
"home.openHours.updated": "Versio päivitetty",
"home.openHours.deleted": "Versio poistettu", "home.openHours.deleted": "Versio poistettu",
"about.title": "Tietoja", "about.title": "Tietoja",
"about.apiVersion": "API-versio", "about.apiVersion": "API-versio",
@@ -97,12 +105,7 @@ const translations = {
"notFound.message": "Valitettavasti etsimääsi sivua ei ole olemassa", "notFound.message": "Valitettavasti etsimääsi sivua ei ole olemassa",
"notFound.goHome": "Takaisin etusivulle", "notFound.goHome": "Takaisin etusivulle",
"error.title": "Virhe", "error.title": "Virhe",
"counter.clicks": "Klikkauksia",
"errors.requiredEmailPassword": "Sähköposti ja salasana vaaditaan", "errors.requiredEmailPassword": "Sähköposti ja salasana vaaditaan",
"errors.invalidStoredPasswordFormat":
"Tallennetun salasanan muoto on virheellinen",
"errors.invalidEmailOrPassword": "Virheellinen sähköposti tai salasana",
"errors.oauthOnly": "Tili löytyy OAuthin kautta. Kirjaudu OAuth-palvelulla",
}, },
} as const; } as const;
@@ -111,25 +114,27 @@ export type TranslationKey = keyof typeof translations.en;
export const normalizeLanguage = (value: unknown): Language => export const normalizeLanguage = (value: unknown): Language =>
value === "fi" ? "fi" : "en"; value === "fi" ? "fi" : "en";
const [language, setLanguageSignal] = createSignal<Language>("en"); export const initializeLanguage = (setLanguage: (lang: Language) => void) => {
if (!isServer) {
const stored = normalizeLanguage(localStorage.getItem(STORAGE_KEY)); const stored = normalizeLanguage(localStorage.getItem(STORAGE_KEY));
setLanguageSignal(stored); setLanguage(stored);
}
export const setLanguage = (lang: Language) => {
setLanguageSignal(lang);
if (!isServer) {
localStorage.setItem(STORAGE_KEY, lang);
}
}; };
export { language }; export const useLanguage = () => {
const [language, setLanguageState] = useRecoilState(languageAtom);
export const getTranslations = (lang: Language) => translations[lang]; const setLanguage = (lang: Language) => {
setLanguageState(lang);
localStorage.setItem(STORAGE_KEY, lang);
};
export const t = (key: TranslationKey) => translations[language()][key]; return { language, setLanguage };
};
export const getLanguageFromFormData = (formData: FormData): Language => export const useT = () => {
normalizeLanguage(formData.get("lang")); const language = useRecoilValue(languageAtom);
return useMemo(
() => (key: TranslationKey) => translations[language][key],
[language],
);
};

12
ui/src/main.tsx Normal file
View File

@@ -0,0 +1,12 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { RecoilRoot } from "recoil";
import App from "~/app";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<RecoilRoot>
<App />
</RecoilRoot>
</React.StrictMode>,
);

View File

@@ -1,18 +1,24 @@
import { Title } from "@solidjs/meta"; import { Link } from "react-router-dom";
import { t } from "~/i18n"; import { useEffect } from "react";
import { useT } from "~/i18n";
export default function NotFound() { export default function NotFound() {
const t = useT();
useEffect(() => {
document.title = t("notFound.title");
}, [t]);
return ( return (
<main class="text-center"> <main className="text-center">
<Title>{t("notFound.title")}</Title>
<h1>{t("notFound.heading")}</h1> <h1>{t("notFound.heading")}</h1>
{t("notFound.message")} <p>{t("notFound.message")}</p>
<a <Link
href="/" to="/"
class="px-4 py-2 border border-[#C99763] rounded-xl text-[#70421E] hover:bg-[#F5D1A9] transition-colors duration-200" className="rounded-xl border border-[#C99763] px-4 py-2 text-[#70421E] transition-colors duration-200 hover:bg-[#F5D1A9]"
> >
{t("notFound.goHome")} {t("notFound.goHome")}
</a> </Link>
</main> </main>
); );
} }

View File

@@ -1,20 +1,39 @@
import { Title } from "@solidjs/meta"; import { useEffect, useState } from "react";
import { createAsync } from "@solidjs/router";
import { Show } from "solid-js";
import { queryApiVersion } from "~/api"; import { queryApiVersion } from "~/api";
import { t } from "~/i18n"; import { useT } from "~/i18n";
export default function About() { export default function About() {
const apiVersion = createAsync(() => queryApiVersion()); const t = useT();
const [apiVersion, setApiVersion] = useState<string | null>(null);
useEffect(() => {
document.title = t("about.title");
}, [t]);
useEffect(() => {
let active = true;
queryApiVersion()
.then((version) => {
if (active) {
setApiVersion(version);
}
})
.catch(() => {
if (active) {
setApiVersion(null);
}
});
return () => {
active = false;
};
}, []);
return ( return (
<main> <main>
<Title>{t("about.title")}</Title> <p className="text-center text-[#70421E]">
<p class="text-[#70421E] text-center"> {t("about.apiVersion")}: {apiVersion ?? t("about.loading")}
{t("about.apiVersion")}:
<Show when={apiVersion()} fallback={t("about.loading")}>
{apiVersion()}
</Show>
</p> </p>
</main> </main>
); );

View File

@@ -1,12 +0,0 @@
import OAuth from "start-oauth";
import { createUser, findUser } from "~/auth/db";
import { createSession, getSessionSecret } from "~/auth/server";
export const GET = OAuth({
password: getSessionSecret(),
async handler({ email }, redirectTo) {
let user = await findUser({ email });
if (!user) user = await createUser({ email });
return createSession(user, redirectTo);
},
});

View File

@@ -1,13 +1,18 @@
import { Title } from "@solidjs/meta"; import { useEffect } from "react";
import OpenHoursForm from "~/components/OpenHoursForm"; import OpenHoursForm from "~/components/OpenHoursForm";
import { t } from "~/i18n"; import { useT } from "~/i18n";
export default function Home() { export default function Home() {
const t = useT();
useEffect(() => {
document.title = t("home.title");
}, [t]);
return ( return (
<main> <main>
<Title>{t("home.title")}</Title> <h1 className="text-center">{t("home.heading")}</h1>
<h1 class="text-center">{t("home.heading")}</h1> <img src="/favicon.svg" alt={t("home.logoAlt")} className="w-28" />
<img src="/favicon.svg" alt={t("home.logoAlt")} class="w-28" />
<OpenHoursForm /> <OpenHoursForm />
</main> </main>
); );

View File

@@ -1,65 +1,83 @@
import { Title } from "@solidjs/meta"; import { FormEvent, useEffect, useState } from "react";
import { useSubmission } from "@solidjs/router"; import { useNavigate } from "react-router-dom";
import { Show } from "solid-js"; import { useRecoilState } from "recoil";
import { useOAuthLogin } from "start-oauth"; import { useT } from "~/i18n";
import { formLogin } from "~/auth"; import { sessionAtom } from "~/state/appState";
import { language, t } from "~/i18n";
export default function Login() { export default function Login() {
const login = useOAuthLogin(); const t = useT();
const navigate = useNavigate();
const [session, setSession] = useRecoilState(sessionAtom);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
useEffect(() => {
document.title = t("login.title");
}, [t]);
useEffect(() => {
if (!session) return;
navigate("/");
}, [session, navigate]);
const submit = (event: FormEvent) => {
event.preventDefault();
if (!email.trim() || !password.trim()) {
setError(t("errors.requiredEmailPassword"));
return;
}
const normalizedEmail = email.trim().toLowerCase();
setSession({ email: normalizedEmail });
localStorage.setItem("session-email", normalizedEmail);
navigate("/");
};
return ( return (
<main> <main>
<Title>{t("login.title")}</Title>
<h1>{t("login.heading")}</h1> <h1>{t("login.heading")}</h1>
<div class="space-y-6 font-medium"> <form onSubmit={submit} className="w-full max-w-md space-y-4 px-4">
<PasswordLogin /> <label htmlFor="email" className="block w-full text-left">
</div>
</main>
);
}
function PasswordLogin() {
const submission = useSubmission(formLogin);
return (
<form action={formLogin} method="post" class="space-y-4 space-x-12">
<input type="hidden" name="lang" value={language()} />
<label for="email" class="block text-left w-full">
{t("login.email")} {t("login.email")}
<input <input
id="email" id="email"
name="email" name="email"
type="email" type="email"
autocomplete="email" autoComplete="email"
placeholder="john@doe.com" placeholder="john@doe.com"
required required
class="bg-[#FFF7EE] mt-1 block w-full px-4 py-2 border border-[#C99763] rounded-md focus:outline-none focus:ring-2 focus:ring-[#A56C38]" value={email}
onChange={(event) => setEmail(event.target.value)}
className="mt-1 block w-full rounded-md border border-[#C99763] bg-[#FFF7EE] px-4 py-2 focus:outline-none focus:ring-2 focus:ring-[#A56C38]"
/> />
</label> </label>
<label for="password" class="block text-left w-full">
<label htmlFor="password" className="block w-full text-left">
{t("login.password")} {t("login.password")}
<input <input
id="password" id="password"
name="password" name="password"
type="password" type="password"
autocomplete="current-password" autoComplete="current-password"
placeholder="••••••••"
minLength={6} minLength={6}
required required
class="bg-[#FFF7EE] mt-1 block w-full px-4 py-2 border border-[#C99763] rounded-md focus:outline-none focus:ring-2 focus:ring-[#A56C38]" value={password}
onChange={(event) => setPassword(event.target.value)}
className="mt-1 block w-full rounded-md border border-[#C99763] bg-[#FFF7EE] px-4 py-2 focus:outline-none focus:ring-2 focus:ring-[#A56C38]"
/> />
</label> </label>
<button <button
type="submit" type="submit"
disabled={submission.pending} className="w-full rounded-lg bg-gradient-to-r from-[#A56C38] to-[#70421E] px-4 py-2 text-[#FFF7EE] shadow-lg shadow-[#70421E]/30 transition-colors duration-300 hover:from-[#8E4F24] hover:to-[#4C250E]"
class="w-full px-4 py-2 bg-gradient-to-r from-[#A56C38] to-[#70421E] text-[#FFF7EE] rounded-lg hover:from-[#8E4F24] hover:to-[#4C250E] focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-300 shadow-lg shadow-[#70421E]/30"
> >
{t("login.submit")} {t("login.submit")}
</button> </button>
<Show when={submission.error} keyed>
{({ message }) => <p class="text-[#8E4F24] mt-2 text-xs text-center">{message}</p>} {error && <p className="mt-2 text-center text-xs text-[#8E4F24]">{error}</p>}
</Show>
</form> </form>
</main>
); );
} }

35
ui/src/state/appState.ts Normal file
View File

@@ -0,0 +1,35 @@
import { atom } from "recoil";
import type { LokOpenHours } from "~/api";
export type Language = "fi" | "en";
export type Session = {
email: string;
};
export type Toast = {
id: number;
message: string;
kind: "success" | "error";
leaving: boolean;
};
export const languageAtom = atom<Language>({
key: "languageAtom",
default: "en",
});
export const sessionAtom = atom<Session | null>({
key: "sessionAtom",
default: null,
});
export const openHoursAtom = atom<LokOpenHours[]>({
key: "openHoursAtom",
default: [],
});
export const toastsAtom = atom<Toast[]>({
key: "toastsAtom",
default: [],
});

View File

@@ -5,13 +5,14 @@
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"esModuleInterop": true, "esModuleInterop": true,
"jsx": "preserve", "jsx": "react-jsx",
"jsxImportSource": "solid-js",
"allowJs": true, "allowJs": true,
"strict": true, "strict": true,
"noEmit": true, "noEmit": true,
"types": ["vinxi/types/client"], "types": ["vite/client"],
"isolatedModules": true, "isolatedModules": true,
"baseUrl": ".",
"ignoreDeprecations": "6.0",
"paths": { "paths": {
"~/*": ["./src/*"] "~/*": ["./src/*"]
} }

22
ui/vite.config.ts Normal file
View File

@@ -0,0 +1,22 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import path from "node:path";
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
proxy: {
"/api": {
target: "http://localhost:5013",
changeOrigin: true,
rewrite: (pathValue) => pathValue.replace(/^\/api/, ""),
},
},
},
resolve: {
alias: {
"~": path.resolve(__dirname, "./src"),
},
},
});