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>>();
|
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;
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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.
@@ -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",
|
"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": {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(/^\/+/, "")}`;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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 { 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 you’re looking for doesn't exist",
|
"notFound.message": "Sorry, the page you’re 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
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 { 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
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",
|
"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
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