Rewrite with React after AI got stuck in some obscure state errors on SolidJS
This commit is contained in:
@@ -65,6 +65,64 @@ public class ApiEndpointsTests(ApiTestFactory factory) : IClassFixture<ApiTestFa
|
||||
var openHours = await getResponse.Content.ReadFromJsonAsync<List<LokOpenHoursDto>>();
|
||||
Assert.NotNull(openHours);
|
||||
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}");
|
||||
Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode);
|
||||
@@ -117,6 +175,8 @@ public class LokOpenHoursDto
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public bool IsActive { get; set; }
|
||||
|
||||
public DateTime Version { get; set; }
|
||||
|
||||
public string Paragraph1 { get; set; } = string.Empty;
|
||||
|
||||
@@ -71,5 +71,69 @@ public static class LokEndpoints
|
||||
httpContext.Response.StatusCode = StatusCodes.Status204NoContent;
|
||||
})
|
||||
.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");
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ public class LokOpenHours
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public bool IsActive { get; set; }
|
||||
|
||||
public DateTime Version { get; set; }
|
||||
|
||||
public string Paragraph1 { get; set; } = string.Empty;
|
||||
|
||||
@@ -21,6 +21,20 @@ public class Program
|
||||
|
||||
builder.Services.AddScoped(_ => new SqliteConnection(resolvedConnectionString));
|
||||
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();
|
||||
|
||||
@@ -64,6 +78,57 @@ public class Program
|
||||
command.CommandText = "ALTER TABLE LokOpenHours ADD COLUMN name TEXT NOT NULL DEFAULT '';";
|
||||
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.UseCors("UiCors");
|
||||
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseHttpsRedirection();
|
||||
}
|
||||
|
||||
SystemEndpoints.MapSystemEndpoints(app);
|
||||
LokEndpoints.MapLokEndpoints(app);
|
||||
|
||||
@@ -19,7 +19,7 @@ public class LokService
|
||||
|
||||
await using var command = _connection.CreateCommand();
|
||||
command.CommandText = @"
|
||||
SELECT id, name, version, paragraph1, paragraph2, paragraph3, paragraph4, kitchenNotice
|
||||
SELECT id, name, isActive, version, paragraph1, paragraph2, paragraph3, paragraph4, kitchenNotice
|
||||
FROM LokOpenHours
|
||||
ORDER BY datetime(version) DESC, id DESC
|
||||
LIMIT 5";
|
||||
@@ -34,6 +34,7 @@ public class LokService
|
||||
{
|
||||
Id = reader["id"] is long id ? id : Convert.ToInt64(reader["id"]),
|
||||
Name = reader["name"]?.ToString() ?? string.Empty,
|
||||
IsActive = ParseBoolean(reader["isActive"]),
|
||||
Version = ParseVersion(reader["version"]?.ToString()),
|
||||
Paragraph1 = reader["paragraph1"]?.ToString() ?? string.Empty,
|
||||
Paragraph2 = reader["paragraph2"]?.ToString() ?? string.Empty,
|
||||
@@ -55,13 +56,22 @@ public class LokService
|
||||
|
||||
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();
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = @"
|
||||
INSERT INTO LokOpenHours (name, version, paragraph1, paragraph2, paragraph3, paragraph4, kitchenNotice)
|
||||
VALUES (@name, @version, @paragraph1, @paragraph2, @paragraph3, @paragraph4, @kitchenNotice);
|
||||
INSERT INTO LokOpenHours (name, isActive, version, paragraph1, paragraph2, paragraph3, paragraph4, kitchenNotice)
|
||||
VALUES (@name, @isActive, @version, @paragraph1, @paragraph2, @paragraph3, @paragraph4, @kitchenNotice);
|
||||
SELECT last_insert_rowid();";
|
||||
|
||||
command.Parameters.AddWithValue("@name", openHours.Name ?? string.Empty);
|
||||
command.Parameters.AddWithValue("@isActive", 1);
|
||||
command.Parameters.AddWithValue("@version", version.ToString("O"));
|
||||
command.Parameters.AddWithValue("@paragraph1", openHours.Paragraph1 ?? 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);
|
||||
|
||||
var insertedId = await command.ExecuteScalarAsync();
|
||||
var insertedIdValue = Convert.ToInt64(insertedId);
|
||||
|
||||
transaction.Commit();
|
||||
|
||||
return new LokOpenHours
|
||||
{
|
||||
Id = Convert.ToInt64(insertedId),
|
||||
Id = insertedIdValue,
|
||||
Name = openHours.Name ?? string.Empty,
|
||||
IsActive = true,
|
||||
Version = version,
|
||||
Paragraph1 = openHours.Paragraph1 ?? string.Empty,
|
||||
Paragraph2 = openHours.Paragraph2 ?? string.Empty,
|
||||
@@ -91,6 +105,16 @@ public class LokService
|
||||
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();
|
||||
command.CommandText = @"
|
||||
DELETE FROM LokOpenHours
|
||||
@@ -99,9 +123,115 @@ public class LokService
|
||||
command.Parameters.AddWithValue("@id", id);
|
||||
|
||||
var affectedRows = await command.ExecuteNonQueryAsync();
|
||||
|
||||
if (affectedRows > 0)
|
||||
{
|
||||
await EnsureSingleActiveInvariant();
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value) && DateTime.TryParse(value, out var parsed))
|
||||
@@ -111,4 +241,61 @@ public class LokService
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
CREATE TABLE IF NOT EXISTS LokOpenHours (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
isActive INTEGER NOT NULL DEFAULT 0,
|
||||
version TEXT NOT NULL,
|
||||
paragraph1 TEXT NOT NULL DEFAULT '',
|
||||
paragraph2 TEXT NOT NULL DEFAULT '',
|
||||
|
||||
Binary file not shown.
@@ -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()] }
|
||||
});
|
||||
1191
ui/bun.lock
1191
ui/bun.lock
File diff suppressed because it is too large
Load Diff
13
ui/index.html
Normal file
13
ui/index.html
Normal 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
2792
ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,28 +2,29 @@
|
||||
"name": "example-with-auth",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vinxi dev",
|
||||
"build": "vinxi build",
|
||||
"start": "vinxi start",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"start": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"lint": "biome lint src",
|
||||
"lint:fix": "biome check --write src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@solidjs/meta": "^0.29.4",
|
||||
"@solidjs/router": "^0.15.3",
|
||||
"@solidjs/start": "^1.1.7",
|
||||
"@types/node": "^25.2.0",
|
||||
"solid-js": "^1.9.9",
|
||||
"start-oauth": "^1.3.0",
|
||||
"unstorage": "1.17.1",
|
||||
"vinxi": "^0.5.8"
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"recoil": "^0.7.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@tailwindcss/vite": "^4.1.13",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"vite": "^5.4.10",
|
||||
"vitest": "^2.1.9"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { action, query } from "@solidjs/router";
|
||||
import { buildApiUrl } from "./url";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export const queryApiVersion = query(async () => {
|
||||
"use server";
|
||||
const data = await fetchApi<{ version: string }>("/");
|
||||
return data.version;
|
||||
}, "api-version");
|
||||
|
||||
export type LokOpenHours = {
|
||||
id: number;
|
||||
name: string;
|
||||
isActive: boolean;
|
||||
version: string;
|
||||
paragraph1: string;
|
||||
paragraph2: string;
|
||||
@@ -39,14 +33,28 @@ export type LokOpenHours = {
|
||||
kitchenNotice: string;
|
||||
};
|
||||
|
||||
export const queryLokOpenHours = query(async (_refreshKey = 0) => {
|
||||
"use server";
|
||||
return await fetchApi<LokOpenHours[]>("/lok/open-hours");
|
||||
}, "lok-open-hours");
|
||||
export type LokOpenHoursInput = {
|
||||
name: string;
|
||||
paragraph1: string;
|
||||
paragraph2: string;
|
||||
paragraph3: string;
|
||||
paragraph4: string;
|
||||
kitchenNotice: string;
|
||||
};
|
||||
|
||||
export const createLokOpenHours = action(async (formData: FormData) => {
|
||||
"use server";
|
||||
const name = String(formData.get("name") ?? "").trim();
|
||||
export async function queryApiVersion(): Promise<string> {
|
||||
const data = await fetchApi<{ version: string }>("/");
|
||||
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) {
|
||||
throw new Error("Open hours version name is required.");
|
||||
@@ -55,25 +63,54 @@ export const createLokOpenHours = action(async (formData: FormData) => {
|
||||
const payload = {
|
||||
id: 0,
|
||||
name,
|
||||
isActive: false,
|
||||
version: new Date().toISOString(),
|
||||
paragraph1: String(formData.get("paragraph1") ?? ""),
|
||||
paragraph2: String(formData.get("paragraph2") ?? ""),
|
||||
paragraph3: String(formData.get("paragraph3") ?? ""),
|
||||
paragraph4: String(formData.get("paragraph4") ?? ""),
|
||||
kitchenNotice: String(formData.get("kitchenNotice") ?? ""),
|
||||
paragraph1: input.paragraph1,
|
||||
paragraph2: input.paragraph2,
|
||||
paragraph3: input.paragraph3,
|
||||
paragraph4: input.paragraph4,
|
||||
kitchenNotice: input.kitchenNotice,
|
||||
} satisfies LokOpenHours;
|
||||
|
||||
return await fetchApi<LokOpenHours>("/lok/open-hours", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateLokOpenHours(
|
||||
id: number,
|
||||
input: LokOpenHoursInput,
|
||||
): 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 const deleteLokOpenHours = action(async (formData: FormData) => {
|
||||
"use server";
|
||||
const idValue = String(formData.get("id") ?? "").trim();
|
||||
const id = Number(idValue);
|
||||
|
||||
export async function deleteLokOpenHours(id: number): Promise<void> {
|
||||
if (!Number.isFinite(id) || id <= 0) {
|
||||
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}`, {
|
||||
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",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
`${API_BASE_URL.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
font-family: "Manrope", "Avenir Next", "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
#app {
|
||||
#root {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,41 +1,57 @@
|
||||
import { type RouteDefinition, Router } from "@solidjs/router";
|
||||
import { FileRoutes } from "@solidjs/start/router";
|
||||
import { Meta, MetaProvider } from "@solidjs/meta";
|
||||
import { createEffect, Suspense } from "solid-js";
|
||||
import { querySession } from "./auth";
|
||||
import Auth from "./components/Context";
|
||||
import Nav from "./components/Nav";
|
||||
import ErrorNotification from "./components/Error";
|
||||
import { language, t } from "~/i18n";
|
||||
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||
import { useEffect } from "react";
|
||||
import { useSetRecoilState } from "recoil";
|
||||
import Nav from "~/components/Nav";
|
||||
import Home from "~/routes/index";
|
||||
import About from "~/routes/about";
|
||||
import Login from "~/routes/login";
|
||||
import NotFound from "~/routes/[...404]";
|
||||
import Toasts from "~/components/Toasts";
|
||||
import { initializeLanguage, useLanguage } from "~/i18n";
|
||||
import { sessionAtom } from "~/state/appState";
|
||||
import "./app.css";
|
||||
|
||||
export const route: RouteDefinition = {
|
||||
preload: ({ location }) => querySession(location.pathname)
|
||||
};
|
||||
function AppShell() {
|
||||
const { language, setLanguage } = useLanguage();
|
||||
const setSession = useSetRecoilState(sessionAtom);
|
||||
|
||||
export default function App() {
|
||||
createEffect(() => {
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.lang = language();
|
||||
useEffect(() => {
|
||||
initializeLanguage(setLanguage);
|
||||
}, [setLanguage]);
|
||||
|
||||
useEffect(() => {
|
||||
const storedEmail = localStorage.getItem("session-email");
|
||||
if (!storedEmail) {
|
||||
setSession(null);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
setSession({ email: storedEmail });
|
||||
}, [setSession]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = language;
|
||||
}, [language]);
|
||||
|
||||
return (
|
||||
<Router
|
||||
root={props => (
|
||||
<MetaProvider>
|
||||
<Meta name="description" content={t("meta.description")} />
|
||||
<Auth>
|
||||
<Suspense>
|
||||
<>
|
||||
<Nav />
|
||||
{props.children}
|
||||
<ErrorNotification />
|
||||
</Suspense>
|
||||
</Auth>
|
||||
</MetaProvider>
|
||||
)}
|
||||
>
|
||||
<FileRoutes />
|
||||
</Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/about" element={<About />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
<Route path="/index.html" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
<Toasts />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AppShell />
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" });
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -1,69 +1,73 @@
|
||||
import { useMatch } from "@solidjs/router";
|
||||
import { Show } from "solid-js";
|
||||
import { useAuth } from "~/components/Context";
|
||||
import { language, setLanguage, t } from "~/i18n";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useRecoilState } from "recoil";
|
||||
import { sessionAtom } from "~/state/appState";
|
||||
import { useLanguage, useT } from "~/i18n";
|
||||
|
||||
export default function Nav() {
|
||||
const { session, signedIn, logout } = useAuth();
|
||||
const isHome = useMatch(() => "/");
|
||||
const isAbout = useMatch(() => "/about");
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
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 (
|
||||
<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">
|
||||
<a
|
||||
href="/"
|
||||
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]"
|
||||
}`}
|
||||
>
|
||||
{t("nav.home")}
|
||||
</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">
|
||||
<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">
|
||||
<Link to="/" className={linkClass("/")}>{t("nav.home")}</Link>
|
||||
<Link to="/about" className={linkClass("/about")}>{t("nav.about")}</Link>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<b className="text-[#F5D1A9]">{session?.email ?? ""}</b>
|
||||
<div className="flex items-center gap-1 rounded-md border border-[#8E4F24] bg-[#8E4F24]/45 p-1">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLanguage("fi")}
|
||||
class={`px-2 py-1 text-xs rounded ${language() === "fi" ? "bg-[#E3A977] text-[#4C250E]" : "text-[#F5D1A9] hover:text-[#FFF7EE]"
|
||||
onClick={() => setLanguage("fi")}
|
||||
className={`rounded px-2 py-1 text-xs ${language === "fi"
|
||||
? "bg-[#E3A977] text-[#4C250E]"
|
||||
: "text-[#F5D1A9] hover:text-[#FFF7EE]"
|
||||
}`}
|
||||
>
|
||||
{t("nav.language.fi")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLanguage("en")}
|
||||
class={`px-2 py-1 text-xs rounded ${language() === "en" ? "bg-[#E3A977] text-[#4C250E]" : "text-[#F5D1A9] hover:text-[#FFF7EE]"
|
||||
onClick={() => setLanguage("en")}
|
||||
className={`rounded px-2 py-1 text-xs ${language === "en"
|
||||
? "bg-[#E3A977] text-[#4C250E]"
|
||||
: "text-[#F5D1A9] hover:text-[#FFF7EE]"
|
||||
}`}
|
||||
>
|
||||
{t("nav.language.en")}
|
||||
</button>
|
||||
</div>
|
||||
<Show
|
||||
when={signedIn()}
|
||||
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">
|
||||
|
||||
{session ? (
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
type="button"
|
||||
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")}
|
||||
</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>
|
||||
</nav>
|
||||
);
|
||||
|
||||
@@ -1,228 +1,416 @@
|
||||
import { createAsync, useSubmission } from "@solidjs/router";
|
||||
import { For, Show, createEffect, createMemo, createSignal } from "solid-js";
|
||||
import { createLokOpenHours, deleteLokOpenHours, queryLokOpenHours } from "~/api";
|
||||
import { t } from "~/i18n";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRecoilState } from "recoil";
|
||||
import {
|
||||
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__";
|
||||
|
||||
export default function OpenHoursForm() {
|
||||
const [refreshKey, setRefreshKey] = createSignal(0);
|
||||
const openHours = createAsync(() => queryLokOpenHours(refreshKey()).catch(() => []));
|
||||
const createSubmission = useSubmission(createLokOpenHours);
|
||||
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 selectedOpenHours = createMemo(() =>
|
||||
latestFive().find(version => String(version.id) === selectedVersion())
|
||||
);
|
||||
|
||||
createEffect(() => {
|
||||
if (!createSubmission.result) return;
|
||||
setRefreshKey(previous => previous + 1);
|
||||
setSelectedVersion("");
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (!deleteSubmission.result) return;
|
||||
setRefreshKey(previous => previous + 1);
|
||||
setSelectedVersion("");
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const versions = latestFive();
|
||||
const current = selectedVersion();
|
||||
|
||||
if (versions.length === 0) {
|
||||
if (current !== NEW_VERSION_OPTION) {
|
||||
setSelectedVersion(NEW_VERSION_OPTION);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
type FormState = {
|
||||
name: string;
|
||||
paragraph1: string;
|
||||
paragraph2: string;
|
||||
paragraph3: string;
|
||||
paragraph4: string;
|
||||
kitchenNotice: string;
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
reuseSelected();
|
||||
const EMPTY_FORM: FormState = {
|
||||
name: "",
|
||||
paragraph1: "",
|
||||
paragraph2: "",
|
||||
paragraph3: "",
|
||||
paragraph4: "",
|
||||
kitchenNotice: "",
|
||||
};
|
||||
|
||||
export default function OpenHoursForm() {
|
||||
const t = useT();
|
||||
const [versions, setVersions] = useRecoilState(openHoursAtom);
|
||||
const [, setToasts] = useRecoilState(toastsAtom);
|
||||
|
||||
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 initializedRef = useRef(false);
|
||||
const toastGuardRef = useRef<Record<string, number>>({});
|
||||
|
||||
const isUpdateMode = editingVersionId.length > 0;
|
||||
|
||||
const pushToast = (message: string, kind: Toast["kind"], dedupeKey: string) => {
|
||||
const now = Date.now();
|
||||
const lastShown = toastGuardRef.current[dedupeKey] ?? 0;
|
||||
if (now - lastShown < 800) return;
|
||||
|
||||
toastGuardRef.current[dedupeKey] = now;
|
||||
|
||||
setToasts((previous) => [
|
||||
...previous,
|
||||
{
|
||||
id: now + Math.floor(Math.random() * 1000),
|
||||
message,
|
||||
kind,
|
||||
leaving: false,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const clearForm = () => {
|
||||
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 (
|
||||
<section class="w-full max-w-3xl rounded-2xl border border-[#C99763] bg-[#F5D1A9] p-6 shadow-md">
|
||||
<h2 class="text-2xl font-semibold text-[#4C250E]">{t("home.openHours.heading")}</h2>
|
||||
<section className="w-full max-w-3xl rounded-2xl border border-[#C99763] bg-[#F5D1A9] p-4 shadow-md sm:p-6">
|
||||
<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 class="mt-2 flex gap-2">
|
||||
<select
|
||||
id="open-hours-version"
|
||||
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]"
|
||||
value={selectedVersion()}
|
||||
onInput={event => setSelectedVersion(event.currentTarget.value)}
|
||||
<div className="mt-4">
|
||||
<div
|
||||
className={`transition-all duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] ${!isEditing
|
||||
? "max-h-[1200px] translate-y-0 opacity-100"
|
||||
: "pointer-events-none max-h-0 -translate-y-3 overflow-hidden opacity-0"
|
||||
}`}
|
||||
>
|
||||
<For each={latestFive()}>
|
||||
{version => (
|
||||
<option value={String(version.id)}>
|
||||
{version.name || t("home.openHours.latest")} · {new Date(version.version).toLocaleString()}
|
||||
</option>
|
||||
)}
|
||||
</For>
|
||||
<option value={NEW_VERSION_OPTION}>{t("home.openHours.new")}</option>
|
||||
</select>
|
||||
<div className="mb-3 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={startCreate}
|
||||
className="w-full rounded-md border border-[#70421E] bg-[#8E4F24] px-4 py-2 text-[#FFF7EE] hover:bg-[#70421E] sm:w-auto"
|
||||
>
|
||||
{t("home.openHours.new")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form action={deleteLokOpenHours} method="post" class="mt-3">
|
||||
<input type="hidden" name="id" value={selectedOpenHours()?.id ?? ""} />
|
||||
{versions.length === 0 ? (
|
||||
<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
|
||||
type="submit"
|
||||
disabled={!selectedOpenHours() || deleteSubmission.pending}
|
||||
class="rounded-md border border-[#8E4F24] bg-[#EED5B8] px-4 py-2 text-[#70421E] hover:bg-[#E3A977] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
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")}
|
||||
</button>
|
||||
</form>
|
||||
</Show>
|
||||
</div>
|
||||
</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>
|
||||
<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
|
||||
id="name"
|
||||
name="name"
|
||||
required
|
||||
value={name()}
|
||||
onInput={event => setName(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]"
|
||||
value={form.name}
|
||||
onChange={(event) => setForm((previous) => ({ ...previous, name: 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]"
|
||||
/>
|
||||
<Show when={!name().trim()}>
|
||||
<p class="mt-1 text-xs text-[#8E4F24]">{t("home.openHours.nameRequired")}</p>
|
||||
</Show>
|
||||
{nameRequired && (
|
||||
<p className="mt-1 text-xs text-[#8E4F24]">{t("home.openHours.nameRequired")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="paragraph1" class="block text-sm font-medium text-[#4C250E]">{t("home.openHours.paragraph1")}</label>
|
||||
{(["paragraph1", "paragraph2", "paragraph3", "paragraph4", "kitchenNotice"] as const).map((field) => (
|
||||
<div key={field}>
|
||||
<label htmlFor={field} className="block text-sm font-medium text-[#4C250E]">
|
||||
{t(`home.openHours.${field}`)}
|
||||
</label>
|
||||
<textarea
|
||||
id="paragraph1"
|
||||
name="paragraph1"
|
||||
id={field}
|
||||
name={field}
|
||||
rows={2}
|
||||
value={paragraph1()}
|
||||
onInput={event => setParagraph1(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]"
|
||||
value={form[field]}
|
||||
onChange={(event) =>
|
||||
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>
|
||||
<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>
|
||||
<div className="flex flex-wrap gap-3 pt-1">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createSubmission.pending}
|
||||
class="rounded-md border border-[#70421E] bg-[#8E4F24] px-4 py-2 text-[#FFF7EE] hover:bg-[#70421E] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={saving}
|
||||
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>
|
||||
</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>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
64
ui/src/components/Toasts.tsx
Normal file
64
ui/src/components/Toasts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
2
ui/src/global.d.ts
vendored
@@ -1 +1 @@
|
||||
/// <reference types="@solidjs/start/env" />
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createSignal } from "solid-js";
|
||||
import { isServer } from "solid-js/web";
|
||||
|
||||
export type Language = "fi" | "en";
|
||||
import { useMemo } from "react";
|
||||
import { useRecoilState, useRecoilValue } from "recoil";
|
||||
import { languageAtom, type Language } from "~/state/appState";
|
||||
|
||||
const STORAGE_KEY = "ui-language";
|
||||
|
||||
@@ -13,7 +12,7 @@ const translations = {
|
||||
"nav.signOut": "Sign Out",
|
||||
"nav.language.fi": "FI",
|
||||
"nav.language.en": "EN",
|
||||
"meta.description": "SolidStart with-auth example",
|
||||
"meta.description": "React + Recoil example",
|
||||
"home.title": "Home",
|
||||
"home.heading": "KlAPI",
|
||||
"home.signedInAs": "You are signed in as",
|
||||
@@ -21,8 +20,12 @@ const translations = {
|
||||
"home.openHours.heading": "Open hours versions",
|
||||
"home.openHours.latest": "Latest",
|
||||
"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.delete": "Delete selected",
|
||||
"home.openHours.delete": "Delete",
|
||||
"home.openHours.empty": "No open-hours versions found yet",
|
||||
"home.openHours.name": "Version name",
|
||||
"home.openHours.nameRequired": "Version name is required",
|
||||
@@ -32,8 +35,11 @@ const translations = {
|
||||
"home.openHours.paragraph4": "Paragraph 4",
|
||||
"home.openHours.kitchenNotice": "Kitchen notice",
|
||||
"home.openHours.submit": "Add new version",
|
||||
"home.openHours.update": "Save changes",
|
||||
"home.openHours.saving": "Saving...",
|
||||
"home.openHours.updating": "Saving changes...",
|
||||
"home.openHours.saved": "New version saved",
|
||||
"home.openHours.updated": "Version updated",
|
||||
"home.openHours.deleted": "Version deleted",
|
||||
"about.title": "About",
|
||||
"about.apiVersion": "API version",
|
||||
@@ -48,12 +54,7 @@ const translations = {
|
||||
"notFound.message": "Sorry, the page you’re looking for doesn't exist",
|
||||
"notFound.goHome": "Go Home",
|
||||
"error.title": "Error",
|
||||
"counter.clicks": "Clicks",
|
||||
"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: {
|
||||
"nav.home": "Etusivu",
|
||||
@@ -62,7 +63,7 @@ const translations = {
|
||||
"nav.signOut": "Kirjaudu ulos",
|
||||
"nav.language.fi": "FI",
|
||||
"nav.language.en": "EN",
|
||||
"meta.description": "SolidStart with-auth -esimerkki",
|
||||
"meta.description": "React + Recoil -esimerkki",
|
||||
"home.title": "Etusivu",
|
||||
"home.heading": "KlAPI",
|
||||
"home.signedInAs": "Olet kirjautunut käyttäjänä",
|
||||
@@ -70,8 +71,12 @@ const translations = {
|
||||
"home.openHours.heading": "Aukioloaikaversiot",
|
||||
"home.openHours.latest": "Viimeisin",
|
||||
"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.delete": "Poista valittu",
|
||||
"home.openHours.delete": "Poista",
|
||||
"home.openHours.empty": "Aukioloaikaversioita ei vielä löytynyt",
|
||||
"home.openHours.name": "Version nimi",
|
||||
"home.openHours.nameRequired": "Version nimi on pakollinen",
|
||||
@@ -81,8 +86,11 @@ const translations = {
|
||||
"home.openHours.paragraph4": "Kappale 4",
|
||||
"home.openHours.kitchenNotice": "Keittiöhuomio",
|
||||
"home.openHours.submit": "Lisää uusi versio",
|
||||
"home.openHours.update": "Tallenna muutokset",
|
||||
"home.openHours.saving": "Tallennetaan...",
|
||||
"home.openHours.updating": "Tallennetaan muutoksia...",
|
||||
"home.openHours.saved": "Uusi versio tallennettu",
|
||||
"home.openHours.updated": "Versio päivitetty",
|
||||
"home.openHours.deleted": "Versio poistettu",
|
||||
"about.title": "Tietoja",
|
||||
"about.apiVersion": "API-versio",
|
||||
@@ -97,12 +105,7 @@ const translations = {
|
||||
"notFound.message": "Valitettavasti etsimääsi sivua ei ole olemassa",
|
||||
"notFound.goHome": "Takaisin etusivulle",
|
||||
"error.title": "Virhe",
|
||||
"counter.clicks": "Klikkauksia",
|
||||
"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;
|
||||
|
||||
@@ -111,25 +114,27 @@ export type TranslationKey = keyof typeof translations.en;
|
||||
export const normalizeLanguage = (value: unknown): Language =>
|
||||
value === "fi" ? "fi" : "en";
|
||||
|
||||
const [language, setLanguageSignal] = createSignal<Language>("en");
|
||||
|
||||
if (!isServer) {
|
||||
export const initializeLanguage = (setLanguage: (lang: Language) => void) => {
|
||||
const stored = normalizeLanguage(localStorage.getItem(STORAGE_KEY));
|
||||
setLanguageSignal(stored);
|
||||
}
|
||||
|
||||
export const setLanguage = (lang: Language) => {
|
||||
setLanguageSignal(lang);
|
||||
if (!isServer) {
|
||||
localStorage.setItem(STORAGE_KEY, lang);
|
||||
}
|
||||
setLanguage(stored);
|
||||
};
|
||||
|
||||
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 =>
|
||||
normalizeLanguage(formData.get("lang"));
|
||||
export const useT = () => {
|
||||
const language = useRecoilValue(languageAtom);
|
||||
|
||||
return useMemo(
|
||||
() => (key: TranslationKey) => translations[language][key],
|
||||
[language],
|
||||
);
|
||||
};
|
||||
|
||||
12
ui/src/main.tsx
Normal file
12
ui/src/main.tsx
Normal 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>,
|
||||
);
|
||||
@@ -1,18 +1,24 @@
|
||||
import { Title } from "@solidjs/meta";
|
||||
import { t } from "~/i18n";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useEffect } from "react";
|
||||
import { useT } from "~/i18n";
|
||||
|
||||
export default function NotFound() {
|
||||
const t = useT();
|
||||
|
||||
useEffect(() => {
|
||||
document.title = t("notFound.title");
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<main class="text-center">
|
||||
<Title>{t("notFound.title")}</Title>
|
||||
<main className="text-center">
|
||||
<h1>{t("notFound.heading")}</h1>
|
||||
{t("notFound.message")}
|
||||
<a
|
||||
href="/"
|
||||
class="px-4 py-2 border border-[#C99763] rounded-xl text-[#70421E] hover:bg-[#F5D1A9] transition-colors duration-200"
|
||||
<p>{t("notFound.message")}</p>
|
||||
<Link
|
||||
to="/"
|
||||
className="rounded-xl border border-[#C99763] px-4 py-2 text-[#70421E] transition-colors duration-200 hover:bg-[#F5D1A9]"
|
||||
>
|
||||
{t("notFound.goHome")}
|
||||
</a>
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,39 @@
|
||||
import { Title } from "@solidjs/meta";
|
||||
import { createAsync } from "@solidjs/router";
|
||||
import { Show } from "solid-js";
|
||||
import { useEffect, useState } from "react";
|
||||
import { queryApiVersion } from "~/api";
|
||||
import { t } from "~/i18n";
|
||||
import { useT } from "~/i18n";
|
||||
|
||||
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 (
|
||||
<main>
|
||||
<Title>{t("about.title")}</Title>
|
||||
<p class="text-[#70421E] text-center">
|
||||
{t("about.apiVersion")}:
|
||||
<Show when={apiVersion()} fallback={t("about.loading")}>
|
||||
{apiVersion()}
|
||||
</Show>
|
||||
<p className="text-center text-[#70421E]">
|
||||
{t("about.apiVersion")}: {apiVersion ?? t("about.loading")}
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -1,13 +1,18 @@
|
||||
import { Title } from "@solidjs/meta";
|
||||
import { useEffect } from "react";
|
||||
import OpenHoursForm from "~/components/OpenHoursForm";
|
||||
import { t } from "~/i18n";
|
||||
import { useT } from "~/i18n";
|
||||
|
||||
export default function Home() {
|
||||
const t = useT();
|
||||
|
||||
useEffect(() => {
|
||||
document.title = t("home.title");
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<Title>{t("home.title")}</Title>
|
||||
<h1 class="text-center">{t("home.heading")}</h1>
|
||||
<img src="/favicon.svg" alt={t("home.logoAlt")} class="w-28" />
|
||||
<h1 className="text-center">{t("home.heading")}</h1>
|
||||
<img src="/favicon.svg" alt={t("home.logoAlt")} className="w-28" />
|
||||
<OpenHoursForm />
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -1,65 +1,83 @@
|
||||
import { Title } from "@solidjs/meta";
|
||||
import { useSubmission } from "@solidjs/router";
|
||||
import { Show } from "solid-js";
|
||||
import { useOAuthLogin } from "start-oauth";
|
||||
import { formLogin } from "~/auth";
|
||||
import { language, t } from "~/i18n";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useRecoilState } from "recoil";
|
||||
import { useT } from "~/i18n";
|
||||
import { sessionAtom } from "~/state/appState";
|
||||
|
||||
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 (
|
||||
<main>
|
||||
<Title>{t("login.title")}</Title>
|
||||
<h1>{t("login.heading")}</h1>
|
||||
<div class="space-y-6 font-medium">
|
||||
<PasswordLogin />
|
||||
</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">
|
||||
<form onSubmit={submit} className="w-full max-w-md space-y-4 px-4">
|
||||
<label htmlFor="email" className="block w-full text-left">
|
||||
{t("login.email")}
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
autoComplete="email"
|
||||
placeholder="john@doe.com"
|
||||
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 for="password" class="block text-left w-full">
|
||||
|
||||
<label htmlFor="password" className="block w-full text-left">
|
||||
{t("login.password")}
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
minLength={6}
|
||||
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>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submission.pending}
|
||||
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"
|
||||
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]"
|
||||
>
|
||||
{t("login.submit")}
|
||||
</button>
|
||||
<Show when={submission.error} keyed>
|
||||
{({ message }) => <p class="text-[#8E4F24] mt-2 text-xs text-center">{message}</p>}
|
||||
</Show>
|
||||
|
||||
{error && <p className="mt-2 text-center text-xs text-[#8E4F24]">{error}</p>}
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
35
ui/src/state/appState.ts
Normal file
35
ui/src/state/appState.ts
Normal 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: [],
|
||||
});
|
||||
@@ -5,13 +5,14 @@
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"types": ["vinxi/types/client"],
|
||||
"types": ["vite/client"],
|
||||
"isolatedModules": true,
|
||||
"baseUrl": ".",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"paths": {
|
||||
"~/*": ["./src/*"]
|
||||
}
|
||||
|
||||
22
ui/vite.config.ts
Normal file
22
ui/vite.config.ts
Normal 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"),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user