Compare commits
5 Commits
20fba31f58
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e97d698387 | |||
| ea4747ec16 | |||
| fe34408b13 | |||
| ce622087e8 | |||
| 260ba2d486 |
23
README.md
23
README.md
@@ -7,18 +7,22 @@ Klapi on Tietokonepajan tarjoama monikäyttöinen API rajapinta sekä tietovaras
|
|||||||
Projektissa on valmiit reseptit [justfile](justfile)-tiedostossa.
|
Projektissa on valmiit reseptit [justfile](justfile)-tiedostossa.
|
||||||
|
|
||||||
Esivaatimukset:
|
Esivaatimukset:
|
||||||
|
|
||||||
- `just`
|
- `just`
|
||||||
- `dotnet`
|
- `dotnet`
|
||||||
- `bun`
|
- `bun`
|
||||||
- `sqlite3` (DB-resepteihin)
|
- `sqlite3` (DB-resepteihin)
|
||||||
|
|
||||||
Listaa kaikki reseptit:
|
Listaa kaikki reseptit:
|
||||||
|
|
||||||
- `just --list`
|
- `just --list`
|
||||||
|
|
||||||
Sovelluksen käynnistys:
|
Sovelluksen käynnistys:
|
||||||
|
|
||||||
- `just dev`
|
- `just dev`
|
||||||
|
|
||||||
API (.NET):
|
API (.NET):
|
||||||
|
|
||||||
- `just api-restore`
|
- `just api-restore`
|
||||||
- `just api-build`
|
- `just api-build`
|
||||||
- `just api-clean`
|
- `just api-clean`
|
||||||
@@ -28,6 +32,7 @@ API (.NET):
|
|||||||
- `just api-publish`
|
- `just api-publish`
|
||||||
|
|
||||||
UI:
|
UI:
|
||||||
|
|
||||||
- `just ui-install`
|
- `just ui-install`
|
||||||
- `just ui-dev`
|
- `just ui-dev`
|
||||||
- `just ui-build`
|
- `just ui-build`
|
||||||
@@ -35,6 +40,24 @@ UI:
|
|||||||
- `just ui-lint`
|
- `just ui-lint`
|
||||||
|
|
||||||
Tietokanta (SQLite):
|
Tietokanta (SQLite):
|
||||||
|
|
||||||
- `just db-setup`
|
- `just db-setup`
|
||||||
- `just db-reset`
|
- `just db-reset`
|
||||||
- `just db-shell`
|
- `just db-shell`
|
||||||
|
|
||||||
|
## Tuotantoon julkaisu
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
Skripti ([scripts/deploy.sh](scripts/deploy.sh)) suorittaa:
|
||||||
|
|
||||||
|
1. `git pull` — hakee uusimmat muutokset
|
||||||
|
2. `dotnet publish` — kääntää API:n release-tilaan
|
||||||
|
3. `db-setup` — ajaa `init.sql`:n tietokantaan (luo puuttuvat taulut)
|
||||||
|
4. `systemctl restart klapi-api` — käynnistää API-palvelun uudelleen
|
||||||
|
5. `bun install && bun run build` — asentaa riippuvuudet ja kääntää UI:n
|
||||||
|
6. `pm2 restart klapi-ui` — käynnistää UI-prosessin uudelleen
|
||||||
|
|
||||||
|
> Inkrementaaliset skeemamuutokset (sarakkeiden lisäykset ym.) ajetaan automaattisesti API:n käynnistyksessä `Program.cs`-migraatioiden kautta.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="MailKit" Version="4.16.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
|
||||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.0" />
|
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.0" />
|
||||||
|
|||||||
71
api/App/Endpoints/FeedbackEndpoints.cs
Normal file
71
api/App/Endpoints/FeedbackEndpoints.cs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
using MailKit.Net.Smtp;
|
||||||
|
using MailKit.Security;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using MimeKit;
|
||||||
|
|
||||||
|
public record FeedbackRequest(string? Message);
|
||||||
|
|
||||||
|
public class EmailOptions
|
||||||
|
{
|
||||||
|
public string SmtpHost { get; set; } = string.Empty;
|
||||||
|
public int SmtpPort { get; set; } = 587;
|
||||||
|
public string FromAddress { get; set; } = string.Empty;
|
||||||
|
public string ToAddress { get; set; } = string.Empty;
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
public string Password { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class FeedbackEndpoints
|
||||||
|
{
|
||||||
|
public static void MapFeedbackEndpoints(WebApplication app)
|
||||||
|
{
|
||||||
|
app.MapPost("/feedback", async (
|
||||||
|
FeedbackRequest request,
|
||||||
|
IOptions<EmailOptions> emailOptions,
|
||||||
|
ILogger<Program> logger) =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Message))
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { Message = "Feedback message is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var message = request.Message.Trim();
|
||||||
|
|
||||||
|
if (message.Length > 5000)
|
||||||
|
{
|
||||||
|
return Results.BadRequest(new { Message = "Feedback message is too long." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var options = emailOptions.Value;
|
||||||
|
|
||||||
|
var email = new MimeMessage();
|
||||||
|
email.From.Add(MailboxAddress.Parse(options.FromAddress));
|
||||||
|
email.To.Add(MailboxAddress.Parse(options.ToAddress));
|
||||||
|
email.Subject = "Klapi – New feedback";
|
||||||
|
email.Body = new TextPart("plain") { Text = message };
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var smtp = new SmtpClient();
|
||||||
|
await smtp.ConnectAsync(options.SmtpHost, options.SmtpPort, SecureSocketOptions.StartTls);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(options.Username))
|
||||||
|
{
|
||||||
|
await smtp.AuthenticateAsync(options.Username, options.Password);
|
||||||
|
}
|
||||||
|
|
||||||
|
await smtp.SendAsync(email);
|
||||||
|
await smtp.DisconnectAsync(true);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to send feedback email.");
|
||||||
|
return Results.Problem("Failed to send feedback. Please try again later.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok(new { Message = "Feedback sent." });
|
||||||
|
})
|
||||||
|
.RequireCors("FrontendWriteCors")
|
||||||
|
.WithName("SubmitFeedback");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,29 @@ public class Program
|
|||||||
|
|
||||||
builder.Services.Configure<AuthOptions>(builder.Configuration.GetSection("Auth"));
|
builder.Services.Configure<AuthOptions>(builder.Configuration.GetSection("Auth"));
|
||||||
|
|
||||||
|
var emailOptions = builder.Configuration.GetSection("Email").Get<EmailOptions>()
|
||||||
|
?? throw new InvalidOperationException("Email configuration was not found.");
|
||||||
|
|
||||||
|
if (builder.Environment.IsProduction())
|
||||||
|
{
|
||||||
|
emailOptions.Username =
|
||||||
|
Environment.GetEnvironmentVariable("KLAPI_SMTP_USERNAME")
|
||||||
|
?? throw new InvalidOperationException("SMTP username must be set in production using KLAPI_SMTP_USERNAME environment variable.");
|
||||||
|
emailOptions.Password =
|
||||||
|
Environment.GetEnvironmentVariable("KLAPI_SMTP_PASSWORD")
|
||||||
|
?? throw new InvalidOperationException("SMTP password must be set in production using KLAPI_SMTP_PASSWORD environment variable.");
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.Services.Configure<EmailOptions>(o =>
|
||||||
|
{
|
||||||
|
o.SmtpHost = emailOptions.SmtpHost;
|
||||||
|
o.SmtpPort = emailOptions.SmtpPort;
|
||||||
|
o.FromAddress = emailOptions.FromAddress;
|
||||||
|
o.ToAddress = emailOptions.ToAddress;
|
||||||
|
o.Username = emailOptions.Username;
|
||||||
|
o.Password = emailOptions.Password;
|
||||||
|
});
|
||||||
|
|
||||||
builder.Services.AddScoped(_ => new SqliteConnection(resolvedConnectionString));
|
builder.Services.AddScoped(_ => new SqliteConnection(resolvedConnectionString));
|
||||||
builder.Services.AddScoped<LokService>();
|
builder.Services.AddScoped<LokService>();
|
||||||
builder.Services.AddScoped<UserService>();
|
builder.Services.AddScoped<UserService>();
|
||||||
@@ -285,6 +308,7 @@ public class Program
|
|||||||
AuthEndpoints.MapAuthEndpoints(app);
|
AuthEndpoints.MapAuthEndpoints(app);
|
||||||
LokEndpoints.MapLokEndpoints(app);
|
LokEndpoints.MapLokEndpoints(app);
|
||||||
UserEndpoints.MapUserEndpoints(app);
|
UserEndpoints.MapUserEndpoints(app);
|
||||||
|
FeedbackEndpoints.MapFeedbackEndpoints(app);
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,5 +23,13 @@
|
|||||||
"Password": "changeme",
|
"Password": "changeme",
|
||||||
"DisplayName": "Administrator"
|
"DisplayName": "Administrator"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Email": {
|
||||||
|
"SmtpHost": "mail.tietokonepaja.fi",
|
||||||
|
"SmtpPort": 587,
|
||||||
|
"FromAddress": "feedback@tietokonepaja.fi",
|
||||||
|
"ToAddress": "veikko@lintujarvi.fi",
|
||||||
|
"Username": "",
|
||||||
|
"Password": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,12 +12,22 @@
|
|||||||
"Issuer": "klapi-api",
|
"Issuer": "klapi-api",
|
||||||
"Audience": "klapi-ui",
|
"Audience": "klapi-ui",
|
||||||
"SigningKey": "change-this-to-a-long-random-32-char-minimum-key",
|
"SigningKey": "change-this-to-a-long-random-32-char-minimum-key",
|
||||||
"AllowedOrigins": ["https://klapi.tietokonepaja.fi"],
|
"AllowedOrigins": [
|
||||||
|
"https://klapi.tietokonepaja.fi"
|
||||||
|
],
|
||||||
"Admin": {
|
"Admin": {
|
||||||
"Username": "admin",
|
"Username": "admin",
|
||||||
"Password": "<set in env var KLAPI_ADMIN_PASSWORD>",
|
"Password": "<set in env var KLAPI_ADMIN_PASSWORD>",
|
||||||
"DisplayName": "Administrator"
|
"DisplayName": "Administrator"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Email": {
|
||||||
|
"SmtpHost": "mail.tietokonepaja.fi",
|
||||||
|
"SmtpPort": 587,
|
||||||
|
"FromAddress": "feedback@tietokonepaja.fi",
|
||||||
|
"ToAddress": "veikko@lintujarvi.fi",
|
||||||
|
"Username": "<set in env var KLAPI_SMTP_USERNAME>",
|
||||||
|
"Password": "<set in env var KLAPI_SMTP_PASSWORD>"
|
||||||
|
},
|
||||||
"AllowedHosts": "*"
|
"AllowedHosts": "*"
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Klapi Käyttöliittymä
|
# Klapi Käyttöliittymä
|
||||||
|
|
||||||
Freimis: SolidJS
|
Freimis: React
|
||||||
|
|
||||||
## Käynnistys
|
## Käynnistys
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="fi">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>KlAPI UI</title>
|
<title>Klapi</title>
|
||||||
|
<meta name="description" content="Helppokäyttöinen tietokantapalvelu selaimessasi." />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:url" content="https://klapi.tietokonepaja.fi/" />
|
||||||
|
<meta property="og:title" content="Klapi" />
|
||||||
|
<meta property="og:description" content="Helppokäyttöinen tietokantapalvelu selaimessasi." />
|
||||||
|
<meta property="og:image" content="https://klapi.tietokonepaja.fi/logo.png" />
|
||||||
|
<meta name="twitter:card" content="summary" />
|
||||||
|
<meta name="twitter:title" content="Klapi" />
|
||||||
|
<meta name="twitter:description" content="Helppokäyttöinen tietokantapalvelu selaimessasi." />
|
||||||
|
<meta name="twitter:image" content="https://klapi.tietokonepaja.fi/logo.png" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
BIN
ui/public/logo.png
Normal file
BIN
ui/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
@@ -241,3 +241,10 @@ export async function deleteUser(username: string): Promise<void> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function submitFeedback(message: string): Promise<void> {
|
||||||
|
await fetchApi<{ message: string }>("/feedback", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ message }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
user-select: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
|
|||||||
@@ -1,27 +1,38 @@
|
|||||||
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useRecoilValue, useSetRecoilState } from "recoil";
|
import { useRecoilValue, useSetRecoilState } from "recoil";
|
||||||
import Nav from "~/components/Nav";
|
import Nav from "~/components/Nav";
|
||||||
import Home from "~/routes/index";
|
import Home from "~/routes/index";
|
||||||
import About from "~/routes/about";
|
import About from "~/routes/about";
|
||||||
|
import Feedback from "~/routes/feedback";
|
||||||
import Login from "~/routes/login";
|
import Login from "~/routes/login";
|
||||||
import Management from "~/routes/management";
|
import Management from "~/routes/management";
|
||||||
import NotFound from "~/routes/[...404]";
|
import NotFound from "~/routes/[...404]";
|
||||||
import Toasts from "~/components/Toasts";
|
import Toasts from "~/components/Toasts";
|
||||||
import { APP_ROLES, hasAnyRole } from "~/auth/roles";
|
import { APP_ROLES, hasAnyRole } from "~/auth/roles";
|
||||||
import { initializeLanguage, useLanguage } from "~/i18n";
|
import { LANGUAGE_STORAGE_KEY, normalizeLanguage, useLanguage } from "~/i18n";
|
||||||
import { sessionAtom } from "~/state/appState";
|
import { languageAtom, sessionAtom } from "~/state/appState";
|
||||||
import "./app.css";
|
import "./app.css";
|
||||||
|
|
||||||
function AppShell() {
|
function AppShell() {
|
||||||
|
const location = useLocation();
|
||||||
const { language, setLanguage } = useLanguage();
|
const { language, setLanguage } = useLanguage();
|
||||||
const session = useRecoilValue(sessionAtom);
|
const session = useRecoilValue(sessionAtom);
|
||||||
const setSession = useSetRecoilState(sessionAtom);
|
const setSession = useSetRecoilState(sessionAtom);
|
||||||
|
const setLanguageAtom = useSetRecoilState(languageAtom);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Feedback page manages its own language default
|
||||||
|
if (location.pathname === "/feedback") return;
|
||||||
const storedPreferredLanguage = localStorage.getItem("session-preferred-language");
|
const storedPreferredLanguage = localStorage.getItem("session-preferred-language");
|
||||||
initializeLanguage(setLanguage, storedPreferredLanguage === "en" || storedPreferredLanguage === "sk" ? storedPreferredLanguage : "fi");
|
const fallback = normalizeLanguage(storedPreferredLanguage === "en" || storedPreferredLanguage === "sk" ? storedPreferredLanguage : "fi");
|
||||||
}, [setLanguage]);
|
const stored = localStorage.getItem(LANGUAGE_STORAGE_KEY);
|
||||||
|
if (stored !== null) {
|
||||||
|
setLanguage(normalizeLanguage(stored)); // explicit prior choice — keep it saved
|
||||||
|
} else {
|
||||||
|
setLanguageAtom(fallback); // just set the atom, don't persist the default
|
||||||
|
}
|
||||||
|
}, [location.pathname, setLanguage, setLanguageAtom]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const storedUsername = localStorage.getItem("session-username");
|
const storedUsername = localStorage.getItem("session-username");
|
||||||
@@ -53,6 +64,7 @@ function AppShell() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
<Route path="/about" element={<About />} />
|
<Route path="/about" element={<About />} />
|
||||||
|
<Route path="/feedback" element={<Feedback />} />
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route
|
<Route
|
||||||
path="/management"
|
path="/management"
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export default function Nav() {
|
|||||||
const { language, setLanguage } = useLanguage();
|
const { language, setLanguage } = useLanguage();
|
||||||
const [session, setSession] = useRecoilState(sessionAtom);
|
const [session, setSession] = useRecoilState(sessionAtom);
|
||||||
|
|
||||||
|
const isFeedbackPage = location.pathname === "/feedback";
|
||||||
|
|
||||||
const signOut = () => {
|
const signOut = () => {
|
||||||
setSession(null);
|
setSession(null);
|
||||||
localStorage.removeItem("session-username");
|
localStorage.removeItem("session-username");
|
||||||
@@ -28,68 +30,84 @@ export default function Nav() {
|
|||||||
}`;
|
}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="fixed top-0 left-0 z-50 w-full bg-[#70421E] shadow-sm">
|
<nav className={`fixed top-0 left-0 z-50 w-full shadow-sm ${isFeedbackPage ? "bg-[#2D6A4F]" : "bg-[#70421E]"}`}>
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center px-4 py-2 text-sm font-medium">
|
<div className="flex flex-col sm:flex-row sm:items-center px-4 py-2 text-sm font-medium">
|
||||||
{/* Links — bottom on mobile (order-2), left on desktop (sm:order-1) */}
|
{/* Links — hidden on feedback page */}
|
||||||
<div className="order-2 sm:order-1 flex items-center justify-center sm:justify-start">
|
{!isFeedbackPage && (
|
||||||
<Link to="/" className={linkClass("/")}>{t("nav.home")}</Link>
|
<div className="order-2 sm:order-1 flex items-center justify-center sm:justify-start">
|
||||||
<Link to="/about" className={linkClass("/about")}>{t("nav.about")}</Link>
|
<Link to="/" className={linkClass("/")}>{t("nav.home")}</Link>
|
||||||
{hasAnyRole(session, [APP_ROLES.admin]) ? (
|
<Link to="/about" className={linkClass("/about")}>{t("nav.about")}</Link>
|
||||||
<Link to="/management" className={linkClass("/management")}>{t("nav.management")}</Link>
|
{hasAnyRole(session, [APP_ROLES.admin]) ? (
|
||||||
) : null}
|
<Link to="/management" className={linkClass("/management")}>{t("nav.management")}</Link>
|
||||||
</div>
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Centered title — shown only on feedback page */}
|
||||||
|
{isFeedbackPage && (
|
||||||
|
<div className="order-2 sm:order-1 flex-1 flex items-center justify-center sm:absolute sm:left-1/2 sm:-translate-x-1/2">
|
||||||
|
<span className="text-[#D8F3DC] font-semibold tracking-wide">{t("nav.feedbackTitle")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Controls — top on mobile (order-1), right on desktop (sm:order-2 sm:ml-auto) */}
|
{/* Controls — top on mobile (order-1), right on desktop (sm:order-2 sm:ml-auto) */}
|
||||||
<div className="order-1 sm:order-2 sm:ml-auto flex items-center justify-center gap-2">
|
<div className="order-1 sm:order-2 sm:ml-auto flex items-center justify-center gap-2">
|
||||||
<b className="hidden sm:block text-[#F5D1A9]">{session?.displayName ?? ""}</b>
|
{!isFeedbackPage && (
|
||||||
<div className="flex items-center gap-1 rounded-md border border-[#8E4F24] bg-[#8E4F24]/45 p-1">
|
<b className="hidden sm:block text-[#F5D1A9]">{session?.displayName ?? ""}</b>
|
||||||
|
)}
|
||||||
|
<div className={`flex items-center gap-1 rounded-md border p-1 ${isFeedbackPage ? "border-[#52B788] bg-[#52B788]/30" : "border-[#8E4F24] bg-[#8E4F24]/45"}`}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setLanguage("fi")}
|
onClick={() => setLanguage("fi")}
|
||||||
className={`rounded px-2 py-1 text-xs ${language === "fi"
|
className={`rounded px-2 py-1 text-xs ${
|
||||||
? "bg-[#E3A977] text-[#4C250E]"
|
language === "fi"
|
||||||
: "text-[#F5D1A9] hover:text-[#FFF7EE]"
|
? isFeedbackPage ? "bg-[#74C69D] text-[#1B4332]" : "bg-[#E3A977] text-[#4C250E]"
|
||||||
}`}
|
: isFeedbackPage ? "text-[#D8F3DC] hover:text-white" : "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")}
|
||||||
className={`rounded px-2 py-1 text-xs ${language === "en"
|
className={`rounded px-2 py-1 text-xs ${
|
||||||
? "bg-[#E3A977] text-[#4C250E]"
|
language === "en"
|
||||||
: "text-[#F5D1A9] hover:text-[#FFF7EE]"
|
? isFeedbackPage ? "bg-[#74C69D] text-[#1B4332]" : "bg-[#E3A977] text-[#4C250E]"
|
||||||
}`}
|
: isFeedbackPage ? "text-[#D8F3DC] hover:text-white" : "text-[#F5D1A9] hover:text-[#FFF7EE]"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{t("nav.language.en")}
|
{t("nav.language.en")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setLanguage("sk")}
|
onClick={() => setLanguage("sk")}
|
||||||
className={`rounded px-2 py-1 text-xs ${language === "sk"
|
className={`rounded px-2 py-1 text-xs ${
|
||||||
? "bg-[#E3A977] text-[#4C250E]"
|
language === "sk"
|
||||||
: "text-[#F5D1A9] hover:text-[#FFF7EE]"
|
? isFeedbackPage ? "bg-[#74C69D] text-[#1B4332]" : "bg-[#E3A977] text-[#4C250E]"
|
||||||
}`}
|
: isFeedbackPage ? "text-[#D8F3DC] hover:text-white" : "text-[#F5D1A9] hover:text-[#FFF7EE]"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{t("nav.language.sk")}
|
{t("nav.language.sk")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{session ? (
|
{!isFeedbackPage && (
|
||||||
<button
|
session ? (
|
||||||
type="button"
|
<button
|
||||||
onClick={signOut}
|
type="button"
|
||||||
className="rounded-md border border-[#A56C38] bg-[#8E4F24] px-4 py-2 text-[#F5D1A9] transition-colors duration-200 hover:bg-[#A56C38] hover:text-[#FFF7EE]"
|
onClick={signOut}
|
||||||
>
|
className="rounded-md border border-[#A56C38] bg-[#8E4F24] px-4 py-2 text-[#F5D1A9] transition-colors duration-200 hover:bg-[#A56C38] hover:text-[#FFF7EE]"
|
||||||
{t("nav.signOut")}
|
>
|
||||||
</button>
|
{t("nav.signOut")}
|
||||||
) : (
|
</button>
|
||||||
<Link
|
) : (
|
||||||
to="/login"
|
<Link
|
||||||
className="rounded-md border border-[#A56C38] bg-[#8E4F24] px-4 py-2 text-[#F5D1A9] transition-colors duration-200 hover:bg-[#A56C38] hover:text-[#FFF7EE]"
|
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>
|
{t("nav.login")}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { languageAtom, type Language } from "~/state/appState";
|
|||||||
|
|
||||||
const STORAGE_KEY = "ui-language";
|
const STORAGE_KEY = "ui-language";
|
||||||
|
|
||||||
|
export { STORAGE_KEY as LANGUAGE_STORAGE_KEY };
|
||||||
|
|
||||||
const translations = {
|
const translations = {
|
||||||
en: {
|
en: {
|
||||||
"nav.home": "Home",
|
"nav.home": "Home",
|
||||||
@@ -14,7 +16,8 @@ const translations = {
|
|||||||
"nav.language.fi": "FI",
|
"nav.language.fi": "FI",
|
||||||
"nav.language.en": "EN",
|
"nav.language.en": "EN",
|
||||||
"nav.language.sk": "SK",
|
"nav.language.sk": "SK",
|
||||||
"meta.description": "React + Recoil example",
|
"nav.feedback": "Feedback",
|
||||||
|
"nav.feedbackTitle": "Livonsaari volunteering project",
|
||||||
"home.title": "Home",
|
"home.title": "Home",
|
||||||
"home.heading": "Klapi",
|
"home.heading": "Klapi",
|
||||||
"home.subheading": "Livonsaaren Tietokonepaja's administration console",
|
"home.subheading": "Livonsaaren Tietokonepaja's administration console",
|
||||||
@@ -93,6 +96,18 @@ const translations = {
|
|||||||
"error.title": "Error",
|
"error.title": "Error",
|
||||||
"errors.requiredUsernamePassword": "Username and password are required",
|
"errors.requiredUsernamePassword": "Username and password are required",
|
||||||
"errors.invalidUsernameOrPassword": "Invalid username or password",
|
"errors.invalidUsernameOrPassword": "Invalid username or password",
|
||||||
|
"feedback.title": "Feedback",
|
||||||
|
"feedback.heading": "Send feedback",
|
||||||
|
"feedback.description":
|
||||||
|
"Have something to tell to Livonsaari volunteering project anonymously? Let us know in the message below.",
|
||||||
|
"feedback.label": "Your message",
|
||||||
|
"feedback.placeholder": "Write your feedback here...",
|
||||||
|
"feedback.submit": "Send",
|
||||||
|
"feedback.sending": "Sending...",
|
||||||
|
"feedback.success": "Thank you! Your feedback has been sent.",
|
||||||
|
"feedback.error": "Something went wrong. Please try again.",
|
||||||
|
"feedback.tooLong": "Message is too long (max 5000 characters).",
|
||||||
|
"feedback.required": "Please write something before submitting.",
|
||||||
},
|
},
|
||||||
fi: {
|
fi: {
|
||||||
"nav.home": "Etusivu",
|
"nav.home": "Etusivu",
|
||||||
@@ -103,7 +118,8 @@ const translations = {
|
|||||||
"nav.language.fi": "FI",
|
"nav.language.fi": "FI",
|
||||||
"nav.language.en": "EN",
|
"nav.language.en": "EN",
|
||||||
"nav.language.sk": "SK",
|
"nav.language.sk": "SK",
|
||||||
"meta.description": "React + Recoil -esimerkki",
|
"nav.feedback": "Palaute",
|
||||||
|
"nav.feedbackTitle": "Livonsaaren vapaaehtoisprojekti",
|
||||||
"home.title": "Etusivu",
|
"home.title": "Etusivu",
|
||||||
"home.heading": "Klapi",
|
"home.heading": "Klapi",
|
||||||
"home.subheading": "Livonsaaren Tietokonepajan hallintakonsoli",
|
"home.subheading": "Livonsaaren Tietokonepajan hallintakonsoli",
|
||||||
@@ -183,6 +199,18 @@ const translations = {
|
|||||||
"errors.requiredUsernamePassword": "Käyttäjätunnus ja salasana vaaditaan",
|
"errors.requiredUsernamePassword": "Käyttäjätunnus ja salasana vaaditaan",
|
||||||
"errors.invalidUsernameOrPassword":
|
"errors.invalidUsernameOrPassword":
|
||||||
"Virheellinen käyttäjätunnus tai salasana",
|
"Virheellinen käyttäjätunnus tai salasana",
|
||||||
|
"feedback.title": "Palaute",
|
||||||
|
"feedback.heading": "Lähetä palautetta",
|
||||||
|
"feedback.description":
|
||||||
|
"Haluatko kertoa jotain Livonsaaren vapaaehtoisprojektin vetäjille nimettömästi? Jätä meille viesti.",
|
||||||
|
"feedback.label": "Viestisi",
|
||||||
|
"feedback.placeholder": "Kirjoita palautteesi tähän...",
|
||||||
|
"feedback.submit": "Lähetä",
|
||||||
|
"feedback.sending": "Lähetetään...",
|
||||||
|
"feedback.success": "Kiitos! Palautteesi on lähetetty.",
|
||||||
|
"feedback.error": "Jotain meni pieleen. Yritä uudelleen.",
|
||||||
|
"feedback.tooLong": "Viesti on liian pitkä (max 5000 merkkiä).",
|
||||||
|
"feedback.required": "Kirjoita jotain ennen lähettämistä.",
|
||||||
},
|
},
|
||||||
sk: {
|
sk: {
|
||||||
"nav.home": "Domov",
|
"nav.home": "Domov",
|
||||||
@@ -193,7 +221,8 @@ const translations = {
|
|||||||
"nav.language.fi": "FI",
|
"nav.language.fi": "FI",
|
||||||
"nav.language.en": "EN",
|
"nav.language.en": "EN",
|
||||||
"nav.language.sk": "SK",
|
"nav.language.sk": "SK",
|
||||||
"meta.description": "Ukážka React + Recoil",
|
"nav.feedback": "Spätná väzba",
|
||||||
|
"nav.feedbackTitle": "Dobrovoľnícky projekt Livonsaari",
|
||||||
"home.title": "Domov",
|
"home.title": "Domov",
|
||||||
"home.heading": "Klapi",
|
"home.heading": "Klapi",
|
||||||
"home.subheading": "Administrátorská konzola Livonsaaren Tietokonepaja",
|
"home.subheading": "Administrátorská konzola Livonsaaren Tietokonepaja",
|
||||||
@@ -274,6 +303,18 @@ const translations = {
|
|||||||
"errors.requiredUsernamePassword": "Používateľské meno a heslo sú povinné",
|
"errors.requiredUsernamePassword": "Používateľské meno a heslo sú povinné",
|
||||||
"errors.invalidUsernameOrPassword":
|
"errors.invalidUsernameOrPassword":
|
||||||
"Neplatné používateľské meno alebo heslo",
|
"Neplatné používateľské meno alebo heslo",
|
||||||
|
"feedback.title": "Spätná väzba",
|
||||||
|
"feedback.heading": "Odoslať spätnú väzbu",
|
||||||
|
"feedback.description":
|
||||||
|
"Chcete niečo anonymne odkázať dobrovoľníckemu projektu Livonsaari? Zanechajte nám správu.",
|
||||||
|
"feedback.label": "Vaša správa",
|
||||||
|
"feedback.placeholder": "Tu napíšte svoju spätnú väzbu...",
|
||||||
|
"feedback.submit": "Odoslať",
|
||||||
|
"feedback.sending": "Odosiela sa...",
|
||||||
|
"feedback.success": "Ďakujeme! Vaša spätná väzba bola odoslaná.",
|
||||||
|
"feedback.error": "Niečo sa pokazilo. Skúste to znova.",
|
||||||
|
"feedback.tooLong": "Správa je príliš dlhá (max 5000 znakov).",
|
||||||
|
"feedback.required": "Pred odoslaním niečo napíšte.",
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
113
ui/src/routes/feedback.tsx
Normal file
113
ui/src/routes/feedback.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { FormEvent, useEffect, useState } from "react";
|
||||||
|
import { useSetRecoilState } from "recoil";
|
||||||
|
import { submitFeedback } from "~/api";
|
||||||
|
import { LANGUAGE_STORAGE_KEY, useT } from "~/i18n";
|
||||||
|
import { languageAtom } from "~/state/appState";
|
||||||
|
|
||||||
|
export default function Feedback() {
|
||||||
|
const t = useT();
|
||||||
|
const setLanguageAtom = useSetRecoilState(languageAtom);
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [status, setStatus] = useState<"idle" | "sending" | "success" | "error">("idle");
|
||||||
|
const [validationError, setValidationError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Default to English on the feedback page only when no language has been explicitly chosen
|
||||||
|
if (localStorage.getItem(LANGUAGE_STORAGE_KEY) === null) {
|
||||||
|
setLanguageAtom("en");
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
// Restore Finnish default when leaving, if the user still hasn't made an explicit choice
|
||||||
|
if (localStorage.getItem(LANGUAGE_STORAGE_KEY) === null) {
|
||||||
|
setLanguageAtom("fi");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [setLanguageAtom]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = t("feedback.title");
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
const handleSubmit = async (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setValidationError("");
|
||||||
|
|
||||||
|
if (!message.trim()) {
|
||||||
|
setValidationError(t("feedback.required"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.trim().length > 5000) {
|
||||||
|
setValidationError(t("feedback.tooLong"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus("sending");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await submitFeedback(message.trim());
|
||||||
|
setStatus("success");
|
||||||
|
setMessage("");
|
||||||
|
} catch {
|
||||||
|
setStatus("error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="!bg-[#D8EDDA] !text-[#1A3A2A] px-4 py-8">
|
||||||
|
<div className="mx-auto max-w-lg space-y-6">
|
||||||
|
<div className="space-y-2 text-center">
|
||||||
|
<h1 className="!text-[#1B4332] text-2xl font-bold">{t("feedback.heading")}</h1>
|
||||||
|
<p className="text-sm text-[#2D6A4F]/80">{t("feedback.description")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status === "success" ? (
|
||||||
|
<div className="rounded-md border border-[#74C69D] bg-[#EBF7EF] p-4 text-center text-sm font-medium text-[#1B4332]">
|
||||||
|
{t("feedback.success")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label
|
||||||
|
htmlFor="feedback-message"
|
||||||
|
className="block text-sm font-medium text-[#1B4332]"
|
||||||
|
>
|
||||||
|
{t("feedback.label")}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="feedback-message"
|
||||||
|
autoFocus
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => {
|
||||||
|
setMessage(e.target.value);
|
||||||
|
setValidationError("");
|
||||||
|
if (status === "error") setStatus("idle");
|
||||||
|
}}
|
||||||
|
placeholder={t("feedback.placeholder")}
|
||||||
|
rows={6}
|
||||||
|
maxLength={5000}
|
||||||
|
className="w-full rounded-md border border-[#74C69D] bg-[#EBF7EF] px-3 py-2 text-sm text-[#1A3A2A] placeholder:text-[#74C69D] focus:border-[#2D6A4F] focus:outline-none focus:ring-1 focus:ring-[#2D6A4F] disabled:opacity-60"
|
||||||
|
disabled={status === "sending"}
|
||||||
|
/>
|
||||||
|
{validationError && (
|
||||||
|
<p className="text-xs text-red-600">{validationError}</p>
|
||||||
|
)}
|
||||||
|
{status === "error" && (
|
||||||
|
<p className="text-xs text-red-600">{t("feedback.error")}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-right text-xs text-[#2D6A4F]/50">{message.length} / 5000</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={status === "sending"}
|
||||||
|
className="w-full rounded-md bg-[#2D6A4F] px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-[#1B4332] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2D6A4F] focus-visible:ring-offset-2 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{status === "sending" ? t("feedback.sending") : t("feedback.submit")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user