Compare commits

..
7 Commits
Author SHA1 Message Date
Rafał Tyński 5598fa5156 build(docker): add container setup with persistent ./data volume
Add a multi-stage Dockerfile (Vue build + .NET 10 publish) and a docker-compose service exposing port 8080 with a single bind mount ./data -> /app/data for SurrealDB persistence. Include an example compose file, a .dockerignore, and document Docker usage and version 0.0.1 in the README.
2026-10-03 20:23:25 +02:00
Rafał Tyński 3b94358162 feat(frontend): connect client to local SurrealDB
Add a SurrealDB service with a local IndexedDB-backed (indxdb://) connection for private, local-first data and an optional remote connection for sync. Initialize the local connection on app start and document the required VITE_* variables in .env.example.

Bump the client version to 0.0.1.
2026-10-03 20:23:18 +02:00
Rafał Tyński e8e1658b3a feat(backend): integrate SurrealDB storage with embedded RocksDB
Add StorageService backed by SurrealDB using the embedded RocksDB provider that persists to ./data (never in memory). Support an optional remote server via SurrealDb:RemoteUrl for frontend sync. Expose readiness and storage probe endpoints under /api/system and add a helper script to start a local SurrealDB server.

Set the backend version to 0.0.1.
2026-10-03 20:23:11 +02:00
Rafał Tyński ac9ce68fbe style(frontend): use outlined variant for feature cards 2026-10-03 18:54:17 +02:00
Rafał Tyński 1ad16fb35b feat(frontend): add light/dark theme switcher
Add persisted theme mode (light, dark, system) with a header menu.
Rework the light and dark palettes for stronger contrast and drop the
high-contrast theme.
2026-10-03 18:54:13 +02:00
Rafał Tyński 2b6a9f5433 build(frontend): add @vueuse/core dependency 2026-10-03 18:54:09 +02:00
Rafał Tyński 8541e3b42e chore: ignore local backend docs 2026-10-03 18:54:05 +02:00
26 changed files with 785 additions and 63 deletions
+27
View File
@@ -0,0 +1,27 @@
# Git / IDE
.git
.gitignore
.vs
.idea
.vscode
*.user
# .NET build artifacts
**/bin
**/obj
# Node
**/node_modules
# Build output (budowany wewnątrz obrazu)
client/dist
backend/wwwroot
# Local SurrealDB data — dostarczane przez wolumen ./data
data
backend/data
# Env / docs
.env
.env.*
backend/local-docs
+10
View File
@@ -13,6 +13,9 @@ Thumbs.db
bin/ bin/
obj/ obj/
# Local SurrealDB data (embedded RocksDB) — persisted next to the running app
data/
# Node # Node
node_modules/ node_modules/
dist/ dist/
@@ -21,7 +24,14 @@ dist/
# Client build output copied into backend # Client build output copied into backend
backend/wwwroot/ backend/wwwroot/
# Local documentation (not tracked)
backend/local-docs/
# Env / secrets # Env / secrets
.env .env
.env.* .env.*
!.env.example !.env.example
# Docker — lokalne nadpisania
docker-compose.override.yaml
docker-compose.override.yml
+46
View File
@@ -0,0 +1,46 @@
# syntax=docker/dockerfile:1
# --- Stage 1: build klienta (Vue 3 + Vite) ---
FROM node:22-alpine AS client
WORKDIR /src/client
COPY client/package.json client/package-lock.json ./
RUN npm ci
COPY client/ ./
RUN npm run build
# --- Stage 2: build i publish backendu (ASP.NET Core, .NET 10) ---
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY backend/TarninaHub.csproj ./backend/
RUN dotnet restore ./backend/TarninaHub.csproj
COPY backend/ ./backend/
COPY --from=client /src/client/dist ./backend/wwwroot/
RUN dotnet publish ./backend/TarninaHub.csproj \
--configuration Release \
--output /app/publish \
/p:UseAppHost=false
# --- Stage 3: runtime ---
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
ARG APP_VERSION=0.0.1
LABEL org.opencontainers.image.title="TarninaHub" \
org.opencontainers.image.version="${APP_VERSION}"
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
ENV ASPNETCORE_ENVIRONMENT=Production \
ASPNETCORE_URLS=http://+:8080 \
DOTNET_EnableDiagnostics=0
COPY --from=build /app/publish ./
# Jedyny wolumen aplikacji: trwałe dane SurrealDB (embedded RocksDB).
RUN mkdir -p /app/data
VOLUME ["/app/data"]
EXPOSE 8080
ENTRYPOINT ["dotnet", "TarninaHub.dll"]
+26 -5
View File
@@ -1,11 +1,14 @@
# hubmi # TarninaHub
**Wersja: 0.0.1**
Platforma Małopolskiego Hubu Innowacji Społecznych (MVP hackathonu). Platforma Małopolskiego Hubu Innowacji Społecznych (MVP hackathonu).
- **backend/** — aplikacja backendowa w C# (ASP.NET Core, `HubMi.Api`). - **backend/** — aplikacja backendowa w C# (ASP.NET Core, `TarninaHub`).
- **client/** — aplikacja frontendowa Vue 3 + TypeScript (Vite). - **client/** — aplikacja frontendowa Vue 3 + TypeScript (Vite).
- **scripts/** — skrypty budujące całość. - **scripts/** — skrypty budujące całość.
- `HubMi.slnx` — solution .NET. - `TarninaHub.slnx` — solution .NET.
- `Dockerfile` + `docker-compose.yaml` — uruchomienie kontenerowe.
## Wymagania ## Wymagania
@@ -34,15 +37,33 @@ npm --prefix client run copy # kopiuje client/dist -> backend/wwwroot
node scripts/build.mjs # buduje całość (client + copy + backend) node scripts/build.mjs # buduje całość (client + copy + backend)
``` ```
## Docker
```pwsh
cp docker-compose.yaml.example docker-compose.yaml
docker compose up --build
```
- Aplikacja: http://localhost:8080
- Dane: `./data` (bind mount, trwałe — SurrealDB embedded RocksDB)
- Obraz: `tarninahub:0.0.1`
Wolumen hosta można wskazać zmienną `TARNINAHUB_PORT` (domyślnie `8080`).
Plik `docker-compose.yaml.example` jest szablonem; `docker-compose.yaml` możesz
dostosować lokalnie.
## Struktura ## Struktura
``` ```
hubmi/ TarninaHub/
├─ backend/ # ASP.NET Core Web API + wwwroot (build z client) ├─ backend/ # ASP.NET Core Web API + wwwroot (build z client)
├─ client/ # Vue 3 + TS + Vite ├─ client/ # Vue 3 + TS + Vite
├─ scripts/ ├─ scripts/
│ ├─ build.mjs # build całości │ ├─ build.mjs # build całości
│ └─ copy-client.mjs # client/dist -> backend/wwwroot │ └─ copy-client.mjs # client/dist -> backend/wwwroot
├─ HubMi.slnx ├─ Dockerfile # multi-stage: client + backend
├─ docker-compose.yaml # uruchomienie (bind ./data)
├─ docker-compose.yaml.example
├─ TarninaHub.slnx
└─ README.md └─ README.md
``` ```
+39
View File
@@ -0,0 +1,39 @@
using Microsoft.AspNetCore.Mvc;
using TarninaHub.Models;
using TarninaHub.Services;
namespace TarninaHub.Controllers;
[ApiController]
[Route("api/system")]
public class SystemController : ControllerBase
{
private readonly IStorageService _storageService;
public SystemController(IStorageService storageService)
{
_storageService = storageService;
}
[HttpGet("storage")]
public async Task<ActionResult<StorageStatusResponse>> GetStorage(CancellationToken cancellationToken)
{
var info = _storageService.GetInfo();
var probeCount = await _storageService.GetProbeCountAsync(cancellationToken);
return Ok(new StorageStatusResponse(
info.Engine,
info.DataDirectory,
info.Namespace,
info.Database,
probeCount));
}
[HttpPost("storage/probes")]
public async Task<ActionResult<StorageProbeResponse>> CreateProbe(CancellationToken cancellationToken)
{
var probe = await _storageService.AddProbeAsync(cancellationToken);
return Ok(new StorageProbeResponse(probe.Label, probe.CreatedAt));
}
}
+25
View File
@@ -0,0 +1,25 @@
namespace TarninaHub.Models;
public sealed class StorageProbe
{
public string Label { get; set; } = "probe";
public DateTimeOffset CreatedAt { get; set; }
}
public sealed record StorageInfo(
string Engine,
string DataDirectory,
string Namespace,
string Database);
public sealed record StorageStatusResponse(
string Engine,
string DataDirectory,
string Namespace,
string Database,
int ProbeCount);
public sealed record StorageProbeResponse(
string Label,
DateTimeOffset CreatedAt);
+55
View File
@@ -1,12 +1,55 @@
using TarninaHub.Services;
using TarninaHub.Settings;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
var surrealSettings = builder.Configuration.GetSection("SurrealDb").Get<SurrealDbSettings>()
?? new SurrealDbSettings();
builder.Services.Configure<SurrealDbSettings>(builder.Configuration.GetSection("SurrealDb"));
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi(); builder.Services.AddOpenApi();
builder.Services.AddControllers(); builder.Services.AddControllers();
builder.Services.AddSingleton<IStorageService, StorageService>();
var surrealOptions = new SurrealDbOptions
{
Endpoint = BuildEndpoint(surrealSettings),
Namespace = surrealSettings.Namespace,
Database = surrealSettings.Database,
};
if (string.IsNullOrWhiteSpace(surrealSettings.RemoteUrl))
{
// Embedded RocksDB, persisted next to the running app (./data), never in memory.
var dataDirectory = Path.Combine(AppContext.BaseDirectory, surrealSettings.DataDirectory);
Directory.CreateDirectory(dataDirectory);
builder.Services
.AddSurreal(surrealOptions, ServiceLifetime.Singleton)
.AddRocksDbProvider();
}
else
{
// Remote SurrealDB server on its own dedicated port (used for sync with the frontend).
surrealOptions.Username = surrealSettings.Username;
surrealOptions.Password = surrealSettings.Password;
builder.Services.AddSurreal(surrealOptions, ServiceLifetime.Singleton);
}
var app = builder.Build(); var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var storageService = scope.ServiceProvider.GetRequiredService<IStorageService>();
await storageService.InitializeAsync(CancellationToken.None);
}
// Configure the HTTP request pipeline. // Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{ {
@@ -25,3 +68,15 @@ app.MapControllers();
app.MapFallbackToFile("index.html"); app.MapFallbackToFile("index.html");
app.Run(); app.Run();
static string BuildEndpoint(SurrealDbSettings settings)
{
if (!string.IsNullOrWhiteSpace(settings.RemoteUrl))
{
return settings.RemoteUrl;
}
var dataDirectory = Path.Combine(AppContext.BaseDirectory, settings.DataDirectory);
return $"rocksdb://{dataDirectory.Replace('\\', '/')}";
}
+14
View File
@@ -0,0 +1,14 @@
using TarninaHub.Models;
namespace TarninaHub.Services;
public interface IStorageService
{
Task InitializeAsync(CancellationToken cancellationToken);
StorageInfo GetInfo();
Task<int> GetProbeCountAsync(CancellationToken cancellationToken);
Task<StorageProbe> AddProbeAsync(CancellationToken cancellationToken);
}
+57
View File
@@ -0,0 +1,57 @@
using Microsoft.Extensions.Options;
using SurrealDb.Net;
using TarninaHub.Models;
using TarninaHub.Settings;
namespace TarninaHub.Services;
public sealed class StorageService : IStorageService
{
private const string ProbeTable = "storage_probe";
private readonly ISurrealDbClient _client;
private readonly SurrealDbSettings _settings;
private readonly string _dataDirectory;
public StorageService(ISurrealDbClient client, IOptions<SurrealDbSettings> settings)
{
_client = client;
_settings = settings.Value;
_dataDirectory = Path.Combine(AppContext.BaseDirectory, _settings.DataDirectory);
}
public async Task InitializeAsync(CancellationToken cancellationToken)
{
await _client.RawQuery(
$"DEFINE TABLE IF NOT EXISTS {ProbeTable} SCHEMALESS;",
cancellationToken: cancellationToken);
}
public StorageInfo GetInfo()
{
var isRemote = !string.IsNullOrWhiteSpace(_settings.RemoteUrl);
return new StorageInfo(
isRemote ? "remote" : "rocksdb",
isRemote ? _settings.RemoteUrl! : _dataDirectory,
_settings.Namespace,
_settings.Database);
}
public async Task<int> GetProbeCountAsync(CancellationToken cancellationToken)
{
var probes = await _client.Select<StorageProbe>(ProbeTable, cancellationToken);
return probes.Count();
}
public Task<StorageProbe> AddProbeAsync(CancellationToken cancellationToken)
{
var probe = new StorageProbe
{
CreatedAt = DateTimeOffset.UtcNow,
};
return _client.Create(ProbeTable, probe, cancellationToken);
}
}
+21
View File
@@ -0,0 +1,21 @@
namespace TarninaHub.Settings;
public sealed class SurrealDbSettings
{
public string Namespace { get; set; } = "hubmi";
public string Database { get; set; } = "hubmi";
public string DataDirectory { get; set; } = "data";
/// <summary>
/// When set (e.g. ws://127.0.0.1:8010/rpc), the backend connects to a
/// dedicated SurrealDB server instead of the embedded RocksDB engine.
/// The server must persist to ./data, not to memory.
/// </summary>
public string? RemoteUrl { get; set; }
public string? Username { get; set; }
public string? Password { get; set; }
}
+4
View File
@@ -4,10 +4,14 @@
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Version>0.0.1</Version>
<InformationalVersion>0.0.1</InformationalVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
<PackageReference Include="SurrealDb.Embedded.RocksDb" Version="1.0.0" />
<PackageReference Include="SurrealDb.Net" Version="1.0.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+8
View File
@@ -4,5 +4,13 @@
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
},
"SurrealDb": {
"Namespace": "hubmi",
"Database": "hubmi",
"DataDirectory": "data",
"RemoteUrl": "ws://127.0.0.1:8010/rpc",
"Username": "root",
"Password": "root"
} }
} }
+6 -1
View File
@@ -5,5 +5,10 @@
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
}, },
"AllowedHosts": "*" "AllowedHosts": "*",
"SurrealDb": {
"Namespace": "hubmi",
"Database": "hubmi",
"DataDirectory": "data"
}
} }
+16
View File
@@ -0,0 +1,16 @@
# SurrealDB — frontend (Vite)
# Wspólna przestrzeń nazw / baza
VITE_SURREAL_NAMESPACE=hubmi
VITE_SURREAL_DATABASE=hubmi
# A) Lokalna baza w przeglądarce (WASM + IndexedDB; dane zostają lokalnie)
VITE_SURREAL_LOCAL_URL=indxdb://tarninahub
# B) Backend na dedykowanym porcie (tylko dla SurrealDB) — synchronizacja
VITE_SURREAL_REMOTE_URL=ws://127.0.0.1:8010/rpc
# Poświadczenia do zdalnej bazy. W produkcji NIE używaj root w przeglądarce —
# użyj tokenu dostępu rekordowego (record access) lub konta o wąskich uprawnieniach.
VITE_SURREAL_USERNAME=
VITE_SURREAL_PASSWORD=
+119 -3
View File
@@ -1,14 +1,17 @@
{ {
"name": "hubmi-client", "name": "hubmi-client",
"version": "0.0.0", "version": "0.0.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "hubmi-client", "name": "hubmi-client",
"version": "0.0.0", "version": "0.0.1",
"dependencies": { "dependencies": {
"@mdi/font": "^7.4.47", "@mdi/font": "^7.4.47",
"@surrealdb/wasm": "^2.6.1",
"@vueuse/core": "^15.0.0",
"surrealdb": "^2.0.10",
"vue": "^3.5.42", "vue": "^3.5.42",
"vuetify": "^4.2.3" "vuetify": "^4.2.3"
}, },
@@ -369,6 +372,24 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@surrealdb/cbor": {
"version": "2.0.0-alpha.4",
"resolved": "https://registry.npmjs.org/@surrealdb/cbor/-/cbor-2.0.0-alpha.4.tgz",
"integrity": "sha512-l/hNZ3ZCOJq8rzwx1y5ZV/8b2bYCWdhGqRVY33Pu50NAHyAW5qLsMn3ZBkZow77mAKwNCsbDsRGnyYEuU95mjw==",
"license": "Apache-2.0",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@surrealdb/wasm": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/@surrealdb/wasm/-/wasm-2.6.1.tgz",
"integrity": "sha512-pBxgOX/wZ+aBvMIJbDv6UtUDCT0RjhpPqa31NMUmAZuTx9WfZb1PQan4vCBH9rcSPDR6BC8zJ4pFQUuliFQUEQ==",
"license": "Apache-2.0",
"peerDependencies": {
"surrealdb": "^2.0.0-0"
}
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "24.19.1", "version": "24.19.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.1.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.1.tgz",
@@ -379,6 +400,12 @@
"undici-types": ">=7.24.0 <7.24.7" "undici-types": ">=7.24.0 <7.24.7"
} }
}, },
"node_modules/@types/web-bluetooth": {
"version": "0.0.21",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-vue": { "node_modules/@vitejs/plugin-vue": {
"version": "6.0.9", "version": "6.0.9",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.9.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.9.tgz",
@@ -615,6 +642,53 @@
} }
} }
}, },
"node_modules/@vueuse/core": {
"version": "15.0.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-15.0.0.tgz",
"integrity": "sha512-Zv67h5mrk7YHDJsRObx+DXaCBz6RKBUpxqLesJ+3xZNPG6yuQs7ja7kg3veFrrouYLWAqydQ7CwGEbLVxB43Ng==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "15.0.0",
"@vueuse/shared": "15.0.0"
},
"engines": {
"node": ">=22"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/@vueuse/metadata": {
"version": "15.0.0",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-15.0.0.tgz",
"integrity": "sha512-sfWE5LADAKJ0KnjtGliMdSN8Ij/L7uP6rXIZlgPloAI4zvvefbw6H5Lt7KJA6RqQOYgVqWF5Guj50HHMIzygBg==",
"license": "MIT",
"engines": {
"node": ">=22"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
"version": "15.0.0",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-15.0.0.tgz",
"integrity": "sha512-vbNXhGrbV53xPOMSyOzMHQRvhMgZf7oNDw2RChUetVxwTIEkInQ1joZ9JbT0G3z2n2AJNjvmCWeM9ySYrnvogA==",
"license": "MIT",
"engines": {
"node": ">=22"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/alien-signals": { "node_modules/alien-signals": {
"version": "3.2.1", "version": "3.2.1",
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz", "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz",
@@ -1093,6 +1167,33 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/surrealdb": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/surrealdb/-/surrealdb-2.0.10.tgz",
"integrity": "sha512-4r3LqhvGIRdEMUDO2u21jSi0C8x0x374mOmqCEEM5TrTsQWi5dUihBAfUFee+EBkrJeVDhBgcnvaMNPn+owc2Q==",
"license": "Apache-2.0",
"dependencies": {
"@surrealdb/sqon": "^0.1.5"
},
"peerDependencies": {
"tslib": "^2.6.3",
"typescript": "^5.0.0 || ^6.0.0"
}
},
"node_modules/surrealdb/node_modules/@surrealdb/sqon": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/@surrealdb/sqon/-/sqon-0.1.5.tgz",
"integrity": "sha512-wTo5eREYb+spcErgyKsyNt/QVnz9MaPhOrhqalNp6BWWQNXqgd0gZ8Pub2jjckLrULxRi9Cujamif0T34LBhBA==",
"license": "Apache-2.0",
"dependencies": {
"@surrealdb/cbor": "2.0.0-alpha.4",
"uuidv7": "^1.0.1"
},
"peerDependencies": {
"tslib": "^2.6.3",
"typescript": "^5.0.0"
}
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -1110,11 +1211,17 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"peer": true
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
@@ -1131,6 +1238,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/uuidv7": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/uuidv7/-/uuidv7-1.2.1.tgz",
"integrity": "sha512-4kPkK3/XTQW9Hbm4CaqfICn+kY9LJtDVEOfgsRRra/+n2Ofg4NqzRFceAkxvQ/Ud/6BpHOPzj8cirqM7TzTN5Q==",
"license": "Apache-2.0",
"bin": {
"uuidv7": "cli.js"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "8.3.2", "version": "8.3.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.2.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.2.tgz",
+4 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "hubmi-client", "name": "hubmi-client",
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -11,6 +11,9 @@
}, },
"dependencies": { "dependencies": {
"@mdi/font": "^7.4.47", "@mdi/font": "^7.4.47",
"@surrealdb/wasm": "^2.6.1",
"@vueuse/core": "^15.0.0",
"surrealdb": "^2.0.10",
"vue": "^3.5.42", "vue": "^3.5.42",
"vuetify": "^4.2.3" "vuetify": "^4.2.3"
}, },
+33 -1
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { computed, ref } from 'vue'
import MainView from './pages/MainView.vue' import MainView from './pages/MainView.vue'
import { useThemeMode } from './composables/useThemeMode'
interface NavItem { interface NavItem {
title: string title: string
@@ -10,6 +11,11 @@ interface NavItem {
const drawer = ref(false) const drawer = ref(false)
const { mode, themeOptions } = useThemeMode()
const activeTheme = computed(
() => themeOptions.find((option) => option.value === mode.value) ?? themeOptions[2]!,
)
const navItems: NavItem[] = [ const navItems: NavItem[] = [
{ title: 'Misja', href: '#misja', icon: 'mdi-compass-outline' }, { title: 'Misja', href: '#misja', icon: 'mdi-compass-outline' },
{ title: 'Jak to działa', href: '#jak-to-dziala', icon: 'mdi-sitemap-outline' }, { title: 'Jak to działa', href: '#jak-to-dziala', icon: 'mdi-sitemap-outline' },
@@ -29,6 +35,32 @@ const navItems: NavItem[] = [
/> />
<v-app-bar-title>TarninaHub</v-app-bar-title> <v-app-bar-title>TarninaHub</v-app-bar-title>
<v-spacer /> <v-spacer />
<v-menu location="bottom end">
<template #activator="{ props }">
<v-btn
v-bind="props"
:prepend-icon="activeTheme.icon"
:aria-label="`Motyw: ${activeTheme.title}. Zmień motyw`"
color="on-primary"
variant="text"
class="me-1"
>
{{ activeTheme.title }}
</v-btn>
</template>
<v-list aria-label="Wybór motywu">
<v-list-item
v-for="option in themeOptions"
:key="option.value"
:prepend-icon="option.icon"
:title="option.title"
:active="mode === option.value"
@click="mode = option.value"
/>
</v-list>
</v-menu>
<nav aria-label="Menu główne" class="d-none d-md-block"> <nav aria-label="Menu główne" class="d-none d-md-block">
<v-btn <v-btn
v-for="item in navItems" v-for="item in navItems"
+32
View File
@@ -0,0 +1,32 @@
import { computed, watch } from 'vue'
import { usePreferredDark, useStorage } from '@vueuse/core'
import { useTheme } from 'vuetify'
export type ThemeMode = 'light' | 'dark' | 'system'
export type ResolvedTheme = 'light' | 'dark'
export interface ThemeOption {
value: ThemeMode
title: string
icon: string
}
export const themeOptions: ThemeOption[] = [
{ value: 'light', title: 'Jasny', icon: 'mdi-white-balance-sunny' },
{ value: 'dark', title: 'Ciemny', icon: 'mdi-weather-night' },
{ value: 'system', title: 'Systemowy', icon: 'mdi-theme-light-dark' },
]
export function useThemeMode() {
const theme = useTheme()
const preferredDark = usePreferredDark()
const mode = useStorage<ThemeMode>('tarninahub-theme', 'system')
const resolved = computed<ResolvedTheme>(() =>
mode.value === 'system' ? (preferredDark.value ? 'dark' : 'light') : mode.value,
)
watch(resolved, (value) => theme.change(value), { immediate: true })
return { mode, resolved, preferredDark, themeOptions }
}
+5
View File
@@ -1,7 +1,12 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import vuetify from './plugins/vuetify' import vuetify from './plugins/vuetify'
import { connectLocalSurreal } from './services/surreal'
import './style.css' import './style.css'
import './styles/a11y.css' import './styles/a11y.css'
import App from './App.vue' import App from './App.vue'
void connectLocalSurreal().catch((error: unknown) => {
console.error('Nie udało się połączyć z lokalną bazą SurrealDB', error)
})
createApp(App).use(vuetify).mount('#app') createApp(App).use(vuetify).mount('#app')
+1 -1
View File
@@ -109,7 +109,7 @@ const steps: Step[] = [
<h2 id="dla-kogo-title" class="text-h4 text-center mb-8">Dla kogo?</h2> <h2 id="dla-kogo-title" class="text-h4 text-center mb-8">Dla kogo?</h2>
<v-row> <v-row>
<v-col v-for="feature in features" :key="feature.title" cols="12" md="4"> <v-col v-for="feature in features" :key="feature.title" cols="12" md="4">
<v-card class="h-100 pa-2" variant="elevated" elevation="2"> <v-card class="h-100 pa-2" variant="outlined">
<v-card-item> <v-card-item>
<v-icon <v-icon
:icon="feature.icon" :icon="feature.icon"
+62 -47
View File
@@ -5,66 +5,81 @@ import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives' import * as directives from 'vuetify/directives'
import { en, pl } from 'vuetify/locale' import { en, pl } from 'vuetify/locale'
const light: ThemeDefinition = { export const light: ThemeDefinition = {
dark: false, dark: false,
colors: { colors: {
background: '#FFFFFF', background: '#FBF8FF',
'on-background': '#1A1030',
surface: '#FFFFFF', surface: '#FFFFFF',
'surface-variant': '#4D4D4D', 'on-surface': '#1A1030',
primary: '#1565C0', 'surface-bright': '#FFFFFF',
'on-surface-bright': '#1A1030',
'surface-light': '#F1EAFB',
'on-surface-light': '#1A1030',
'surface-variant': '#ECE4FA',
'on-surface-variant': '#1A1030',
primary: '#5B21B6',
'on-primary': '#FFFFFF', 'on-primary': '#FFFFFF',
secondary: '#455A64', secondary: '#6A1B9A',
'on-secondary': '#FFFFFF', 'on-secondary': '#FFFFFF',
info: '#0B4F9E',
'on-info': '#FFFFFF',
success: '#166534',
'on-success': '#FFFFFF',
warning: '#7C4A00',
'on-warning': '#FFFFFF',
error: '#B3261E', error: '#B3261E',
'on-error': '#FFFFFF', 'on-error': '#FFFFFF',
success: '#1B5E20',
'on-success': '#FFFFFF', outline: '#6E5D93',
warning: '#8A4B00', focus: '#4C1D95',
'on-warning': '#FFFFFF', },
info: '#01579B', variables: {
'on-info': '#FFFFFF', 'border-color': '#6E5D93',
'border-opacity': 1,
'high-emphasis-opacity': 1,
'medium-emphasis-opacity': 0.8,
'disabled-opacity': 0.5,
}, },
} }
const dark: ThemeDefinition = { export const dark: ThemeDefinition = {
dark: true, dark: true,
colors: { colors: {
background: '#121212', background: '#151020',
surface: '#1E1E1E', 'on-background': '#F1EBFF',
'surface-variant': '#CFCFCF', surface: '#1E1730',
primary: '#90CAF9', 'on-surface': '#F1EBFF',
'on-primary': '#0D1B2A', 'surface-bright': '#2A2142',
secondary: '#B0BEC5', 'on-surface-bright': '#F1EBFF',
'on-secondary': '#10181C', 'surface-light': '#2A2142',
'on-surface-light': '#F1EBFF',
'surface-variant': '#2A2142',
'on-surface-variant': '#F1EBFF',
primary: '#B79CFF',
'on-primary': '#240850',
secondary: '#E3AEF2',
'on-secondary': '#3A0F4A',
info: '#9EC5FF',
'on-info': '#0A2447',
success: '#86DDA0',
'on-success': '#08311A',
warning: '#FFC766',
'on-warning': '#3A2600',
error: '#FFB4AB', error: '#FFB4AB',
'on-error': '#410002', 'on-error': '#4A0A06',
success: '#A5D6A7',
'on-success': '#0B2A0E',
warning: '#FFCC80',
'on-warning': '#2E1A00',
info: '#81D4FA',
'on-info': '#00263A',
},
}
const highContrast: ThemeDefinition = { outline: '#9A8FB5',
dark: true, focus: '#FFD54F',
colors: { },
background: '#000000', variables: {
surface: '#000000', 'border-color': '#9A8FB5',
'surface-variant': '#FFFFFF', 'border-opacity': 1,
primary: '#FFFF00', 'high-emphasis-opacity': 1,
'on-primary': '#000000', 'medium-emphasis-opacity': 0.8,
secondary: '#00FFFF', 'disabled-opacity': 0.5,
'on-secondary': '#000000',
error: '#FF8A80',
'on-error': '#000000',
success: '#69F0AE',
'on-success': '#000000',
warning: '#FFD740',
'on-warning': '#000000',
info: '#80D8FF',
'on-info': '#000000',
}, },
} }
@@ -72,7 +87,7 @@ export default createVuetify({
components, components,
directives, directives,
locale: { locale: 'pl', fallback: 'en', messages: { pl, en } }, locale: { locale: 'pl', fallback: 'en', messages: { pl, en } },
theme: { defaultTheme: 'system', themes: { light, dark, highContrast } }, theme: { defaultTheme: 'system', themes: { light, dark } },
defaults: { defaults: {
global: { ripple: false }, global: { ripple: false },
VBtn: { size: 'large', variant: 'flat', color: 'primary' }, VBtn: { size: 'large', variant: 'flat', color: 'primary' },
+59
View File
@@ -0,0 +1,59 @@
import { Surreal, createRemoteEngines } from 'surrealdb'
import { createWasmEngines } from '@surrealdb/wasm'
const namespace = import.meta.env.VITE_SURREAL_NAMESPACE ?? 'hubmi'
const database = import.meta.env.VITE_SURREAL_DATABASE ?? 'hubmi'
/**
* A) Lokalna baza SurrealDB w przeglądarce.
* Persistencja przez IndexedDB (`indxdb://`) — localStorage jest zbyt mały.
* Dane prywatne użytkownika zostają tutaj i nie są wysyłane automatycznie.
*/
const localDatabase = import.meta.env.VITE_SURREAL_LOCAL_DATABASE ?? 'tarninahub'
const localUrl = import.meta.env.VITE_SURREAL_LOCAL_URL ?? `indxdb://${localDatabase}`
export const localSurreal = new Surreal({
engines: {
...createRemoteEngines(),
...createWasmEngines(),
},
})
let localConnection: Promise<void> | null = null
export function connectLocalSurreal(): Promise<void> {
localConnection ??= localSurreal
.connect(localUrl, { namespace, database })
.then(() => undefined)
return localConnection
}
/**
* B) Zdalna baza backendu na dedykowanym porcie (tylko dla SurrealDB),
* używana do synchronizacji danych uznanych za nadające się do wysłania.
*/
const remoteUrl =
import.meta.env.VITE_SURREAL_REMOTE_URL ?? 'ws://127.0.0.1:8010/rpc'
const username = import.meta.env.VITE_SURREAL_USERNAME as string | undefined
const password = import.meta.env.VITE_SURREAL_PASSWORD as string | undefined
export const remoteSurreal = new Surreal()
let remoteConnection: Promise<void> | null = null
export function connectRemoteSurreal(): Promise<void> {
remoteConnection ??= remoteSurreal
.connect(remoteUrl, {
namespace,
database,
...(username && password
? { authentication: { username, password } }
: {}),
})
.then(() => undefined)
return remoteConnection
}
export { namespace, database }
+1 -4
View File
@@ -1,12 +1,9 @@
.v-theme--light { .v-theme--light {
--a11y-focus: #0b3d91; --a11y-focus: #4c1d95;
} }
.v-theme--dark { .v-theme--dark {
--a11y-focus: #ffd54f; --a11y-focus: #ffd54f;
} }
.v-theme--highContrast {
--a11y-focus: #ffff00;
}
html { html {
font-size: 112.5%; font-size: 112.5%;
+31
View File
@@ -0,0 +1,31 @@
name: tarninahub
services:
app:
image: tarninahub:0.0.1
build:
context: .
dockerfile: Dockerfile
args:
APP_VERSION: 0.0.1
container_name: tarninahub
restart: unless-stopped
# RocksDB zapisuje do bind-mountu ./data; root upraszcza uprawnienia (MVP).
user: "0:0"
ports:
- "${TARNINAHUB_PORT:-8080}:8080"
environment:
ASPNETCORE_ENVIRONMENT: Production
ASPNETCORE_URLS: http://+:8080
SurrealDb__Namespace: hubmi
SurrealDb__Database: hubmi
SurrealDb__DataDirectory: data
volumes:
# Jedyny volumen aplikacji — trwałe dane (SurrealDB / RocksDB).
- ./data:/app/data
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/api/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
+48
View File
@@ -0,0 +1,48 @@
# TarninaHub — szablon docker-compose (wersja projektu 0.0.1)
#
# Użycie:
# cp docker-compose.yaml.example docker-compose.yaml
# docker compose up --build
#
# Aplikacja: http://localhost:8080
# Dane: ./data (bind mount, trwałe, SurrealDB embedded RocksDB)
name: tarninahub
services:
app:
# Obraz budowany lokalnie z Dockerfile (multi-stage: klient Vue + backend .NET).
image: tarninahub:${APP_VERSION:-0.0.1}
build:
context: .
dockerfile: Dockerfile
args:
APP_VERSION: ${APP_VERSION:-0.0.1}
container_name: tarninahub
restart: unless-stopped
# RocksDB zapisuje do bind-mountu ./data; root upraszcza uprawnienia (MVP).
# W produkcji użyj named volume albo dopasuj właściciela katalogu ./data.
user: "0:0"
ports:
- "${TARNINAHUB_PORT:-8080}:8080"
environment:
ASPNETCORE_ENVIRONMENT: Production
ASPNETCORE_URLS: http://+:8080
# SurrealDB — wbudowany silnik RocksDB (domyślnie), dane w /app/data.
SurrealDb__Namespace: hubmi
SurrealDb__Database: hubmi
SurrealDb__DataDirectory: data
# Tryb zdalny (opcjonalnie) — wskaż zewnętrzny serwer SurrealDB zamiast
# wbudowanego RocksDB. Wtedy usuń `user: "0:0"` i przekieruj port 8010.
# SurrealDb__RemoteUrl: ws://127.0.0.1:8010/rpc
# SurrealDb__Username: root
# SurrealDb__Password: root
volumes:
# Jedyny volumen aplikacji — trwałe dane (SurrealDB / RocksDB).
- ./data:/app/data
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/api/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
+36
View File
@@ -0,0 +1,36 @@
<#
.SYNOPSIS
Uruchamia SurrealDB jako serwer na dedykowanym porcie (tylko dla bazy),
z trwałym magazynem RocksDB w katalogu ./data (nie w pamięci).
.DESCRIPTION
Port (domyślnie 8010) służy wyłącznie SurrealDB i jest używany zarówno przez
backend (HubMi.Api), jak i przez frontend do synchronizacji (WebSocket /rpc).
Dane trwale zapisywane są w backend/data (RocksDB).
#>
param(
[int]$Port = 8010,
[string]$Bind = "127.0.0.1",
[string]$Username = "root",
[string]$Password = "root",
[string]$DataPath = (Join-Path $PSScriptRoot "..\backend\data")
)
$ErrorActionPreference = "Stop"
$surreal = (Get-Command surreal -ErrorAction SilentlyContinue)?.Source
if (-not $surreal) {
throw "Nie znaleziono 'surreal' w PATH. Zainstaluj SurrealDB: https://surrealdb.com/install"
}
$resolvedData = [System.IO.Path]::GetFullPath($DataPath)
New-Item -ItemType Directory -Force -Path $resolvedData | Out-Null
Write-Host "SurrealDB: bind $Bind`:$Port, rocksdb:$resolvedData"
& $surreal start `
--user $Username `
--pass $Password `
--bind "${Bind}:${Port}" `
--log info `
"rocksdb:$resolvedData"