Deployment fixes, add HPP logo

This commit is contained in:
2026-05-04 22:21:07 +12:00
parent 2799946091
commit ebee72d4df
24 changed files with 768 additions and 45 deletions
+2
View File
@@ -12,4 +12,6 @@ PUBLIC_API_BASE_URL=https://clients.lean-101.com.au
INTERNAL_API_BASE_URL=http://backend:8000 INTERNAL_API_BASE_URL=http://backend:8000
CORS_ALLOW_ORIGINS=https://clients.lean-101.com.au CORS_ALLOW_ORIGINS=https://clients.lean-101.com.au
CLIENTS_APP_PORT=808 CLIENTS_APP_PORT=808
PUBLIC_MIX_CALCULATOR_SESSION_HISTORY=false
PUBLIC_MIX_CALCULATOR_SESSION_SAVE=false
DATABASE_URL=sqlite:////data/data_entry_app.db DATABASE_URL=sqlite:////data/data_entry_app.db
+2
View File
@@ -12,4 +12,6 @@ PUBLIC_API_BASE_URL=https://clients.lean-101.com.au
INTERNAL_API_BASE_URL=http://backend:8000 INTERNAL_API_BASE_URL=http://backend:8000
CORS_ALLOW_ORIGINS=https://clients.lean-101.com.au CORS_ALLOW_ORIGINS=https://clients.lean-101.com.au
CLIENTS_APP_PORT=8081 CLIENTS_APP_PORT=8081
PUBLIC_MIX_CALCULATOR_SESSION_HISTORY=false
PUBLIC_MIX_CALCULATOR_SESSION_SAVE=false
DATABASE_URL=sqlite:////data/data_entry_app.db DATABASE_URL=sqlite:////data/data_entry_app.db
+26
View File
@@ -0,0 +1,26 @@
APP_NAME=Lean 101 Clients API
CLIENT_NAME=Hunter Premium Produce
CLIENT_EMAIL=operator@example.com
CLIENT_PASSWORD=replace-with-strong-password
CLIENT_TENANT_ID=hunter-premium-produce
ADMIN_NAME=Lean 101
ADMIN_EMAIL=admin@lean101.local
ADMIN_PASSWORD=replace-with-strong-password
AUTH_SECRET=replace-with-a-long-random-secret
# Postgres credentials. The compose file builds DATABASE_URL from these
# so you do not need to set DATABASE_URL explicitly. Override DATABASE_URL
# only if you want to point at a managed Postgres outside the compose stack.
POSTGRES_USER=lean101
POSTGRES_PASSWORD=replace-with-a-long-random-password
POSTGRES_DB=lean101
# DATABASE_URL=postgresql+psycopg://USER:PASS@HOST:5432/DBNAME
ORIGIN=https://clients.lean-101.com.au
PUBLIC_API_BASE_URL=https://clients.lean-101.com.au
INTERNAL_API_BASE_URL=http://backend:8000
CORS_ALLOW_ORIGINS=https://clients.lean-101.com.au
CLIENTS_APP_PORT=8081
PUBLIC_MIX_CALCULATOR_SESSION_HISTORY=false
PUBLIC_MIX_CALCULATOR_SESSION_SAVE=false
+2
View File
@@ -15,4 +15,6 @@ frontend/node_modules/
*.pyd *.pyd
*.sqlite3 *.sqlite3
*.db *.db
.env.production
.env.alpha
+34
View File
@@ -60,6 +60,40 @@ This follows the same external pattern as your website container:
If your server already has a host-level nginx handling domains and TLS, use `deploy/nginx/clients.lean-101.proxy.conf` as the upstream template and point the domain at `http://127.0.0.1:8081`. If your server already has a host-level nginx handling domains and TLS, use `deploy/nginx/clients.lean-101.proxy.conf` as the upstream template and point the domain at `http://127.0.0.1:8081`.
## Production deployment (Postgres + Digital Ocean)
`docker-compose.production.yml` provisions a managed-style stack with a containerised Postgres 16 service replacing the SQLite file used in alpha. The PowerShell script `deploy/Deploy.ps1` drives both first-time bootstrap and incremental updates from a Windows workstation against a Digital Ocean droplet.
1. **One-time droplet prep**: install Docker Engine + the compose plugin (`apt install docker.io docker-compose-plugin` or the official Docker repo). Open inbound 80/443 on your reverse proxy and forward to `127.0.0.1:${CLIENTS_APP_PORT}` (default 8081).
2. **Local secrets**: copy the example env and fill in real secrets — strong `POSTGRES_PASSWORD`, `AUTH_SECRET`, `CLIENT_PASSWORD`, `ADMIN_PASSWORD`. The compose file refuses to start without them.
```powershell
Copy-Item .env.production.example .env.production
notepad .env.production
```
3. **First deploy** (clones the repo on the droplet, uploads the env file, brings the stack up):
```powershell
./deploy/Deploy.ps1 `
-RemoteHost 203.0.113.10 `
-Bootstrap `
-RepoUrl git@github.com:ponzischeme89/data-entry-app.git `
-Seed
```
4. **Subsequent updates** (the same script — pulls latest `main`, rebuilds, and rolls containers without touching the Postgres volume):
```powershell
./deploy/Deploy.ps1 -RemoteHost 203.0.113.10
```
Useful flags: `-Branch <name>` to deploy a feature branch, `-SkipBuild` for env-only changes, `-Seed` to re-run reference data seeding, `-Logs` to tail logs after the deploy, `-SshKey` to point at a specific private key.
5. **Database**: the backend reads `DATABASE_URL`. The production compose file synthesises it as `postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}` so you only need to set the three `POSTGRES_*` vars. Override `DATABASE_URL` directly if you point at a managed Postgres (e.g. DigitalOcean managed databases).
The schema is auto-managed — `app/db/migrations.py` runs at backend startup and is idempotent across SQLite and Postgres. To migrate alpha SQLite data into the new Postgres instance, dump tables to CSV from the alpha container and import via `\copy` in `psql`; there is no automatic SQLite → Postgres path.
## Backend ## Backend
Create a virtual environment, install dependencies, then run: Create a virtual environment, install dependencies, then run:
+2 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "data-entry-app-backend" name = "data-entry-app-backend"
version = "0.1.4" version = "0.1.5"
description = "Costing platform MVP backend" description = "Costing platform MVP backend"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
@@ -14,6 +14,7 @@ dependencies = [
"sqlalchemy>=2.0,<3.0", "sqlalchemy>=2.0,<3.0",
"pydantic>=2.8,<3.0", "pydantic>=2.8,<3.0",
"pytest>=8.0,<9.0", "pytest>=8.0,<9.0",
"psycopg[binary]>=3.2,<4.0",
] ]
[tool.setuptools] [tool.setuptools]
+220
View File
@@ -0,0 +1,220 @@
<#
.SYNOPSIS
Build and deploy the Lean 101 Clients app to a Digital Ocean droplet over SSH.
.DESCRIPTION
Runs `docker compose` against `docker-compose.production.yml` on the remote
host. The same script handles first-time bootstrap and subsequent updates:
* On bootstrap (-Bootstrap): creates the remote directory, clones the
repo (or updates if already present), uploads the local env file, and
brings the stack up with `docker compose ... up -d --build`.
* On update (default): SSHes to the host, fetches the requested branch,
uploads a refreshed env file (if changed), then runs
`docker compose ... up -d --build` followed by a healthcheck.
The script never executes destructive commands without asking, except for
recreating containers (which preserves the named Postgres volume).
.PARAMETER RemoteHost
Hostname or IP of the Digital Ocean droplet. Required.
.PARAMETER RemoteUser
SSH user on the droplet. Defaults to `root`.
.PARAMETER RemotePath
Absolute path on the droplet where the repo lives. Defaults to
`/srv/lean101-clients`.
.PARAMETER Branch
Git branch to deploy. Defaults to `main`.
.PARAMETER RepoUrl
Git URL used during bootstrap when the remote directory is empty.
Required only with -Bootstrap.
.PARAMETER EnvFile
Local path to the env file that should land on the droplet as
`<RemotePath>/.env.production`. Defaults to `.env.production`.
.PARAMETER SshKey
Optional path to an SSH private key. If omitted, the script relies on
ssh-agent / default keys.
.PARAMETER ComposeFile
Compose file name on the remote host. Defaults to
`docker-compose.production.yml`.
.PARAMETER Bootstrap
Run first-time setup (clone, upload env, build, up).
.PARAMETER SkipBuild
Pass `--no-build` to docker compose (use when only env changed).
.PARAMETER Seed
Run `python -m app.seed` inside the backend container after the stack is up.
.PARAMETER Logs
After deploy, tail logs for ~20 lines so you can verify the stack came up.
.EXAMPLE
./deploy/Deploy.ps1 -RemoteHost 203.0.113.10 -Bootstrap -RepoUrl git@github.com:ponzischeme89/data-entry-app.git
.EXAMPLE
./deploy/Deploy.ps1 -RemoteHost 203.0.113.10
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)] [string] $RemoteHost,
[string] $RemoteUser = "root",
[string] $RemotePath = "/srv/lean101-clients",
[string] $Branch = "main",
[string] $RepoUrl,
[string] $EnvFile = ".env.production",
[string] $SshKey,
[string] $ComposeFile = "docker-compose.production.yml",
[switch] $Bootstrap,
[switch] $SkipBuild,
[switch] $Seed,
[switch] $Logs
)
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
function Write-Step($message) {
Write-Host "==> $message" -ForegroundColor Cyan
}
function Write-Warn($message) {
Write-Host "!! $message" -ForegroundColor Yellow
}
function Resolve-RepoRoot {
$scriptDir = Split-Path -Parent $MyInvocation.ScriptName
if (-not $scriptDir) { $scriptDir = $PSScriptRoot }
return (Resolve-Path (Join-Path $scriptDir "..")).Path
}
$RepoRoot = Resolve-RepoRoot
Push-Location $RepoRoot
try {
$envPath = if ([System.IO.Path]::IsPathRooted($EnvFile)) { $EnvFile } else { Join-Path $RepoRoot $EnvFile }
if (-not (Test-Path $envPath)) {
throw "Env file not found at '$envPath'. Copy .env.production.example to $EnvFile and fill in production secrets first."
}
$sshTarget = "$RemoteUser@$RemoteHost"
$sshOpts = @("-o", "StrictHostKeyChecking=accept-new")
if ($SshKey) { $sshOpts += @("-i", $SshKey) }
function Invoke-Ssh([string] $remoteCommand) {
& ssh @sshOpts $sshTarget $remoteCommand
if ($LASTEXITCODE -ne 0) {
throw "Remote command failed (exit $LASTEXITCODE): $remoteCommand"
}
}
function Invoke-Scp([string] $localPath, [string] $remoteDest) {
& scp @sshOpts $localPath "$($sshTarget):$remoteDest"
if ($LASTEXITCODE -ne 0) {
throw "scp failed for $localPath -> $remoteDest"
}
}
Write-Step "Verifying SSH connectivity to $sshTarget"
Invoke-Ssh "echo connected as `$(whoami) on `$(hostname)"
Write-Step "Verifying Docker is installed on the droplet"
Invoke-Ssh "command -v docker >/dev/null 2>&1 && docker --version && docker compose version"
if ($Bootstrap) {
if (-not $RepoUrl) {
throw "-RepoUrl is required when using -Bootstrap."
}
Write-Step "Bootstrapping $RemotePath from $RepoUrl ($Branch)"
$bootstrapScript = @"
set -euo pipefail
mkdir -p '$RemotePath'
cd '$RemotePath'
if [ ! -d .git ]; then
git clone --branch '$Branch' '$RepoUrl' .
else
git remote set-url origin '$RepoUrl'
git fetch origin '$Branch'
git checkout '$Branch'
git reset --hard 'origin/$Branch'
fi
"@
Invoke-Ssh $bootstrapScript
} else {
Write-Step "Updating $RemotePath to latest $Branch"
$updateScript = @"
set -euo pipefail
cd '$RemotePath'
git fetch origin '$Branch'
git checkout '$Branch'
git reset --hard 'origin/$Branch'
"@
Invoke-Ssh $updateScript
}
Write-Step "Uploading $EnvFile to $RemotePath/.env.production"
Invoke-Scp $envPath "$RemotePath/.env.production"
Invoke-Ssh "chmod 600 '$RemotePath/.env.production'"
$composeArgs = @(
"--env-file", ".env.production",
"-f", $ComposeFile
) -join " "
$buildFlag = if ($SkipBuild) { "" } else { "--build" }
Write-Step "Pulling base images"
Invoke-Ssh "cd '$RemotePath' && docker compose $composeArgs pull --ignore-pull-failures || true"
Write-Step "Bringing the stack up (build=$([bool](-not $SkipBuild)))"
Invoke-Ssh "cd '$RemotePath' && docker compose $composeArgs up -d $buildFlag --remove-orphans"
Write-Step "Waiting for backend healthcheck"
$healthScript = @"
set -e
cd '$RemotePath'
for attempt in `$(seq 1 30); do
status=`$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' lean101-clients-backend 2>/dev/null || echo missing)
case "`$status" in
healthy|running)
echo "backend is `$status"
exit 0;;
*)
printf '.'
sleep 4;;
esac
done
echo
echo 'backend did not become healthy in time' >&2
docker compose $composeArgs ps backend
exit 1
"@
Invoke-Ssh $healthScript
if ($Seed) {
Write-Step "Seeding reference data"
Invoke-Ssh "cd '$RemotePath' && docker compose $composeArgs exec -T backend python -m app.seed"
}
Write-Step "Final container status"
Invoke-Ssh "cd '$RemotePath' && docker compose $composeArgs ps"
if ($Logs) {
Write-Step "Recent logs (last 60 lines)"
Invoke-Ssh "cd '$RemotePath' && docker compose $composeArgs logs --tail=60"
}
Write-Host "Deployment complete." -ForegroundColor Green
}
finally {
Pop-Location
}
+2
View File
@@ -39,6 +39,8 @@ services:
PUBLIC_API_BASE_URL: ${PUBLIC_API_BASE_URL:-https://clients.lean-101.com.au} PUBLIC_API_BASE_URL: ${PUBLIC_API_BASE_URL:-https://clients.lean-101.com.au}
INTERNAL_API_BASE_URL: ${INTERNAL_API_BASE_URL:-http://backend:8000} INTERNAL_API_BASE_URL: ${INTERNAL_API_BASE_URL:-http://backend:8000}
PUBLIC_API_PORT: ${PUBLIC_API_PORT:-8000} PUBLIC_API_PORT: ${PUBLIC_API_PORT:-8000}
PUBLIC_MIX_CALCULATOR_SESSION_HISTORY: ${PUBLIC_MIX_CALCULATOR_SESSION_HISTORY:-false}
PUBLIC_MIX_CALCULATOR_SESSION_SAVE: ${PUBLIC_MIX_CALCULATOR_SESSION_SAVE:-false}
depends_on: depends_on:
backend: backend:
condition: service_healthy condition: service_healthy
+81
View File
@@ -0,0 +1,81 @@
services:
db:
container_name: lean101-clients-db
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-lean101}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
POSTGRES_DB: ${POSTGRES_DB:-lean101}
volumes:
- clients_db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-lean101} -d ${POSTGRES_DB:-lean101}"]
interval: 10s
timeout: 5s
retries: 10
start_period: 15s
backend:
container_name: lean101-clients-backend
build:
context: .
dockerfile: backend/Dockerfile
restart: unless-stopped
environment:
APP_NAME: ${APP_NAME:-Lean 101 Clients API}
DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg://${POSTGRES_USER:-lean101}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-lean101}}
CLIENT_NAME: ${CLIENT_NAME:-Hunter Premium Produce}
CLIENT_EMAIL: ${CLIENT_EMAIL:-operator@example.com}
CLIENT_PASSWORD: ${CLIENT_PASSWORD:?CLIENT_PASSWORD is required}
CLIENT_TENANT_ID: ${CLIENT_TENANT_ID:-hunter-premium-produce}
ADMIN_NAME: ${ADMIN_NAME:-Lean 101}
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@lean101.local}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD is required}
AUTH_SECRET: ${AUTH_SECRET:?AUTH_SECRET is required}
CORS_ALLOW_ORIGINS: ${CORS_ALLOW_ORIGINS:-https://clients.lean-101.com.au}
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')"]
interval: 30s
timeout: 5s
retries: 5
start_period: 25s
frontend:
container_name: lean101-clients-frontend
build:
context: .
dockerfile: frontend/Dockerfile
restart: unless-stopped
environment:
ORIGIN: ${ORIGIN:-https://clients.lean-101.com.au}
PORT: 3000
HOST: 0.0.0.0
PUBLIC_API_BASE_URL: ${PUBLIC_API_BASE_URL:-https://clients.lean-101.com.au}
INTERNAL_API_BASE_URL: ${INTERNAL_API_BASE_URL:-http://backend:8000}
PUBLIC_API_PORT: ${PUBLIC_API_PORT:-8000}
PUBLIC_MIX_CALCULATOR_SESSION_HISTORY: ${PUBLIC_MIX_CALCULATOR_SESSION_HISTORY:-false}
PUBLIC_MIX_CALCULATOR_SESSION_SAVE: ${PUBLIC_MIX_CALCULATOR_SESSION_SAVE:-false}
depends_on:
backend:
condition: service_healthy
nginx:
container_name: lean101-clients
image: nginx:1.27-alpine
restart: unless-stopped
depends_on:
frontend:
condition: service_started
backend:
condition: service_healthy
ports:
- "${CLIENTS_APP_PORT:-8081}:80"
volumes:
- ./deploy/nginx/clients.lean-101.conf:/etc/nginx/conf.d/default.conf:ro
volumes:
clients_db_data:
+2
View File
@@ -39,6 +39,8 @@ services:
PUBLIC_API_BASE_URL: ${PUBLIC_API_BASE_URL:-https://clients.lean-101.com.au} PUBLIC_API_BASE_URL: ${PUBLIC_API_BASE_URL:-https://clients.lean-101.com.au}
INTERNAL_API_BASE_URL: ${INTERNAL_API_BASE_URL:-http://backend:8000} INTERNAL_API_BASE_URL: ${INTERNAL_API_BASE_URL:-http://backend:8000}
PUBLIC_API_PORT: ${PUBLIC_API_PORT:-8000} PUBLIC_API_PORT: ${PUBLIC_API_PORT:-8000}
PUBLIC_MIX_CALCULATOR_SESSION_HISTORY: ${PUBLIC_MIX_CALCULATOR_SESSION_HISTORY:-false}
PUBLIC_MIX_CALCULATOR_SESSION_SAVE: ${PUBLIC_MIX_CALCULATOR_SESSION_SAVE:-false}
depends_on: depends_on:
backend: backend:
condition: service_healthy condition: service_healthy
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "data-entry-app-frontend", "name": "data-entry-app-frontend",
"version": "0.1.4", "version": "0.1.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "data-entry-app-frontend", "name": "data-entry-app-frontend",
"version": "0.1.4", "version": "0.1.5",
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^3.2.0", "@sveltejs/adapter-auto": "^3.2.0",
"@sveltejs/adapter-node": "^5.2.12", "@sveltejs/adapter-node": "^5.2.12",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "data-entry-app-frontend", "name": "data-entry-app-frontend",
"version": "0.1.4", "version": "0.1.5",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+39 -21
View File
@@ -3,8 +3,8 @@
import { invalidateAll } from '$app/navigation'; import { invalidateAll } from '$app/navigation';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/state'; import { page } from '$app/state';
import Lean101Logo from '$lib/components/Lean101Logo.svelte';
import { clientSession, hasModuleAccess, sessionHydrated } from '$lib/session'; import { clientSession, hasModuleAccess, sessionHydrated } from '$lib/session';
import { featureFlags } from '$lib/features';
import { onMount, tick } from 'svelte'; import { onMount, tick } from 'svelte';
import packageInfo from '../../../package.json'; import packageInfo from '../../../package.json';
@@ -24,7 +24,12 @@
}; };
const dashboardItem: NavItem = { href: '/', label: 'Dashboard', shortLabel: 'DB', icon: 'home', moduleKey: 'dashboard' }; const dashboardItem: NavItem = { href: '/', label: 'Dashboard', shortLabel: 'DB', icon: 'home', moduleKey: 'dashboard' };
const mixCalculatorItem: NavItem = { href: '/mix-calculator', label: 'Mix Calculator', shortLabel: 'MC', moduleKey: 'mix_calculator' }; const mixCalculatorItem: NavItem = {
href: featureFlags.mixCalculatorSessionHistory ? '/mix-calculator' : '/mix-calculator/new',
label: 'Mix Calculator',
shortLabel: 'MC',
moduleKey: 'mix_calculator'
};
const workingDocumentItems: NavItem[] = [ const workingDocumentItems: NavItem[] = [
{ href: '/raw-materials', label: 'Raw Materials', shortLabel: 'RM', moduleKey: 'raw_materials' }, { href: '/raw-materials', label: 'Raw Materials', shortLabel: 'RM', moduleKey: 'raw_materials' },
{ href: '/mixes', label: 'Mix Master', shortLabel: 'MM', moduleKey: 'mix_master' }, { href: '/mixes', label: 'Mix Master', shortLabel: 'MM', moduleKey: 'mix_master' },
@@ -64,12 +69,16 @@
description: 'Start a new costing worksheet for Hunter Premium Produce.', description: 'Start a new costing worksheet for Hunter Premium Produce.',
keywords: 'new mix create worksheet hunter premium produce formula' keywords: 'new mix create worksheet hunter premium produce formula'
}, },
{ ...(featureFlags.mixCalculatorSessionHistory
href: '/mix-calculator', ? [
label: 'Open Mix Calculator', {
description: 'Review saved production sessions and batch calculations.', href: '/mix-calculator',
keywords: 'mix calculator production sessions batch bags client product' label: 'Open Mix Calculator',
}, description: 'Review saved production sessions and batch calculations.',
keywords: 'mix calculator production sessions batch bags client product'
}
]
: []),
{ {
href: '/mix-calculator/new', href: '/mix-calculator/new',
label: 'Create Mix Calculation', label: 'Create Mix Calculation',
@@ -281,7 +290,9 @@
Promise.all([ Promise.all([
hasModuleAccess(session, 'products') ? api.products() : Promise.resolve([]), hasModuleAccess(session, 'products') ? api.products() : Promise.resolve([]),
hasModuleAccess(session, 'mix_master') ? api.mixes() : Promise.resolve([]), hasModuleAccess(session, 'mix_master') ? api.mixes() : Promise.resolve([]),
hasModuleAccess(session, 'mix_calculator') ? api.mixCalculatorSessions() : Promise.resolve([]) featureFlags.mixCalculatorSessionHistory && hasModuleAccess(session, 'mix_calculator')
? api.mixCalculatorSessions()
: Promise.resolve([])
]) ])
.then(([products, mixes, sessions]) => { .then(([products, mixes, sessions]) => {
if (seededSearchToken !== token) { if (seededSearchToken !== token) {
@@ -382,7 +393,7 @@
<aside class="sidebar"> <aside class="sidebar">
<div class="brand-row"> <div class="brand-row">
<a class="brand" href="/"> <a class="brand" href="/">
<Lean101Logo className="sidebar-logo" showTagline={false} /> <img class="sidebar-logo" src="/logo-hsf.png" alt="Hunter Premium Produce" />
</a> </a>
</div> </div>
@@ -421,7 +432,9 @@
{#if visibleMixCalculatorItem} {#if visibleMixCalculatorItem}
<a class:active={matchesRoute(visibleMixCalculatorItem.href, page.url.pathname)} href={visibleMixCalculatorItem.href}> <a class:active={matchesRoute(visibleMixCalculatorItem.href, page.url.pathname)} href={visibleMixCalculatorItem.href}>
<span class="nav-icon">{visibleMixCalculatorItem.shortLabel}</span> <span class="nav-icon">
<span class="nav-icon-mask" style="--nav-icon-url: url('/icons/calculator.svg');" aria-hidden="true"></span>
</span>
<span>{visibleMixCalculatorItem.label}</span> <span>{visibleMixCalculatorItem.label}</span>
</a> </a>
{/if} {/if}
@@ -560,7 +573,7 @@
<div class="menu-panel quick-fab-panel"> <div class="menu-panel quick-fab-panel">
<a href="/mixes">Open mix costing</a> <a href="/mixes">Open mix costing</a>
<a href="/mixes/new">Create mix worksheet</a> <a href="/mixes/new">Create mix worksheet</a>
<a href="/mix-calculator">Open mix calculator</a> <a href={featureFlags.mixCalculatorSessionHistory ? '/mix-calculator' : '/mix-calculator/new'}>Open mix calculator</a>
<a href="/mix-calculator/new">Create mix session</a> <a href="/mix-calculator/new">Create mix session</a>
<a href="/products">Review delivered pricing</a> <a href="/products">Review delivered pricing</a>
<button type="button" onclick={() => openPalette('')}>Search the workspace</button> <button type="button" onclick={() => openPalette('')}>Search the workspace</button>
@@ -687,7 +700,9 @@
{#if visibleMixCalculatorItem} {#if visibleMixCalculatorItem}
<a class:active={matchesRoute(visibleMixCalculatorItem.href, page.url.pathname)} href={visibleMixCalculatorItem.href} onclick={() => (navOpen = false)}> <a class:active={matchesRoute(visibleMixCalculatorItem.href, page.url.pathname)} href={visibleMixCalculatorItem.href} onclick={() => (navOpen = false)}>
<span class="nav-icon">{visibleMixCalculatorItem.shortLabel}</span> <span class="nav-icon">
<span class="nav-icon-mask" style="--nav-icon-url: url('/icons/calculator.svg');" aria-hidden="true"></span>
</span>
<span>{visibleMixCalculatorItem.label}</span> <span>{visibleMixCalculatorItem.label}</span>
</a> </a>
{/if} {/if}
@@ -918,16 +933,19 @@
letter-spacing: 0.04em; letter-spacing: 0.04em;
} }
.sidebar :global(.sidebar-logo) { .nav-icon-mask {
width: min(100%, 12.2rem); display: inline-block;
width: 60%;
height: 60%;
background-color: currentColor;
-webkit-mask: var(--nav-icon-url) center / contain no-repeat;
mask: var(--nav-icon-url) center / contain no-repeat;
} }
.sidebar :global(.sidebar-logo .logo-copy strong) { .sidebar .sidebar-logo {
font-size: 1.6rem; width: min(100%, 13.5rem);
} height: auto;
display: block;
.sidebar :global(.sidebar-logo .logo-mark) {
width: 2.6rem;
} }
.nav-toggle, .nav-toggle,
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { featureFlags } from '$lib/features';
import { clientSession, hasModuleAccess } from '$lib/session'; import { clientSession, hasModuleAccess } from '$lib/session';
import type { import type {
MixCalculatorCreateInput, MixCalculatorCreateInput,
@@ -169,7 +170,25 @@
} }
} }
async function saveSession(mode: 'update' | 'create') { function clearForm() {
clientName = options.clients[0] ?? '';
productId = 0;
mixDate = todayIso;
batchSizeKg = '';
preparedByName = $clientSession?.name ?? '';
notes = '';
preview = null;
formError = '';
formSuccess = '';
}
function printPreview() {
if (typeof window !== 'undefined') {
window.print();
}
}
async function saveSession(mode: 'update' | 'create', destination: 'detail' | 'print' = 'detail') {
const payload = buildPayload(); const payload = buildPayload();
if (!payload) { if (!payload) {
return; return;
@@ -182,7 +201,8 @@
? await api.updateMixCalculatorSession(initialSession.id, payload) ? await api.updateMixCalculatorSession(initialSession.id, payload)
: await api.createMixCalculatorSession(payload); : await api.createMixCalculatorSession(payload);
await goto(`/mix-calculator/${saved.id}`); const target = destination === 'print' ? `/mix-calculator/${saved.id}/print` : `/mix-calculator/${saved.id}`;
await goto(target);
} catch (error) { } catch (error) {
formError = error instanceof Error ? error.message : 'Unable to save the mix calculator session.'; formError = error instanceof Error ? error.message : 'Unable to save the mix calculator session.';
saveLoading = false; saveLoading = false;
@@ -195,17 +215,24 @@
<p class="eyebrow">Mix Calculator</p> <p class="eyebrow">Mix Calculator</p>
<h2>Edit access is required to create a new session.</h2> <h2>Edit access is required to create a new session.</h2>
<p>View-only users can open saved sessions from history, but cannot create or update production calculations.</p> <p>View-only users can open saved sessions from history, but cannot create or update production calculations.</p>
<a class="secondary-button" href="/mix-calculator">Back to session history</a> {#if featureFlags.mixCalculatorSessionHistory}
<a class="secondary-button" href="/mix-calculator">Back to session history</a>
{/if}
</section> </section>
{:else} {:else}
<section class="page-intro"> <section class="page-intro">
<div> <div>
<p class="eyebrow">Mix Calculator</p> <p class="eyebrow">
<span class="eyebrow-icon" style="--button-icon-url: url('/icons/calculator.svg');" aria-hidden="true"></span>
<span>Mix Calculator</span>
</p>
<h2>{isExistingSession ? 'Edit saved mix session' : 'New mix calculation session'}</h2> <h2>{isExistingSession ? 'Edit saved mix session' : 'New mix calculation session'}</h2>
<p>Scale a saved product mix by batch size, review required raw materials, then save the session for history and printing.</p> <p>Scale a saved product mix by batch size, review required raw materials, then save the session for history and printing.</p>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<a class="secondary-button" href="/mix-calculator">Session history</a> {#if featureFlags.mixCalculatorSessionHistory}
<a class="secondary-button" href="/mix-calculator">Session history</a>
{/if}
{#if initialSession} {#if initialSession}
<a class="secondary-button" href={`/mix-calculator/${initialSession.id}/print`}>Printable view</a> <a class="secondary-button" href={`/mix-calculator/${initialSession.id}/print`}>Printable view</a>
{/if} {/if}
@@ -288,18 +315,36 @@
{#if canEdit} {#if canEdit}
<div class="action-row"> <div class="action-row">
<button class="primary-button" disabled={previewLoading || saveLoading} type="button" onclick={calculatePreview}> <button class="primary-button" disabled={previewLoading || saveLoading} type="button" onclick={calculatePreview}>
{previewLoading ? 'Calculating...' : 'Calculate mix'} <span class="button-icon" style="--button-icon-url: url('/icons/calculator.svg');" aria-hidden="true"></span>
<span>{previewLoading ? 'Calculating...' : 'Calculate mix'}</span>
</button> </button>
<button class="secondary-button" disabled={saveLoading || previewLoading} type="button" onclick={() => saveSession(isExistingSession ? 'update' : 'create')}> {#if featureFlags.mixCalculatorSessionSave}
{saveLoading ? 'Saving...' : isExistingSession ? 'Save changes' : 'Save session'} <button class="secondary-button" disabled={saveLoading || previewLoading} type="button" onclick={() => saveSession(isExistingSession ? 'update' : 'create')}>
</button> {saveLoading ? 'Saving...' : isExistingSession ? 'Save changes' : 'Save session'}
</button>
{#if initialSession} <button class="secondary-button" disabled={saveLoading || previewLoading} type="button" onclick={() => saveSession(isExistingSession ? 'update' : 'create', 'print')}>
<button class="secondary-button" disabled={saveLoading || previewLoading} type="button" onclick={() => saveSession('create')}> <span class="button-icon" style="--button-icon-url: url('/icons/print.svg');" aria-hidden="true"></span>
Save as new <span>{saveLoading ? 'Saving...' : 'Save & print'}</span>
</button>
{#if initialSession}
<button class="secondary-button" disabled={saveLoading || previewLoading} type="button" onclick={() => saveSession('create')}>
Save as new
</button>
{/if}
{:else}
<button class="secondary-button" disabled={previewLoading || saveLoading || !preview} type="button" onclick={printPreview}>
<span class="button-icon" style="--button-icon-url: url('/icons/print.svg');" aria-hidden="true"></span>
<span>Print</span>
</button> </button>
{/if} {/if}
<button class="secondary-button" disabled={previewLoading || saveLoading} type="button" onclick={clearForm}>
<span class="button-icon" style="--button-icon-url: url('/icons/trash.svg');" aria-hidden="true"></span>
<span>Clear</span>
</button>
</div> </div>
{/if} {/if}
</article> </article>
@@ -396,6 +441,92 @@
{/if} {/if}
</article> </article>
</section> </section>
{#if preview}
<section class="print-only" aria-hidden="true">
<article class="print-sheet">
<header class="print-header">
<div>
<p class="print-eyebrow">Mix Calculator</p>
<h1>{preview.product_name}</h1>
<p class="print-subtitle">{preview.client_name} · {preview.mix_name}</p>
</div>
<div class="print-meta">
<div>
<span>Mix date</span>
<strong>{formatDate(preview.mix_date)}</strong>
</div>
<div>
<span>Prepared by</span>
<strong>{preview.prepared_by_name}</strong>
</div>
</div>
</header>
<section class="print-summary">
<div>
<span>Batch size</span>
<strong>{formatNumber(preview.batch_size_kg, 2)}kg</strong>
</div>
<div>
<span>Total kilograms</span>
<strong>{formatNumber(preview.total_kg, 2)}kg</strong>
</div>
<div>
<span>Total bags</span>
<strong>{formatNumber(preview.total_bags, 2)}</strong>
</div>
<div>
<span>Unit size</span>
<strong>{formatNumber(preview.product_unit_size_kg, 2)}kg</strong>
</div>
</section>
{#if preview.notes}
<section class="print-notes">
<h2>Notes</h2>
<p>{preview.notes}</p>
</section>
{/if}
{#if preview.warnings.length}
<section class="print-warnings">
<h2>Warnings</h2>
{#each preview.warnings as warning}
<p>{warning}</p>
{/each}
</section>
{/if}
<section class="print-table">
<div class="print-table-header">
<h2>Required raw materials</h2>
<span>{preview.product_unit_of_measure} · {formatNumber(preview.product_unit_size_kg, 2)}kg per unit</span>
</div>
<table>
<thead>
<tr>
<th>Raw material</th>
<th>Mix %</th>
<th>Required kg</th>
<th>Unit</th>
</tr>
</thead>
<tbody>
{#each preview.lines as line}
<tr>
<td>{line.raw_material_name}</td>
<td>{formatNumber(line.mix_percentage, 2)}%</td>
<td>{formatNumber(line.required_kg, 2)}kg</td>
<td>{line.unit}</td>
</tr>
{/each}
</tbody>
</table>
</section>
</article>
</section>
{/if}
{/if} {/if}
<style> <style>
@@ -411,6 +542,18 @@
font-weight: 600; font-weight: 600;
letter-spacing: 0.08em; letter-spacing: 0.08em;
text-transform: uppercase; text-transform: uppercase;
display: inline-flex;
align-items: center;
gap: 0.45rem;
}
.eyebrow-icon {
display: inline-block;
width: 0.95rem;
height: 0.95rem;
background-color: currentColor;
-webkit-mask: var(--button-icon-url) center / contain no-repeat;
mask: var(--button-icon-url) center / contain no-repeat;
} }
.page-intro, .page-intro,
@@ -593,6 +736,7 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.5rem;
padding: 0.78rem 0.96rem; padding: 0.78rem 0.96rem;
border-radius: 0.9rem; border-radius: 0.9rem;
border: 1px solid var(--line-strong); border: 1px solid var(--line-strong);
@@ -632,6 +776,16 @@
box-shadow: 0 10px 22px rgba(24, 38, 29, 0.08); box-shadow: 0 10px 22px rgba(24, 38, 29, 0.08);
} }
.button-icon {
display: inline-block;
width: 1rem;
height: 1rem;
background-color: currentColor;
-webkit-mask: var(--button-icon-url) center / contain no-repeat;
mask: var(--button-icon-url) center / contain no-repeat;
flex-shrink: 0;
}
button:disabled { button:disabled {
cursor: wait; cursor: wait;
opacity: 0.7; opacity: 0.7;
@@ -781,4 +935,151 @@
text-transform: uppercase; text-transform: uppercase;
} }
} }
.print-only {
display: none;
}
@media print {
:global(body) {
background: #fff !important;
margin: 0 !important;
}
:global(body *) {
visibility: hidden !important;
}
.print-only,
.print-only :global(*) {
visibility: visible !important;
}
.print-only {
display: block;
position: absolute;
inset: 0;
padding: 1.4cm;
background: #fff;
color: #1a2421;
font-family: inherit;
}
.print-sheet {
width: 100%;
}
.print-header {
display: flex;
justify-content: space-between;
gap: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid #cbd6cf;
}
.print-header h1 {
margin: 0.25rem 0 0.3rem;
font-size: 1.7rem;
}
.print-eyebrow {
color: #5f6f67;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
margin: 0;
}
.print-subtitle {
color: #5f6f67;
margin: 0;
}
.print-meta {
display: grid;
gap: 0.55rem;
min-width: 12rem;
}
.print-meta div,
.print-summary div {
display: grid;
gap: 0.1rem;
}
.print-meta span,
.print-summary span,
.print-table-header span {
color: #5f6f67;
font-size: 0.72rem;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.print-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.85rem;
padding: 1rem 0;
border-bottom: 1px solid #cbd6cf;
}
.print-notes,
.print-warnings {
margin-top: 0.85rem;
padding: 0.75rem 0.9rem;
border: 1px solid #cbd6cf;
border-radius: 0.5rem;
}
.print-warnings {
border-color: #d8a76b;
background: #fff6e6;
color: #8b5b1e;
}
.print-notes h2,
.print-warnings h2,
.print-table-header h2 {
margin: 0 0 0.35rem;
font-size: 0.95rem;
}
.print-table {
margin-top: 1rem;
}
.print-table-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.5rem;
}
.print-table table {
width: 100%;
border-collapse: collapse;
}
.print-table th,
.print-table td {
padding: 0.55rem 0.5rem;
text-align: left;
border-bottom: 1px solid #cbd6cf;
font-size: 0.92rem;
}
.print-table th {
color: #5f6f67;
font-size: 0.72rem;
letter-spacing: 0.06em;
text-transform: uppercase;
}
@page {
margin: 1cm;
}
}
</style> </style>
+13
View File
@@ -0,0 +1,13 @@
import { env } from '$env/dynamic/public';
function flagEnabled(value: string | undefined, defaultValue: boolean): boolean {
if (value === undefined || value === null || value.trim() === '') {
return defaultValue;
}
return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase());
}
export const featureFlags = {
mixCalculatorSessionHistory: flagEnabled(env.PUBLIC_MIX_CALCULATOR_SESSION_HISTORY, false),
mixCalculatorSessionSave: flagEnabled(env.PUBLIC_MIX_CALCULATOR_SESSION_SAVE, false)
};
+6 -6
View File
@@ -303,7 +303,7 @@
<div class="auth-card auth-card-loading"> <div class="auth-card auth-card-loading">
<div class="auth-header"> <div class="auth-header">
<div class="client-logo-block"> <div class="client-logo-block">
<img class="hero-login-logo" src="/lean101-login-logo.png" alt="Lean 101" /> <img class="hero-login-logo" src="/logo-hsf.png" alt="Lean 101" />
<div class="client-logo-copy"> <div class="client-logo-copy">
<p class="eyebrow">Client Workspace</p> <p class="eyebrow">Client Workspace</p>
<strong>Hunter Premium Produce</strong> <strong>Hunter Premium Produce</strong>
@@ -327,7 +327,7 @@
<div class="auth-footer"> <div class="auth-footer">
<div class="lean-brand"> <div class="lean-brand">
<img class="footer-login-logo" src="/lean101-login-logo.png" alt="Lean 101" /> <img class="footer-login-logo" src="/logo-hsf.png" alt="Lean 101" />
</div> </div>
<div class="auth-meta"> <div class="auth-meta">
<span class="version-badge"> <span class="version-badge">
@@ -344,7 +344,7 @@
<div class="auth-card auth-card-login"> <div class="auth-card auth-card-login">
<div class="auth-header"> <div class="auth-header">
<div class="client-logo-block"> <div class="client-logo-block">
<img class="hero-login-logo" src="/lean101-login-logo.png" alt="Lean 101" /> <img class="hero-login-logo" src="/logo-hsf.png" alt="Lean 101" />
<div class="client-logo-copy"> <div class="client-logo-copy">
<p class="eyebrow">Client Sign-In</p> <p class="eyebrow">Client Sign-In</p>
<strong>Hunter Premium Produce</strong> <strong>Hunter Premium Produce</strong>
@@ -389,7 +389,7 @@
<div class="auth-footer"> <div class="auth-footer">
<div class="lean-brand"> <div class="lean-brand">
<img class="footer-login-logo" src="/lean101-login-logo.png" alt="Lean 101" /> <img class="footer-login-logo" src="/logo-hsf.png" alt="Lean 101" />
</div> </div>
<div class="auth-meta"> <div class="auth-meta">
<span class="version-badge"> <span class="version-badge">
@@ -798,13 +798,13 @@
} }
.hero-login-logo { .hero-login-logo {
width: min(100%, 19rem); width: min(100%, 24rem);
height: auto; height: auto;
display: block; display: block;
} }
.footer-login-logo { .footer-login-logo {
width: 9.8rem; width: 12rem;
height: auto; height: auto;
display: block; display: block;
} }
@@ -1,7 +1,13 @@
import { redirect } from '@sveltejs/kit';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { featureFlags } from '$lib/features';
import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session'; import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session';
export async function load({ fetch }) { export async function load({ fetch }) {
if (!featureFlags.mixCalculatorSessionHistory) {
throw redirect(307, '/mix-calculator/new');
}
if (!hasStoredClientSession()) { if (!hasStoredClientSession()) {
return { return {
sessions: [] sessions: []
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import MixCalculatorWorkspace from '$lib/components/MixCalculatorWorkspace.svelte'; import MixCalculatorWorkspace from '$lib/components/MixCalculatorWorkspace.svelte';
import { featureFlags } from '$lib/features';
let { data } = $props(); let { data } = $props();
</script> </script>
@@ -11,7 +12,11 @@
<p class="eyebrow">Mix Calculator</p> <p class="eyebrow">Mix Calculator</p>
<h2>Session unavailable.</h2> <h2>Session unavailable.</h2>
<p>The requested mix calculator session could not be loaded with the current access scope.</p> <p>The requested mix calculator session could not be loaded with the current access scope.</p>
<a class="secondary-button" href="/mix-calculator">Back to session history</a> {#if featureFlags.mixCalculatorSessionHistory}
<a class="secondary-button" href="/mix-calculator">Back to session history</a>
{:else}
<a class="secondary-button" href="/mix-calculator/new">New mix session</a>
{/if}
</section> </section>
{/if} {/if}
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import MixCalculatorPrintSheet from '$lib/components/MixCalculatorPrintSheet.svelte'; import MixCalculatorPrintSheet from '$lib/components/MixCalculatorPrintSheet.svelte';
import { featureFlags } from '$lib/features';
let { data } = $props(); let { data } = $props();
</script> </script>
@@ -11,7 +12,11 @@
<p class="eyebrow">Mix Calculator</p> <p class="eyebrow">Mix Calculator</p>
<h2>Printable session unavailable.</h2> <h2>Printable session unavailable.</h2>
<p>The saved session could not be loaded for printing.</p> <p>The saved session could not be loaded for printing.</p>
<a class="secondary-button" href="/mix-calculator">Back to session history</a> {#if featureFlags.mixCalculatorSessionHistory}
<a class="secondary-button" href="/mix-calculator">Back to session history</a>
{:else}
<a class="secondary-button" href="/mix-calculator/new">New mix session</a>
{/if}
</section> </section>
{/if} {/if}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!--! Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2024 Fonticons, Inc. --><path d="M0 64C0 28.7 28.7 0 64 0L320 0c35.3 0 64 28.7 64 64l0 384c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64L0 64zM96 80l0 64c0 8.8 7.2 16 16 16l160 0c8.8 0 16-7.2 16-16l0-64c0-8.8-7.2-16-16-16L112 64c-8.8 0-16 7.2-16 16zm32 128a32 32 0 1 0 -64 0 32 32 0 1 0 64 0zm64 32a32 32 0 1 0 0-64 32 32 0 1 0 0 64zm96-32a32 32 0 1 0 -64 0 32 32 0 1 0 64 0zM64 304a32 32 0 1 0 64 0 32 32 0 1 0 -64 0zm32 128a32 32 0 1 0 0-64 32 32 0 1 0 0 64zm64-128a32 32 0 1 0 64 0 32 32 0 1 0 -64 0zm32 128a32 32 0 1 0 0-64 32 32 0 1 0 0 64zm64-96l0 96c0 17.7 14.3 32 32 32s32-14.3 32-32l0-96c0-17.7-14.3-32-32-32s-32 14.3-32 32z"/></svg>

After

Width:  |  Height:  |  Size: 895 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2024 Fonticons, Inc. --><path d="M128 0C92.7 0 64 28.7 64 64l0 96 64 0 0-96 226.7 0L384 93.3l0 66.7 64 0 0-66.7c0-17-6.7-33.3-18.7-45.3L400 18.7C388 6.7 371.7 0 354.7 0L128 0zM384 352l0 32 0 64-256 0 0-64 0-16 0-16 256 0zm64 32l32 0c17.7 0 32-14.3 32-32l0-96c0-35.3-28.7-64-64-64L64 192c-35.3 0-64 28.7-64 64l0 96c0 17.7 14.3 32 32 32l32 0 0 64c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-64zM432 248a24 24 0 1 1 0 48 24 24 0 1 1 0-48z"/></svg>

After

Width:  |  Height:  |  Size: 699 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2024 Fonticons, Inc. --><path d="M135.2 17.7L128 32 32 32C14.3 32 0 46.3 0 64S14.3 96 32 96l384 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-96 0-7.2-14.3C307.4 6.8 296.3 0 284.2 0L163.8 0c-12.1 0-23.2 6.8-28.6 17.7zM416 128L32 128 53.2 467c1.6 25.3 22.6 45 47.9 45l245.8 0c25.3 0 46.3-19.7 47.9-45L416 128z"/></svg>

After

Width:  |  Height:  |  Size: 559 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 693 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 693 KiB