Compare commits
3 commits
a2500a919b
...
54903a3bed
| Author | SHA1 | Date | |
|---|---|---|---|
| 54903a3bed | |||
| d938c9999b | |||
| d62812274b |
6 changed files with 352 additions and 52 deletions
|
|
@ -33,7 +33,7 @@ API docs available at `http://localhost:8000/docs`.
|
||||||
|
|
||||||
## Frontend quick start
|
## Frontend quick start
|
||||||
|
|
||||||
**Requirements:** Node.js 22+, [pnpm](https://pnpm.io/)
|
**Requirements:** Node.js 24 LTS+, [pnpm](https://pnpm.io/)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
|
|
|
||||||
|
|
@ -356,12 +356,21 @@ def parse_product_text(data: ProductParseRequest) -> ProductParseResponse:
|
||||||
config=genai_types.GenerateContentConfig(
|
config=genai_types.GenerateContentConfig(
|
||||||
system_instruction=_product_parse_system_prompt(),
|
system_instruction=_product_parse_system_prompt(),
|
||||||
response_mime_type="application/json",
|
response_mime_type="application/json",
|
||||||
max_output_tokens=4096,
|
max_output_tokens=8192,
|
||||||
temperature=0.0,
|
temperature=0.0,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
raw = response.text
|
||||||
|
if not raw:
|
||||||
|
raise HTTPException(status_code=502, detail="LLM returned an empty response")
|
||||||
|
# Fallback: extract JSON object in case the model adds preamble or markdown fences
|
||||||
|
if not raw.lstrip().startswith("{"):
|
||||||
|
start = raw.find("{")
|
||||||
|
end = raw.rfind("}")
|
||||||
|
if start != -1 and end != -1:
|
||||||
|
raw = raw[start : end + 1]
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(response.text)
|
parsed = json.loads(raw)
|
||||||
except (json.JSONDecodeError, Exception) as e:
|
except (json.JSONDecodeError, Exception) as e:
|
||||||
raise HTTPException(status_code=502, detail=f"LLM returned invalid JSON: {e}")
|
raise HTTPException(status_code=502, detail=f"LLM returned invalid JSON: {e}")
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -53,23 +53,43 @@ pct enter 200 # or SSH into the container
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
apt update && apt upgrade -y
|
apt update && apt upgrade -y
|
||||||
apt install -y git nginx curl ca-certificates gnupg lsb-release
|
apt install -y git nginx curl ca-certificates gnupg lsb-release libpq5
|
||||||
```
|
```
|
||||||
|
|
||||||
### Python 3.12+ + uv
|
### Python 3.12+ + uv
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
apt install -y python3 python3-venv
|
apt install -y python3 python3-venv
|
||||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
curl -LsSf https://astral.sh/uv/install.sh | UV_INSTALL_DIR=/usr/local/bin sh
|
||||||
source $HOME/.local/bin/env # or re-login
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Node.js 22 + pnpm
|
Installing to `/usr/local/bin` makes `uv` available system-wide (required for `sudo -u innercontext uv sync`).
|
||||||
|
|
||||||
|
### Node.js 24 LTS + pnpm
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash
|
||||||
apt install -y nodejs
|
. "$HOME/.nvm/nvm.sh"
|
||||||
npm install -g pnpm
|
nvm install 24
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy `node` to `/usr/local/bin` so it is accessible system-wide
|
||||||
|
(required for `sudo -u innercontext` and for systemd).
|
||||||
|
Symlinking into `/root/.nvm/` won't work — other users can't traverse `/root/`.
|
||||||
|
Use `--remove-destination` to replace any existing symlink with a real file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp --remove-destination "$(nvm which current)" /usr/local/bin/node
|
||||||
|
```
|
||||||
|
|
||||||
|
Install pnpm as a standalone binary from GitHub releases — self-contained,
|
||||||
|
no wrapper scripts, works system-wide. Do **not** use `corepack enable pnpm`
|
||||||
|
(the shim requires its nvm directory structure and breaks when copied/linked):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL "https://github.com/pnpm/pnpm/releases/latest/download/pnpm-linux-x64" \
|
||||||
|
-o /usr/local/bin/pnpm
|
||||||
|
chmod 755 /usr/local/bin/pnpm
|
||||||
```
|
```
|
||||||
|
|
||||||
### Application user
|
### Application user
|
||||||
|
|
@ -132,6 +152,8 @@ sudo -u innercontext uv sync
|
||||||
```bash
|
```bash
|
||||||
cat > /opt/innercontext/backend/.env <<'EOF'
|
cat > /opt/innercontext/backend/.env <<'EOF'
|
||||||
DATABASE_URL=postgresql+psycopg://innercontext:change-me@<pg-lxc-ip>/innercontext
|
DATABASE_URL=postgresql+psycopg://innercontext:change-me@<pg-lxc-ip>/innercontext
|
||||||
|
GEMINI_API_KEY=your-gemini-api-key
|
||||||
|
# GEMINI_MODEL=gemini-flash-latest # optional, this is the default
|
||||||
EOF
|
EOF
|
||||||
chmod 600 /opt/innercontext/backend/.env
|
chmod 600 /opt/innercontext/backend/.env
|
||||||
chown innercontext:innercontext /opt/innercontext/backend/.env
|
chown innercontext:innercontext /opt/innercontext/backend/.env
|
||||||
|
|
@ -183,6 +205,7 @@ cd /opt/innercontext/frontend
|
||||||
```bash
|
```bash
|
||||||
cat > /opt/innercontext/frontend/.env.production <<'EOF'
|
cat > /opt/innercontext/frontend/.env.production <<'EOF'
|
||||||
PUBLIC_API_BASE=http://innercontext.lan/api
|
PUBLIC_API_BASE=http://innercontext.lan/api
|
||||||
|
ORIGIN=http://innercontext.lan
|
||||||
EOF
|
EOF
|
||||||
chmod 600 /opt/innercontext/frontend/.env.production
|
chmod 600 /opt/innercontext/frontend/.env.production
|
||||||
chown innercontext:innercontext /opt/innercontext/frontend/.env.production
|
chown innercontext:innercontext /opt/innercontext/frontend/.env.production
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { createSkinSnapshot, getSkinSnapshots } from '$lib/api';
|
import { createSkinSnapshot, deleteSkinSnapshot, getSkinSnapshots, updateSkinSnapshot } from '$lib/api';
|
||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
|
@ -50,5 +50,59 @@ export const actions: Actions = {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(500, { error: (e as Error).message });
|
return fail(500, { error: (e as Error).message });
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async ({ request }) => {
|
||||||
|
const form = await request.formData();
|
||||||
|
const id = form.get('id') as string;
|
||||||
|
const snapshot_date = form.get('snapshot_date') as string;
|
||||||
|
const overall_state = form.get('overall_state') as string;
|
||||||
|
const texture = form.get('texture') as string;
|
||||||
|
const notes = form.get('notes') as string;
|
||||||
|
const hydration_level = form.get('hydration_level') as string;
|
||||||
|
const sensitivity_level = form.get('sensitivity_level') as string;
|
||||||
|
const barrier_state = form.get('barrier_state') as string;
|
||||||
|
const active_concerns_raw = form.get('active_concerns') as string;
|
||||||
|
const skin_type = form.get('skin_type') as string;
|
||||||
|
const sebum_tzone = form.get('sebum_tzone') as string;
|
||||||
|
const sebum_cheeks = form.get('sebum_cheeks') as string;
|
||||||
|
|
||||||
|
if (!id) return fail(400, { error: 'Missing id' });
|
||||||
|
|
||||||
|
const active_concerns = active_concerns_raw
|
||||||
|
?.split(',')
|
||||||
|
.map((c) => c.trim())
|
||||||
|
.filter(Boolean) ?? [];
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = { active_concerns };
|
||||||
|
if (snapshot_date) body.snapshot_date = snapshot_date;
|
||||||
|
if (overall_state) body.overall_state = overall_state;
|
||||||
|
if (texture) body.texture = texture;
|
||||||
|
if (notes) body.notes = notes;
|
||||||
|
if (hydration_level) body.hydration_level = Number(hydration_level);
|
||||||
|
if (sensitivity_level) body.sensitivity_level = Number(sensitivity_level);
|
||||||
|
if (barrier_state) body.barrier_state = barrier_state;
|
||||||
|
if (skin_type) body.skin_type = skin_type;
|
||||||
|
if (sebum_tzone) body.sebum_tzone = Number(sebum_tzone);
|
||||||
|
if (sebum_cheeks) body.sebum_cheeks = Number(sebum_cheeks);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateSkinSnapshot(id, body);
|
||||||
|
return { updated: true };
|
||||||
|
} catch (e) {
|
||||||
|
return fail(500, { error: (e as Error).message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async ({ request }) => {
|
||||||
|
const form = await request.formData();
|
||||||
|
const id = form.get('id') as string;
|
||||||
|
if (!id) return fail(400, { error: 'Missing id' });
|
||||||
|
try {
|
||||||
|
await deleteSkinSnapshot(id);
|
||||||
|
return { deleted: true };
|
||||||
|
} catch (e) {
|
||||||
|
return fail(500, { error: (e as Error).message });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
|
|
||||||
let showForm = $state(false);
|
let showForm = $state(false);
|
||||||
|
|
||||||
// Form state (bound to inputs so AI can pre-fill)
|
// Create form state (bound to inputs so AI can pre-fill)
|
||||||
let snapshotDate = $state(new Date().toISOString().slice(0, 10));
|
let snapshotDate = $state(new Date().toISOString().slice(0, 10));
|
||||||
let overallState = $state('');
|
let overallState = $state('');
|
||||||
let texture = $state('');
|
let texture = $state('');
|
||||||
|
|
@ -38,6 +38,36 @@
|
||||||
let activeConcernsRaw = $state('');
|
let activeConcernsRaw = $state('');
|
||||||
let notes = $state('');
|
let notes = $state('');
|
||||||
|
|
||||||
|
// Edit state
|
||||||
|
let editingId = $state<string | null>(null);
|
||||||
|
let editSnapshotDate = $state('');
|
||||||
|
let editOverallState = $state('');
|
||||||
|
let editTexture = $state('');
|
||||||
|
let editBarrierState = $state('');
|
||||||
|
let editSkinType = $state('');
|
||||||
|
let editHydrationLevel = $state('');
|
||||||
|
let editSensitivityLevel = $state('');
|
||||||
|
let editSebumTzone = $state('');
|
||||||
|
let editSebumCheeks = $state('');
|
||||||
|
let editActiveConcernsRaw = $state('');
|
||||||
|
let editNotes = $state('');
|
||||||
|
|
||||||
|
function startEdit(snap: (typeof data.snapshots)[number]) {
|
||||||
|
editingId = snap.id;
|
||||||
|
editSnapshotDate = snap.snapshot_date;
|
||||||
|
editOverallState = snap.overall_state ?? '';
|
||||||
|
editTexture = snap.texture ?? '';
|
||||||
|
editBarrierState = snap.barrier_state ?? '';
|
||||||
|
editSkinType = snap.skin_type ?? '';
|
||||||
|
editHydrationLevel = snap.hydration_level != null ? String(snap.hydration_level) : '';
|
||||||
|
editSensitivityLevel = snap.sensitivity_level != null ? String(snap.sensitivity_level) : '';
|
||||||
|
editSebumTzone = snap.sebum_tzone != null ? String(snap.sebum_tzone) : '';
|
||||||
|
editSebumCheeks = snap.sebum_cheeks != null ? String(snap.sebum_cheeks) : '';
|
||||||
|
editActiveConcernsRaw = snap.active_concerns?.join(', ') ?? '';
|
||||||
|
editNotes = snap.notes ?? '';
|
||||||
|
showForm = false;
|
||||||
|
}
|
||||||
|
|
||||||
// AI photo analysis state
|
// AI photo analysis state
|
||||||
let aiPanelOpen = $state(false);
|
let aiPanelOpen = $state(false);
|
||||||
let selectedFiles = $state<File[]>([]);
|
let selectedFiles = $state<File[]>([]);
|
||||||
|
|
@ -101,6 +131,12 @@
|
||||||
{#if form?.created}
|
{#if form?.created}
|
||||||
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">Snapshot added.</div>
|
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">Snapshot added.</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if form?.updated}
|
||||||
|
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">Snapshot updated.</div>
|
||||||
|
{/if}
|
||||||
|
{#if form?.deleted}
|
||||||
|
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">Snapshot deleted.</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if showForm}
|
{#if showForm}
|
||||||
<!-- AI photo analysis card -->
|
<!-- AI photo analysis card -->
|
||||||
|
|
@ -284,52 +320,230 @@
|
||||||
{#each sortedSnapshots as snap (snap.id)}
|
{#each sortedSnapshots as snap (snap.id)}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent class="pt-4">
|
<CardContent class="pt-4">
|
||||||
<div class="flex items-center justify-between mb-3">
|
{#if editingId === snap.id}
|
||||||
<span class="font-medium">{snap.snapshot_date}</span>
|
<!-- Inline edit form -->
|
||||||
<div class="flex gap-2">
|
<form
|
||||||
{#if snap.overall_state}
|
method="POST"
|
||||||
<span
|
action="?/update"
|
||||||
class="rounded-full px-2 py-0.5 text-xs font-medium {stateColors[
|
use:enhance={() => async ({ result, update }) => {
|
||||||
snap.overall_state
|
await update();
|
||||||
] ?? ''}"
|
if (result.type === 'success') editingId = null;
|
||||||
|
}}
|
||||||
|
class="grid grid-cols-2 gap-4"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={snap.id} />
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="edit_snapshot_date">Date *</Label>
|
||||||
|
<Input
|
||||||
|
id="edit_snapshot_date"
|
||||||
|
name="snapshot_date"
|
||||||
|
type="date"
|
||||||
|
bind:value={editSnapshotDate}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label>Overall state</Label>
|
||||||
|
<input type="hidden" name="overall_state" value={editOverallState} />
|
||||||
|
<Select
|
||||||
|
type="single"
|
||||||
|
value={editOverallState}
|
||||||
|
onValueChange={(v) => (editOverallState = v)}
|
||||||
>
|
>
|
||||||
{snap.overall_state}
|
<SelectTrigger>{editOverallState || 'Select'}</SelectTrigger>
|
||||||
</span>
|
<SelectContent>
|
||||||
|
{#each states as s (s)}
|
||||||
|
<SelectItem value={s}>{s}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label>Texture</Label>
|
||||||
|
<input type="hidden" name="texture" value={editTexture} />
|
||||||
|
<Select
|
||||||
|
type="single"
|
||||||
|
value={editTexture}
|
||||||
|
onValueChange={(v) => (editTexture = v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>{editTexture || 'Select'}</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{#each skinTextures as t (t)}
|
||||||
|
<SelectItem value={t}>{t}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label>Skin type</Label>
|
||||||
|
<input type="hidden" name="skin_type" value={editSkinType} />
|
||||||
|
<Select
|
||||||
|
type="single"
|
||||||
|
value={editSkinType}
|
||||||
|
onValueChange={(v) => (editSkinType = v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
>{editSkinType ? editSkinType.replace(/_/g, ' ') : 'Select'}</SelectTrigger
|
||||||
|
>
|
||||||
|
<SelectContent>
|
||||||
|
{#each skinTypes as st (st)}
|
||||||
|
<SelectItem value={st}>{st.replace(/_/g, ' ')}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label>Barrier state</Label>
|
||||||
|
<input type="hidden" name="barrier_state" value={editBarrierState} />
|
||||||
|
<Select
|
||||||
|
type="single"
|
||||||
|
value={editBarrierState}
|
||||||
|
onValueChange={(v) => (editBarrierState = v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
>{editBarrierState
|
||||||
|
? editBarrierState.replace(/_/g, ' ')
|
||||||
|
: 'Select'}</SelectTrigger
|
||||||
|
>
|
||||||
|
<SelectContent>
|
||||||
|
{#each barrierStates as b (b)}
|
||||||
|
<SelectItem value={b}>{b.replace(/_/g, ' ')}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="edit_hydration_level">Hydration (1–5)</Label>
|
||||||
|
<Input
|
||||||
|
id="edit_hydration_level"
|
||||||
|
name="hydration_level"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="5"
|
||||||
|
bind:value={editHydrationLevel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="edit_sensitivity_level">Sensitivity (1–5)</Label>
|
||||||
|
<Input
|
||||||
|
id="edit_sensitivity_level"
|
||||||
|
name="sensitivity_level"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="5"
|
||||||
|
bind:value={editSensitivityLevel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="edit_sebum_tzone">Sebum T-zone (1–5)</Label>
|
||||||
|
<Input
|
||||||
|
id="edit_sebum_tzone"
|
||||||
|
name="sebum_tzone"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="5"
|
||||||
|
bind:value={editSebumTzone}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Label for="edit_sebum_cheeks">Sebum cheeks (1–5)</Label>
|
||||||
|
<Input
|
||||||
|
id="edit_sebum_cheeks"
|
||||||
|
name="sebum_cheeks"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="5"
|
||||||
|
bind:value={editSebumCheeks}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1 col-span-2">
|
||||||
|
<Label for="edit_active_concerns">Active concerns (comma-separated)</Label>
|
||||||
|
<Input
|
||||||
|
id="edit_active_concerns"
|
||||||
|
name="active_concerns"
|
||||||
|
placeholder="acne, redness, dehydration"
|
||||||
|
bind:value={editActiveConcernsRaw}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1 col-span-2">
|
||||||
|
<Label for="edit_notes">Notes</Label>
|
||||||
|
<Input id="edit_notes" name="notes" bind:value={editNotes} />
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2 flex gap-2">
|
||||||
|
<Button type="submit">Save</Button>
|
||||||
|
<Button type="button" variant="outline" onclick={() => (editingId = null)}
|
||||||
|
>Cancel</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<!-- Read view -->
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<span class="font-medium">{snap.snapshot_date}</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{#if snap.overall_state}
|
||||||
|
<span
|
||||||
|
class="rounded-full px-2 py-0.5 text-xs font-medium {stateColors[
|
||||||
|
snap.overall_state
|
||||||
|
] ?? ''}"
|
||||||
|
>
|
||||||
|
{snap.overall_state}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if snap.texture}
|
||||||
|
<Badge variant="secondary">{snap.texture}</Badge>
|
||||||
|
{/if}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => startEdit(snap)}
|
||||||
|
class="h-7 px-2 text-xs"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<form method="POST" action="?/delete" use:enhance>
|
||||||
|
<input type="hidden" name="id" value={snap.id} />
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-7 px-2 text-xs text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-3 gap-3 text-sm mb-3">
|
||||||
|
{#if snap.hydration_level != null}
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-muted-foreground">Hydration</p>
|
||||||
|
<p class="font-medium">{snap.hydration_level}/5</p>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if snap.texture}
|
{#if snap.sensitivity_level != null}
|
||||||
<Badge variant="secondary">{snap.texture}</Badge>
|
<div>
|
||||||
|
<p class="text-xs text-muted-foreground">Sensitivity</p>
|
||||||
|
<p class="font-medium">{snap.sensitivity_level}/5</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if snap.barrier_state}
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-muted-foreground">Barrier</p>
|
||||||
|
<p class="font-medium">{snap.barrier_state.replace(/_/g, ' ')}</p>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{#if snap.active_concerns.length}
|
||||||
<div class="grid grid-cols-3 gap-3 text-sm mb-3">
|
<div class="flex flex-wrap gap-1">
|
||||||
{#if snap.hydration_level != null}
|
{#each snap.active_concerns as c (c)}
|
||||||
<div>
|
<Badge variant="secondary" class="text-xs">{c.replace(/_/g, ' ')}</Badge>
|
||||||
<p class="text-xs text-muted-foreground">Hydration</p>
|
{/each}
|
||||||
<p class="font-medium">{snap.hydration_level}/5</p>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if snap.sensitivity_level != null}
|
{#if snap.notes}
|
||||||
<div>
|
<p class="mt-2 text-sm text-muted-foreground">{snap.notes}</p>
|
||||||
<p class="text-xs text-muted-foreground">Sensitivity</p>
|
|
||||||
<p class="font-medium">{snap.sensitivity_level}/5</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
{#if snap.barrier_state}
|
|
||||||
<div>
|
|
||||||
<p class="text-xs text-muted-foreground">Barrier</p>
|
|
||||||
<p class="font-medium">{snap.barrier_state.replace(/_/g, ' ')}</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if snap.active_concerns.length}
|
|
||||||
<div class="flex flex-wrap gap-1">
|
|
||||||
{#each snap.active_concerns as c (c)}
|
|
||||||
<Badge variant="secondary" class="text-xs">{c.replace(/_/g, ' ')}</Badge>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if snap.notes}
|
|
||||||
<p class="mt-2 text-sm text-muted-foreground">{snap.notes}</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ WorkingDirectory=/opt/innercontext/frontend
|
||||||
Environment=PORT=3000
|
Environment=PORT=3000
|
||||||
Environment=HOST=127.0.0.1
|
Environment=HOST=127.0.0.1
|
||||||
EnvironmentFile=/opt/innercontext/frontend/.env.production
|
EnvironmentFile=/opt/innercontext/frontend/.env.production
|
||||||
ExecStart=/usr/bin/node /opt/innercontext/frontend/build/index.js
|
ExecStart=/usr/local/bin/node /opt/innercontext/frontend/build/index.js
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue