Add avatar upload to Settings, document Settings page, update README
- Profile section: click avatar to upload a personal photo (FileReader preview, hover camera icon overlay), replacing static initials - README.md rewritten with project-specific page status table, dev setup, tech stack, and deployment notes - design-decisions.md: add Settings Page subsection documenting layout, section-switching, and the avatar upload technique Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
This commit is contained in:
parent
e386e327b4
commit
f24edb74b2
3 changed files with 90 additions and 66 deletions
90
README.md
90
README.md
|
|
@ -1,73 +1,39 @@
|
||||||
# React + TypeScript + Vite
|
# ArchNest
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
A self-hosted ops dashboard — infrastructure monitoring, a bookmark hub for your homelab/cloud links, an embedded terminal, and system settings, all in one place.
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
Built with React 19 + TypeScript + Vite, styled with Tailwind CSS v4, charts via Recharts, icons via Lucide React.
|
||||||
|
|
||||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
## Pages
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
|
||||||
|
|
||||||
## React Compiler
|
| Page | Route | Status |
|
||||||
|
|------|-------|--------|
|
||||||
|
| Glance | `/` | Done — main ops dashboard (system status, resource overview, alerts, network traffic) |
|
||||||
|
| Infrastructure | `/infrastructure` | Done — resource distribution, node status grid, cost/trend breakdown. "Network" sub-tab planned as a future addition. |
|
||||||
|
| BookNest | `/booknest` | Done — categorized bookmark hub with quick access, favorites, link health, and category breakdown |
|
||||||
|
| Terminal | `/terminal` | Pending — will be based on a fork of the (archived) Termix project, not yet merged in |
|
||||||
|
| Settings | `/settings` | Done — Profile (incl. avatar upload), Appearance, Integrations, Notifications, Data & Backup, About |
|
||||||
|
|
||||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
See `archnest-blueprint.md` for the original per-page design spec and `design-decisions.md` for the visual/UX conventions and lessons learned while building each page — read that file before making layout changes, it documents *why* things are built the way they are (hero banner layering, card blend techniques, icon library gotchas, etc.).
|
||||||
|
|
||||||
## Expanding the ESLint configuration
|
## Development
|
||||||
|
|
||||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
```bash
|
||||||
|
npm install
|
||||||
```js
|
npm run dev
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
|
|
||||||
// Remove tseslint.configs.recommended and replace with this
|
|
||||||
tseslint.configs.recommendedTypeChecked,
|
|
||||||
// Alternatively, use this for stricter rules
|
|
||||||
tseslint.configs.strictTypeChecked,
|
|
||||||
// Optionally, add this for stylistic rules
|
|
||||||
tseslint.configs.stylisticTypeChecked,
|
|
||||||
|
|
||||||
// Other configs...
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
Type-check with `npx tsc --noEmit` before committing — Vite/the browser surface some runtime errors (e.g. missing icon exports) that the type-checker won't catch, so also smoke-test pages in a browser.
|
||||||
|
|
||||||
```js
|
## Tech Stack
|
||||||
// eslint.config.js
|
|
||||||
import reactX from 'eslint-plugin-react-x'
|
|
||||||
import reactDom from 'eslint-plugin-react-dom'
|
|
||||||
|
|
||||||
export default defineConfig([
|
- React 19 + Vite + TypeScript
|
||||||
globalIgnores(['dist']),
|
- React Router for routing
|
||||||
{
|
- Tailwind CSS v4
|
||||||
files: ['**/*.{ts,tsx}'],
|
- Recharts (donuts, line/area charts)
|
||||||
extends: [
|
- Lucide React (icons)
|
||||||
// Other configs...
|
- Deploy target: Docker on racknerd1 → NPM proxy at archnest.snsnetlabs.com
|
||||||
// Enable lint rules for React
|
|
||||||
reactX.configs['recommended-typescript'],
|
## Deployment
|
||||||
// Enable lint rules for React DOM
|
|
||||||
reactDom.configs.recommended,
|
This project is deployed via Docker on `racknerd1`, proxied through Nginx Proxy Manager at `archnest.snsnetlabs.com`.
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,32 @@
|
||||||
brand-flavored; substitutes used here: `GitBranch`/`GitFork` (GitHub/GitLab/Gitea),
|
brand-flavored; substitutes used here: `GitBranch`/`GitFork` (GitHub/GitLab/Gitea),
|
||||||
`SquarePlay` (YouTube), `Briefcase` (LinkedIn Learning).
|
`SquarePlay` (YouTube), `Briefcase` (LinkedIn Learning).
|
||||||
|
|
||||||
|
### Settings Page
|
||||||
|
- No mockup image existed for this page — built directly from the blueprint's Page 6
|
||||||
|
spec rather than iterating against a screenshot.
|
||||||
|
- Layout: fixed-width (200px) left nav listing the 6 sections (Profile, Appearance,
|
||||||
|
Integrations, Notifications, Data & Backup, About) + a scrollable content panel on
|
||||||
|
the right showing the active section. No hero banner (not in the blueprint spec for
|
||||||
|
this page, and a settings page doesn't need one).
|
||||||
|
- Active section is local component state (a string id) mapped through a
|
||||||
|
`sectionComponents` record to the corresponding section-renderer function — simplest
|
||||||
|
approach for a page with no routing/deep-linking requirement.
|
||||||
|
- Shared style helpers (`cardBase`, `sectionTitle`, `labelStyle`, `inputStyle`) plus two
|
||||||
|
small reusable components defined in the same file: `Toggle` (on/off pill switch) and
|
||||||
|
`GoldButton` (gold-filled primary / danger-outline variant) — kept local to
|
||||||
|
`Settings.tsx` rather than extracted, since no other page needs them yet.
|
||||||
|
- **Integrations** cards mask secret fields (API keys/tokens) behind dots with an eye
|
||||||
|
icon to reveal/hide, plus a "Test Connection" button per card — matches the blueprint's
|
||||||
|
explicit "masked secrets with eye toggle" instruction.
|
||||||
|
- **Avatar upload** (Profile section): the avatar circle is clickable and opens a hidden
|
||||||
|
`<input type="file" accept="image/*">` via a `useRef` + `.click()` call rather than a
|
||||||
|
visible file input — keeps the round avatar as the only visible control. On change,
|
||||||
|
`FileReader.readAsDataURL` converts the selected image to a base64 data URL stored in
|
||||||
|
component state, which becomes the circle's `backgroundImage` (cover-fit), replacing
|
||||||
|
the "AO" initials fallback. A hover-only camera-icon overlay (Tailwind `group` /
|
||||||
|
`group-hover:opacity-100`) signals the circle is clickable without cluttering the
|
||||||
|
default state. This is a frontend-only preview (no backend upload endpoint exists yet).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Future Integration Notes
|
## Future Integration Notes
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
User,
|
User,
|
||||||
Palette,
|
Palette,
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
Upload,
|
Upload,
|
||||||
Trash2,
|
Trash2,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
|
Camera,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const navSections = [
|
const navSections = [
|
||||||
|
|
@ -133,16 +134,47 @@ function GoldButton({ children, danger }: { children: React.ReactNode; danger?:
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProfileSection() {
|
function ProfileSection() {
|
||||||
|
const [avatar, setAvatar] = useState<string | null>(null)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
function handleAvatarChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => setAvatar(reader.result as string)
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={cardBase}>
|
<div style={cardBase}>
|
||||||
<h3 style={sectionTitle}>Profile</h3>
|
<h3 style={sectionTitle}>Profile</h3>
|
||||||
<div className="flex items-center gap-4" style={{ marginBottom: '24px' }}>
|
<div className="flex items-center gap-4" style={{ marginBottom: '24px' }}>
|
||||||
<div
|
<div
|
||||||
className="rounded-full border-2 flex items-center justify-center font-bold"
|
onClick={() => fileInputRef.current?.click()}
|
||||||
style={{ width: '64px', height: '64px', borderColor: '#C8A434', color: '#C8A434', fontSize: '20px', backgroundColor: 'rgba(200,164,52,0.08)' }}
|
className="relative rounded-full border-2 flex items-center justify-center font-bold cursor-pointer group"
|
||||||
|
style={{
|
||||||
|
width: '64px',
|
||||||
|
height: '64px',
|
||||||
|
borderColor: '#C8A434',
|
||||||
|
color: '#C8A434',
|
||||||
|
fontSize: '20px',
|
||||||
|
backgroundColor: 'rgba(200,164,52,0.08)',
|
||||||
|
backgroundImage: avatar ? `url(${avatar})` : undefined,
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
title="Upload photo"
|
||||||
>
|
>
|
||||||
AO
|
{!avatar && 'AO'}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.55)' }}
|
||||||
|
>
|
||||||
|
<Camera size={18} color="#E8E6E0" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleAvatarChange} className="hidden" />
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span style={{ fontSize: '15px', color: '#E8E6E0', fontWeight: 600 }}>ArchNest Ops</span>
|
<span style={{ fontSize: '15px', color: '#E8E6E0', fontWeight: 600 }}>ArchNest Ops</span>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue