This commit is contained in:
Nathan Anderson 2026-01-20 09:02:26 -06:00
commit 3593fee561
286 changed files with 53238 additions and 0 deletions

19
.gitignore vendored Executable file
View File

@ -0,0 +1,19 @@
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
.DS_Store
# Fresh build directory
_fresh/
# npm + other dependencies
node_modules/
publish/
host.azure.console/bin/
host.azure.console/obj/
data
playwright-report/
test-results/

6
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,6 @@
{
"recommendations": [
"denoland.vscode-deno",
"bradlc.vscode-tailwindcss"
]
}

20
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,20 @@
{
"deno.enable": true,
"deno.lint": true,
"editor.defaultFormatter": "denoland.vscode-deno",
"[typescriptreact]": {
"editor.defaultFormatter": "denoland.vscode-deno"
},
"[typescript]": {
"editor.defaultFormatter": "denoland.vscode-deno"
},
"[javascriptreact]": {
"editor.defaultFormatter": "denoland.vscode-deno"
},
"[javascript]": {
"editor.defaultFormatter": "denoland.vscode-deno"
},
"files.associations": {
"*.css": "tailwindcss"
}
}

124
AGENTS.md Normal file
View File

@ -0,0 +1,124 @@
# CyberHybridHub - Deno Fresh Web App Setup Guide
## Overview
CyberHybridHub is a modern web application built with Deno Fresh framework, utilizing Drizzle ORM for database management, TailwindCSS for styling, and designed for Datona sandbox environments.
<dependencies_and_installation>
**Package Manager**: Use Deno CLI exclusively (no npm/yarn required)
**Core Dependencies**:
- **Deno**: Latest runtime (https://deno.land/)
- **Fresh Framework**: `jsr:@fresh/core@^2.2.0`
- **Database**: Deno KV (built-in key-value storage)
- **Styling**: TailwindCSS v4.1.17
- **UI**: Preact components with signals
- **Authentication**: Local authentication system
**Database Setup**:
- Deno KV is used for Datona sandbox compatibility
- Database initialized automatically on startup
- No external database server required
- Built-in Deno KV storage handles all data persistence
**External Services**:
- **Local File Storage**: Used for blob storage in sandbox
- **In-Memory Cache**: Built-in Deno KV for caching
- No external service dependencies required for sandbox
**Environment Variables**:
Set the following environment variables before running the application:
```bash
# Server Configuration
PORT="8080"
HOSTNAME_DENO="localhost"
DEVELOPMENT="true" # Set to "false" for production
# File Storage Paths
KV_CACHE_PATH="./data"
POSTGRES_OPTIONS="postgresql://postgres:9UMrd6hKBho4RhoyP47t@data1.cyberhybridhub.com:5432/postgres?sslmode=verify-full&connect_timeout=10&sslrootcert=./certs/root.crt"
# Datona Sandbox Specific
SANDBOX_MODE="true"
DISABLE_EXTERNAL_APIS="true"
LOCAL_DEV_MODE="true"
```
**Startup Sequence**:
1. Install Deno: `curl -fsSL https://deno.land/install.sh | sh`
2. Clone repository and navigate to project directory
4. Set up environment variables (copy `.env.example` to `.env`)
5. Start development server: `deno task dev` (Deno KV initializes automatically)
**Available Deno Tasks**:
- `deno task dev` - Start development server with Vite
- `deno task build` - Build for production
- `deno task start` - Start production server
- `deno task check` - Format, lint, and type check
- `deno task test` - Run unit tests
- `deno task e2e` - Run Playwright end-to-end tests
**Development Workflow**:
1. Make code changes
2. Run `deno task check` to validate code quality
3. Test with `deno task test` and `deno task e2e`
4. Deno KV handles data persistence automatically
**Datona Sandbox Deployment**:
1. Set `SANDBOX_MODE="true"` in environment
2. Ensure all external API calls are disabled
3. Use local file storage instead of cloud services
4. Build application: `deno task build`
5. Start production server: `deno task start`
</dependencies_and_installation>
## Architecture Overview
### Directory Structure
- `routes/` - Fresh framework route handlers and pages
- `islands/` - Interactive Preact components
- `components/` - Reusable UI components
- `db/` - Database schema and connection logic
- `services/` - Business logic and external service integrations
- `utils/` - Utility functions and helpers
- `types/` - TypeScript type definitions
- `static/` - Static assets (CSS, images, etc.)
- `drizzle/` - Database migration files
### Key Technologies
- **Fresh**: Full-stack web framework with edge-first architecture
- **Deno KV**: Built-in key-value storage for data persistence
- **TailwindCSS**: Utility-first CSS framework
- **Preact**: Fast, lightweight React alternative
- **Local Authentication**: JWT-based authentication system
### Authentication Flow
1. User registers or logs in via local authentication
2. Credentials validated against Deno KV storage
3. JWT token generated for session management
4. User session maintained with secure cookies
5. Deno KV handles user data and session persistence
## Development Guidelines
### Code Style
- Use TypeScript for all new code
- Follow Fresh framework conventions
- Utilize TailwindCSS for styling
- Implement proper error handling and logging
- Write tests for new features and functions
### Database Operations
- All data operations use Deno KV key-value storage
- No schema migrations required
- Automatic data persistence and backup
- Built-in caching and performance optimization
- Simple API for CRUD operations
### Security Considerations
- Never commit secrets or API keys
- Use environment variables for sensitive configuration
- Implement proper input validation and sanitization
- Follow OWASP security guidelines
- Use local authentication to avoid external dependencies
- Regular security audits and dependency updates

321
README.md Executable file
View File

@ -0,0 +1,321 @@
# CyberHybridHub - Website Goals and Services
## Overview
CyberHybridHub is a comprehensive web platform designed to serve two primary
purposes: providing free financial calendar services and offering a dedicated
portal for rental property management.
This repo implements the rental property portal as a Fresh (Deno) web app with
landlord and tenant flows, a lightweight in-memory E2E test mode, and a
Playwright test suite designed to be deterministic and repeatable.
## Primary Services
### 1. Free Financial Calendar Services
Our platform offers complimentary financial calendar tools designed to help
users:
- Track important financial dates and deadlines
- Monitor payment schedules and due dates
- Plan for financial events and obligations
- Stay organized with customizable calendar views
- Receive notifications for upcoming financial milestones
These services are provided at no cost to ensure financial planning tools are
accessible to everyone.
### 2. Rental Management Portal
CyberHybridHub serves as a centralized hub for rental property management,
connecting:
#### For Renters:
- **Payment Management**: Secure online rent payments and transaction history
- **Service Requests**: Submit and track maintenance requests
- **Communication**: Direct messaging with landlords and property managers
- **Document Management**: Access lease agreements, receipts, and important
documents
- **Payment Reminders**: Automated notifications for upcoming rent payments
#### For Landlords:
- **Rent Collection**: Streamlined payment processing and tracking
- **Property Management**: Organize multiple properties and units
- **Tenant Communication**: Centralized messaging and announcements
- **Maintenance Coordination**: Efficient service request management
- **Financial Reporting**: Revenue tracking and expense management
- **Document Storage**: Secure storage for leases, inspections, and records
## Key Features
### Security & Privacy
- Encrypted data transmission and storage
- Secure payment processing
- User authentication and access controls
- Privacy-focused design
### User Experience
- Intuitive dashboard for both renters and landlords
- Mobile-responsive design
- Real-time notifications and updates
- Easy navigation and clean interface
### Integration
- Calendar synchronization with personal devices
- Payment gateway integration
- Email and SMS notifications
- Document upload and management system
## Mission Statement
CyberHybridHub aims to simplify financial planning and rental management by
providing free, accessible tools that empower users to take control of their
financial obligations while fostering better communication and efficiency in the
rental market.
## Target Audience
- Individual renters seeking better payment and service management
- Landlords and property managers looking for streamlined operations
- Anyone interested in free financial planning and calendar tools
- Property management companies seeking efficient digital solutions
## Technology Stack
Built with modern web technologies to ensure reliability, security, and
scalability:
- Frontend: Responsive web interface
- Backend: Secure API and database management
- Payment Processing: Industry-standard payment gateways
- Security: End-to-end encryption and best practices
## App Architecture Summary (Rental Portal)
- **Core flows**: onboarding (landlord + tenant), dashboards, properties,
leases, maintenance, messages, payments, documents, reminders,
portfolio/multi-property.
- **Routing**: UI pages live under `routes/` (e.g., `routes/landlord/*`,
`routes/tenant/*`, `routes/onboarding/*`), API endpoints under `routes/api/*`.
- **Services**: business logic in `services/*`. When `E2E_TEST=true`, services
read/write deterministic fixtures from `services/e2eFixtures.ts` instead of
the database.
- **Auth in tests**: E2E mode accepts a header `x-e2e-user` to bypass auth
(`services/stateMiddleware.ts`), and UI tests set a skip-encryption flag via
`tests/e2e/helpers.ts`.
## Deterministic E2E Testing (Playwright)
The goal is to always verify functional behavior without flakiness or state
drift.
### How E2E Mode Works
- **E2E env**: `E2E_TEST=true` enables fixture-backed services
(`services/e2eFixtures.ts`).
- **Auth bypass**: use `x-e2e-user` header (values like `landlord`, `tenant`,
`landlord-onboarding`) to map to fixture users.
- **Deterministic time**: `applyDeterministicPage()` stubs `Date.now()` and
`Math.random()` and disables animations.
- **Encryption bypass**: `useE2EUser()` sets `window.__E2E_SKIP_ENCRYPTION__`
and a localStorage flag to keep test data readable.
- **Web server**: Playwright config uses `deno task dev` with `E2E_TEST=true`
and a fixed base URL.
### Writing / Updating `tests/e2e/*`
- Prefer **resilient assertions** (e.g., check for a section header or a
state-dependent branch) because E2E data can be mutated across tests.
- When adding UI flows, make sure the corresponding service uses E2E fixtures
(or add fixture support in `services/e2eFixtures.ts`).
- Use `applyDeterministicPage()` and `useE2EUser()` at the start of each UI
test.
- Use `e2eHeaders()` for API-level tests to route through the E2E user context.
- If a flow writes data (create/update), the fixture state persists for the rest
of the run. Avoid assuming pristine state unless you add explicit resets.
### Available Test Methodologies (Deterministic Playwright)
- **UI E2E (browser)**: `page.goto(...)` + user interactions + assertions on UI
outcomes.
- **API E2E**: `request.get/post(...)` with `e2eHeaders(...)` for deterministic
API coverage without UI.
- **Hybrid E2E**: create data via API, validate via UI (or vice versa) for
full-stack coverage.
### Running Tests
- `deno task e2e` (uses Playwright + deterministic server config from
`playwright.config.ts`).
## Future Development
Planned enhancements include:
- Advanced financial analytics and reporting
- Mobile applications for iOS and Android
- Integration with accounting software
- Expanded property management features
- AI-powered financial insights and recommendations
# Fresh project
Your new Fresh project is ready to go. You can follow the Fresh "Getting
Started" guide here: https://fresh.deno.dev/docs/getting-started
### Usage
Make sure to install Deno:
https://docs.deno.com/runtime/getting_started/installation
Then start the project in development mode:
```
deno task dev
```
deno run -A -r jsr:@fresh/update
deno compile -A --target x86_64-unknown-linux-gnu --include ./_fresh/client
--output ../server ./compiled-entry.js deno compile -A --target
x86_64-pc-windows-msvc --include ./_fresh/client --output ../server.exe
./_fresh/compiled-entry.js
deno compile -A --target x86_64-apple-darwin --include ./_fresh/client --output
./server ./_fresh/compiled-entry.js
docker login cyberhybridhubreg.azurecr.io docker pull
cyberhybridhubreg.azurecr.io/cyberhybridhub:latest docker run -it -p 8080:8080
cyberhybridhubreg.azurecr.io/cyberhybridhub:latest tail -f /dev/null
az acr import --name cyberhybridhub --source denoland/deno:latest --image
denoland/deno:latest CMD ["tail", "-f", "/bin/bash] ENTRYPOINT ["dotnet",
"DotNet.Docker.dll"]
docker build -t app . && docker run -it -p 8080:8080 app
docker commit confident_lederberg cyberhybridhub/deno2.5.4-dotnet8.0.414:latest
docker login cyberhybridhub.azurecr.io docker tag
cyberhybridhub/deno2.5.4-dotnet8.0.414:latest
cyberhybridhub.azurecr.io/deno2.5.4-dotnet8.0.414:latest docker push
cyberhybridhub.azurecr.io/deno2.5.4-dotnet8.0.414:latest
docker build -t app3 . && docker run -it -p 8080:8080 app3 sh
apt-get update RUN if ! command -v git; then\
apt-get update && apt-get install -y git;\
fi && git --version
docker run -d -p 8080:80 --name myapp --entrypoint "dotnet"
cyberhybridhub.azurecr.io/dotnet/aspnet:8.0-alpine <your-app>.dll
apt-get update apt-get install -y wget apt-transport-https wget
https://packages.microsoft.com/config/debian/12/packages-microsoft-prod.deb -O
packages-microsoft-prod.deb dpkg -i packages-microsoft-prod.deb rm
packages-microsoft-prod.deb
mkdir -p /tmp/deno-compile-server && ln -sfn "$(pwd)/server"
/tmp/deno-compile-server/server && dotnet AzureFront.CyberHybridHub.dll
2025-10-16T13:19:46.1385179Z NotFound: path not found: stat
'/tmp/deno-compile-server/_fresh/client\assets\client-entry-DNH4ZUpx.css'
2025-10-16T13:19:46.1385699Z at Object.stat (ext:deno_fs/30_fs.js:425:21)
2025-10-16T13:19:46.1385787Z at ProdBuildCache.readFile
(file:///tmp/deno-compile-server/_fresh/server/server-entry.mjs:5279:50)
2025-10-16T13:19:46.1385814Z at freshServeStaticFiles
(file:///tmp/deno-compile-server/_fresh/server/server-entry.mjs:5835:35)
2025-10-16T13:19:46.1385839Z at Context.fn
(file:///tmp/deno-compile-server/_fresh/server/server-entry.mjs:4403:34)
2025-10-16T13:19:46.1385862Z at segmentMiddleware
(file:///tmp/deno-compile-server/_fresh/server/server-entry.mjs:4588:26)
2025-10-16T13:19:46.1385885Z at fn
(file:///tmp/deno-compile-server/_fresh/server/server-entry.mjs:4403:34)
2025-10-16T13:19:46.1385908Z at
file:///tmp/deno-compile-server/_fresh/server/server-entry.mjs:4419:20
2025-10-16T13:19:46.1385934Z at NoopContextManager.with
(file:///tmp/deno-compile-server/_fresh/server/server-entry.mjs:981:15)
2025-10-16T13:19:46.1385957Z at ContextAPI.with
(file:///tmp/deno-compile-server/_fresh/server/server-entry.mjs:1048:42)
2025-10-16T13:19:46.1385981Z at NoopTracer.startActiveSpan
(file:///tmp/deno-compile-server/_fresh/server/server-entry.mjs:1328:27) {
2025-10-16T13:19:46.1386002Z name: "NotFound", 2025-10-16T13:19:46.1386056Z
code: "ENOENT" 2025-10-16T13:19:46.1386076Z } 2025-10-16T13:19:46.8441754Z
NotFound: path not found: stat
'/tmp/deno-compile-server/_fresh/client\assets\client-entry-9ekbL4RI.js'
2025-10-16T13:19:46.8442151Z at Object.stat (ext:deno_fs/30_fs.js:425:21)
2025-10-16T13:19:46.8442187Z at ProdBuildCache.readFile
(file:///tmp/deno-compile-server/_fresh/server/server-entry.mjs:5279:50)
2025-10-16T13:19:46.8442212Z at freshServeStaticFiles
(file:///tmp/deno-compile-server/_fresh/server/server-entry.mjs:5835:35)
2025-10-16T13:19:46.8442242Z at Context.fn
(file:///tmp/deno-compile-server/_fresh/server/server-entry.mjs:4403:34)
2025-10-16T13:19:46.8442269Z at segmentMiddleware
(file:///tmp/deno-compile-server/_fresh/server/server-entry.mjs:4588:26)
2025-10-16T13:19:46.8442291Z at fn
(file:///tmp/deno-compile-server/_fresh/server/server-entry.mjs:4403:34)
2025-10-16T13:19:46.8442364Z at
file:///tmp/deno-compile-server/_fresh/server/server-entry.mjs:4419:20
2025-10-16T13:19:46.844239Z at NoopContextManager.with
(file:///tmp/deno-compile-server/_fresh/server/server-entry.mjs:981:15)
2025-10-16T13:19:46.8442414Z at ContextAPI.with
(file:///tmp/deno-compile-server/_fresh/server/server-entry.mjs:1048:42)
2025-10-16T13:19:46.8442438Z at NoopTracer.startActiveSpan
(file:///tmp/deno-compile-server/_fresh/server/server-entry.mjs:1328:27) {
2025-10-16T13:19:46.8442458Z name: "NotFound", 2025-10-16T13:19:46.8442477Z
code: "ENOENT" 2025-10-16T13:19:46.8442497Z } 2025-10-16T13:19:49.120541Z
NotFound: path not found: stat
'/tmp/deno-compile-server/_fresh/client\favicon.ico'
2025-10-16T13:19:49.1205856Z at Object.stat (ext:deno_fs/30_fs.js:425:21)
2025-10-16T13:19:49.1205895Z at ProdBuildCache.readFile
(file:///tmp/deno-compile-server/_fresh/server/server-entry.mjs:5279:50)
2025-10-16T13:19:49.1205918Z at freshServeStaticFiles
(file:///tmp/deno-compile-server/_fresh/server/server-entry.mjs:5835:35)
2025-10-16T13:19:49.1205942Z at Context.fn
(file:///tmp/deno-compile-server/_fresh/server/server-entry.mjs:4403:34)
2025-10-16T13:19:49.1205965Z at segmentMiddleware
(file:///tmp/deno-compile-server/_fresh/server/server-entry.mjs:4588:26)
2025-10-16T13:19:49.1205988Z at fn
(file:///tmp/deno-compile-server/_fresh/server/server-entry.mjs:4403:34)
2025-10-16T13:19:49.120601Z at
file:///tmp/deno-compile-server/_fresh/server/server-entry.mjs:4419:20
2025-10-16T13:19:49.1206647Z at NoopContextManager.with
(file:///tmp/deno-compile-server/_fresh/server/server-entry.mjs:981:15)
2025-10-16T13:19:49.1206683Z at ContextAPI.with
(file:///tmp/deno-compile-server/_fresh/server/server-entry.mjs:1048:42)
2025-10-16T13:19:49.120674Z at NoopTracer.startActiveSpan
(file:///tmp/deno-compile-server/_fresh/server/server-entry.mjs:1328:27) {
2025-10-16T13:19:49.120676Z name: "NotFound", 2025-10-16T13:19:49.1206778Z code:
"ENOENT" 2025-10-16T13:19:49.1206796Z }
// {
// "id": "MXhUaBIBQkePiTHfY-3y3hrn5C7OZ1RAjFmyNHAKbQA",
// "deletedDateTime": null,
// "appRoleId": "d4ce6e5d-4a93-43ae-b2f4-230e76248917",
// "createdDateTime": "2025-10-20T19:17:23.9036855Z",
// "principalDisplayName": "Nathan Live Anderson",
// "principalId": "68547831-0112-4742-8f89-31df63edf2de",
// "principalType": "User",
// "resourceDisplayName": "cyberhybridhub",
// "resourceId": "c7f12958-4ee8-4a07-acb8-bf4ac2dcac2b"
// }
Request headers: {
accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,_/_;q=0.8,application/signed-exchange;v=b3;q=0.7",
"accept-encoding": "gzip, deflate, br, zstd", "accept-language":
"en-US,en;q=0.9", "cache-control": "max-age=0", connection: "keep-alive",
cookie: "xx-state-id=353bd790-b53e-4a67-89a1-cad4886c9e57", host:
"127.0.0.1:8080", referer: "http://127.0.0.1:8080/", "sec-ch-ua": '"Microsoft
Edge";v="141", "Not?A_Brand";v="8", "Chromium";v="141"', "sec-ch-ua-mobile":
"?0", "sec-ch-ua-platform": '"Linux"', "sec-fetch-dest": "document",
"sec-fetch-mode": "navigate", "sec-fetch-site": "same-origin",
"upgrade-insecure-requests": "1", "user-agent": "Mozilla/5.0 (X11; Linux x86_64)
AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36
Edg/141.0.0.0" } Request cookies: { "xx-state-id":
"353bd790-b53e-4a67-89a1-cad4886c9e57" }

65
assets/kvWorker.ts Executable file
View File

@ -0,0 +1,65 @@
// // deno.server/assets/kvWorker.ts
// // Dedicated worker to manage Deno KV operations for OAuth state (bundled asset)
// export type KvSetRequest = {
// id: string;
// type: "set";
// key: string[];
// value: unknown;
// expireInMs?: number;
// };
// export type KvGetDelRequest = {
// id: string;
// type: "getAndDelete";
// key: string[];
// };
// export type KvRequest = KvSetRequest | KvGetDelRequest;
// export type KvResponse =
// | {
// id: string;
// ok: true;
// value?: unknown;
// }
// | {
// id: string;
// ok: false;
// error: string;
// };
// let kv: Deno.Kv | null = null;
// async function ensureKv() {
// if (!kv) kv = await Deno.openKv();
// return kv!;
// }
// self.onmessage = async (evt: MessageEvent) => {
// let reqId: string | undefined = undefined;
// try {
// const msg = evt.data as KvRequest;
// reqId = msg.id;
// const kv = await ensureKv();
// if (msg.type === "set") {
// const opts = msg.expireInMs ? { expireIn: msg.expireInMs } : undefined;
// await kv.set(msg.key as Deno.KvKey, msg.value, opts);
// self.postMessage({ id: msg.id, ok: true } as KvResponse);
// return;
// }
// if (msg.type === "getAndDelete") {
// const key = msg.key as Deno.KvKey;
// const res = await kv.get(key);
// if (res.value !== null) {
// await kv.delete(key);
// }
// self.postMessage({ id: msg.id, ok: true, value: res.value } as KvResponse);
// return;
// }
// self.postMessage({ id: reqId ?? "", ok: false, error: "Unknown message type" } as KvResponse);
// } catch (err) {
// const errorMsg = (err && (err as { message?: string }).message) || String(err);
// self.postMessage({ id: reqId ?? "", ok: false, error: errorMsg } as KvResponse);
// }
// };

366
assets/styles.css Executable file
View File

@ -0,0 +1,366 @@
@import "tailwindcss";
body {
@apply bg-gradient-to-br from-blue-50 to-indigo-100;
}
@media (prefers-color-scheme: dark) {
body {
@apply bg-gradient-to-br from-gray-900 to-gray-800;
}
}
/* Navbar theme classes */
.theme-navbar {
@apply bg-white/90 backdrop-blur-sm shadow-xl;
}
.theme-navbar-text {
@apply text-gray-700 hover:text-gray-900;
}
.theme-navbar-accent {
@apply text-blue-600 hover:text-blue-700;
}
.theme-navbar-button {
@apply text-blue-600 hover:text-blue-700;
}
@media (prefers-color-scheme: dark) {
.theme-navbar {
@apply bg-black/80 backdrop-blur-sm shadow-xl;
}
.theme-navbar-text {
@apply text-cyan-400 hover:text-cyan-300;
}
.theme-navbar-accent {
@apply text-cyan-400 hover:text-cyan-300;
}
.theme-navbar-button {
@apply text-blue-400 hover:text-cyan-300;
}
}
/* Card theme classes */
.theme-card {
@apply bg-white/90 shadow-md;
}
.theme-card-input {
@apply bg-white/80 text-gray-900 border-gray-300 focus:ring-blue-500;
}
.theme-card-button {
@apply bg-blue-500/80 text-white hover:bg-blue-600/80;
}
@media (prefers-color-scheme: dark) {
.theme-card {
@apply bg-gray-800/90 shadow-md;
}
.theme-card-input {
@apply bg-gray-700/80 text-white border-gray-600 focus:ring-blue-400;
}
.theme-card-button {
@apply bg-blue-600/80 text-white hover:bg-blue-700/80;
}
}
/* Dashboard theme classes - auto-switch based on system preference */
.theme-container {
@apply bg-white/95 text-gray-900 backdrop-blur-xl;
}
.theme-secondary-container {
@apply bg-gray-100/80 text-gray-900;
}
.theme-border {
@apply border-gray-200;
}
.theme-button-text {
@apply text-gray-900;
}
.theme-icon-button {
@apply text-gray-600 hover:text-gray-800;
}
.theme-question-text {
@apply text-black bg-white/70 rounded px-2 py-1 inline;
}
.theme-admin-button {
@apply bg-blue-500 hover:bg-blue-600 text-white;
}
.theme-button {
@apply bg-blue-500 hover:bg-blue-600 text-white;
}
.theme-input {
@apply bg-white/90 text-gray-900 border-gray-300;
}
.theme-text {
@apply text-gray-900;
}
.theme-select {
@apply bg-white text-gray-900 border-gray-300;
}
@media (prefers-color-scheme: dark) {
.theme-container {
@apply bg-gray-800/95 text-white backdrop-blur-xl;
}
.theme-secondary-container {
@apply bg-gray-700/80 text-white;
}
.theme-border {
@apply border-gray-600;
}
.theme-button-text {
@apply text-white;
}
.theme-icon-button {
@apply text-gray-300 hover:text-white;
}
.theme-question-text {
@apply text-white bg-gray-800/70 rounded px-2 py-1 inline;
}
.theme-admin-button {
@apply bg-blue-600 hover:bg-blue-700 text-white;
}
.theme-button {
@apply bg-blue-600 hover:bg-blue-700 text-white;
}
.theme-input {
@apply bg-gray-700/90 text-white border-gray-600;
}
.theme-text {
@apply text-white;
}
.theme-select {
@apply bg-gray-700 text-white border-gray-600;
}
}
/* Popup theme classes */
.theme-popup-overlay {
@apply fixed inset-0 bg-black/50 flex items-center justify-center z-[1000];
}
.theme-popup-container {
@apply bg-white/95 text-gray-900 backdrop-blur-xl p-8 rounded-lg w-[300px]
min-h-[100px] shadow-xl;
}
.theme-popup-button {
@apply bg-blue-500 hover:bg-blue-600 text-white py-2 px-6 rounded-md
cursor-pointer text-base font-bold transition-colors duration-200;
}
@media (prefers-color-scheme: dark) {
.theme-popup-container {
@apply bg-gray-800/95 text-white backdrop-blur-xl;
}
.theme-popup-button {
@apply bg-blue-600 hover:bg-blue-700;
}
}
/* Image generator overlay classes */
.theme-image-overlay {
@apply fixed inset-0 bg-black/50 flex items-center justify-center z-[1000];
}
.theme-image-container {
@apply relative bg-white/95 backdrop-blur-xl rounded-lg shadow-xl
overflow-hidden;
}
.theme-image-input {
@apply w-full p-2 border rounded bg-white/90 text-gray-900 border-gray-300;
}
.theme-image-button {
@apply px-0 py-2 rounded-md bg-blue-500 hover:bg-blue-600 text-white
font-medium transition-colors duration-200;
white-space: nowrap;
font-size: 0.95rem;
width: 120px;
text-align: center;
}
@media (prefers-color-scheme: dark) {
.theme-image-container {
@apply bg-gray-800/95 backdrop-blur-xl;
}
.theme-image-input {
@apply bg-gray-700/90 text-white border-gray-600;
}
.theme-image-button {
@apply bg-blue-600 hover:bg-blue-700;
}
}
/* Voice recorder overlay classes */
.theme-recorder-container {
@apply bg-white/95 text-gray-900 backdrop-blur-xl p-8 rounded-lg w-[364px]
min-h-[220px] flex flex-col justify-center items-center relative shadow-xl;
}
.theme-recorder-close {
@apply absolute top-3 right-3 bg-transparent border-none text-gray-600
hover:text-gray-900 text-2xl cursor-pointer z-10 p-0 leading-none;
}
.theme-recorder-button {
@apply w-[100px] h-12 rounded-full text-base font-bold border-none
cursor-pointer transition-all duration-200 flex items-center justify-center;
}
.theme-recorder-start {
@apply bg-blue-500 hover:bg-blue-600 text-white;
}
.theme-recorder-stop {
@apply bg-red-500 hover:bg-red-600 text-white;
}
.theme-recorder-save {
@apply bg-green-500 hover:bg-green-600 text-white;
}
@media (prefers-color-scheme: dark) {
.theme-recorder-container {
@apply bg-gray-800/95 text-white backdrop-blur-xl;
}
.theme-recorder-close {
@apply text-gray-300 hover:text-white;
}
.theme-recorder-start {
@apply bg-blue-600 hover:bg-blue-700;
}
.theme-recorder-stop {
@apply bg-red-600 hover:bg-red-700;
}
.theme-recorder-save {
@apply bg-green-600 hover:bg-green-700;
}
}
/* Financial Calendar theme classes */
.theme-calendar-form {
@apply bg-gray-50 border-gray-300 shadow-sm;
}
.theme-calendar-label {
@apply text-gray-900;
}
.theme-calendar-input {
@apply bg-white text-gray-900 border-gray-300;
}
.theme-calendar-nav-button {
background: rgba(0, 0, 0, 0.15);
}
.theme-calendar-title {
@apply text-gray-900;
}
.theme-calendar-table {
@apply text-gray-900;
}
.theme-calendar-cell {
@apply border-gray-300 bg-white;
}
.theme-calendar-day {
@apply text-gray-900;
}
.theme-calendar-list-container {
@apply bg-white border-gray-200 shadow-sm;
}
.theme-calendar-list-empty {
@apply text-gray-500;
}
.theme-calendar-list-item {
@apply border-gray-200;
}
.theme-calendar-list-frequency {
@apply text-gray-500;
}
.theme-calendar-close-button {
@apply bg-gray-500 hover:bg-gray-600 text-white;
}
.theme-calendar-today {
@apply border-2 border-blue-500 bg-blue-50;
}
/* Dialog form theme classes */
.theme-dialog-overlay {
@apply fixed inset-0 flex items-start justify-center bg-black/40 z-50;
}
.theme-dialog-container {
@apply bg-white text-gray-900 p-4 rounded-xl shadow-2xl w-full max-w-md;
}
.theme-dialog-title {
@apply text-xl font-bold text-center mb-2 text-gray-900;
}
.theme-dialog-input {
@apply w-full border border-gray-300 rounded-md px-3 py-2 text-base bg-white
text-gray-900;
}
.theme-dialog-select {
@apply w-full border border-gray-300 rounded-md px-3 py-2 text-base bg-white
text-gray-900;
}
.theme-dialog-cancel-button {
@apply bg-gray-200 text-gray-800 px-4 py-2 rounded-md font-semibold
hover:bg-gray-300 transition-colors;
}
.theme-dialog-submit-button {
@apply bg-green-600 text-white px-4 py-2 rounded-md font-semibold
hover:bg-green-700 transition-colors;
}
@media (prefers-color-scheme: dark) {
.theme-dialog-container {
@apply bg-gray-800 text-white shadow-2xl;
}
.theme-dialog-title {
@apply text-white;
}
.theme-dialog-input {
@apply border-gray-600 bg-gray-700 text-white;
}
.theme-dialog-select {
@apply border-gray-600 bg-gray-700 text-white;
}
.theme-dialog-cancel-button {
@apply bg-gray-600 text-white hover:bg-gray-500;
}
.theme-dialog-submit-button {
@apply bg-green-600 text-white hover:bg-green-500;
}
}
@media (prefers-color-scheme: dark) {
.theme-calendar-form {
@apply bg-gray-800 border-gray-600 shadow-md;
}
.theme-calendar-label {
@apply text-white;
}
.theme-calendar-input {
@apply bg-gray-700 text-white border-gray-600;
}
.theme-calendar-nav-button {
background: rgba(255, 255, 255, 0.15);
}
.theme-calendar-title {
@apply text-white;
}
.theme-calendar-table {
@apply text-white;
}
.theme-calendar-cell {
@apply border-gray-600 bg-gray-800;
}
.theme-calendar-day {
@apply text-white;
}
.theme-calendar-list-container {
@apply bg-gray-800 border-gray-600 shadow-md;
}
.theme-calendar-list-empty {
@apply text-gray-400;
}
.theme-calendar-list-item {
@apply border-gray-600;
}
.theme-calendar-list-frequency {
@apply text-gray-400;
}
.theme-calendar-close-button {
@apply bg-gray-600 hover:bg-gray-500 text-white;
}
.theme-calendar-today {
@apply border-2 border-cyan-400 bg-cyan-900/30;
}
}

31
certs/isrg-root-x1.pem Normal file
View File

@ -0,0 +1,31 @@
-----BEGIN CERTIFICATE-----
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
-----END CERTIFICATE-----

79
certs/root.crt Normal file
View File

@ -0,0 +1,79 @@
-----BEGIN CERTIFICATE-----
MIIDnjCCAySgAwIBAgISBncKsbnyJJESwnTHHGOw2CD+MAoGCCqGSM49BAMDMDIx
CzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQDEwJF
ODAeFw0yNTExMjYwMzQxMDRaFw0yNjAyMjQwMzQxMDNaMCMxITAfBgNVBAMTGGRh
dGExLmN5YmVyaHlicmlkaHViLmNvbTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IA
BAJK1UWX1LYYBmf+u+Xcjvt2OcGPURZwOTdGaHAINnuLhChC3oJXnw74uzXTVI6V
77R06YdPGKj1lMnkJgV1OcmjggInMIICIzAOBgNVHQ8BAf8EBAMCB4AwHQYDVR0l
BBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYE
FNrfOM5Aaj3VWPU/Y2ZJK371kJk0MB8GA1UdIwQYMBaAFI8NE6L2Ln7RUGwzGDhd
WY4jcpHKMDIGCCsGAQUFBwEBBCYwJDAiBggrBgEFBQcwAoYWaHR0cDovL2U4Lmku
bGVuY3Iub3JnLzAjBgNVHREEHDAaghhkYXRhMS5jeWJlcmh5YnJpZGh1Yi5jb20w
EwYDVR0gBAwwCjAIBgZngQwBAgEwLgYDVR0fBCcwJTAjoCGgH4YdaHR0cDovL2U4
LmMubGVuY3Iub3JnLzEyNy5jcmwwggEEBgorBgEEAdZ5AgQCBIH1BIHyAPAAdgBk
EcRspBLsp4kcogIuALyrTygH1B41J6vq/tUDyX3N8AAAAZq+da2RAAAEAwBHMEUC
IQC9z/J4vmKyw6uNhGVzvjjbpWZNmrfcPbBtxzv6hpx9ZgIgOaQkguD6+25L4lxL
pND9kG6mda6M8RyeQrKMfJBl8eAAdgAOV5S8866pPjMbLJkHs/eQ35vCPXEyJd0h
qSWsYcVOIQAAAZq+da2QAAAEAwBHMEUCIB1mXcfQvQmTb8n8foP6zk5hLekYHtTO
MD7d0k9FdcdgAiEAsJL9vpTGNINJd4KeAsfuKrePQ5hBmoUxR1XJlW8NN44wCgYI
KoZIzj0EAwMDaAAwZQIxAIxF5apK+O1cVzdtXeuBp6fPwlWm3HSpg3T84w9cCbZg
jG/fezCfm2pVP1hZIMM4aQIwbhyz/zkHb3O/Avf0mnXn834GpCZXhL1jW31Unw7d
EZnKzgGOEvjcej/xUgdZVM9R
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEVjCCAj6gAwIBAgIQY5WTY8JOcIJxWRi/w9ftVjANBgkqhkiG9w0BAQsFADBP
MQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFy
Y2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMTAeFw0yNDAzMTMwMDAwMDBa
Fw0yNzAzMTIyMzU5NTlaMDIxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBF
bmNyeXB0MQswCQYDVQQDEwJFODB2MBAGByqGSM49AgEGBSuBBAAiA2IABNFl8l7c
S7QMApzSsvru6WyrOq44ofTUOTIzxULUzDMMNMchIJBwXOhiLxxxs0LXeb5GDcHb
R6EToMffgSZjO9SNHfY9gjMy9vQr5/WWOrQTZxh7az6NSNnq3u2ubT6HTKOB+DCB
9TAOBgNVHQ8BAf8EBAMCAYYwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMB
MBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFI8NE6L2Ln7RUGwzGDhdWY4j
cpHKMB8GA1UdIwQYMBaAFHm0WeZ7tuXkAXOACIjIGlj26ZtuMDIGCCsGAQUFBwEB
BCYwJDAiBggrBgEFBQcwAoYWaHR0cDovL3gxLmkubGVuY3Iub3JnLzATBgNVHSAE
DDAKMAgGBmeBDAECATAnBgNVHR8EIDAeMBygGqAYhhZodHRwOi8veDEuYy5sZW5j
ci5vcmcvMA0GCSqGSIb3DQEBCwUAA4ICAQBnE0hGINKsCYWi0Xx1ygxD5qihEjZ0
RI3tTZz1wuATH3ZwYPIp97kWEayanD1j0cDhIYzy4CkDo2jB8D5t0a6zZWzlr98d
AQFNh8uKJkIHdLShy+nUyeZxc5bNeMp1Lu0gSzE4McqfmNMvIpeiwWSYO9w82Ob8
otvXcO2JUYi3svHIWRm3+707DUbL51XMcY2iZdlCq4Wa9nbuk3WTU4gr6LY8MzVA
aDQG2+4U3eJ6qUF10bBnR1uuVyDYs9RhrwucRVnfuDj29CMLTsplM5f5wSV5hUpm
Uwp/vV7M4w4aGunt74koX71n4EdagCsL/Yk5+mAQU0+tue0JOfAV/R6t1k+Xk9s2
HMQFeoxppfzAVC04FdG9M+AC2JWxmFSt6BCuh3CEey3fE52Qrj9YM75rtvIjsm/1
Hl+u//Wqxnu1ZQ4jpa+VpuZiGOlWrqSP9eogdOhCGisnyewWJwRQOqK16wiGyZeR
xs/Bekw65vwSIaVkBruPiTfMOo0Zh4gVa8/qJgMbJbyrwwG97z/PRgmLKCDl8z3d
tA0Z7qq7fta0Gl24uyuB05dqI5J1LvAzKuWdIjT1tP8qCoxSE/xpix8hX2dt3h+/
jujUgFPFZ0EVZ0xSyBNRF3MboGZnYXFUxpNjTWPKpagDHJQmqrAcDmWJnMsFY3jS
u1igv3OefnWjSQ==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
-----END CERTIFICATE-----

48
certs/server.crt Normal file
View File

@ -0,0 +1,48 @@
-----BEGIN CERTIFICATE-----
MIIDnjCCAySgAwIBAgISBncKsbnyJJESwnTHHGOw2CD+MAoGCCqGSM49BAMDMDIx
CzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQDEwJF
ODAeFw0yNTExMjYwMzQxMDRaFw0yNjAyMjQwMzQxMDNaMCMxITAfBgNVBAMTGGRh
dGExLmN5YmVyaHlicmlkaHViLmNvbTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IA
BAJK1UWX1LYYBmf+u+Xcjvt2OcGPURZwOTdGaHAINnuLhChC3oJXnw74uzXTVI6V
77R06YdPGKj1lMnkJgV1OcmjggInMIICIzAOBgNVHQ8BAf8EBAMCB4AwHQYDVR0l
BBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYE
FNrfOM5Aaj3VWPU/Y2ZJK371kJk0MB8GA1UdIwQYMBaAFI8NE6L2Ln7RUGwzGDhd
WY4jcpHKMDIGCCsGAQUFBwEBBCYwJDAiBggrBgEFBQcwAoYWaHR0cDovL2U4Lmku
bGVuY3Iub3JnLzAjBgNVHREEHDAaghhkYXRhMS5jeWJlcmh5YnJpZGh1Yi5jb20w
EwYDVR0gBAwwCjAIBgZngQwBAgEwLgYDVR0fBCcwJTAjoCGgH4YdaHR0cDovL2U4
LmMubGVuY3Iub3JnLzEyNy5jcmwwggEEBgorBgEEAdZ5AgQCBIH1BIHyAPAAdgBk
EcRspBLsp4kcogIuALyrTygH1B41J6vq/tUDyX3N8AAAAZq+da2RAAAEAwBHMEUC
IQC9z/J4vmKyw6uNhGVzvjjbpWZNmrfcPbBtxzv6hpx9ZgIgOaQkguD6+25L4lxL
pND9kG6mda6M8RyeQrKMfJBl8eAAdgAOV5S8866pPjMbLJkHs/eQ35vCPXEyJd0h
qSWsYcVOIQAAAZq+da2QAAAEAwBHMEUCIB1mXcfQvQmTb8n8foP6zk5hLekYHtTO
MD7d0k9FdcdgAiEAsJL9vpTGNINJd4KeAsfuKrePQ5hBmoUxR1XJlW8NN44wCgYI
KoZIzj0EAwMDaAAwZQIxAIxF5apK+O1cVzdtXeuBp6fPwlWm3HSpg3T84w9cCbZg
jG/fezCfm2pVP1hZIMM4aQIwbhyz/zkHb3O/Avf0mnXn834GpCZXhL1jW31Unw7d
EZnKzgGOEvjcej/xUgdZVM9R
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEVjCCAj6gAwIBAgIQY5WTY8JOcIJxWRi/w9ftVjANBgkqhkiG9w0BAQsFADBP
MQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFy
Y2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMTAeFw0yNDAzMTMwMDAwMDBa
Fw0yNzAzMTIyMzU5NTlaMDIxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBF
bmNyeXB0MQswCQYDVQQDEwJFODB2MBAGByqGSM49AgEGBSuBBAAiA2IABNFl8l7c
S7QMApzSsvru6WyrOq44ofTUOTIzxULUzDMMNMchIJBwXOhiLxxxs0LXeb5GDcHb
R6EToMffgSZjO9SNHfY9gjMy9vQr5/WWOrQTZxh7az6NSNnq3u2ubT6HTKOB+DCB
9TAOBgNVHQ8BAf8EBAMCAYYwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMB
MBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFI8NE6L2Ln7RUGwzGDhdWY4j
cpHKMB8GA1UdIwQYMBaAFHm0WeZ7tuXkAXOACIjIGlj26ZtuMDIGCCsGAQUFBwEB
BCYwJDAiBggrBgEFBQcwAoYWaHR0cDovL3gxLmkubGVuY3Iub3JnLzATBgNVHSAE
DDAKMAgGBmeBDAECATAnBgNVHR8EIDAeMBygGqAYhhZodHRwOi8veDEuYy5sZW5j
ci5vcmcvMA0GCSqGSIb3DQEBCwUAA4ICAQBnE0hGINKsCYWi0Xx1ygxD5qihEjZ0
RI3tTZz1wuATH3ZwYPIp97kWEayanD1j0cDhIYzy4CkDo2jB8D5t0a6zZWzlr98d
AQFNh8uKJkIHdLShy+nUyeZxc5bNeMp1Lu0gSzE4McqfmNMvIpeiwWSYO9w82Ob8
otvXcO2JUYi3svHIWRm3+707DUbL51XMcY2iZdlCq4Wa9nbuk3WTU4gr6LY8MzVA
aDQG2+4U3eJ6qUF10bBnR1uuVyDYs9RhrwucRVnfuDj29CMLTsplM5f5wSV5hUpm
Uwp/vV7M4w4aGunt74koX71n4EdagCsL/Yk5+mAQU0+tue0JOfAV/R6t1k+Xk9s2
HMQFeoxppfzAVC04FdG9M+AC2JWxmFSt6BCuh3CEey3fE52Qrj9YM75rtvIjsm/1
Hl+u//Wqxnu1ZQ4jpa+VpuZiGOlWrqSP9eogdOhCGisnyewWJwRQOqK16wiGyZeR
xs/Bekw65vwSIaVkBruPiTfMOo0Zh4gVa8/qJgMbJbyrwwG97z/PRgmLKCDl8z3d
tA0Z7qq7fta0Gl24uyuB05dqI5J1LvAzKuWdIjT1tP8qCoxSE/xpix8hX2dt3h+/
jujUgFPFZ0EVZ0xSyBNRF3MboGZnYXFUxpNjTWPKpagDHJQmqrAcDmWJnMsFY3jS
u1igv3OefnWjSQ==
-----END CERTIFICATE-----

49
certs/server2.crt Normal file
View File

@ -0,0 +1,49 @@
-----BEGIN CERTIFICATE-----
MIIDvjCCA0OgAwIBAgISBZdc+0ZuWnJAKj5A5bdiXto/MAoGCCqGSM49BAMDMDIx
CzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQDEwJF
ODAeFw0yNTEwMzAwMDQ2MTlaFw0yNjAxMjgwMDQ2MThaMCMxITAfBgNVBAMTGGRh
dGExLmN5YmVyaHlicmlkaHViLmNvbTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IA
BLswikGQWDI08ifHRiXJe/p7VEjbJQh2uedQcYW5NEOs4SpzhgQVg6QCcd/hXNvf
J9TO0mgxCfVmG3BHEhidBoyjggJGMIICQjAOBgNVHQ8BAf8EBAMCB4AwHQYDVR0l
BBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYE
FDXM6hGs4DH0bs0yei6y0wR9oUKxMB8GA1UdIwQYMBaAFI8NE6L2Ln7RUGwzGDhd
WY4jcpHKMDIGCCsGAQUFBwEBBCYwJDAiBggrBgEFBQcwAoYWaHR0cDovL2U4Lmku
bGVuY3Iub3JnLzBBBgNVHREEOjA4ghhkYXRhMS5jeWJlcmh5YnJpZGh1Yi5jb22C
HHd3dy5kYXRhMS5jeWJlcmh5YnJpZGh1Yi5jb20wEwYDVR0gBAwwCjAIBgZngQwB
AgEwLQYDVR0fBCYwJDAioCCgHoYcaHR0cDovL2U4LmMubGVuY3Iub3JnLzY0LmNy
bDCCAQYGCisGAQQB1nkCBAIEgfcEgfQA8gB3AEmcm2neHXzs/DbezYdkprhbrwqH
gBnRVVL76esp3fjDAAABmjLJ/c8AAAQDAEgwRgIhAOblehV7pM89Y8TSrLHKOMeq
ztc530am6yFPyGNSZdikAiEAmxVII0r3G4b/xeQwf09/6aHXZydRA0Hl1QRp+zru
mwcAdwDLOPcViXyEoURfW8Hd+8lu8ppZzUcKaQWFsMsUwxRY5wAAAZoyyf3xAAAE
AwBIMEYCIQCujZ2YOC16K7+CaOmLCxatjq02od8Les/5bn6UH/FOqwIhAJLuGydQ
7Oaf4uqYYd+3A7lG/w89TMZK6WFxqhnidCtUMAoGCCqGSM49BAMDA2kAMGYCMQDv
EUcx7kj9sjd4q0MrVQ+5uaJoY4Cp/17QztaAo3VxvZFAP213Y6meQZDReFTRHR8C
MQCDyPyeBqT8HyPSPJv1djyOmGLJ1yyz8cdtwAgQ8vC9FTL2rDJEqn9UBWwATxWt
sfU=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEVjCCAj6gAwIBAgIQY5WTY8JOcIJxWRi/w9ftVjANBgkqhkiG9w0BAQsFADBP
MQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFy
Y2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMTAeFw0yNDAzMTMwMDAwMDBa
Fw0yNzAzMTIyMzU5NTlaMDIxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBF
bmNyeXB0MQswCQYDVQQDEwJFODB2MBAGByqGSM49AgEGBSuBBAAiA2IABNFl8l7c
S7QMApzSsvru6WyrOq44ofTUOTIzxULUzDMMNMchIJBwXOhiLxxxs0LXeb5GDcHb
R6EToMffgSZjO9SNHfY9gjMy9vQr5/WWOrQTZxh7az6NSNnq3u2ubT6HTKOB+DCB
9TAOBgNVHQ8BAf8EBAMCAYYwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMB
MBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFI8NE6L2Ln7RUGwzGDhdWY4j
cpHKMB8GA1UdIwQYMBaAFHm0WeZ7tuXkAXOACIjIGlj26ZtuMDIGCCsGAQUFBwEB
BCYwJDAiBggrBgEFBQcwAoYWaHR0cDovL3gxLmkubGVuY3Iub3JnLzATBgNVHSAE
DDAKMAgGBmeBDAECATAnBgNVHR8EIDAeMBygGqAYhhZodHRwOi8veDEuYy5sZW5j
ci5vcmcvMA0GCSqGSIb3DQEBCwUAA4ICAQBnE0hGINKsCYWi0Xx1ygxD5qihEjZ0
RI3tTZz1wuATH3ZwYPIp97kWEayanD1j0cDhIYzy4CkDo2jB8D5t0a6zZWzlr98d
AQFNh8uKJkIHdLShy+nUyeZxc5bNeMp1Lu0gSzE4McqfmNMvIpeiwWSYO9w82Ob8
otvXcO2JUYi3svHIWRm3+707DUbL51XMcY2iZdlCq4Wa9nbuk3WTU4gr6LY8MzVA
aDQG2+4U3eJ6qUF10bBnR1uuVyDYs9RhrwucRVnfuDj29CMLTsplM5f5wSV5hUpm
Uwp/vV7M4w4aGunt74koX71n4EdagCsL/Yk5+mAQU0+tue0JOfAV/R6t1k+Xk9s2
HMQFeoxppfzAVC04FdG9M+AC2JWxmFSt6BCuh3CEey3fE52Qrj9YM75rtvIjsm/1
Hl+u//Wqxnu1ZQ4jpa+VpuZiGOlWrqSP9eogdOhCGisnyewWJwRQOqK16wiGyZeR
xs/Bekw65vwSIaVkBruPiTfMOo0Zh4gVa8/qJgMbJbyrwwG97z/PRgmLKCDl8z3d
tA0Z7qq7fta0Gl24uyuB05dqI5J1LvAzKuWdIjT1tP8qCoxSE/xpix8hX2dt3h+/
jujUgFPFZ0EVZ0xSyBNRF3MboGZnYXFUxpNjTWPKpagDHJQmqrAcDmWJnMsFY3jS
u1igv3OefnWjSQ==
-----END CERTIFICATE-----

2
client.ts Executable file
View File

@ -0,0 +1,2 @@
// Import CSS files here for hot module reloading to work.
import "./assets/styles.css";

17
components/Button.tsx Normal file
View File

@ -0,0 +1,17 @@
import type { ComponentChildren } from "preact";
export interface ButtonProps {
id?: string;
onClick?: () => void;
children?: ComponentChildren;
disabled?: boolean;
}
export function Button(props: ButtonProps) {
return (
<button
{...props}
class="px-2 py-1 border-gray-500 border-2 rounded-sm bg-white hover:bg-gray-200 transition-colors"
/>
);
}

281
cyberhybridhub-web.sh Executable file
View File

@ -0,0 +1,281 @@
#!/bin/bash
# CyberHybridHub Web App Service Manager
# Usage: ./cyberhybridhub-web.sh [up|down]
set -e
# Configuration
APP_DIR="/home/nanderson/nathanAndrinoid/WebSites/cyberhybridhub.com/publish"
APP_NAME="cyberhybridhub-web"
PID_FILE="$APP_DIR/.cyberhybridhub-web.pid"
LOG_FILE="$APP_DIR/.cyberhybridhub-web.log"
SERVICE_FILE="/etc/systemd/system/cyberhybridhub-web.service"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Function to print colored output
print_status() {
local color=$1
local message=$2
echo -e "${color}${message}${NC}"
}
# Function to check if the service is running
is_running() {
if [[ -f "$PID_FILE" ]]; then
local pid=$(cat "$PID_FILE")
if ps -p "$pid" > /dev/null 2>&1; then
return 0
else
# PID file exists but process is not running
rm -f "$PID_FILE"
return 1
fi
fi
return 1
}
# Function to kill any process running on port 8080
kill_port_8080() {
local pid_on_port=$(lsof -ti:8080 2>/dev/null || true)
if [[ -n "$pid_on_port" ]]; then
print_status "$YELLOW" "Killing process running on port 8080 (PID: $pid_on_port)..."
kill "$pid_on_port" 2>/dev/null || true
# Wait a moment and force kill if still running
sleep 1
if lsof -ti:8080 >/dev/null 2>&1; then
print_status "$YELLOW" "Force killing process on port 8080..."
kill -9 "$pid_on_port" 2>/dev/null || true
fi
fi
}
# Function to load environment variables
load_env_vars() {
if [ -f ".env" ]; then
while IFS= read -r line || [[ -n "$line" ]]; do
# Skip empty lines and comments
[[ "$line" =~ ^[[:space:]]*# ]] && continue
[[ -z "${line// }" ]] && continue
# Export the variable properly handling quoted values
if [[ "$line" =~ ^[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
export "${BASH_REMATCH[1]}=${BASH_REMATCH[2]}"
fi
done < ".env"
print_status "$GREEN" "Environment variables loaded from .env"
else
print_status "$YELLOW" "Warning: .env file not found"
fi
}
# Function to create systemd service for auto-start
create_systemd_service() {
cat << EOF | sudo tee "$SERVICE_FILE" > /dev/null
[Unit]
Description=CyberHybridHub Web Service
After=network.target
[Service]
Type=simple
User=nanderson
WorkingDirectory=$APP_DIR
ExecStart=/bin/bash -c 'set -a && source $APP_DIR/../.env 2>/dev/null || true && set +a && exec $APP_DIR/server -A'
Restart=always
RestartSec=10
StandardOutput=append:$LOG_FILE
StandardError=append:$LOG_FILE
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable cyberhybridhub-web.service
print_status "$GREEN" "Systemd service created and enabled for auto-start on reboot"
}
# Function to remove systemd service
remove_systemd_service() {
if [[ -f "$SERVICE_FILE" ]]; then
sudo systemctl stop cyberhybridhub-web.service 2>/dev/null || true
sudo systemctl disable cyberhybridhub-web.service 2>/dev/null || true
sudo rm -f "$SERVICE_FILE"
sudo systemctl daemon-reload
print_status "$GREEN" "Systemd service removed and auto-start disabled"
fi
}
# Function to start the service
start_service() {
if is_running; then
print_status "$YELLOW" "CyberHybridHub web service is already running (PID: $(cat "$PID_FILE"))"
return 0
fi
print_status "$GREEN" "Starting CyberHybridHub web service..."
# Load environment variables
load_env_vars
# Kill any process running on port 8080 first
kill_port_8080
# Check if the app directory exists
if [[ ! -d "$APP_DIR" ]]; then
print_status "$RED" "Error: Application directory '$APP_DIR' does not exist"
exit 1
fi
# Check if the server binary exists
if [[ ! -f "$APP_DIR/server" ]]; then
print_status "$RED" "Error: Server binary '$APP_DIR/server' not found"
print_status "$YELLOW" "Please ensure the server binary is built and available"
exit 1
fi
# Create systemd service for auto-start
create_systemd_service
# Start the service using systemctl
sudo systemctl start cyberhybridhub-web.service
# Wait a moment to check if the service started successfully
sleep 3
if sudo systemctl is-active --quiet cyberhybridhub-web.service; then
print_status "$GREEN" "CyberHybridHub web service started successfully with auto-start enabled"
print_status "$GREEN" "Log file: $LOG_FILE"
else
print_status "$RED" "Failed to start CyberHybridHub web service"
print_status "$YELLOW" "Check the log file: $LOG_FILE"
remove_systemd_service
exit 1
fi
}
# Function to stop the service
stop_service() {
if ! is_running; then
print_status "$YELLOW" "CyberHybridHub web service is not running"
else
local pid=$(cat "$PID_FILE")
print_status "$GREEN" "Stopping CyberHybridHub web service (PID: $pid)..."
# Send SIGTERM signal for graceful shutdown
kill "$pid" 2>/dev/null || true
# Wait for graceful shutdown
local count=0
while is_running && [[ $count -lt 10 ]]; do
sleep 1
count=$((count + 1))
done
# If still running, force kill
if is_running; then
print_status "$YELLOW" "Service didn't stop gracefully, forcing shutdown..."
kill -9 "$pid" 2>/dev/null || true
sleep 1
fi
# Clean up PID file
rm -f "$PID_FILE"
if ! is_running; then
print_status "$GREEN" "CyberHybridHub web service stopped successfully"
else
print_status "$RED" "Failed to stop CyberHybridHub web service"
exit 1
fi
fi
# Remove systemd service and disable auto-start
remove_systemd_service
}
# Function to reset the service
reset_service() {
print_status "$YELLOW" "Resetting CyberHybridHub web service..."
# Stop the service and remove systemd service
stop_service
# Kill any process on port 8080
kill_port_8080
# Clean up any remaining files
rm -f "$PID_FILE"
print_status "$GREEN" "CyberHybridHub web service has been reset"
}
# Function to show service status
show_status() {
print_status "$GREEN" "=== CyberHybridHub Web Service Status ==="
# Show systemctl status first
if systemctl list-unit-files | grep -q "cyberhybridhub-web.service"; then
print_status "$GREEN" "Systemd service status:"
sudo systemctl status cyberhybridhub-web.service --no-pager -l
echo ""
else
print_status "$YELLOW" "Systemd service not installed"
fi
# Show custom status
if sudo systemctl is-active --quiet cyberhybridhub-web.service 2>/dev/null; then
print_status "$GREEN" "Service is running (systemd managed)"
print_status "$GREEN" "Auto-start on reboot: ENABLED"
print_status "$GREEN" "Log file: $LOG_FILE"
elif is_running; then
local pid=$(cat "$PID_FILE")
print_status "$YELLOW" "Service is running (manual, PID: $pid)"
print_status "$YELLOW" "Auto-start on reboot: DISABLED"
print_status "$GREEN" "Log file: $LOG_FILE"
else
print_status "$YELLOW" "Service is not running"
if [[ -f "$SERVICE_FILE" ]]; then
print_status "$YELLOW" "Auto-start on reboot: ENABLED (service installed)"
else
print_status "$YELLOW" "Auto-start on reboot: DISABLED"
fi
fi
}
# Main script logic
case "${1:-}" in
"up")
start_service
;;
"down")
stop_service
;;
"reset")
reset_service
;;
"status")
show_status
;;
"restart")
stop_service
sleep 2
start_service
;;
*)
echo "CyberHybridHub Web App Service Manager"
echo "Usage: $0 {up|down|reset|status|restart}"
echo ""
echo "Commands:"
echo " up - Start the web service and enable auto-start on reboot"
echo " down - Stop the web service and disable auto-start on reboot"
echo " reset - Completely reset the service (stop + cleanup)"
echo " status - Show service status and auto-start configuration"
echo " restart - Restart the web service"
exit 1
;;
esac

24
db/db.ts Executable file
View File

@ -0,0 +1,24 @@
// db/db.ts
// PostgreSQL connection
import * as schema from "./schema.ts";
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
const pool = new Pool({
connectionString: Deno.env.get("POSTGRES_OPTIONS")!,
});
export const db = drizzle({ client: pool, schema });
/** Check if database connection is available */
export async function checkDatabaseConnection(): Promise<boolean> {
try {
const client = await pool.connect();
await client.query("SELECT 1");
client.release();
return true;
} catch (error) {
console.error("Database connection check failed:", error);
return false;
}
}

275
db/interfaces.ts Executable file
View File

@ -0,0 +1,275 @@
// ========================================
// TypeScript Interfaces for all pgTables
// ========================================
export interface User {
id: string;
name?: string | null;
email?: string | null;
phone?: string | null;
address_1?: string | null;
address_2?: string | null;
city?: string | null;
state?: string | null;
zip?: string | null;
country?: string | null;
user_type?: string | null;
onboarded_landlord?: boolean | null;
onboarded_tenant?: boolean | null;
email_verified?: boolean | null;
banking_setup_complete?: boolean | null;
onboarding_step?: number | null;
notification_preferences_json?: string | null;
payment_preferences_json?: string | null;
encrypted_data?: string | null;
started_on?: number | null;
updated_on?: number | null;
}
export interface Role {
id: string;
description?: string | null;
}
export interface UserRole {
id: string;
userId?: string | null;
roleId?: string | null;
created_on?: number | null;
updated_on?: number | null;
}
export interface StateKey {
seal_key: string;
state_id: string;
cookie_hash: string;
created_on: number;
}
export interface WebToken {
token_id: string;
state_id: string;
user_id: string;
token_type: string;
token_value: string;
expires_on: number;
created_on: number;
updated_on: number;
last_used_on?: number | null;
is_active?: boolean | null;
metadata?: any | null;
}
export interface Obligation {
id: string;
user_id?: string | null;
description?: string | null;
amount?: number | null;
frequency?: string | null;
due_date?: number | null;
encrypted_data?: string | null;
created_on?: number | null;
updated_on?: number | null;
}
export interface Income {
id: string;
user_id?: string | null;
source?: string | null;
amount?: number | null;
frequency?: string | null;
received_date?: number | null;
encrypted_data?: string | null;
created_on?: number | null;
updated_on?: number | null;
}
export interface FinancialBalance {
id: string;
user_id?: string | null;
balance?: number | null;
balance_date?: number | null;
encrypted_data?: string | null;
created_on?: number | null;
updated_on?: number | null;
}
export interface Property {
id: string;
title?: string | null;
description?: string | null;
address_1?: string | null;
address_2?: string | null;
city?: string | null;
state?: string | null;
zip?: string | null;
country?: string | null;
bedrooms?: number | null;
bathrooms?: number | null;
square_footage?: number | null;
lot_size?: number | null;
year_built?: number | null;
price?: number | null;
property_type?: string | null;
listing_status?: string | null;
rental_status?: string | null;
monthly_rent?: number | null;
security_deposit?: number | null;
default_payment_method_id?: string | null;
lease_duration_months?: number | null;
amenities?: string | null;
property_photos_count?: number | null;
image_url?: string | null;
encrypted_data?: string | null;
created_on?: number | null;
updated_on?: number | null;
owner_id?: string | null;
}
export interface RentSchedule {
id: string;
property_id?: string | null;
rent_amount?: number | null;
due_day?: number | null;
late_fee_amount?: number | null;
grace_period_days?: number | null;
payment_methods_allowed?: string | null;
created_on?: number | null;
updated_on?: number | null;
}
export interface Lease {
id: string;
title?: string | null;
property_id?: string | null;
property_address?: string | null;
tenant_id?: string | null;
owner_id?: string | null;
start_date?: number | null;
end_date?: number | null;
rent?: number | null;
security_deposit?: number | null;
default_payment_method_id?: string | null;
lease_status?: string | null;
lease_document_url?: string | null;
payment_schedule_id?: string | null;
late_fee_rules?: string | null;
auto_renewal?: boolean | null;
encrypted_data?: string | null;
created_on?: number | null;
updated_on?: number | null;
}
export interface PaymentMethod {
id: string;
user_id?: string | null;
method_type?: string | null;
bank_name?: string | null;
account_type?: string | null;
routing_number_encrypted?: string | null;
account_number_encrypted?: string | null;
is_default?: boolean | null;
created_on?: number | null;
updated_on?: number | null;
}
export interface PropertyMedia {
id: string;
property_id?: string | null;
media_type?: string | null;
file_url?: string | null;
file_name?: string | null;
display_order?: number | null;
created_on?: number | null;
}
export interface LeaseDocument {
id: string;
lease_id?: string | null;
document_type?: string | null;
file_url?: string | null;
file_name?: string | null;
signed_by_landlord?: boolean | null;
signed_by_tenant?: boolean | null;
signature_date?: number | null;
created_on?: number | null;
}
export interface TenantApplication {
id: string;
property_id?: string | null;
applicant_id?: string | null;
application_status?: string | null;
application_data_encrypted?: string | null;
document_urls_json?: any | null;
submitted_on?: number | null;
reviewed_on?: number | null;
}
export interface TenantInvite {
id: string;
landlord_id?: string | null;
property_id?: string | null;
available_on?: number | null;
invited_emails?: string | null;
created_on?: number | null;
updated_on?: number | null;
}
export interface PaymentTransaction {
id: string;
lease_id?: string | null;
amount?: number | null;
payment_date?: number | null;
payment_method?: string | null;
transaction_status?: string | null;
stripe_payment_id?: string | null;
created_on?: number | null;
}
export interface Reminder {
id: string;
user_id: string;
lease_id?: string | null;
reminder_type: string;
title: string;
description?: string | null;
due_date: number;
is_completed?: boolean | null;
created_on?: number | null;
updated_on?: number | null;
}
export interface Message {
id: string;
lease_id?: string | null;
sender_id?: string | null;
recipient_id?: string | null;
message_type?: string | null;
subject?: string | null;
parent_message_id?: string | null;
attachments_json?: any | null;
content?: string | null;
content_encrypted?: string | null;
is_read?: boolean | null;
created_on?: number | null;
}
export interface MaintenanceRequest {
id: string;
property_id?: string | null;
tenant_id?: string | null;
request_type?: string | null;
description?: string | null;
status?: string | null;
priority?: string | null;
created_on?: number | null;
resolved_on?: number | null;
}
export interface UserPin {
user_id: string;
pin: string;
created_on: number;
modified_on: number;
}

390
db/schema.ts Executable file
View File

@ -0,0 +1,390 @@
// db/schema.ts
import {
bigint,
boolean,
doublePrecision,
integer,
json,
pgTable,
text,
varchar,
} from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: text("id").primaryKey(), // OAuth2 user ID
name: text("name"),
email: text("email"),
phone: text("phone"),
address_1: text("address_1"),
address_2: text("address_2"),
city: text("city"),
state: text("state"),
zip: text("zip"),
country: text("country"),
user_type: text("user_type"), // 'landlord', 'tenant', 'admin'
onboarded_landlord: boolean("onboarded_landlord").default(false),
onboarded_tenant: boolean("onboarded_tenant").default(false),
email_verified: boolean("email_verified").default(false),
banking_setup_complete: boolean("banking_setup_complete").default(false),
notification_preferences_json: json("notification_preferences_json"),
payment_preferences_json: json("payment_preferences_json"),
onboarding_step: integer("onboarding_step").default(0), // Track onboarding progress
encrypted_data: text("encrypted_data"), // Encrypted personal information
started_on: bigint("started_on", { mode: "number" }),
updated_on: bigint("updated_on", { mode: "number" }),
});
export const roles = pgTable("roles", {
id: text("id").primaryKey(), // e.g., 'admin', 'guest', 'client', 'owner'
description: text("description"),
});
export const userRoles = pgTable("user_roles", {
id: text("id").primaryKey(),
userId: text("user_id").references(() => users.id),
roleId: text("role_id").references(() => roles.id),
created_on: bigint("created_on", { mode: "number" }),
updated_on: bigint("updated_on", { mode: "number" }),
});
export const stateKeys = pgTable("state_keys", {
seal_key: varchar("seal_key", { length: 32 }).primaryKey(),
state_id: varchar("state_id", { length: 36 }).notNull(),
cookie_hash: varchar("cookie_hash", { length: 64 }).notNull(),
created_on: bigint("created_on", { mode: "number" }).notNull(),
});
export const userTokens = pgTable("user_tokens", {
user_id: text("user_id").primaryKey().references(() => users.id),
state_id: text("state_id").notNull(),
access_token: text("access_token").notNull(),
refresh_token: text("refresh_token").notNull(),
created_on: bigint("created_on", { mode: "number" }),
updated_on: bigint("updated_on", { mode: "number" }),
});
export const webTokens = pgTable("web_tokens", {
token_id: varchar("token_id", { length: 64 }).primaryKey(),
state_id: varchar("state_id", { length: 36 }).notNull(),
user_id: text("user_id").notNull().references(() => users.id),
token_type: text("token_type").notNull(),
token_value: text("token_value").notNull(),
expires_on: bigint("expires_on", { mode: "number" }).notNull(),
created_on: bigint("created_on", { mode: "number" }).notNull(),
updated_on: bigint("updated_on", { mode: "number" }).notNull(),
last_used_on: bigint("last_used_on", { mode: "number" }),
is_active: boolean("is_active").default(true),
metadata: json("metadata"),
});
// Obligations table
export const obligations = pgTable("obligations", {
id: text("id").primaryKey(),
user_id: text("user_id").references(() => users.id),
description: text("description"),
amount: doublePrecision("amount"),
frequency: text("frequency"), // e.g., 'monthly', 'yearly'
due_date: bigint("due_date", { mode: "number" }), // unix timestamp
encrypted_data: text("encrypted_data"), // Encrypted financial information
created_on: bigint("created_on", { mode: "number" }),
updated_on: bigint("updated_on", { mode: "number" }),
});
// Incomes table
export const incomes = pgTable("incomes", {
id: text("id").primaryKey(),
user_id: text("user_id").references(() => users.id),
source: text("source"),
amount: doublePrecision("amount"),
frequency: text("frequency"), // e.g., 'monthly', 'yearly'
received_date: bigint("received_date", { mode: "number" }), // unix timestamp
encrypted_data: text("encrypted_data"), // Encrypted financial information
created_on: bigint("created_on", { mode: "number" }),
updated_on: bigint("updated_on", { mode: "number" }),
});
// Financial balances table - stores balance-form data from FinancialCalendar
export const financial_balances = pgTable("financial_balances", {
id: text("id").primaryKey(),
user_id: text("user_id").references(() => users.id),
balance: doublePrecision("balance"), // balance amount
balance_date: bigint("balance_date", { mode: "number" }), // date when balance was set (YYYY-MM-DD format)
encrypted_data: text("encrypted_data"), // Encrypted financial information
created_on: bigint("created_on", { mode: "number" }),
updated_on: bigint("updated_on", { mode: "number" }),
});
export const properties = pgTable("properties", {
id: text("id").primaryKey(),
title: text("title"),
description: text("description"),
address_1: text("address_1"),
address_2: text("address_2"),
city: text("city"),
state: text("state"),
zip: text("zip"),
country: text("country"),
bedrooms: integer("bedrooms"),
bathrooms: integer("bathrooms"),
square_footage: integer("square_footage"),
lot_size: integer("lot_size"),
year_built: integer("year_built"),
price: doublePrecision("price"),
property_type: text("property_type"),
listing_status: text("listing_status"),
rental_status: text("rental_status"), // 'available', 'occupied', 'maintenance', 'draft'
monthly_rent: doublePrecision("monthly_rent"),
security_deposit: doublePrecision("security_deposit"),
default_payment_method_id: text("default_payment_method_id").references(() =>
payment_methods.id
),
lease_duration_months: integer("lease_duration_months"),
amenities: text("amenities"), // JSON string of amenities
property_photos_count: integer("property_photos_count").default(0),
image_url: text("image_url"),
encrypted_data: text("encrypted_data"), // Encrypted property information
created_on: bigint("created_on", { mode: "number" }),
updated_on: bigint("updated_on", { mode: "number" }),
owner_id: text("owner_id").references(() => users.id),
});
// Rent Schedules & Rules
export const rent_schedules = pgTable("rent_schedules", {
id: text("id").primaryKey(),
property_id: text("property_id").references(() => properties.id),
rent_amount: doublePrecision("rent_amount"),
due_day: integer("due_day"), // Day of month rent is due (1-31)
late_fee_amount: doublePrecision("late_fee_amount"),
grace_period_days: integer("grace_period_days").default(0),
payment_methods_allowed: text("payment_methods_allowed"), // JSON array of allowed methods
created_on: bigint("created_on", { mode: "number" }),
updated_on: bigint("updated_on", { mode: "number" }),
});
export const leases = pgTable("leases", {
id: text("id").primaryKey(),
title: text("title"),
property_id: text("property_id").references(() => properties.id),
property_address: text("property_address"),
tenant_id: text("tenant_id").references(() => users.id),
owner_id: text("owner_id").references(() => users.id),
start_date: bigint("start_date", { mode: "number" }),
end_date: bigint("end_date", { mode: "number" }),
rent: doublePrecision("rent"),
security_deposit: doublePrecision("security_deposit"),
default_payment_method_id: text("default_payment_method_id").references(() =>
payment_methods.id
),
lease_status: text("lease_status"),
lease_document_url: text("lease_document_url"),
payment_schedule_id: text("payment_schedule_id").references(() =>
rent_schedules.id
),
late_fee_rules: text("late_fee_rules"), // JSON string of late fee rules
auto_renewal: boolean("auto_renewal").default(false),
encrypted_data: text("encrypted_data"), // Encrypted lease information
created_on: bigint("created_on", { mode: "number" }),
updated_on: bigint("updated_on", { mode: "number" }),
});
// Payment Methods & Banking
export const payment_methods = pgTable("payment_methods", {
id: text("id").primaryKey(),
user_id: text("user_id").references(() => users.id),
method_type: text("method_type"), // 'bank_account', 'credit_card'
bank_name: text("bank_name"),
account_type: text("account_type"), // 'checking', 'savings'
routing_number_encrypted: text("routing_number_encrypted"),
account_number_encrypted: text("account_number_encrypted"),
is_default: boolean("is_default").default(false),
created_on: bigint("created_on", { mode: "number" }),
updated_on: bigint("updated_on", { mode: "number" }),
});
// Property Photos & Documents
export const property_media = pgTable("property_media", {
id: text("id").primaryKey(),
property_id: text("property_id").references(() => properties.id),
media_type: text("media_type"), // 'photo', 'video', 'document'
file_url: text("file_url"),
file_name: text("file_name"),
display_order: integer("display_order").default(0),
created_on: bigint("created_on", { mode: "number" }),
});
// Lease Documents & Templates
export const lease_documents = pgTable("lease_documents", {
id: text("id").primaryKey(),
lease_id: text("lease_id").references(() => leases.id),
document_type: text("document_type"), // 'lease', 'addendum', 'receipt'
file_url: text("file_url"),
file_name: text("file_name"),
signed_by_landlord: boolean("signed_by_landlord").default(false),
signed_by_tenant: boolean("signed_by_tenant").default(false),
signature_date: bigint("signature_date", { mode: "number" }),
created_on: bigint("created_on", { mode: "number" }),
});
// Tenant Applications
export const tenant_applications = pgTable("tenant_applications", {
id: text("id").primaryKey(),
property_id: text("property_id").references(() => properties.id),
applicant_id: text("applicant_id").references(() => users.id),
application_status: text("application_status"), // 'pending', 'approved', 'rejected'
application_data_encrypted: text("application_data_encrypted"), // Encrypted application details
document_urls_json: json("document_urls_json"), // JSON array of document URLs
submitted_on: bigint("submitted_on", { mode: "number" }),
reviewed_on: bigint("reviewed_on", { mode: "number" }),
});
export const tenant_invites = pgTable("tenant_invites", {
id: text("id").primaryKey(),
landlord_id: text("landlord_id").references(() => users.id),
property_id: text("property_id").references(() => properties.id),
available_on: bigint("available_on", { mode: "number" }),
invited_emails: text("invited_emails"), // JSON array of invited emails
created_on: bigint("created_on", { mode: "number" }),
updated_on: bigint("updated_on", { mode: "number" }),
});
// Payment Transactions
export const payment_transactions = pgTable("payment_transactions", {
id: text("id").primaryKey(),
lease_id: text("lease_id").references(() => leases.id),
amount: doublePrecision("amount"),
payment_date: bigint("payment_date", { mode: "number" }),
payment_method: text("payment_method"), // 'card', 'ach', 'check', 'cash'
transaction_status: text("transaction_status"), // 'pending', 'completed', 'failed'
stripe_payment_id: text("stripe_payment_id"), // External payment processor ID
created_on: bigint("created_on", { mode: "number" }),
});
// Reminders
export const reminders = pgTable("reminders", {
id: text("id").primaryKey(),
user_id: text("user_id").references(() => users.id).notNull(),
lease_id: text("lease_id").references(() => leases.id),
reminder_type: text("reminder_type").notNull(), // 'rent_payment', 'maintenance_followup', 'custom', 'document_expiration'
title: text("title").notNull(),
description: text("description"),
due_date: bigint("due_date", { mode: "number" }).notNull(),
is_completed: boolean("is_completed").default(false),
created_on: bigint("created_on", { mode: "number" }),
updated_on: bigint("updated_on", { mode: "number" }),
});
// Communication/Messages
export const messages = pgTable("messages", {
id: text("id").primaryKey(),
lease_id: text("lease_id").references(() => leases.id),
sender_id: text("sender_id").references(() => users.id),
recipient_id: text("recipient_id").references(() => users.id),
message_type: text("message_type"), // 'general', 'maintenance', 'payment', 'notice'
subject: text("subject"),
parent_message_id: text("parent_message_id"), // For threading
attachments_json: json("attachments_json"), // JSON array of file URLs/metadata
content: text("content"), // Encrypted message content
content_encrypted: text("content_encrypted"), // Encrypted message content
is_read: boolean("is_read").default(false),
created_on: bigint("created_on", { mode: "number" }),
});
// Maintenance Requests
export const maintenance_requests = pgTable("maintenance_requests", {
id: text("id").primaryKey(),
property_id: text("property_id").references(() => properties.id),
tenant_id: text("tenant_id").references(() => users.id),
request_type: text("request_type"), // 'plumbing', 'electrical', 'hvac', 'general'
description: text("description"), // Encrypted request details
status: text("status"), // 'open', 'in_progress', 'completed', 'cancelled'
priority: text("priority"), // 'low', 'medium', 'high', 'urgent'
created_on: bigint("created_on", { mode: "number" }),
resolved_on: bigint("resolved_on", { mode: "number" }),
});
// User Pins
export const user_pins = pgTable("user_pins", {
user_id: text("user_id").primaryKey().references(() => users.id),
pin: text("pin").notNull(), // Encrypted pin/password
created_on: bigint("created_on", { mode: "number" }).notNull(),
modified_on: bigint("modified_on", { mode: "number" }).notNull(),
});
// Session Config table
export const session_configs = pgTable("session_configs", {
device_id: text("device_id").primaryKey(),
user_id: text("user_id").references(() => users.id),
other_data: text("other_data"),
active_date: bigint("active_date", { mode: "number" }),
quiz_configs: json("quiz_configs"), // Array<Array<string>>
active_route: text("active_route"),
page_data: text("page_data"),
active_quiz_index: integer("active_quiz_index"),
active_question_index: integer("active_question_index"),
});
// Quiz Config table
export const quiz_configs = pgTable("quiz_configs", {
quiz_config_id: text("quiz_config_id").primaryKey(),
quiz_name: text("quiz_name"),
owner_id: text("owner_id").references(() => users.id),
quiz_mode: text("quiz_mode"),
active_date: bigint("active_date", { mode: "number" }),
tab_name: text("tab_name"),
tab_index: integer("tab_index").default(0),
list_index: integer("list_index").default(0),
questions: json("questions"), // Array of question objects
results: json("results"), // Array of result objects
});
// Quiz Questions table (normalized for better querying)
export const quiz_questions = pgTable("quiz_questions", {
id: text("id").primaryKey(),
quiz_id: text("quiz_id").references(() => quiz_configs.quiz_config_id),
owner_id: text("owner_id").references(() => users.id),
question_index: integer("question_index").default(0),
language_1: text("language_1"),
language_2: text("language_2"),
text_1: text("text_1"),
audio_file_1: text("audio_file_1"),
text_2: text("text_2"),
audio_file_2: text("audio_file_2"),
order_num: integer("order_num"),
background_file: text("background_file"),
background_image_data: text("background_image_data"),
video_file: text("video_file"),
video_file_data: text("video_file_data"),
});
// Quiz Results table (normalized for better querying)
export const quiz_results = pgTable("quiz_results", {
id: text("id").primaryKey(),
quiz_config_id: text("quiz_config_id").references(() =>
quiz_configs.quiz_config_id
),
user_id: text("user_id").references(() => users.id),
success: boolean("success"),
total_time: bigint("total_time", { mode: "number" }),
help_count: integer("help_count"),
completed_on: bigint("completed_on", { mode: "number" }),
});
// Brain Question Progress table (for spaced repetition learning with mode progression)
export const brain_question_progress = pgTable("brain_question_progress", {
id: text("id").primaryKey(), // composite: `${user_id}_${question_id}` (mode-agnostic)
user_id: text("user_id").references(() => users.id).notNull(),
quiz_id: text("quiz_id").references(() => quiz_configs.quiz_config_id),
question_id: text("question_id").notNull(),
current_mode: text("current_mode").notNull().default("photo-see-bisaya"), // Current mode in progression
mode_index: integer("mode_index").default(0), // Index in QUIZ_MODES array (0-5)
attempts: integer("attempts").default(0),
first_try_successes: integer("first_try_successes").default(0),
consecutive_first_try: integer("consecutive_first_try").default(0), // Streak of first-try correct
last_result: boolean("last_result"), // true = correct on first try
next_due_at: bigint("next_due_at", { mode: "number" }), // timestamp for spaced repetition
interval_days: integer("interval_days").default(1), // current spacing interval
ease_factor: doublePrecision("ease_factor").default(2.5), // SM-2 algorithm factor
is_review_only: boolean("is_review_only").default(false), // Card has mastered all modes
created_on: bigint("created_on", { mode: "number" }),
updated_on: bigint("updated_on", { mode: "number" }),
});

1034
db/sqlUserRepository.ts Normal file

File diff suppressed because it is too large Load Diff

BIN
debug-invite-onboarding.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

BIN
debug-onboarding.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

82
deno.json Executable file
View File

@ -0,0 +1,82 @@
{
"lock": false,
"tasks": {
"check": "deno fmt --check . && deno lint . && deno check",
"dev": "vite",
"build": "vite build",
"start": "deno serve -A --port 8080 _fresh/server.js",
"update": "deno run -A -r jsr:@fresh/update .",
"test": "deno test -A tests/",
"e2e": "npx playwright test",
"e2e:ui": "npx playwright test --ui",
"e2e:report": "npx playwright show-report",
"db:generate": "deno run -A npm:drizzle-kit generate",
"db:migrate": "deno run -A npm:drizzle-kit migrate",
"db:push": "deno run -A npm:drizzle-kit push",
"db:studio": "deno run -A npm:drizzle-kit studio"
},
"lint": { "rules": { "tags": ["fresh", "recommended"] } },
"exclude": ["**/_fresh/*"],
"imports": {
"fresh": "jsr:@fresh/core@^2.2.0",
"preact": "npm:preact@^10.27.2",
"@preact/signals": "npm:@preact/signals@^2.5.0",
"@fresh/plugin-vite": "jsr:@fresh/plugin-vite@^1.0.4",
"vite": "npm:vite@^7.2.4",
"tailwindcss": "npm:tailwindcss@^4.1.17",
"@tailwindcss/vite": "npm:@tailwindcss/vite@^4.1.17",
"@types/pg": "npm:@types/pg@^8.15.4",
"drizzle-kit": "npm:drizzle-kit@^0.31.7",
"drizzle-orm": "npm:drizzle-orm@^0.44.7",
"pg": "npm:pg@^8.16.3",
"@tailwindcss/postcss": "npm:@tailwindcss/postcss@^4.1.17",
"postcss": "npm:postcss@^8.5.6",
"iron": "npm:iron-webcrypto",
"$std/": "https://deno.land/std@0.224.0/",
"djwt": "https://deno.land/x/djwt@v3.0.2/mod.ts",
"msal-node": "npm:@azure/msal-node@3.8.0",
"base64Utils": "https://deno.land/std@0.224.0/encoding/base64url.ts",
"denoPath": "https://deno.land/std@0.224.0/path/mod.ts",
"denoFs": "https://deno.land/std@0.224.0/fs/mod.ts",
"encoder": "jsr:@std/encoding/base64",
"@vite-plugin-static-copy": "npm:vite-plugin-static-copy",
"@GraphClient": "npm:@microsoft/microsoft-graph-client@3.0.0",
"@IsomorphicFetch": "npm:isomorphic-fetch",
"@openai": "https://deno.land/x/openai@v4.69.0/mod.ts",
"gcs": "https://deno.land/x/google_cloud_storage/mod.ts",
"google-cloud-storage": "npm:@google-cloud/storage",
"gLog": "npm:google-logging-utils",
"cosmos": "npm:@azure/cosmos"
},
"compilerOptions": {
"lib": [
"dom",
"dom.asynciterable",
"dom.iterable",
"deno.ns",
"deno.worker",
"deno.unstable"
],
"jsx": "precompile",
"jsxImportSource": "preact",
"jsxPrecompileSkipElements": [
"a",
"img",
"source",
"body",
"html",
"head",
"title",
"meta",
"script",
"link",
"style",
"base",
"noscript",
"template"
],
"types": ["vite/client"]
},
"nodeModulesDir": "auto",
"unstable": ["kv"]
}

10
drizzle.config.ts Normal file
View File

@ -0,0 +1,10 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./db/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
url: Deno.env.get("POSTGRES_OPTIONS")!,
},
});

View File

@ -0,0 +1,332 @@
CREATE TABLE "brain_question_progress" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"quiz_id" text,
"question_id" text NOT NULL,
"mode" text NOT NULL,
"attempts" integer DEFAULT 0,
"first_try_successes" integer DEFAULT 0,
"last_result" boolean,
"next_due_at" bigint,
"interval_days" integer DEFAULT 1,
"ease_factor" double precision DEFAULT 2.5,
"created_on" bigint,
"updated_on" bigint
);
--> statement-breakpoint
CREATE TABLE "financial_balances" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text,
"balance" double precision,
"balance_date" bigint,
"encrypted_data" text,
"created_on" bigint,
"updated_on" bigint
);
--> statement-breakpoint
CREATE TABLE "incomes" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text,
"source" text,
"amount" double precision,
"frequency" text,
"received_date" bigint,
"encrypted_data" text,
"created_on" bigint,
"updated_on" bigint
);
--> statement-breakpoint
CREATE TABLE "lease_documents" (
"id" text PRIMARY KEY NOT NULL,
"lease_id" text,
"document_type" text,
"file_url" text,
"file_name" text,
"signed_by_landlord" boolean DEFAULT false,
"signed_by_tenant" boolean DEFAULT false,
"signature_date" bigint,
"created_on" bigint
);
--> statement-breakpoint
CREATE TABLE "leases" (
"id" text PRIMARY KEY NOT NULL,
"title" text,
"property_id" text,
"property_address" text,
"tenant_id" text,
"owner_id" text,
"start_date" bigint,
"end_date" bigint,
"rent" double precision,
"security_deposit" double precision,
"lease_status" text,
"lease_document_url" text,
"payment_schedule_id" text,
"late_fee_rules" text,
"auto_renewal" boolean DEFAULT false,
"encrypted_data" text,
"created_on" bigint,
"updated_on" bigint
);
--> statement-breakpoint
CREATE TABLE "maintenance_requests" (
"id" text PRIMARY KEY NOT NULL,
"property_id" text,
"tenant_id" text,
"request_type" text,
"description" text,
"status" text,
"priority" text,
"created_on" bigint,
"resolved_on" bigint
);
--> statement-breakpoint
CREATE TABLE "messages" (
"id" text PRIMARY KEY NOT NULL,
"lease_id" text,
"sender_id" text,
"recipient_id" text,
"message_type" text,
"subject" text,
"content" text,
"content_encrypted" text,
"is_read" boolean DEFAULT false,
"created_on" bigint
);
--> statement-breakpoint
CREATE TABLE "obligations" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text,
"description" text,
"amount" double precision,
"frequency" text,
"due_date" bigint,
"encrypted_data" text,
"created_on" bigint,
"updated_on" bigint
);
--> statement-breakpoint
CREATE TABLE "payment_methods" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text,
"method_type" text,
"bank_name" text,
"account_type" text,
"routing_number_encrypted" text,
"account_number_encrypted" text,
"is_default" boolean DEFAULT false,
"created_on" bigint,
"updated_on" bigint
);
--> statement-breakpoint
CREATE TABLE "payment_transactions" (
"id" text PRIMARY KEY NOT NULL,
"lease_id" text,
"amount" double precision,
"payment_date" bigint,
"payment_method" text,
"transaction_status" text,
"stripe_payment_id" text,
"created_on" bigint
);
--> statement-breakpoint
CREATE TABLE "properties" (
"id" text PRIMARY KEY NOT NULL,
"title" text,
"description" text,
"address_1" text,
"address_2" text,
"city" text,
"state" text,
"zip" text,
"country" text,
"bedrooms" integer,
"bathrooms" integer,
"square_footage" integer,
"lot_size" integer,
"year_built" integer,
"price" double precision,
"property_type" text,
"listing_status" text,
"rental_status" text,
"monthly_rent" double precision,
"security_deposit" double precision,
"lease_duration_months" integer,
"amenities" text,
"property_photos_count" integer DEFAULT 0,
"image_url" text,
"encrypted_data" text,
"created_on" bigint,
"updated_on" bigint,
"owner_id" text
);
--> statement-breakpoint
CREATE TABLE "property_media" (
"id" text PRIMARY KEY NOT NULL,
"property_id" text,
"media_type" text,
"file_url" text,
"file_name" text,
"display_order" integer DEFAULT 0,
"created_on" bigint
);
--> statement-breakpoint
CREATE TABLE "quiz_configs" (
"quiz_config_id" text PRIMARY KEY NOT NULL,
"quiz_name" text,
"owner_id" text,
"quiz_mode" text,
"active_date" bigint,
"questions" json,
"results" json
);
--> statement-breakpoint
CREATE TABLE "quiz_questions" (
"id" text PRIMARY KEY NOT NULL,
"quiz_id" text,
"owner_id" text,
"language_1" text,
"language_2" text,
"text_1" text,
"audio_file_1" text,
"text_2" text,
"audio_file_2" text,
"order_num" integer,
"background_file" text,
"background_image_data" text,
"video_file" text,
"video_file_data" text
);
--> statement-breakpoint
CREATE TABLE "quiz_results" (
"id" text PRIMARY KEY NOT NULL,
"quiz_config_id" text,
"user_id" text,
"success" boolean,
"total_time" bigint,
"help_count" integer,
"completed_on" bigint
);
--> statement-breakpoint
CREATE TABLE "rent_schedules" (
"id" text PRIMARY KEY NOT NULL,
"property_id" text,
"rent_amount" double precision,
"due_day" integer,
"late_fee_amount" double precision,
"grace_period_days" integer DEFAULT 0,
"payment_methods_allowed" text,
"created_on" bigint,
"updated_on" bigint
);
--> statement-breakpoint
CREATE TABLE "roles" (
"id" text PRIMARY KEY NOT NULL,
"description" text
);
--> statement-breakpoint
CREATE TABLE "session_configs" (
"device_id" text PRIMARY KEY NOT NULL,
"user_id" text,
"other_data" text,
"active_date" bigint,
"quiz_configs" json,
"active_route" text,
"page_data" text,
"active_quiz_index" integer,
"active_question_index" integer
);
--> statement-breakpoint
CREATE TABLE "state_keys" (
"seal_key" varchar(32) PRIMARY KEY NOT NULL,
"state_id" varchar(36) NOT NULL,
"cookie_hash" varchar(64) NOT NULL,
"created_on" bigint NOT NULL
);
--> statement-breakpoint
CREATE TABLE "tenant_applications" (
"id" text PRIMARY KEY NOT NULL,
"property_id" text,
"applicant_id" text,
"application_status" text,
"application_data_encrypted" text,
"submitted_on" bigint,
"reviewed_on" bigint
);
--> statement-breakpoint
CREATE TABLE "user_roles" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text,
"role_id" text,
"created_on" bigint,
"updated_on" bigint
);
--> statement-breakpoint
CREATE TABLE "user_tokens" (
"user_id" text PRIMARY KEY NOT NULL,
"state_id" text NOT NULL,
"access_token" text NOT NULL,
"refresh_token" text NOT NULL,
"created_on" bigint,
"updated_on" bigint
);
--> statement-breakpoint
CREATE TABLE "user_pins" (
"user_id" text PRIMARY KEY NOT NULL,
"pin" text NOT NULL,
"created_on" bigint NOT NULL,
"modified_on" bigint NOT NULL
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" text PRIMARY KEY NOT NULL,
"name" text,
"email" text,
"phone" text,
"address_1" text,
"address_2" text,
"city" text,
"state" text,
"zip" text,
"country" text,
"user_type" text,
"email_verified" boolean DEFAULT false,
"banking_setup_complete" boolean DEFAULT false,
"onboarding_step" integer DEFAULT 0,
"encrypted_data" text,
"started_on" bigint,
"updated_on" bigint
);
--> statement-breakpoint
ALTER TABLE "brain_question_progress" ADD CONSTRAINT "brain_question_progress_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "brain_question_progress" ADD CONSTRAINT "brain_question_progress_quiz_id_quiz_configs_quiz_config_id_fk" FOREIGN KEY ("quiz_id") REFERENCES "public"."quiz_configs"("quiz_config_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "financial_balances" ADD CONSTRAINT "financial_balances_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "incomes" ADD CONSTRAINT "incomes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "lease_documents" ADD CONSTRAINT "lease_documents_lease_id_leases_id_fk" FOREIGN KEY ("lease_id") REFERENCES "public"."leases"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "leases" ADD CONSTRAINT "leases_property_id_properties_id_fk" FOREIGN KEY ("property_id") REFERENCES "public"."properties"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "leases" ADD CONSTRAINT "leases_tenant_id_users_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "leases" ADD CONSTRAINT "leases_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "leases" ADD CONSTRAINT "leases_payment_schedule_id_rent_schedules_id_fk" FOREIGN KEY ("payment_schedule_id") REFERENCES "public"."rent_schedules"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "maintenance_requests" ADD CONSTRAINT "maintenance_requests_property_id_properties_id_fk" FOREIGN KEY ("property_id") REFERENCES "public"."properties"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "maintenance_requests" ADD CONSTRAINT "maintenance_requests_tenant_id_users_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "messages" ADD CONSTRAINT "messages_lease_id_leases_id_fk" FOREIGN KEY ("lease_id") REFERENCES "public"."leases"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "messages" ADD CONSTRAINT "messages_sender_id_users_id_fk" FOREIGN KEY ("sender_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "messages" ADD CONSTRAINT "messages_recipient_id_users_id_fk" FOREIGN KEY ("recipient_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "obligations" ADD CONSTRAINT "obligations_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "payment_methods" ADD CONSTRAINT "payment_methods_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "payment_transactions" ADD CONSTRAINT "payment_transactions_lease_id_leases_id_fk" FOREIGN KEY ("lease_id") REFERENCES "public"."leases"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "properties" ADD CONSTRAINT "properties_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "property_media" ADD CONSTRAINT "property_media_property_id_properties_id_fk" FOREIGN KEY ("property_id") REFERENCES "public"."properties"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "quiz_configs" ADD CONSTRAINT "quiz_configs_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "quiz_questions" ADD CONSTRAINT "quiz_questions_quiz_id_quiz_configs_quiz_config_id_fk" FOREIGN KEY ("quiz_id") REFERENCES "public"."quiz_configs"("quiz_config_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "quiz_questions" ADD CONSTRAINT "quiz_questions_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "quiz_results" ADD CONSTRAINT "quiz_results_quiz_config_id_quiz_configs_quiz_config_id_fk" FOREIGN KEY ("quiz_config_id") REFERENCES "public"."quiz_configs"("quiz_config_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "quiz_results" ADD CONSTRAINT "quiz_results_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "rent_schedules" ADD CONSTRAINT "rent_schedules_property_id_properties_id_fk" FOREIGN KEY ("property_id") REFERENCES "public"."properties"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "session_configs" ADD CONSTRAINT "session_configs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tenant_applications" ADD CONSTRAINT "tenant_applications_property_id_properties_id_fk" FOREIGN KEY ("property_id") REFERENCES "public"."properties"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tenant_applications" ADD CONSTRAINT "tenant_applications_applicant_id_users_id_fk" FOREIGN KEY ("applicant_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_role_id_roles_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."roles"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_tokens" ADD CONSTRAINT "user_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_pins" ADD CONSTRAINT "user_pins_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;

View File

@ -0,0 +1,43 @@
-- Migration: Add mode progression columns to brain_question_progress
-- This migration alters the brain_question_progress table to support per-card mode progression
-- Step 1: Add new columns with defaults
ALTER TABLE "brain_question_progress"
ADD COLUMN IF NOT EXISTS "current_mode" text DEFAULT 'photo-see-bisaya',
ADD COLUMN IF NOT EXISTS "mode_index" integer DEFAULT 0,
ADD COLUMN IF NOT EXISTS "consecutive_first_try" integer DEFAULT 0,
ADD COLUMN IF NOT EXISTS "is_review_only" boolean DEFAULT false;
--> statement-breakpoint
-- Step 2: Migrate existing data - copy 'mode' to 'current_mode' if it exists
UPDATE "brain_question_progress"
SET "current_mode" = COALESCE("mode", 'photo-see-bisaya')
WHERE "current_mode" IS NULL OR "current_mode" = 'photo-see-bisaya';
--> statement-breakpoint
-- Step 3: Set mode_index based on current_mode value
UPDATE "brain_question_progress"
SET "mode_index" = CASE "current_mode"
WHEN 'photo-see-bisaya' THEN 0
WHEN 'photo-see-english' THEN 1
WHEN 'photo-hide-bisaya' THEN 2
WHEN 'photo-hide-english' THEN 3
WHEN 'blank-see-bisaya' THEN 4
WHEN 'blank-see-english' THEN 5
ELSE 0
END
WHERE "mode_index" = 0 OR "mode_index" IS NULL;
--> statement-breakpoint
-- Step 4: Make current_mode NOT NULL now that all rows have values
ALTER TABLE "brain_question_progress"
ALTER COLUMN "current_mode" SET NOT NULL;
--> statement-breakpoint
-- Step 5: Drop the old 'mode' column (optional - keeping for backwards compatibility during transition)
-- Note: We keep 'mode' column temporarily in case of rollback needs
-- ALTER TABLE "brain_question_progress" DROP COLUMN IF EXISTS "mode";

View File

@ -0,0 +1,17 @@
-- Migration: Add tab/list fields to quiz_configs and question_index to quiz_questions
ALTER TABLE "quiz_configs"
ADD COLUMN IF NOT EXISTS "tab_name" text,
ADD COLUMN IF NOT EXISTS "tab_index" integer DEFAULT 0,
ADD COLUMN IF NOT EXISTS "list_index" integer DEFAULT 0;
--> statement-breakpoint
ALTER TABLE "quiz_questions"
ADD COLUMN IF NOT EXISTS "question_index" integer DEFAULT 0;
--> statement-breakpoint
UPDATE "quiz_configs" SET "tab_index" = 0 WHERE "tab_index" IS NULL;
UPDATE "quiz_configs" SET "list_index" = 0 WHERE "list_index" IS NULL;
UPDATE "quiz_questions" SET "question_index" = 0 WHERE "question_index" IS NULL;

View File

@ -0,0 +1,15 @@
-- Migration: Track onboarding history for landlord and tenant roles
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "onboarded_landlord" boolean DEFAULT false,
ADD COLUMN IF NOT EXISTS "onboarded_tenant" boolean DEFAULT false;
--> statement-breakpoint
UPDATE "users" SET "onboarded_landlord" = false WHERE "onboarded_landlord" IS NULL;
UPDATE "users" SET "onboarded_tenant" = false WHERE "onboarded_tenant" IS NULL;
--> statement-breakpoint
UPDATE "users" SET "onboarded_landlord" = true WHERE "user_type" = 'landlord';
UPDATE "users" SET "onboarded_tenant" = true WHERE "user_type" = 'tenant';

View File

@ -0,0 +1,23 @@
-- Migration: Create tenant invites table
CREATE TABLE IF NOT EXISTS "tenant_invites" (
"id" text PRIMARY KEY,
"landlord_id" text REFERENCES "users"("id"),
"property_id" text REFERENCES "properties"("id"),
"available_on" bigint,
"invited_emails" text,
"created_on" bigint,
"updated_on" bigint
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "tenant_invites_landlord_id_idx"
ON "tenant_invites" ("landlord_id");
CREATE INDEX IF NOT EXISTS "tenant_invites_property_id_idx"
ON "tenant_invites" ("property_id");
CREATE UNIQUE INDEX IF NOT EXISTS "tenant_invites_property_id_unique"
ON "tenant_invites" ("property_id")
WHERE "property_id" IS NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1765422865294,
"tag": "0000_gifted_stepford_cuckoos",
"breakpoints": true
}
]
}

227
drizzle/relations.ts Normal file
View File

@ -0,0 +1,227 @@
import { relations } from "drizzle-orm/relations";
import {
financialBalances,
incomes,
leaseDocuments,
leases,
maintenanceRequests,
messages,
obligations,
paymentMethods,
paymentTransactions,
properties,
propertyMedia,
rentSchedules,
roles,
tenantApplications,
tenantInvites,
userRoles,
users,
userTokens,
} from "./schema.ts";
export const incomesRelations = relations(incomes, ({ one }) => ({
user: one(users, {
fields: [incomes.userId],
references: [users.id],
}),
}));
export const usersRelations = relations(users, ({ many }) => ({
incomes: many(incomes),
leases_ownerId: many(leases, {
relationName: "leases_ownerId_users_id",
}),
leases_tenantId: many(leases, {
relationName: "leases_tenantId_users_id",
}),
properties: many(properties),
messages_recipientId: many(messages, {
relationName: "messages_recipientId_users_id",
}),
messages_senderId: many(messages, {
relationName: "messages_senderId_users_id",
}),
obligations: many(obligations),
paymentMethods: many(paymentMethods),
maintenanceRequests: many(maintenanceRequests),
financialBalances: many(financialBalances),
userRoles: many(userRoles),
tenantApplications: many(tenantApplications),
tenantInvites: many(tenantInvites),
userTokens: many(userTokens),
}));
export const leasesRelations = relations(leases, ({ one, many }) => ({
user_ownerId: one(users, {
fields: [leases.ownerId],
references: [users.id],
relationName: "leases_ownerId_users_id",
}),
rentSchedule: one(rentSchedules, {
fields: [leases.paymentScheduleId],
references: [rentSchedules.id],
}),
property: one(properties, {
fields: [leases.propertyId],
references: [properties.id],
}),
user_tenantId: one(users, {
fields: [leases.tenantId],
references: [users.id],
relationName: "leases_tenantId_users_id",
}),
messages: many(messages),
paymentTransactions: many(paymentTransactions),
leaseDocuments: many(leaseDocuments),
}));
export const rentSchedulesRelations = relations(
rentSchedules,
({ one, many }) => ({
leases: many(leases),
property: one(properties, {
fields: [rentSchedules.propertyId],
references: [properties.id],
}),
}),
);
export const propertiesRelations = relations(properties, ({ one, many }) => ({
leases: many(leases),
rentSchedules: many(rentSchedules),
user: one(users, {
fields: [properties.ownerId],
references: [users.id],
}),
propertyMedias: many(propertyMedia),
maintenanceRequests: many(maintenanceRequests),
tenantApplications: many(tenantApplications),
tenantInvites: many(tenantInvites),
}));
export const messagesRelations = relations(messages, ({ one }) => ({
lease: one(leases, {
fields: [messages.leaseId],
references: [leases.id],
}),
user_recipientId: one(users, {
fields: [messages.recipientId],
references: [users.id],
relationName: "messages_recipientId_users_id",
}),
user_senderId: one(users, {
fields: [messages.senderId],
references: [users.id],
relationName: "messages_senderId_users_id",
}),
}));
export const obligationsRelations = relations(obligations, ({ one }) => ({
user: one(users, {
fields: [obligations.userId],
references: [users.id],
}),
}));
export const paymentMethodsRelations = relations(paymentMethods, ({ one }) => ({
user: one(users, {
fields: [paymentMethods.userId],
references: [users.id],
}),
}));
export const paymentTransactionsRelations = relations(
paymentTransactions,
({ one }) => ({
lease: one(leases, {
fields: [paymentTransactions.leaseId],
references: [leases.id],
}),
}),
);
export const propertyMediaRelations = relations(propertyMedia, ({ one }) => ({
property: one(properties, {
fields: [propertyMedia.propertyId],
references: [properties.id],
}),
}));
export const maintenanceRequestsRelations = relations(
maintenanceRequests,
({ one }) => ({
property: one(properties, {
fields: [maintenanceRequests.propertyId],
references: [properties.id],
}),
user: one(users, {
fields: [maintenanceRequests.tenantId],
references: [users.id],
}),
}),
);
export const financialBalancesRelations = relations(
financialBalances,
({ one }) => ({
user: one(users, {
fields: [financialBalances.userId],
references: [users.id],
}),
}),
);
export const userRolesRelations = relations(userRoles, ({ one }) => ({
role: one(roles, {
fields: [userRoles.roleId],
references: [roles.id],
}),
user: one(users, {
fields: [userRoles.userId],
references: [users.id],
}),
}));
export const rolesRelations = relations(roles, ({ many }) => ({
userRoles: many(userRoles),
}));
export const leaseDocumentsRelations = relations(leaseDocuments, ({ one }) => ({
lease: one(leases, {
fields: [leaseDocuments.leaseId],
references: [leases.id],
}),
}));
export const tenantApplicationsRelations = relations(
tenantApplications,
({ one }) => ({
user: one(users, {
fields: [tenantApplications.applicantId],
references: [users.id],
}),
property: one(properties, {
fields: [tenantApplications.propertyId],
references: [properties.id],
}),
}),
);
export const tenantInvitesRelations = relations(tenantInvites, ({ one }) => ({
landlord: one(users, {
fields: [tenantInvites.landlordId],
references: [users.id],
}),
property: one(properties, {
fields: [tenantInvites.propertyId],
references: [properties.id],
}),
}));
export const userTokensRelations = relations(userTokens, ({ one }) => ({
user: one(users, {
fields: [userTokens.userId],
references: [users.id],
}),
}));

446
drizzle/schema.ts Normal file
View File

@ -0,0 +1,446 @@
import {
bigint,
boolean,
doublePrecision,
foreignKey,
integer,
pgTable,
text,
varchar,
} from "drizzle-orm/pg-core";
import { sql as _sql } from "drizzle-orm";
export const incomes = pgTable("incomes", {
id: text().primaryKey().notNull(),
userId: text("user_id"),
source: text(),
amount: doublePrecision(),
frequency: text(),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
receivedDate: bigint("received_date", { mode: "number" }),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
createdOn: bigint("created_on", { mode: "number" }),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
updatedOn: bigint("updated_on", { mode: "number" }),
encryptedData: text("encrypted_data"),
}, (table) => [
foreignKey({
columns: [table.userId],
foreignColumns: [users.id],
name: "incomes_user_id_users_id_fk",
}),
]);
export const leases = pgTable("leases", {
id: text().primaryKey().notNull(),
title: text(),
propertyId: text("property_id"),
tenantId: text("tenant_id"),
ownerId: text("owner_id"),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
startDate: bigint("start_date", { mode: "number" }),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
endDate: bigint("end_date", { mode: "number" }),
rent: doublePrecision(),
securityDeposit: doublePrecision("security_deposit"),
leaseStatus: text("lease_status"),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
createdOn: bigint("created_on", { mode: "number" }),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
updatedOn: bigint("updated_on", { mode: "number" }),
encryptedData: text("encrypted_data"),
propertyAddress: text("property_address"),
leaseDocumentUrl: text("lease_document_url"),
paymentScheduleId: text("payment_schedule_id"),
lateFeeRules: text("late_fee_rules"),
autoRenewal: boolean("auto_renewal").default(false),
}, (table) => [
foreignKey({
columns: [table.ownerId],
foreignColumns: [users.id],
name: "leases_owner_id_users_id_fk",
}),
foreignKey({
columns: [table.paymentScheduleId],
foreignColumns: [rentSchedules.id],
name: "leases_payment_schedule_id_rent_schedules_id_fk",
}),
foreignKey({
columns: [table.propertyId],
foreignColumns: [properties.id],
name: "leases_property_id_properties_id_fk",
}),
foreignKey({
columns: [table.tenantId],
foreignColumns: [users.id],
name: "leases_tenant_id_users_id_fk",
}),
]);
export const rentSchedules = pgTable("rent_schedules", {
id: text().primaryKey().notNull(),
propertyId: text("property_id"),
rentAmount: doublePrecision("rent_amount"),
dueDay: integer("due_day"),
lateFeeAmount: doublePrecision("late_fee_amount"),
gracePeriodDays: integer("grace_period_days").default(0),
paymentMethodsAllowed: text("payment_methods_allowed"),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
createdOn: bigint("created_on", { mode: "number" }),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
updatedOn: bigint("updated_on", { mode: "number" }),
}, (table) => [
foreignKey({
columns: [table.propertyId],
foreignColumns: [properties.id],
name: "rent_schedules_property_id_properties_id_fk",
}),
]);
export const properties = pgTable("properties", {
id: text().primaryKey().notNull(),
title: text(),
description: text(),
address1: text("address_1"),
address2: text("address_2"),
city: text(),
state: text(),
zip: text(),
country: text(),
bedrooms: integer(),
bathrooms: integer(),
squareFootage: integer("square_footage"),
lotSize: integer("lot_size"),
yearBuilt: integer("year_built"),
price: doublePrecision(),
propertyType: text("property_type"),
listingStatus: text("listing_status"),
imageUrl: text("image_url"),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
createdOn: bigint("created_on", { mode: "number" }),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
updatedOn: bigint("updated_on", { mode: "number" }),
ownerId: text("owner_id"),
encryptedData: text("encrypted_data"),
rentalStatus: text("rental_status"),
monthlyRent: doublePrecision("monthly_rent"),
securityDeposit: doublePrecision("security_deposit"),
leaseDurationMonths: integer("lease_duration_months"),
amenities: text(),
propertyPhotosCount: integer("property_photos_count").default(0),
}, (table) => [
foreignKey({
columns: [table.ownerId],
foreignColumns: [users.id],
name: "properties_owner_id_users_id_fk",
}),
]);
export const messages = pgTable("messages", {
id: text().primaryKey().notNull(),
leaseId: text("lease_id"),
senderId: text("sender_id"),
recipientId: text("recipient_id"),
messageType: text("message_type"),
subject: text(),
contentEncrypted: text("content_encrypted"),
isRead: boolean("is_read").default(false),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
createdOn: bigint("created_on", { mode: "number" }),
content: text(),
}, (table) => [
foreignKey({
columns: [table.leaseId],
foreignColumns: [leases.id],
name: "messages_lease_id_leases_id_fk",
}),
foreignKey({
columns: [table.recipientId],
foreignColumns: [users.id],
name: "messages_recipient_id_users_id_fk",
}),
foreignKey({
columns: [table.senderId],
foreignColumns: [users.id],
name: "messages_sender_id_users_id_fk",
}),
]);
export const obligations = pgTable("obligations", {
id: text().primaryKey().notNull(),
userId: text("user_id"),
description: text(),
amount: doublePrecision(),
frequency: text(),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
dueDate: bigint("due_date", { mode: "number" }),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
createdOn: bigint("created_on", { mode: "number" }),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
updatedOn: bigint("updated_on", { mode: "number" }),
encryptedData: text("encrypted_data"),
}, (table) => [
foreignKey({
columns: [table.userId],
foreignColumns: [users.id],
name: "obligations_user_id_users_id_fk",
}),
]);
export const paymentMethods = pgTable("payment_methods", {
id: text().primaryKey().notNull(),
userId: text("user_id"),
methodType: text("method_type"),
bankName: text("bank_name"),
accountType: text("account_type"),
routingNumberEncrypted: text("routing_number_encrypted"),
accountNumberEncrypted: text("account_number_encrypted"),
isDefault: boolean("is_default").default(false),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
createdOn: bigint("created_on", { mode: "number" }),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
updatedOn: bigint("updated_on", { mode: "number" }),
}, (table) => [
foreignKey({
columns: [table.userId],
foreignColumns: [users.id],
name: "payment_methods_user_id_users_id_fk",
}),
]);
export const paymentTransactions = pgTable("payment_transactions", {
id: text().primaryKey().notNull(),
leaseId: text("lease_id"),
amount: doublePrecision(),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
paymentDate: bigint("payment_date", { mode: "number" }),
paymentMethod: text("payment_method"),
transactionStatus: text("transaction_status"),
stripePaymentId: text("stripe_payment_id"),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
createdOn: bigint("created_on", { mode: "number" }),
}, (table) => [
foreignKey({
columns: [table.leaseId],
foreignColumns: [leases.id],
name: "payment_transactions_lease_id_leases_id_fk",
}),
]);
export const propertyMedia = pgTable("property_media", {
id: text().primaryKey().notNull(),
propertyId: text("property_id"),
mediaType: text("media_type"),
fileUrl: text("file_url"),
fileName: text("file_name"),
displayOrder: integer("display_order").default(0),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
createdOn: bigint("created_on", { mode: "number" }),
}, (table) => [
foreignKey({
columns: [table.propertyId],
foreignColumns: [properties.id],
name: "property_media_property_id_properties_id_fk",
}),
]);
export const maintenanceRequests = pgTable("maintenance_requests", {
id: text().primaryKey().notNull(),
propertyId: text("property_id"),
tenantId: text("tenant_id"),
requestType: text("request_type"),
description: text(),
status: text(),
priority: text(),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
createdOn: bigint("created_on", { mode: "number" }),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
resolvedOn: bigint("resolved_on", { mode: "number" }),
}, (table) => [
foreignKey({
columns: [table.propertyId],
foreignColumns: [properties.id],
name: "maintenance_requests_property_id_properties_id_fk",
}),
foreignKey({
columns: [table.tenantId],
foreignColumns: [users.id],
name: "maintenance_requests_tenant_id_users_id_fk",
}),
]);
export const roles = pgTable("roles", {
id: text().primaryKey().notNull(),
description: text(),
});
export const stateKeys = pgTable("state_keys", {
sealKey: varchar("seal_key", { length: 32 }).primaryKey().notNull(),
stateId: varchar("state_id", { length: 36 }).notNull(),
cookieHash: varchar("cookie_hash", { length: 64 }).notNull(),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
createdOn: bigint("created_on", { mode: "number" }).notNull(),
});
export const financialBalances = pgTable("financial_balances", {
id: text().primaryKey().notNull(),
userId: text("user_id"),
balance: doublePrecision(),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
balanceDate: bigint("balance_date", { mode: "number" }),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
createdOn: bigint("created_on", { mode: "number" }),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
updatedOn: bigint("updated_on", { mode: "number" }),
encryptedData: text("encrypted_data"),
}, (table) => [
foreignKey({
columns: [table.userId],
foreignColumns: [users.id],
name: "financial_balances_user_id_users_id_fk",
}),
]);
export const userRoles = pgTable("user_roles", {
id: text().primaryKey().notNull(),
userId: text("user_id"),
roleId: text("role_id"),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
createdOn: bigint("created_on", { mode: "number" }),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
updatedOn: bigint("updated_on", { mode: "number" }),
}, (table) => [
foreignKey({
columns: [table.roleId],
foreignColumns: [roles.id],
name: "user_roles_role_id_roles_id_fk",
}),
foreignKey({
columns: [table.userId],
foreignColumns: [users.id],
name: "user_roles_user_id_users_id_fk",
}),
]);
export const users = pgTable("users", {
id: text().primaryKey().notNull(),
name: text(),
email: text(),
phone: text(),
address1: text("address_1"),
address2: text("address_2"),
city: text(),
state: text(),
zip: text(),
country: text(),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
startedOn: bigint("started_on", { mode: "number" }),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
updatedOn: bigint("updated_on", { mode: "number" }),
encryptedData: text("encrypted_data"),
userType: text("user_type"),
onboardedLandlord: boolean("onboarded_landlord").default(false),
onboardedTenant: boolean("onboarded_tenant").default(false),
emailVerified: boolean("email_verified").default(false),
bankingSetupComplete: boolean("banking_setup_complete").default(false),
onboardingStep: integer("onboarding_step").default(0),
});
export const leaseDocuments = pgTable("lease_documents", {
id: text().primaryKey().notNull(),
leaseId: text("lease_id"),
documentType: text("document_type"),
fileUrl: text("file_url"),
fileName: text("file_name"),
signedByLandlord: boolean("signed_by_landlord").default(false),
signedByTenant: boolean("signed_by_tenant").default(false),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
signatureDate: bigint("signature_date", { mode: "number" }),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
createdOn: bigint("created_on", { mode: "number" }),
}, (table) => [
foreignKey({
columns: [table.leaseId],
foreignColumns: [leases.id],
name: "lease_documents_lease_id_leases_id_fk",
}),
]);
export const tenantApplications = pgTable("tenant_applications", {
id: text().primaryKey().notNull(),
propertyId: text("property_id"),
applicantId: text("applicant_id"),
applicationStatus: text("application_status"),
applicationDataEncrypted: text("application_data_encrypted"),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
submittedOn: bigint("submitted_on", { mode: "number" }),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
reviewedOn: bigint("reviewed_on", { mode: "number" }),
}, (table) => [
foreignKey({
columns: [table.applicantId],
foreignColumns: [users.id],
name: "tenant_applications_applicant_id_users_id_fk",
}),
foreignKey({
columns: [table.propertyId],
foreignColumns: [properties.id],
name: "tenant_applications_property_id_properties_id_fk",
}),
]);
export const tenantInvites = pgTable("tenant_invites", {
id: text().primaryKey().notNull(),
landlordId: text("landlord_id"),
propertyId: text("property_id"),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
availableOn: bigint("available_on", { mode: "number" }),
invitedEmails: text("invited_emails"),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
createdOn: bigint("created_on", { mode: "number" }),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
updatedOn: bigint("updated_on", { mode: "number" }),
}, (table) => [
foreignKey({
columns: [table.landlordId],
foreignColumns: [users.id],
name: "tenant_invites_landlord_id_users_id_fk",
}),
foreignKey({
columns: [table.propertyId],
foreignColumns: [properties.id],
name: "tenant_invites_property_id_properties_id_fk",
}),
]);
export const userTokens = pgTable("user_tokens", {
userId: text("user_id").primaryKey().notNull(),
stateId: text("state_id").notNull(),
accessToken: text("access_token").notNull(),
refreshToken: text("refresh_token").notNull(),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
createdOn: bigint("created_on", { mode: "number" }),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
updatedOn: bigint("updated_on", { mode: "number" }),
}, (table) => [
foreignKey({
columns: [table.userId],
foreignColumns: [users.id],
name: "user_tokens_user_id_users_id_fk",
}),
]);
export const userPins = pgTable("user_pins", {
userId: text("user_id").primaryKey().notNull(),
pin: text().notNull(),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
createdOn: bigint("created_on", { mode: "number" }).notNull(),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
modifiedOn: bigint("modified_on", { mode: "number" }).notNull(),
}, (table) => [
foreignKey({
columns: [table.userId],
foreignColumns: [users.id],
name: "user_pins_user_id_users_id_fk",
}),
]);

257
helpers.ts Executable file
View File

@ -0,0 +1,257 @@
// Simple encryption utilities - only what's needed for the hook
const ENCRYPTION_PASSWORD_KEY_PREFIX = "realestate_app";
const ENCRYPTION_SKIP_KEY_PREFIX = "realestate_app_skip";
// Generate user-specific localStorage key
export function getUserEncryptionKey(userId: string): string {
return `${ENCRYPTION_PASSWORD_KEY_PREFIX}_user_${userId}`;
}
// Generate user-specific skip key
export function getUserSkipKey(userId: string): string {
return `${ENCRYPTION_SKIP_KEY_PREFIX}_user_${userId}`;
}
export function MakeNewDate(
input: string | number,
timezone: string = "T00:00:00.000Z",
): Date {
if (typeof input === "string") {
if (input.length === 10) {
// If it's a 10-character string (YYYY-MM-DD), append T00:00:00.000Z for consistent UTC midnight parsing
const d = new Date(input + timezone);
return d;
} else if (input.length === 13) {
// If it's a 13-character string, try to parse as timestamp
const timestamp = parseInt(input, 10);
if (isNaN(timestamp)) {
throw new Error("Invalid 13-character timestamp string");
}
const tDate = new Date(timestamp);
const d = MakeNewDate(tDate.toISOString().split("T")[0]);
return d;
} else {
throw new Error(
"String input must be exactly 10 characters (YYYY-MM-DD) or 13 characters (timestamp)",
);
}
} else if (typeof input === "number") {
// If it's a number (timestamp), use it directly
const tDate = new Date(input);
const d = MakeNewDate(tDate.toISOString().split("T")[0]);
return d;
} else {
throw new Error("MakeNewDate expects a string or number input");
}
}
export function getDate(dateTime: Date): number {
const d = MakeNewDate(dateTime.getTime());
d.setDate(d.getDate() + 1);
return d.getDate();
}
export function getMonth(dateTime: Date): number {
const d = MakeNewDate(dateTime.getTime());
d.setDate(d.getDate() + 1);
return d.getMonth();
}
export function getYear(dateTime: Date): number {
const d = MakeNewDate(dateTime.getTime());
d.setDate(d.getDate() + 1);
return d.getFullYear();
}
export function getDay(dateTime: Date): number {
const d = MakeNewDate(dateTime.getTime());
d.setDate(d.getDate() + 1);
return d.getDay();
}
export async function createPublicId(guid: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(guid);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
return btoa(String.fromCharCode(...new Uint8Array(hashBuffer)))
.replace(/[+/=]/g, "")
.substring(0, 22); // Truncate for shorter ID
}
// Utility: derive an AES key from a password using PBKDF2
export async function deriveKey(
password: string,
salt: Uint8Array,
): Promise<CryptoKey> {
const enc = new TextEncoder();
const passKey = await globalThis.crypto.subtle.importKey(
"raw",
enc.encode(password),
"PBKDF2",
false,
["deriveKey"],
);
// Derive a 256-bit AES-GCM key
return globalThis.crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: new Uint8Array(salt),
iterations: 100_000,
hash: "SHA-256",
},
passKey,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
}
// Utility: random salt/IV generation
export function getRandomBytes(length: number): Uint8Array {
const arr = new Uint8Array(length);
globalThis.crypto.getRandomValues(arr);
return arr;
}
// Encrypt data using password
export async function encryptData(
data: string,
password: string,
): Promise<string | null> {
if (!data || !password) {
return null;
}
try {
const salt = getRandomBytes(16);
const iv = getRandomBytes(12);
const key = await deriveKey(password, salt);
const enc = new TextEncoder().encode(data);
const ciphertext = new Uint8Array(
await globalThis.crypto.subtle.encrypt(
{ name: "AES-GCM", iv: new Uint8Array(iv) },
key,
new Uint8Array(enc),
),
);
// Bundle data as base64
const bundle = JSON.stringify({
salt: btoa(String.fromCharCode(...salt)),
iv: btoa(String.fromCharCode(...iv)),
data: btoa(String.fromCharCode(...ciphertext)),
});
return bundle;
} catch (error) {
console.error("Encryption failed:", error);
return null;
}
}
// Decrypt data using password
export async function decryptData(
encryptedBundle: string,
password: string,
): Promise<string | null> {
if (!encryptedBundle || !password) {
return null;
}
try {
const { salt, iv, data } = JSON.parse(encryptedBundle);
const saltBytes = Uint8Array.from(atob(salt), (c) => c.charCodeAt(0));
const ivBytes = Uint8Array.from(atob(iv), (c) => c.charCodeAt(0));
const cipherBytes = Uint8Array.from(atob(data), (c) => c.charCodeAt(0));
const key = await deriveKey(password, saltBytes);
const decryptedBytes = await globalThis.crypto.subtle.decrypt(
{ name: "AES-GCM", iv: new Uint8Array(ivBytes) },
key,
new Uint8Array(cipherBytes),
);
return new TextDecoder().decode(decryptedBytes);
} catch (error) {
console.error("Decryption failed:", error);
return null;
}
}
// Retrieve encryption password from localStorage
export function getEncryptionPassword(userId: string): string | null {
if (!userId) return null;
try {
const userKey = getUserEncryptionKey(userId);
return globalThis.localStorage?.getItem(userKey) || null;
} catch (error) {
console.warn(
"Failed to access localStorage for encryption password:",
error,
);
return null;
}
}
// Encrypt data using stored password for a specific user (convenience function)
export async function encryptDataWithStoredPassword(
userId: string,
data: string,
): Promise<string | null> {
if (!userId) {
console.warn("Cannot encrypt data: userId is required");
return null;
}
const password = getEncryptionPassword(userId);
if (!password) {
console.warn(`No encryption password stored for user ${userId}`);
return null;
}
const encryptedBundle = await encryptData(data, password);
if (!encryptedBundle) {
return null;
}
return btoa(encryptedBundle);
}
// Decrypt data using stored password for a specific user (convenience function)
export async function decryptDataWithStoredPassword(
userId: string,
encryptedBundle: string,
): Promise<string | null> {
if (!userId) {
console.warn("Cannot decrypt data: userId is required");
return null;
}
const password = getEncryptionPassword(userId);
if (!password) {
console.warn(`No encryption password stored for user ${userId}`);
return null;
}
if (!encryptedBundle) {
return null;
}
const decodedBundle = atob(encryptedBundle);
return await decryptData(decodedBundle, password);
}
// Decrypt data using stored password for a specific user (convenience function)
export async function tryDecryptDataWith(
password: string,
encryptedBundle: string,
): Promise<string | null> {
if (!password) {
console.warn("Cannot decrypt data: password is required");
return null;
}
if (!encryptedBundle) {
return null;
}
const decodedBundle = atob(encryptedBundle);
return await decryptData(decodedBundle, password);
}

156
islands/AddIncomeModal.tsx Normal file
View File

@ -0,0 +1,156 @@
import { useCallback, useRef, useState } from "preact/hooks";
import {
encryptDataWithStoredPassword,
getEncryptionPassword,
} from "../helpers.ts";
interface AddIncomeModalProps {
userId?: string;
}
export default function AddIncomeModal(
{ userId }: AddIncomeModalProps,
) {
const formRef = useRef<HTMLFormElement>(null);
const encryptedDataRef = useRef<HTMLInputElement>(null);
const sourceRef = useRef<HTMLInputElement>(null);
const amountRef = useRef<HTMLInputElement>(null);
const [loading, setLoading] = useState(false);
const today = new Date().toISOString().split("T")[0];
// Only encrypt on submit, not on every keystroke - prevents V8 overload
const handleEncryptData = useCallback(async () => {
// Get current values from the form inputs
const source = sourceRef.current?.value || "";
const amount = amountRef.current?.value || "";
// Check if encryption is available and encrypt the sensitive fields
if (userId && (source || amount)) {
const encryptionPassword = getEncryptionPassword(userId);
if (encryptionPassword) {
try {
const dataToEncrypt = {
source: source,
amount: parseFloat(amount) || 0,
};
const encryptedData = await encryptDataWithStoredPassword(
userId,
JSON.stringify(dataToEncrypt),
);
if (encryptedData && encryptedDataRef.current) {
// Set encrypted data
encryptedDataRef.current.value = encryptedData;
}
} catch (error) {
console.error("Failed to encrypt income data:", error);
}
}
}
}, [userId]);
const handleAddClick = async () => {
setLoading(true);
// Encrypt data before submission
await handleEncryptData();
// Get current values from the form inputs
const source = sourceRef.current?.value || "";
const amount = amountRef.current?.value || "";
// Clear plaintext fields if encryption was successful
if (userId && (source || amount)) {
const encryptionPassword = getEncryptionPassword(userId);
if (encryptionPassword && encryptedDataRef.current?.value) {
if (sourceRef.current) sourceRef.current.value = "";
if (amountRef.current) amountRef.current.value = "";
}
}
// Submit the form using formRef
setTimeout(() => {
if (formRef.current) {
formRef.current.submit();
}
}, 100);
};
return (
<div class="bg-white rounded-lg p-8 shadow-lg max-w-md w-full">
<h3 class="text-xl font-bold mb-4">Add Income</h3>
<form
ref={formRef}
method="POST"
action="/api/create/income"
class="flex flex-col gap-4"
>
<input
type="hidden"
name="sender"
value="/calendar/every-income?modal=off"
/>
<input
ref={encryptedDataRef}
type="hidden"
name="encrypted_data"
value=""
/>
<input
ref={sourceRef}
class="border rounded px-3 py-2"
placeholder="Source"
name="source"
required
/>
<input
ref={amountRef}
class="border rounded px-3 py-2"
placeholder="Amount"
type="number"
step="0.01"
name="amount"
required
/>
<select
class="w-full border p-2 rounded"
name="frequency"
required
defaultValue="Monthly"
>
<option value="Biweekly">Biweekly</option>
<option value="Weekly">Weekly</option>
<option value="Monthly">Monthly</option>
<option value="Quarterly">Quarterly</option>
<option value="Yearly">Yearly</option>
</select>
<input
class="border rounded px-3 py-2"
placeholder="Received On (YYYY-MM-DD)"
type="date"
name="received_date"
required
defaultValue={today}
/>
</form>
<div class="flex gap-2 justify-end mt-4">
<a
href="/calendar/every-income"
class="px-4 py-2 rounded bg-gray-300 hover:bg-gray-400 text-center inline-block"
>
Cancel
</a>
<button
type="button"
onClick={handleAddClick}
class="px-4 py-2 rounded bg-green-600 text-white hover:bg-green-700"
disabled={loading}
>
{loading ? "Adding..." : "Add"}
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,160 @@
import { useCallback, useRef, useState } from "preact/hooks";
import {
encryptDataWithStoredPassword,
getEncryptionPassword,
} from "../helpers.ts";
interface AddObligationModalProps {
userId?: string;
}
export default function AddObligationModal(
{ userId }: AddObligationModalProps,
) {
const formRef = useRef<HTMLFormElement>(null);
const encryptedDataRef = useRef<HTMLInputElement>(null);
const descriptionRef = useRef<HTMLInputElement>(null);
const amountRef = useRef<HTMLInputElement>(null);
// Removed duplicate useState for description/amount - use refs only for form values
// State only for UI feedback, not form data
const [error, _setError] = useState("");
const [loading, setLoading] = useState(false);
const today = new Date().toISOString().split("T")[0];
// Only encrypt on submit, not on every keystroke - prevents V8 overload
const handleEncryptData = useCallback(async () => {
// Get current values from the form inputs
const desc = descriptionRef.current?.value || "";
const amt = amountRef.current?.value || "";
// Check if encryption is available and encrypt the sensitive fields
if (userId && (desc || amt)) {
const encryptionPassword = getEncryptionPassword(userId);
if (encryptionPassword) {
try {
const dataToEncrypt = {
description: desc,
amount: parseFloat(amt) || 0,
};
const encryptedData = await encryptDataWithStoredPassword(
userId,
JSON.stringify(dataToEncrypt),
);
if (encryptedData && encryptedDataRef.current) {
// Set encrypted data
encryptedDataRef.current.value = encryptedData;
}
} catch (error) {
console.error("Failed to encrypt obligation data:", error);
}
}
}
}, [userId]);
const handleSubmit = async () => {
setLoading(true);
// Encrypt data before submission
await handleEncryptData();
// Get current values from the form inputs
const desc = descriptionRef.current?.value || "";
const amt = amountRef.current?.value || "";
// Clear plaintext fields if encryption was successful
if (userId && (desc || amt)) {
const encryptionPassword = getEncryptionPassword(userId);
if (encryptionPassword && encryptedDataRef.current?.value) {
if (descriptionRef.current) descriptionRef.current.value = "";
if (amountRef.current) amountRef.current.value = "";
}
}
// Submit the form using formRef
setTimeout(() => {
if (formRef.current) {
formRef.current.submit();
}
}, 100);
};
return (
<>
<form
ref={formRef}
class="bg-white p-6 rounded shadow-lg w-full max-w-md space-y-4"
method="POST"
action="/api/create/obligation"
>
<h2 class="text-xl font-bold mb-2">Add Obligation</h2>
{error && <div class="text-red-500 text-sm">{error}</div>}
<input
type="hidden"
name="sender"
value="/calendar/budget-obligations?modal=off"
/>
<input
ref={encryptedDataRef}
type="hidden"
name="encrypted_data"
value=""
/>
<input
ref={descriptionRef}
class="w-full border p-2 rounded"
name="description"
placeholder="Description"
required
/>
<input
ref={amountRef}
class="w-full border p-2 rounded"
name="amount"
placeholder="Amount"
type="number"
step="0.01"
required
/>
<select
class="w-full border p-2 rounded"
name="frequency"
defaultValue="Monthly"
>
<option value="One-time">One-time</option>
<option value="Biweekly">Biweekly</option>
<option value="Weekly">Weekly</option>
<option value="Monthly">Monthly</option>
<option value="Quarterly">Quarterly</option>
<option value="Yearly">Yearly</option>
</select>
<input
class="w-full border p-2 rounded"
name="due_date"
type="date"
defaultValue={today}
required
/>
</form>
<div class="flex justify-end space-x-2">
<a
href="/calendar/budget-obligations"
class="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300 text-center inline-block"
>
Cancel
</a>
<button
type="button"
onClick={handleSubmit}
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
disabled={loading}
>
{loading ? "Adding..." : "Add"}
</button>
</div>
</>
);
}

121
islands/AdminCenter.tsx Normal file
View File

@ -0,0 +1,121 @@
import { useState } from "preact/hooks";
import { Role, UserWithRole } from "../db/sqlUserRepository.ts";
interface AdminCenterProps {
users: UserWithRole[];
roles: Role[];
}
export default function AdminCenter({ users, roles }: AdminCenterProps) {
// Local state for forms per user
const [formState, setFormState] = useState<
Record<string, { roleId: string }>
>(
() => {
const state: Record<string, { roleId: string }> = {};
users.forEach((u) => {
state[u.id] = { roleId: u.role };
});
return state;
},
);
const handleRoleChange = (userId: string, roleId: string) => {
setFormState((prev) => ({ ...prev, [userId]: { roleId } }));
};
return (
<>
<button
type="button"
aria-label="Go back"
onClick={() => globalThis.location.href = "/"}
style={{
position: "fixed",
bottom: 300,
left: 4,
background: "none",
border: "none",
zIndex: 50,
cursor: "pointer",
}}
>
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
<div class="overflow-x-auto">
<table class="min-w-full bg-white rounded-xl shadow-lg">
<thead>
<tr class="bg-blue-100">
<th class="py-2 px-4 text-left">Name</th>
<th class="py-2 px-4 text-left">Email</th>
<th class="py-2 px-4 text-left">Role</th>
<th class="py-2 px-4"></th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} class="border-b hover:bg-blue-50">
<td class="py-2 px-4">{user.name}</td>
<td class="py-2 px-4">{user.email}</td>
<td class="py-2 px-4">
<form
method="post"
action="/admin-center"
class="flex items-center gap-2"
>
<input type="hidden" name="rowUserId" value={user.id} />
<input
type="hidden"
name="inputUsers"
value={JSON.stringify(users)}
/>
<input
type="hidden"
name="inputRoles"
value={JSON.stringify(roles)}
/>
<select
name="selectedRole"
class="rounded border px-2 py-1"
value={formState[user.id]?.roleId || ""}
onChange={(e) =>
handleRoleChange(
user.id,
(e.target as HTMLSelectElement).value,
)}
>
<option value="">Select role</option>
{roles.map((role) => (
<option key={role.id} value={role.id}>
{role.description} ({role.id})
</option>
))}
</select>
<button
type="submit"
class="ml-2 px-3 py-1 rounded bg-blue-600 text-white hover:bg-blue-700 transition"
>
Save
</button>
</form>
</td>
<td></td>
</tr>
))}
</tbody>
</table>
</div>
</>
);
}

249
islands/AdminPage.tsx Normal file
View File

@ -0,0 +1,249 @@
// islands/QuizConfigure.tsx
import { useEffect } from "preact/hooks";
import { KvObj, QuizConfig } from "../utils/types.ts";
import { Signal } from "@preact/signals";
import { AsyncYesNoPop } from "./AsyncYesNoPop.tsx";
import { User } from "../db/interfaces.ts";
export interface AdminProps {
allUsers: User[];
allQuizs: KvObj<QuizConfig>[];
}
export default function AdminPage(
{ allUsers, allQuizs }: AdminProps,
) {
// Theme classes are defined in styles.css with @media (prefers-color-scheme: dark)
const [ask, popupElement] = AsyncYesNoPop();
const changeMode = new Signal<{ mode: string } | null>(null);
const users = new Signal<User[]>(allUsers);
const nameError = new Signal(false);
const randomOrder = new Signal(true);
const modeType = new Signal("photo-see-bisaya");
const userContext = new Signal((() => {
if (allUsers && allUsers.length) {
return allUsers[0].id;
} else {
return "";
}
})());
//const [isLoading, setIsLoading] = useState(true);
const count = new Signal(0);
// const handlePop = async () => {
// const answer = await ask("Do you want to continue?");
// if (answer) {
// alert("You chose YES!");
// // ...continue logic
// } else {
// alert("You chose NO!");
// // ...alternative logic
// }
// };
const handleClick = () => {
const newCount = count.value + 1;
count.value = newCount;
};
useEffect(() => {
//console.log("allUsers",allUsers.length);
}, []);
const uploadFile = () => {
const input = document.createElement("input");
input.type = "file";
input.accept = ".txt";
// Safari fix: input must be in DOM for change event to fire reliably
input.style.position = "absolute";
input.style.left = "-9999px";
input.style.opacity = "0";
document.body.appendChild(input);
const cleanup = () => {
if (input.parentNode) {
document.body.removeChild(input);
}
};
input.addEventListener("change", async (e) => {
console.log("uploading file");
const file = (e.target as HTMLInputElement).files?.[0];
console.log("file", file);
if (file) {
const filePeaces = (file.name as string)?.split(".txt")[0]?.trim()
.split(" ");
const newQuizName = filePeaces[0];
//console.log("context id", userContext.value);
const text = (await file.text())?.trim();
if (userContext.value && text && text.length > 0) {
const obj: KvObj<QuizConfig> = JSON.parse(text) as KvObj<QuizConfig>;
console.log(obj.value?.quiz_name);
obj.key[0] = "quiz_configs";
obj.key[1] = "" + userContext.value;
obj.key[2] = crypto.randomUUID();
if (obj.value) {
obj.value.owner_id = obj.key[1];
obj.value.quiz_config_id = obj.key[2];
if (obj.value.questions) {
obj.value.questions.forEach((question) => {
question.owner_id = obj.key[1];
question.quiz_id = obj.key[2];
});
}
// const textToQuiz = new TextToQuizTranslations(quizData);
// const parsedQuizData = textToQuiz.parseText(text);
try {
const response = await fetch("/api/quiz-up", {
method: "POST",
body: JSON.stringify(obj),
});
if (!response.ok) {
throw new Error("Upload failed");
}
const data = await response.json();
} catch (err) {
console.error(err);
console.log("Failed to fetch create quiz.");
}
}
}
}
cleanup();
});
// Cleanup if user cancels file dialog (focus returns to window)
const handleFocus = () => {
setTimeout(cleanup, 300);
globalThis.removeEventListener("focus", handleFocus);
};
globalThis.addEventListener("focus", handleFocus);
input.click();
};
const downloadFile = async () => {
allQuizs.forEach((quiz) => {
let quizName = `${JSON.stringify(quiz.key)}.txt`;
if (quiz.value) {
quizName = `${quiz.value.quiz_name} ${quizName}.txt`;
}
const userConfirmed = globalThis.confirm(`Download? ${quizName}`);
if (userConfirmed) {
const content = JSON.stringify(quiz);
const blob = new Blob([content], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = quizName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
});
};
return (
<>
{popupElement}
<div className="theme-container rounded-xl shadow-2xl p-8 max-w-md w-full">
<div hidden>
<button onClick={handleClick}>Increment</button>
<p>Count: {count}</p>
</div>
<h2 className="text-2xl theme-text font-bold mb-4">Configure Quiz</h2>
<div className="w-[300px] mx-0 px-0">
<div className="flex space-x-2 pb-[10px]">
<button
onClick={uploadFile}
className="flex-1 theme-button py-2 px-2 rounded-md transition duration-300"
>
Add from file
</button>
<button
onClick={downloadFile}
className="flex-1 theme-button py-2 px-2 rounded-md transition duration-300"
>
Out to file
</button>
</div>
<div className="mb-4">
<label htmlFor="quizMode" className="block theme-text mb-2">
Admin Page
</label>
<select
id="quizMode"
value={"" + userContext.value}
onChange={(e: any) => {
const selectedUser = users.value.find((user) =>
user.id === e.target.value
);
userContext.value = e.target.value;
//handleOwnerChange(e, selectedUser);
// if (selectedUser) {
// thisQuizConfig.owner_id = selectedUser.id;
// setQuizConfig(thisQuizConfig);
// }
// setChangeMode({ mode:"session" });
// setQuizConfigStr(JSON.stringify({ theUpdate:{ mode:"session" }, quiz: thisQuizConfig, kvObj:kvObj,quizOrder:thisQuizOder }));
}}
className="w-full px-3 py-2 theme-select rounded-md"
>
{users.value.map((user: User) => (
<option
key={user.id}
className="text-black"
value={user.id}
>
{user.name}
</option>
))}
</select>
</div>
</div>
{
/* <form
hidden
action="/dashboard"
method="POST"
>
<input
type="hidden"
name="pageConfigData"
value={postDataStr}
ref={postInputValueRef}
/>
<button hidden ref={postButtonRef} type="submit">
</button>
</form> */
}
</div>
<div class="fixed bottom-4 inset-x-0 flex justify-center z-40 pointer-events-none">
<div class="flex gap-2 w-11/12 max-w-md pointer-events-auto">
<span class={`w-[200px] py-3 px-6`}></span>
<span class={`w-[16px]`}></span>
<button
type="button"
onClick={() => {
globalThis.location.href = "/language/dashboard";
}}
class={`w-[200px] theme-button font-semibold py-3 px-6 rounded-full shadow-lg text-center`}
>
Dashboard
</button>
</div>
</div>
</>
);
}

372
islands/ArrangeQuiz.tsx Normal file
View File

@ -0,0 +1,372 @@
// islands/QuizConfigure.tsx
import { useEffect, useState } from "preact/hooks";
import { QuizConfig } from "../utils/types.ts";
import { AsyncYesNoPop } from "./AsyncYesNoPop.tsx";
export interface ArrangementProps {
quizConfig: QuizConfig;
// kvObj: KvObj<QuizConfig>;
quizOrder: { index: number; slots: string[] };
quizMode: string;
tabs: string[];
}
const NEW_TAB_VALUE = "__new_tab__";
export default function ArrangeQuiz(
{ quizConfig, quizOrder, quizMode, tabs }: ArrangementProps,
) {
// Theme classes are defined in styles.css with @media (prefers-color-scheme: dark)
const [ask, popupElement] = AsyncYesNoPop();
const [thisQuizConfig, setQuizConfig] = useState<QuizConfig>(quizConfig);
const [orderedQuizStr, setOrderQuizStr] = useState<string>(
JSON.stringify(thisQuizConfig),
);
const [thisQuizConfigStr, setQuizConfigStr] = useState<string>(
JSON.stringify({
theUpdate: null,
quiz: quizConfig,
//kvObj: kvObj,
quizOrder: quizOrder,
}),
);
const [selectedSlot, setSelectedSlot] = useState(0);
const [thisQuizOder, setThisQuizOrder] = useState(quizOrder);
const [randomOrder, setRandomOrder] = useState(true);
const [modeType, setModeType] = useState(quizMode);
//const [isLoading, setIsLoading] = useState(true);
// Tab selection state
const [selectedTabIndex, setSelectedTabIndex] = useState<
number | typeof NEW_TAB_VALUE
>(-1);
const [newTabName, setNewTabName] = useState("");
const isNewTab = selectedTabIndex === NEW_TAB_VALUE;
const isExistingTab = typeof selectedTabIndex === "number" &&
selectedTabIndex >= 0;
const [count, setCount] = useState(0);
const handleClick = () => {
setCount((prev) => {
const newCount = prev + 1;
return newCount;
});
};
const handleSaveQuiz = async (event: Event) => {
//console.log("Clicked save");
//if (changeMode && changeMode.mode) {
// setQuizConfigStr(JSON.stringify({ theUpdate:changeMode.mode, quiz: thisQuizConfig, kvObj:kvObj,quizOrder:thisQuizOder }));
// handleClick();
event.preventDefault();
//value={JSON.stringify({ theUpdate:null, quiz: quiz , kvObj:quizProps[index] ? cloneObjectExcept(quizProps[index],["value"]) as KvObj<QuizConfig>: null})}
const form = event.currentTarget as HTMLFormElement;
const userConfirmed = await ask(
"Do you want to save this change?",
Date.now(),
);
if (userConfirmed) {
form.submit();
}
//const userConfirmed = window.confirm("Do you wanted to save changes?");
// if (userConfirmed) {
// (event.currentTarget as HTMLFormElement).submit();
// }
//} else {
// event.preventDefault();
//}
};
useEffect(() => {
//console.log("setting mode!!",quizConfig.quiz_mode)
if (quizConfig.quiz_mode) {
setModeType(quizConfig.quiz_mode);
}
}, []);
useEffect(() => {
// Initialize selectedTabIndex to 0 when tabs are available and no tab is selected
if (tabs.length > 0 && selectedTabIndex === -1) {
setSelectedTabIndex(0);
}
}, [tabs, selectedTabIndex]);
return (
<>
{popupElement}
<div
className={`theme-container p-2 sm:p-3 md:p-4 rounded-lg shadow-md max-w-md mx-auto w-full overflow-auto`}
>
<div hidden>
<button onClick={handleClick}>Increment</button>
<p>Count: {count}</p>
</div>
<h2 className="text-2xl font-bold mb-4 theme-text">
Arrange Quizzes
</h2>
<div className="theme-secondary-container p-2 sm:p-3 md:p-4 rounded-lg shadow-md max-w-md mx-auto w-full overflow-auto">
<h2 className="text-2xl font-bold mb-4 theme-text">
Dashboard Quiz List
</h2>
<form
action="/language/arrange"
method="POST"
onSubmit={handleSaveQuiz}
>
<input
type="hidden"
name="quizConfigData"
value={thisQuizConfigStr}
/>
<input
type="hidden"
name="quizMode"
value={quizMode}
/>
{/* Step 1: Select a quiz */}
<div className="mb-4">
<label
htmlFor="quizSelect"
className="block mb-2 theme-text"
>
Select Quiz
</label>
<select
id="quizSelect"
value={thisQuizOder.index}
onChange={(e: any) => {
const newIndex = parseInt(e.target.value, 10);
setSelectedSlot(newIndex);
thisQuizOder.index = newIndex;
setThisQuizOrder({ ...thisQuizOder });
// Reset tab selection when quiz changes
setSelectedTabIndex(-1);
setNewTabName("");
setQuizConfigStr(
JSON.stringify({
theUpdate: newIndex,
quiz: thisQuizConfig,
quizOrder: thisQuizOder,
}),
);
}}
className="w-full px-3 py-2 rounded-md theme-select"
>
<option value={-1}>Select a quiz to move</option>
{thisQuizOder.slots.map((name: string, i: number) => (
<option
key={i}
className="text-black"
value={i}
>
{`${i} - ${name}`}
</option>
))}
</select>
</div>
{/* Step 2: Select a tab (only shown after quiz is selected) */}
{thisQuizOder.index != -1 && (
<div className="mb-4">
<label
htmlFor="tabSelect"
className="block mb-2 theme-text"
>
Select Tab
</label>
<select
id="tabSelect"
value={selectedTabIndex === NEW_TAB_VALUE
? NEW_TAB_VALUE
: selectedTabIndex}
onChange={(e: any) => {
const value = e.target.value;
if (value === NEW_TAB_VALUE) {
setSelectedTabIndex(NEW_TAB_VALUE);
setNewTabName("");
} else {
setSelectedTabIndex(parseInt(value, 10));
setNewTabName("");
}
}}
className="w-full px-3 py-2 rounded-md theme-select"
>
<option value={-1}>Select a tab</option>
{tabs.map((tabName: string, i: number) => (
<option
key={i}
className="text-black"
value={i}
>
{tabName}
</option>
))}
<option
value={NEW_TAB_VALUE}
className="text-black font-semibold"
>
+ New Tab
</option>
</select>
</div>
)}
{/* Step 3a: New tab name input (only shown if "New Tab" is selected) */}
{thisQuizOder.index != -1 && isNewTab && (
<div className="mb-4">
<label
htmlFor="newTabName"
className="block mb-2 theme-text"
>
New Tab Name
</label>
<input
type="text"
id="newTabName"
value={newTabName}
onChange={(e: any) => setNewTabName(e.target.value)}
placeholder="Enter new tab name"
className="w-full px-3 py-2 rounded-md theme-input border theme-border"
/>
</div>
)}
{/* Step 3b: Position selector (only shown if existing tab is selected) */}
{thisQuizOder.index != -1 && isExistingTab && (
<div className="mb-4">
<label
htmlFor="quizOrder"
className="block mb-2 theme-text"
>
Select Position in Tab
</label>
<select
id="quizOrder"
value={selectedSlot}
onChange={(e: any) => {
setSelectedSlot(parseInt(e.target.value, 10));
setQuizConfigStr(
JSON.stringify({
theUpdate: parseInt(e.target.value, 10),
quiz: thisQuizConfig,
quizOrder: thisQuizOder,
}),
);
}}
className="w-full px-3 py-2 rounded-md theme-select"
>
{thisQuizOder.slots.map((name: string, i: number) => (
<option
key={i}
className="text-black"
value={i}
>
{`Position ${i + 1}`}
</option>
))}
</select>
</div>
)}
{/* Save button for moving to new tab */}
{thisQuizOder.index != -1 && isNewTab &&
newTabName.trim().length > 0 && (
<div className="flex space-x-2 mt-4">
<button
type="submit"
className="theme-button px-4 py-2 rounded w-full"
onClick={() => {
setQuizConfigStr(
JSON.stringify({
theUpdate: thisQuizOder.index,
quiz: thisQuizConfig,
quizOrder: thisQuizOder,
tabAction: "new",
newTabName: newTabName.trim(),
newTabIndex: tabs.length,
}),
);
}}
>
Move to New Tab
</button>
</div>
)}
{/* Save button for reordering position within tab */}
{thisQuizOder.index != -1 && isExistingTab &&
thisQuizOder.index !== selectedSlot && (
<div className="flex space-x-2 mt-4">
<button
type="submit"
className="theme-button px-4 py-2 rounded w-full"
onClick={() => {
setQuizConfigStr(
JSON.stringify({
theUpdate: selectedSlot,
quiz: thisQuizConfig,
quizOrder: thisQuizOder,
tabAction: "reorder",
targetTabIndex: selectedTabIndex,
}),
);
}}
>
Save Position Change
</button>
</div>
)}
{/* Save button for moving to different existing tab (same position) */}
{thisQuizOder.index != -1 && isExistingTab &&
thisQuizOder.index === selectedSlot && (
<div className="flex space-x-2 mt-4">
<button
type="submit"
className="theme-button px-4 py-2 rounded w-full"
onClick={() => {
setQuizConfigStr(
JSON.stringify({
theUpdate: thisQuizOder.index,
quiz: thisQuizConfig,
quizOrder: thisQuizOder,
tabAction: "move",
targetTabIndex: selectedTabIndex,
targetTabName: tabs[selectedTabIndex as number],
}),
);
}}
>
Move to Tab
</button>
</div>
)}
</form>
</div>
</div>
<div class="fixed bottom-4 inset-x-0 flex justify-center z-40 pointer-events-none">
<div class="flex gap-2 w-11/12 max-w-md pointer-events-auto">
<span class={`w-[200px] py-3 px-6`}></span>
<span class={`w-[16px]`}></span>
<button
type="button"
onClick={() => {
globalThis.location.href = "/language/dashboard";
}}
class={`w-[200px] theme-button font-semibold py-3 px-6 rounded-full shadow-lg text-center`}
>
Dashboard
</button>
</div>
</div>
</>
);
}

View File

@ -0,0 +1,44 @@
// islands/AsyncImageGenerater.tsx
import { useState } from "preact/hooks";
import { ImageFile } from "../utils/types.ts";
import ImageGenerater from "./ImageGenerater.tsx";
export function AsyncImageGenerater() {
const [popup, setPopup] = useState<
{
imageFile: ImageFile;
role: string;
resolve: (value: ImageFile | null) => void;
} | null
>(null);
// Show the popup and return a Promise for the answer
const imageGenerate = (
imageFile: ImageFile,
role: string,
): Promise<ImageFile | null> => {
return new Promise((resolve) => {
setPopup({ imageFile, role, resolve });
});
};
// Render the popup if needed
// Priority for text_1: image_text (user's description) > image_file (sanitized filename)
// image_text preserves the user's original typed description with spaces/casing
// image_file is the sanitized filename with underscores and timestamp
const popupImageGenerater = popup
? (
<ImageGenerater
text_1={popup.imageFile.image_text || popup.imageFile.image_file || ""}
image_data={popup.imageFile.image_data}
role={popup.role}
recordingCallback={(answer: any) => {
popup.resolve(answer);
setPopup(null);
}}
/>
)
: null;
return [imageGenerate, popupImageGenerater] as const;
}

View File

@ -0,0 +1,61 @@
// islands/AsyncVoiceRecorder.ts
import { useRef, useState } from "preact/hooks";
import VoiceRecorder from "./VoiceRecorder.tsx";
import { AudioFile } from "../utils/types.ts";
export function AsyncVoiceRecorder() {
const queueRef = useRef<
{
audioFile: AudioFile;
role: string;
resolve: (value: Blob | null) => void;
}[]
>([]);
const [popup, setPopup] = useState<
{
audioFile: AudioFile;
role: string;
resolve: (value: Blob | null) => void;
} | null
>(null);
// Show the popup and return a Promise for the answer
const record = (
audioFile: AudioFile,
role: string,
): Promise<Blob | null> => {
return new Promise((resolve) => {
const req = { audioFile, role, resolve };
setPopup((current) => {
if (current) {
queueRef.current.push(req);
return current;
}
return req;
});
});
};
// Render the popup if needed
const popupRecorder = popup
? (
<VoiceRecorder
text={popup.audioFile.text}
audio_file={popup.audioFile.audio_file}
role={popup.role}
recordingCallback={(answer) => {
setPopup((current) => {
if (current) {
current.resolve(answer);
}
const next = queueRef.current.shift() ?? null;
return next;
});
}}
/>
)
: null;
return [record, popupRecorder] as const;
}

37
islands/AsyncYesNoPop.tsx Normal file
View File

@ -0,0 +1,37 @@
// islands/AsyncYesNoPop.ts
import { useState } from "preact/hooks";
import YesNoPopup from "./YesNoPopup.tsx";
export function AsyncYesNoPop() {
const [popupKey, setPopupKey] = useState(0);
const [popup, setPopup] = useState<
{
question: string;
resolve: (value: boolean) => void;
} | null
>(null);
// Show the popup and return a Promise for the answer
const ask = (question: string, popupKey: number): Promise<boolean> => {
return new Promise((resolve) => {
setPopupKey(popupKey);
setPopup({ question, resolve });
});
};
// Render the popup if needed
const popupElement = popup
? (
<YesNoPopup
key={popupKey}
question={popup.question}
onAnswer={(answer) => {
popup.resolve(answer);
setPopup(null);
}}
/>
)
: null;
return [ask, popupElement] as const;
}

770
islands/BrainStudy.tsx Normal file
View File

@ -0,0 +1,770 @@
// islands/BrainStudy.tsx
// Brain study component with locked-set mastery behavior
// Cards must be mastered through all 6 modes before new cards are introduced
import { useEffect, useRef, useState } from "preact/hooks";
import { signal } from "@preact/signals";
import Card from "./Card.tsx";
import { BrainQueueItem } from "../utils/types.ts";
// Import mode utilities (these will be inlined for client-side use)
const QUIZ_MODES = [
"photo-see-bisaya",
"photo-see-english",
"photo-hide-bisaya",
"photo-hide-english",
"blank-see-bisaya",
"blank-see-english",
] as const;
const MODE_LABELS: Record<string, string> = {
"photo-see-bisaya": "Photo, See Bisaya",
"photo-see-english": "Photo, See English",
"photo-hide-bisaya": "Blank, Hide Bisaya",
"photo-hide-english": "Photo, Hide English",
"blank-see-bisaya": "Blank, See Bisaya",
"blank-see-english": "Blank, See English",
};
function getModeDisplayInfo(mode: string) {
const index = QUIZ_MODES.indexOf(mode as typeof QUIZ_MODES[number]);
const safeIndex = index >= 0 ? index : 0;
return {
index: safeIndex + 1,
total: QUIZ_MODES.length,
label: MODE_LABELS[mode] || mode,
isFinal: safeIndex === QUIZ_MODES.length - 1,
};
}
function getNextMode(currentMode: string): string | null {
const index = QUIZ_MODES.indexOf(currentMode as typeof QUIZ_MODES[number]);
if (index >= 0 && index < QUIZ_MODES.length - 1) {
return QUIZ_MODES[index + 1];
}
return null; // Already at final mode
}
interface BrainStudyProps {
userId: string;
role: string;
initialMode: string;
tab_index?: number;
}
interface QueueResponse {
success: boolean;
queue: BrainQueueItem[];
stats: {
totalReviewed: number;
totalCorrect: number;
dueNow: number;
accuracy: number;
reviewOnlyCount?: number;
};
mode: string;
totalInQueue: number;
dueCount: number;
newCount: number;
activeSetCount?: number;
}
interface SessionStats {
reviewed: number;
correctFirstTry: number;
sessionStart: number;
modeAdvances: number; // Track how many cards advanced to next mode
cardsRetired: number; // Cards that completed all 6 modes this session
}
// Extended queue item for client-side tracking
interface ActiveQueueItem extends BrainQueueItem {
// Client-side tracking
clientModeIndex: number; // Current mode being studied (client-side, may differ from server after correct answer)
clientDisplayMode: string; // Current mode for display
hasRetired: boolean; // Has completed all 6 modes
}
const useInputHint = signal(false);
export default function BrainStudy(
{ userId, role, initialMode, tab_index }: BrainStudyProps,
) {
const [loading, setLoading] = useState(true);
const [hasStarted, setHasStarted] = useState(false);
// Active set: the locked set of cards being mastered
const [activeSet, setActiveSet] = useState<ActiveQueueItem[]>([]);
// Pool of available new cards (from server) that haven't been added to active set yet
const [newCardPool, setNewCardPool] = useState<BrainQueueItem[]>([]);
// Current position in active set rotation
const [currentIndex, setCurrentIndex] = useState(0);
const [sessionStats, setSessionStats] = useState<SessionStats>({
reviewed: 0,
correctFirstTry: 0,
sessionStart: Date.now(),
modeAdvances: 0,
cardsRetired: 0,
});
const [overallStats, setOverallStats] = useState({
totalReviewed: 0,
totalCorrect: 0,
dueNow: 0,
accuracy: 0,
reviewOnlyCount: 0,
});
const [isComplete, setIsComplete] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isFirstAttempt, setIsFirstAttempt] = useState(true);
const [cardKey, setCardKey] = useState(0);
// Card state
const [cardInputParams, setCardInputParams] = useState({
setFullAuto: (_auto: boolean) => {},
runFullAuto: false,
setTogglePause: (_fn: () => void) => {},
setIsPausedDisplay: (_paused: boolean) => {},
questionPhrase: "",
answerPhrase: "",
nextButton: () => {},
audio1: "",
audio2: "",
onHelpUsed: () => {},
flipped: false,
});
const [backgroundUrl, setBackgroundUrl] = useState("");
// Track first attempt per question
const firstAttemptRef = useRef(true);
// Maximum active set size
const MAX_ACTIVE_SET_SIZE = 10;
// Fetch queue on mount
useEffect(() => {
fetchQueue();
}, []);
// Convert server queue items to active queue items with client-side tracking
const initializeActiveSet = (items: BrainQueueItem[]): ActiveQueueItem[] => {
return items.map((item) => ({
...item,
clientModeIndex: item.mode_index,
clientDisplayMode: item.display_mode,
hasRetired: false,
}));
};
const fetchQueue = async () => {
setLoading(true);
setError(null);
try {
// Request more cards than we need for the active set so we have a pool to draw from
// Include tab_index if provided to filter quiz configs by tab
const tabParam = tab_index !== undefined ? `&tab_index=${tab_index}` : "";
const response = await fetch(
`/api/brain/queue?limit=100&newLimit=100${tabParam}`,
);
if (!response.ok) {
throw new Error("Failed to fetch queue");
}
const data: QueueResponse = await response.json();
if (data.success) {
// Separate the queue into active set (up to MAX_ACTIVE_SET_SIZE) and pool
const allCards = data.queue.filter((q) => !q.is_review_only);
const initialActiveCards = allCards.slice(0, MAX_ACTIVE_SET_SIZE);
const remainingPool = allCards.slice(MAX_ACTIVE_SET_SIZE);
setActiveSet(initializeActiveSet(initialActiveCards));
setNewCardPool(remainingPool);
setOverallStats({
...data.stats,
reviewOnlyCount: data.stats.reviewOnlyCount ?? 0,
});
setCurrentIndex(0);
setIsComplete(allCards.length === 0);
} else {
throw new Error("Failed to build queue");
}
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
} finally {
setLoading(false);
}
};
/**
* Configure card display based on mode
* Each mode determines:
* - What text is shown (questionPhrase)
* - What the user types (answerPhrase)
* - Whether to show the photo (backgroundUrl)
* - Whether to hide certain text (done via questionPhrase)
*
* Both audio tracks always play for every mode.
*/
const loadCard = (item: ActiveQueueItem) => {
const question = item.question_data;
// Use client-side mode tracking for display
const mode = item.clientDisplayMode;
firstAttemptRef.current = true;
setIsFirstAttempt(true);
let questionPhrase = "";
let answerPhrase = "";
let showPhoto = true;
let flipped = false;
// Configure based on mode
switch (mode) {
case "photo-see-bisaya":
// Show photo, display Bisaya text, user types English
questionPhrase = question.text_1; // Bisaya
answerPhrase = question.text_2; // English
showPhoto = true;
flipped = false;
break;
case "photo-see-english":
// Show photo, display English text, user types Bisaya
questionPhrase = question.text_2; // English
answerPhrase = question.text_1; // Bisaya
showPhoto = true;
flipped = true;
break;
case "photo-hide-bisaya":
// Show photo, hide Bisaya (rely on audio), user types Bisaya
questionPhrase = "";
answerPhrase = question.text_2; // Bisaya
showPhoto = false;
flipped = false;
break;
case "photo-hide-english":
// Show photo, hide English, user types English
questionPhrase = "";
answerPhrase = question.text_1; // English
showPhoto = true;
flipped = true;
break;
case "blank-see-bisaya":
// No photo, display Bisaya text, user types English
questionPhrase = question.text_2; // Bisaya
answerPhrase = question.text_1; // English
showPhoto = false;
flipped = true;
break;
case "blank-see-english":
// No photo, display English text, user types Bisaya
questionPhrase = question.text_1; // English
answerPhrase = question.text_2; // Bisaya
showPhoto = false;
flipped = false;
break;
default:
// Fallback to photo-see-english behavior
questionPhrase = question.text_2;
answerPhrase = question.text_1;
showPhoto = true;
}
setCardInputParams({
setFullAuto: () => {},
runFullAuto: false,
setTogglePause: () => {},
setIsPausedDisplay: () => {},
questionPhrase,
answerPhrase,
nextButton: () => handleNextCard(true),
// Both audio tracks always provided for every mode
audio1: question.audio_file_1,
audio2: "",
onHelpUsed: handleWrongAttempt,
flipped,
});
// Set background only if mode requires photo
setBackgroundUrl(showPhoto ? (question.background_image_data || "") : "");
setCardKey((prev) => prev + 1);
};
// Load the current card whenever the active set or index changes
useEffect(() => {
if (!hasStarted) return;
if (!activeSet.length) return;
if (isComplete) return;
// Wrap around if we've gone past the end (for rotation)
const safeIndex = currentIndex % activeSet.length;
if (safeIndex !== currentIndex) {
setCurrentIndex(safeIndex);
return;
}
loadCard(activeSet[safeIndex]);
}, [activeSet, currentIndex, isComplete, hasStarted]);
const recordProgress = async (
wasCorrectFirstTry: boolean,
): Promise<{ modeAdvanced: boolean; isReviewOnly: boolean } | null> => {
const safeIndex = currentIndex % activeSet.length;
const currentItem = activeSet[safeIndex];
if (!currentItem) return null;
try {
const response = await fetch("/api/brain/progress", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
questionId: currentItem.question_id,
quizId: currentItem.quiz_id,
wasCorrectFirstTry,
}),
});
if (response.ok) {
const data = await response.json();
return {
modeAdvanced: data.modeAdvanced ?? false,
isReviewOnly: data.isReviewOnly ?? false,
};
}
} catch (err) {
console.error("Failed to record progress:", err);
}
return null;
};
// Pull cards from the pool to fill the active set
const refillActiveSet = () => {
if (newCardPool.length === 0) return;
const currentActiveCount =
activeSet.filter((item) => !item.hasRetired).length;
const slotsAvailable = MAX_ACTIVE_SET_SIZE - currentActiveCount;
if (slotsAvailable > 0 && newCardPool.length > 0) {
const cardsToAdd = newCardPool.slice(0, slotsAvailable);
const remainingPool = newCardPool.slice(slotsAvailable);
setNewCardPool(remainingPool);
setActiveSet((prev) => {
// Filter out retired cards and add new ones
const activeCards = prev.filter((item) => !item.hasRetired);
const newActiveItems = initializeActiveSet(cardsToAdd);
return [...activeCards, ...newActiveItems];
});
}
};
const handleNextCard = async (wasCorrect: boolean) => {
// Only count first attempt
const wasFirstAttempt = firstAttemptRef.current;
firstAttemptRef.current = false;
const safeIndex = currentIndex % activeSet.length;
const currentItem = activeSet[safeIndex];
if (!currentItem) {
setIsComplete(true);
return;
}
let cardRetired = false;
if (wasFirstAttempt) {
// Record progress to server
await recordProgress(wasCorrect);
// Update session stats
setSessionStats((prev) => ({
...prev,
reviewed: prev.reviewed + 1,
correctFirstTry: prev.correctFirstTry + (wasCorrect ? 1 : 0),
}));
if (wasCorrect) {
// Correct on first try: advance mode
const nextMode = getNextMode(currentItem.clientDisplayMode);
if (nextMode !== null) {
// Advance to next mode in the progression
setSessionStats((prev) => ({
...prev,
modeAdvances: prev.modeAdvances + 1,
}));
// Update the card's client-side mode
setActiveSet((prev) => {
const updated = [...prev];
const idx = safeIndex;
if (updated[idx]) {
updated[idx] = {
...updated[idx],
clientModeIndex: updated[idx].clientModeIndex + 1,
clientDisplayMode: nextMode,
};
}
return updated;
});
} else {
// Already at final mode and correct on first try - card is RETIRED
cardRetired = true;
setSessionStats((prev) => ({
...prev,
cardsRetired: prev.cardsRetired + 1,
}));
// Mark the card as retired and remove from active set
setActiveSet((prev) => {
const updated = prev.filter((_, idx) => idx !== safeIndex);
return updated;
});
}
}
// If not correct on first try: card stays in rotation at same mode (no state change needed)
}
// Move to next card in the rotation
setActiveSet((prevActiveSet) => {
if (prevActiveSet.length === 0) {
// All cards retired, try to refill
if (newCardPool.length > 0) {
// Will be handled by refillActiveSet
return prevActiveSet;
}
setIsComplete(true);
return prevActiveSet;
}
return prevActiveSet;
});
// Adjust index for next card
setCurrentIndex((prevIndex) => {
// Get current active set length (accounting for possible removal)
const currentSetLength = cardRetired
? activeSet.length - 1
: activeSet.length;
if (currentSetLength === 0) {
return 0;
}
// Move to next card, wrapping around
const nextIndex = cardRetired
? prevIndex % currentSetLength // Stay at same index (next card shifted into position)
: (prevIndex + 1) % currentSetLength;
return nextIndex;
});
// Check if we need to refill the active set
if (cardRetired) {
// Use setTimeout to ensure state has updated
setTimeout(() => {
refillActiveSet();
}, 0);
}
// Check if session is complete
setTimeout(() => {
setActiveSet((currentActiveSet) => {
if (currentActiveSet.length === 0 && newCardPool.length === 0) {
setIsComplete(true);
}
return currentActiveSet;
});
}, 100);
};
const handleWrongAttempt = () => {
// Mark that this is no longer a first attempt
if (firstAttemptRef.current) {
firstAttemptRef.current = false;
setIsFirstAttempt(false);
}
};
const handleReturnToDashboard = () => {
const baseUrl = "/language/dashboard";
const returnUrl = tab_index !== undefined
? `${baseUrl}?tab_index=${tab_index}`
: baseUrl;
globalThis.location.href = returnUrl;
};
const handleRestartSession = async () => {
setLoading(true);
setError(null);
try {
// Reset all quiz progress on server (back to first study mode)
const response = await fetch("/api/brain/reset-session", {
method: "DELETE",
});
if (!response.ok) {
throw new Error("Failed to reset session");
}
// Reset local state
setSessionStats({
reviewed: 0,
correctFirstTry: 0,
sessionStart: Date.now(),
modeAdvances: 0,
cardsRetired: 0,
});
setHasStarted(false);
setActiveSet([]);
setNewCardPool([]);
setCurrentIndex(0);
setIsComplete(false);
await fetchQueue();
} catch (err) {
setError(
err instanceof Error
? err.message
: "Failed to reset session. Please try again.",
);
} finally {
setLoading(false);
}
};
// Loading state
if (loading) {
return (
<div className="theme-container p-8 rounded-xl shadow-2xl max-w-md w-full text-center">
<h2 className="text-2xl font-bold mb-4 theme-text">Brain Study</h2>
<div className="animate-spin w-12 h-12 border-4 border-blue-500 border-t-transparent rounded-full mx-auto">
</div>
<p className="mt-4 theme-text">Loading study queue...</p>
</div>
);
}
// Error state
if (error) {
return (
<div className="theme-container p-8 rounded-xl shadow-2xl max-w-md w-full text-center">
<h2 className="text-2xl font-bold mb-4 theme-text">Error</h2>
<p className="text-red-500 mb-4">{error}</p>
<button
onClick={fetchQueue}
className="bg-blue-500 text-white px-6 py-2 rounded-full hover:bg-blue-600"
>
Try Again
</button>
</div>
);
}
// Complete state
if (isComplete) {
const accuracy = sessionStats.reviewed > 0
? Math.round((sessionStats.correctFirstTry / sessionStats.reviewed) * 100)
: 0;
return (
<div className="theme-container p-8 rounded-xl shadow-2xl max-w-md w-full text-center">
<h2 className="text-2xl font-bold mb-4 theme-text">
Session Complete!
</h2>
<div className="space-y-4 mb-6">
<div className="bg-green-100 dark:bg-green-900 p-4 rounded-lg">
<p className="text-3xl font-bold text-green-600 dark:text-green-400">
{sessionStats.reviewed}
</p>
<p className="text-sm theme-text">Cards Reviewed</p>
</div>
<div className="bg-blue-100 dark:bg-blue-900 p-4 rounded-lg">
<p className="text-3xl font-bold text-blue-600 dark:text-blue-400">
{accuracy}%
</p>
<p className="text-sm theme-text">First-Try Accuracy</p>
</div>
<div className="bg-purple-100 dark:bg-purple-900 p-4 rounded-lg">
<p className="text-xl font-bold text-purple-600 dark:text-purple-400">
{sessionStats.correctFirstTry} / {sessionStats.reviewed}
</p>
<p className="text-sm theme-text">Correct on First Try</p>
</div>
{sessionStats.modeAdvances > 0 && (
<div className="bg-yellow-100 dark:bg-yellow-900 p-4 rounded-lg">
<p className="text-xl font-bold text-yellow-600 dark:text-yellow-400">
{sessionStats.modeAdvances}
</p>
<p className="text-sm theme-text">Mode Advances</p>
</div>
)}
{sessionStats.cardsRetired > 0 && (
<div className="bg-emerald-100 dark:bg-emerald-900 p-4 rounded-lg">
<p className="text-xl font-bold text-emerald-600 dark:text-emerald-400">
{sessionStats.cardsRetired}
</p>
<p className="text-sm theme-text">
Cards Fully Mastered (All 6 Modes)
</p>
</div>
)}
</div>
<div className="space-y-3">
<button
onClick={handleRestartSession}
className="w-full bg-blue-500 text-white px-6 py-3 rounded-full hover:bg-blue-600 font-semibold"
>
Start New Session
</button>
<button
onClick={handleReturnToDashboard}
className="w-full bg-gray-500 text-white px-6 py-3 rounded-full hover:bg-gray-600 font-semibold"
>
Return to Dashboard
</button>
</div>
</div>
);
}
if (!hasStarted) {
return (
<div className="theme-container p-8 rounded-xl shadow-2xl max-w-md w-full text-center">
<h2 className="text-2xl font-bold mb-4 theme-text">Brain Study</h2>
<p className="theme-text mb-6">
Press Start to begin. This enables audio playback for the first card.
</p>
<button
type="button"
onClick={() => {
try {
const Webkit = globalThis as unknown as {
webkitAudioContext?: typeof AudioContext;
};
const Ctx = globalThis.AudioContext || Webkit.webkitAudioContext;
if (Ctx) {
const ctx = new Ctx();
ctx.resume?.();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
gain.gain.value = 0;
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.01);
setTimeout(() => ctx.close?.(), 50);
}
} catch (_err) {
void _err;
}
setHasStarted(true);
}}
className="w-full bg-blue-500 text-white px-6 py-3 rounded-full hover:bg-blue-600 font-semibold"
>
Start
</button>
</div>
);
}
// Study state
const safeIndex = activeSet.length > 0 ? currentIndex % activeSet.length : 0;
const currentItem = activeSet[safeIndex];
// Show progress as "X cards remaining in active set"
const progress = `${safeIndex + 1} / ${activeSet.length}`;
const modeInfo = getModeDisplayInfo(
currentItem?.clientDisplayMode || QUIZ_MODES[0],
);
return (
<>
<div className="theme-container p-2 sm:p-3 md:p-2 rounded-lg shadow-md max-w-md mx-auto w-full overflow-clip">
{/* Header with progress and mode indicator */}
<div className="flex flex-col gap-2 mb-4">
{/* Mode indicator row */}
{/* Progress row */}
<div className="flex items-center gap-4">
<div className="flex-[0.7]">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs font-medium px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300">
Step {modeInfo.index}/{modeInfo.total}
</span>
</div>
{currentItem?.is_review_only && (
<span className="text-xs font-medium px-2 py-1 rounded-full bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300">
Review
</span>
)}
</div>
</div>
<div className="flex-[0.7]">
<div className="text-sm theme-text">
<span className="font-semibold">{progress}</span>
<span className="ml-2 text-gray-500">
({currentItem?.is_new ? "New" : "Old"})
</span>
</div>
</div>
<div className="flex-[0.6]">
<button
type="button"
onClick={handleReturnToDashboard}
className="px-4 py-2 rounded-full theme-button font-semibold shadow-lg"
>
Dashboard
</button>
</div>
</div>
</div>
<div
className={`theme-container p-2 sm:p-3 md:p-4 rounded-lg shadow-md max-w-md mx-auto w-full overflow-clip`}
>
{/* Card */}
<Card
key={cardKey}
cardInputParams={cardInputParams}
backgroundUrl={backgroundUrl}
hideText={false}
useInputHint={useInputHint}
/>
{/* Skip button */}
<div className="mt-4 flex justify-center">
<button
onClick={() => handleNextCard(false)}
style={{ top: "320px", left: "50%", marginLeft: "-12px" }}
className="bg-opacity-50 fixed bg-blue-500 hover:bg-blue-500 hover:bg-opacity-50 transition duration-300 text-white font-bold p-1 rounded-full"
aria-label="Next question"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</button>
</div>
</div>
</div>
</>
);
}

856
islands/Card.tsx Normal file
View File

@ -0,0 +1,856 @@
// components/Card.tsx
import { useEffect, useRef, useState } from "preact/hooks";
import { TargetedKeyboardEvent } from "preact";
import { Signal } from "@preact/signals";
import { decodeData } from "../utils/urlEncoder.ts";
//import { useSpeechSynthesis } from 'react-speech-kit';
interface CardProps {
cardInputParams: {
setFullAuto: (auto: boolean) => void;
runFullAuto: boolean;
setTogglePause: (fn: () => void) => void;
setIsPausedDisplay: (paused: boolean) => void;
questionPhrase: string;
answerPhrase: string;
nextButton: () => void;
audio1: string;
audio2: string;
onHelpUsed: () => void;
flipped: boolean;
};
backgroundUrl: string;
hideText: boolean;
useInputHint: Signal<boolean>;
}
export default function Card(
{ cardInputParams, backgroundUrl, hideText, useInputHint }: CardProps,
) {
// const [currentBackgroundImage, setCurrentBackgroundImage] = useState(() => {
// //console.log("imageData");
// // if (backgroundUrl) {
// // console.log(backgroundUrl.substring(0, 100));
// // }
// return backgroundUrl;
// });
const audioRef_happy = useRef<HTMLAudioElement>(null);
const audioRef_fail = useRef<HTMLAudioElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [inputValue, setInputValue] = useState("");
const [message, setMessage] = useState("");
const [helpClick, setHelpClick] = useState(false);
const [fullAutoInterval, setFullAutoInterval] = useState(0);
const [isPaused, setIsPaused] = useState(false);
const isPausedRef = useRef(false);
const audioRef1 = useRef<HTMLAudioElement>(null);
const audioRef2 = useRef<HTMLAudioElement>(null);
const [initialAudioUrl1, setInitialAudioUrl1] = useState<string | null>(null);
const [initialAudioUrl2, setInitialAudioUrl2] = useState<string | null>(null);
const playSequenceId = useRef(0);
const [playSequenceComplete, setPlaySequenceComplete] = useState(true);
const [hint, setHintDisplay] = useState("");
// Register toggle pause function
useEffect(() => {
cardInputParams.setTogglePause(() => {
setIsPaused((prev) => {
const newVal = !prev;
isPausedRef.current = newVal;
cardInputParams.setIsPausedDisplay(newVal);
return newVal;
});
});
}, [cardInputParams]);
// Fetch audio files from API when audio1/audio2 props change
useEffect(() => {
let url1: string | null = null;
let url2: string | null = null;
if (cardInputParams.audio1 || cardInputParams.audio2) {
setPlaySequenceComplete(false);
}
const fetchAudio = async (
audioFile: string,
setter: (url: string | null) => void,
) => {
if (!audioFile) {
setter(null);
return null;
}
try {
const response = await fetch(
`/api/audio-blob/${encodeURIComponent(audioFile)}`,
{ credentials: "include" },
);
if (!response.ok) {
console.error("Failed to fetch audio file:", response.statusText);
return null;
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
setter(url);
return url;
} catch (err) {
console.error("Error fetching audio file:", err);
return null;
}
};
const loadAudios = async () => {
url1 = cardInputParams.audio1
? await fetchAudio(cardInputParams.audio1, setInitialAudioUrl1)
: "";
url2 = cardInputParams.audio2
? await fetchAudio(cardInputParams.audio2, setInitialAudioUrl2)
: "";
};
loadAudios();
// Cleanup blob URLs on unmount or when audio props change
return () => {
if (url1) URL.revokeObjectURL(url1);
if (url2) URL.revokeObjectURL(url2);
};
}, [cardInputParams.audio1, cardInputParams.audio2]);
const stopAudio = (audio: HTMLAudioElement | null) => {
if (!audio) return;
try {
audio.pause();
audio.currentTime = 0;
} catch (_err) {
// Ignore
}
};
const waitForCanPlayThrough = (audio: HTMLAudioElement) => {
if (audio.readyState >= HTMLMediaElement.HAVE_ENOUGH_DATA) {
return Promise.resolve();
}
return new Promise<void>((resolve) => {
const onReady = () => {
audio.removeEventListener("canplaythrough", onReady);
resolve();
};
audio.addEventListener("canplaythrough", onReady);
});
};
const playAndWaitDone = async (
audio: HTMLAudioElement,
requestId: number,
): Promise<boolean> => {
await waitForCanPlayThrough(audio);
if (requestId !== playSequenceId.current) return false;
audio.currentTime = 0;
try {
await audio.play();
} catch (err: unknown) {
const errName = (err && typeof err === "object" && "name" in err &&
typeof (err as { name: unknown }).name === "string")
? (err as { name: string }).name
: "";
if (errName === "NotAllowedError") {
console.log("Audio autoplay blocked");
return true;
}
console.error("Audio play error:", err);
return true;
}
await new Promise<void>((resolve) => {
const onDone = () => {
cleanup();
resolve();
};
const cleanup = () => {
audio.removeEventListener("ended", onDone);
audio.removeEventListener("pause", onDone);
audio.removeEventListener("error", onDone);
};
audio.addEventListener("ended", onDone);
audio.addEventListener("pause", onDone);
audio.addEventListener("error", onDone);
});
return true;
};
const playSequence = async () => {
const requestId = ++playSequenceId.current;
stopAudio(audioRef1.current);
stopAudio(audioRef2.current);
const audio1 = initialAudioUrl1 ? audioRef1.current : null;
const audio2 = initialAudioUrl2 ? audioRef2.current : null;
if (audio1) {
const shouldContinue = await playAndWaitDone(audio1, requestId);
if (!shouldContinue || requestId !== playSequenceId.current) {
return;
}
}
if (audio2) {
await playAndWaitDone(audio2, requestId);
}
if (cardInputParams.runFullAuto) {
let firstCouple = 0;
const answerLength = Math.max(
1,
(cardInputParams.answerPhrase ?? "").length,
);
const autoIntervalMs = Math.max(1, Math.floor(3000 / answerLength));
const autoInterval = setInterval(() => {
// Skip if paused
if (!isPausedRef.current) {
if (firstCouple > 3) {
let canClickHelp = false;
setHelpClick((prev) => {
//if (!prev){
canClickHelp = prev;
//return true;
//}
return prev;
});
if (!canClickHelp) {
let inputNow = "";
setInputValue((prev) => {
inputNow = prev;
return prev;
});
//console.log("auto!!!",cardInputParams.answerPhrase, inputNow);
if (inputNow !== cardInputParams.answerPhrase) {
helpNext(inputNow);
} else {
clearInterval(autoInterval);
return;
}
}
} else {
firstCouple += 1;
}
}
}, autoIntervalMs);
setFullAutoInterval(autoInterval);
}
if (requestId === playSequenceId.current) {
setPlaySequenceComplete(true);
}
};
// Auto-play audio1 when loaded, then sequence audio2 after audio1 completes
useEffect(() => {
if (!initialAudioUrl1 && !initialAudioUrl2) return;
playSequence();
return () => {
playSequenceId.current += 1;
stopAudio(audioRef1.current);
stopAudio(audioRef2.current);
setPlaySequenceComplete(true);
};
}, [initialAudioUrl1, initialAudioUrl2]);
const effectSetFullAuto = () => {
setInputValue(defaultInputValue);
return 0;
};
//let lastInputValue = "";
//let lastInputValueLength = -1;
const defaultInputValue = "";
const setHint = (quizInput: string, specialInput = "") => {
const asciitestWord = toAscii(cardInputParams.answerPhrase).toLowerCase();
const hintChars = new Array(asciitestWord.length);
let answerInput = "";
if (specialInput === "") {
if (quizInput) {
answerInput = toAscii(quizInput.trim().toLowerCase());
}
} else {
answerInput = specialInput.toLowerCase();
}
for (let i = 0; i < asciitestWord.length; ++i) {
const testCh = asciitestWord[i];
if (/\s/.test(testCh)) {
hintChars[i] = " ";
} else {
if (answerInput && answerInput.length > i) {
const answerCh = answerInput[i];
if (!/[a-zA-Z0-9]/.test(answerCh) || answerCh === testCh) {
hintChars[i] = cardInputParams.answerPhrase[i];
} else {
hintChars[i] = "-";
}
} else {
if (!/[a-zA-Z0-9]/.test(testCh)) {
hintChars[i] = cardInputParams.answerPhrase[i];
} else {
hintChars[i] = "-";
}
}
}
}
return hintChars.join("");
};
// const loadVoices = () => {
// const voices = window.speechSynthesis.getVoices();
// console.log("voices:")
// voices.forEach(voice => {
// console.log("-> " + voice.name + " " + voice.lang);
// });
// }
//const { speak } = useSpeechSynthesis();
const speak = (text: any) => {
//loadVoices();
// console.log("speech start");
// const utterance = new SpeechSynthesisUtterance("hello world");
// //utterance.lang = 'fil-PH'; // Set language to Filipino
// window.speechSynthesis.speak(utterance);
// console.log("speech done");
};
// Play the initial audio_file_1 automatically when the component mounts
// useEffect(() => {
// if (audio_file.startsWith("http") && initialAudioRef.current) {
// // Attempt to play programmatically for better browser support
// initialAudioRef.current.play().catch(() => {
// // Autoplay might be blocked, user must interact
// });
// }
// }, [audio_file]);
useEffect(() => {
//console.log("intitial url");
//const decoded = decodeKvURL(initialQuizData.questions[0].background_image_data);
//console.log(backgroundImage.substring(0,100));
//setCurrentBackgroundImage(backgroundUrl);
if (inputRef.current) {
inputRef.current.focus();
}
//console.log("background ", backgroundImage);
setHintDisplay(setHint(""));
}, [cardInputParams]);
const handleInputChange = (e: TargetedKeyboardEvent<HTMLInputElement>) => {
setInputValue((prev) => {
// console.log("keypress",
// e.currentTarget.value);
return e.currentTarget.value.trim();
});
};
const waitForAudioEnd = (audio: HTMLAudioElement): Promise<void> => {
return new Promise((resolve) => {
const onDone = () => {
audio.removeEventListener("ended", onDone);
audio.removeEventListener("pause", onDone);
audio.removeEventListener("error", onDone);
resolve();
};
audio.addEventListener("ended", onDone);
audio.addEventListener("pause", onDone);
audio.addEventListener("error", onDone);
});
};
const playFinalAudio = async () => {
if (cardInputParams.audio1 && audioRef1.current) {
try {
await audioRef1.current.play();
await waitForAudioEnd(audioRef1.current);
} catch {
// Ignore play errors
}
}
if (audioRef_happy.current) {
try {
audioRef_happy.current.volume = 0.6;
await audioRef_happy.current.play();
await waitForAudioEnd(audioRef_happy.current);
} catch {
// Ignore play errors
}
}
setTimeout(() => {
setInputValue("");
setMessage("");
cardInputParams.nextButton();
setHelpClick(false);
}, 1000);
};
const checkAnswer = (value: string) => {
const processedInput: boolean = handleValidSubmit(value);
if (processedInput) { //}.toLowerCase() === answerPhrase.toLowerCase()) {
setMessage("Correct! Great job!");
playFinalAudio();
} else {
if (audioRef_fail.current) {
audioRef_fail.current.volume = 0.6;
audioRef_fail.current.play().catch(() => {});
}
setMessage("");
}
};
const handleKeyPress = (e: TargetedKeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
if (e.shiftKey) {
e.preventDefault();
setFullAutoInterval((prev) => {
if (prev) {
clearInterval(prev);
cardInputParams.setFullAuto(false);
}
return 0;
});
if (!helpClick) {
cardInputParams.onHelpUsed(); // Mark as not first attempt
setHelpClick(true);
helpNext(inputValue);
}
} else {
const timValue = e.currentTarget.value.trim();
//console.log(timValue);
setInputValue(timValue);
checkAnswer(timValue);
}
}
};
const handleValidSubmit = (quizInput: string) => {
let answerInput = "";
if (quizInput) {
answerInput = toAscii(quizInput.trim());
//console.log("ascii",answerInput);
} else {
return false;
}
const asciitestWord = toAscii(cardInputParams.answerPhrase);
if (asciitestWord[0] === "?") {
if (answerInput.length === 0) {
answerInput = "?";
} else if (answerInput[0] !== "?") {
answerInput = "?" + answerInput;
}
}
if (asciitestWord[0] === "!") {
if (answerInput.length === 0) {
answerInput = "!";
} else if (answerInput[0] !== "!") {
answerInput = "!" + answerInput;
}
}
if (
asciitestWord[asciitestWord.length - 1] === "?" &&
(asciitestWord.length - 1) === answerInput.length
) {
answerInput = answerInput + "?";
}
if (
asciitestWord[asciitestWord.length - 1] === "!" &&
(asciitestWord.length - 1) === answerInput.length
) {
answerInput = answerInput + "!";
}
if (
asciitestWord[asciitestWord.length - 1] === "." &&
(asciitestWord.length - 1) === answerInput.length
) {
answerInput = answerInput + ".";
}
if (asciitestWord && trimJunk(answerInput) === trimJunk(asciitestWord)) {
setInputValue(answerInput);
setHintDisplay(setHint(answerInput));
return true;
}
let outputStr = "";
let i = 0;
for (; i < cardInputParams.answerPhrase.length; ++i) {
const asciiCh = asciitestWord[i];
if (!/[a-zA-Z0-9]/.test(asciiCh)) {
outputStr += cardInputParams.answerPhrase[i];
continue;
}
if (i >= answerInput.length) {
break;
}
const ch = answerInput[i];
if (ch.toLowerCase() !== asciitestWord[i].toLowerCase()) {
break;
}
outputStr += cardInputParams.answerPhrase[i];
}
if (outputStr === cardInputParams.answerPhrase) {
setInputValue(outputStr);
setHintDisplay(setHint(outputStr));
return true;
}
setHintDisplay(setHint(outputStr));
setInputValue((prev) => {
//console.log("set input end",prev,outputStr);
return outputStr;
});
return false;
};
const helpNext = (value: string) => {
const processedInput: string = handleValidHelp(value.trim());
if (
trimJunk(toAscii(processedInput)).toLowerCase() ===
trimJunk(toAscii(cardInputParams.answerPhrase)).toLowerCase()
) {
setFullAutoInterval((prev) => {
if (prev) {
clearInterval(prev);
}
return 0;
});
setMessage("Correct! Great job!");
playFinalAudio();
} else {
setMessage("");
setHelpClick(false);
}
};
const handleValidHelp = (quizInput: string) => {
let answerInput = "";
if (quizInput) {
answerInput = toAscii(quizInput.trim());
}
const asciitestWord = toAscii(cardInputParams.answerPhrase);
if (asciitestWord[0] === "?") {
if (answerInput.length === 0) {
answerInput = "?";
} else if (answerInput[0] !== "?") {
answerInput = "?" + answerInput;
}
}
if (asciitestWord[0] === "!") {
if (answerInput.length === 0) {
answerInput = "!";
} else if (answerInput[0] !== "!") {
answerInput = "!" + answerInput;
}
}
if (
asciitestWord[asciitestWord.length - 1] === "?" &&
(asciitestWord.length - 1) === answerInput.length
) {
answerInput = answerInput + "?";
}
if (
asciitestWord[asciitestWord.length - 1] === "!" &&
(asciitestWord.length - 1) === answerInput.length
) {
answerInput = answerInput + "!";
}
if (
asciitestWord[asciitestWord.length - 1] === "." &&
(asciitestWord.length - 1) === answerInput.length
) {
answerInput = answerInput + ".";
}
if (asciitestWord && trimJunk(answerInput) === trimJunk(asciitestWord)) {
setInputValue(answerInput);
setHintDisplay(setHint(answerInput));
return answerInput;
}
let outputStr = "";
let i = 0;
for (; i < cardInputParams.answerPhrase.length; ++i) {
const asciiCh = asciitestWord[i];
if (!/[a-zA-Z0-9]/.test(asciiCh)) {
outputStr += cardInputParams.answerPhrase[i];
continue;
}
if (i >= answerInput.length) {
break;
}
const ch = answerInput[i];
if (ch.toLowerCase() !== asciitestWord[i].toLowerCase()) {
break;
}
outputStr += cardInputParams.answerPhrase[i];
}
if (outputStr === cardInputParams.answerPhrase) {
setInputValue(outputStr);
setHintDisplay(setHint(outputStr));
return outputStr;
}
if (asciitestWord.length > outputStr.length) {
outputStr = asciitestWord.substring(0, outputStr.length + 1);
if (outputStr[outputStr.length - 1] === " ") {
outputStr = asciitestWord.substring(0, outputStr.length + 1);
}
setHintDisplay(setHint(quizInput, outputStr));
setInputValue(outputStr);
}
return outputStr;
};
// const divStyle = {
// width: "320px",
// height: "auto",
// backgroundImage: `url(${currentBackgroundImage})`,
// backgroundSize: "cover",
// backgroundPosition: "center",
// //
const questionPhrase = cardInputParams.questionPhrase || "";
const trimmedQuestionPhrase = questionPhrase.trim();
const shouldShowPlaceholder = hideText || trimmedQuestionPhrase === "";
const answerPhraseLength = cardInputParams.answerPhrase
? cardInputParams.answerPhrase.length
: 0;
const placeholderCharCount = Math.min(
Math.max(questionPhrase.length, answerPhraseLength, 20),
120,
);
const placeholderText = "\u00A0".repeat(placeholderCharCount);
if (!shouldShowPlaceholder && !questionPhrase) {
return null;
}
if (cardInputParams.answerPhrase) {
return (
<div
onClick={() => {
if (inputRef.current) {
inputRef.current.focus();
}
}}
>
<div
style={{
width: "320px",
height: "320px",
backgroundImage: `${
backgroundUrl
? backgroundUrl.startsWith("/api")
? "url(" + backgroundUrl + ")"
: backgroundUrl.startsWith("http")
? "url(" + CSS.escape(decodeData(backgroundUrl)) + ")"
: backgroundUrl.length > 500
? "url(" + backgroundUrl + ")"
: ""
: ""
}`,
backgroundSize: "cover",
backgroundPosition: "center",
}}
className="p-4 rounded-lg theme-card w-80 button-padding"
>
<div className="flex flex-col h-full">
<div className="text-center">
{shouldShowPlaceholder
? (
<span aria-hidden="true" className="select-none">
</span>
)
: (
<p className="strokeme theme-question-text text-2xl">
{cardInputParams.questionPhrase}
</p>
)}
</div>
<div className="absolute bottom-[-20px] left-0 right-0 px-4 space-y-2">
<div className="text-center">
{useInputHint.value &&
(
<p className="strokeme theme-question-text">
{cardInputParams.answerPhrase ? hint : ""}
</p>
)}
</div>
<div>
<div hidden>
{initialAudioUrl1 &&
(
<audio
ref={audioRef1}
src={initialAudioUrl1}
preload="auto"
controls={false}
/>
)}
{initialAudioUrl2 &&
(
<audio
ref={audioRef2}
src={initialAudioUrl2}
preload="auto"
controls={false}
/>
)}
</div>
{cardInputParams.answerPhrase &&
(
<input
type="text"
id="input1"
name="input1"
value={inputValue}
onChange={handleInputChange}
onKeyPress={(
e: TargetedKeyboardEvent<HTMLInputElement>,
) => {
if (!helpClick) {
handleKeyPress(e);
}
}}
ref={inputRef}
placeholder={cardInputParams.flipped
? "Type study language"
: "Type meaning language"}
className="theme-card-input strokeme w-full px-2 -pt-1 py-2 border rounded-md focus:outline-none focus:ring-2"
/>
)}
</div>
{cardInputParams.answerPhrase &&
(
<div className="flex pt-0 pb-[30px]">
<span className="w-[30px]"></span>
<button
onClick={() => {
setFullAutoInterval((prev) => {
if (prev) {
clearInterval(prev);
cardInputParams.setFullAuto(false);
}
return 0;
});
if (!helpClick) {
cardInputParams.onHelpUsed(); // Mark as not first attempt
setHelpClick(true);
helpNext(inputValue);
}
}}
className="w-[100px] theme-card-button py-2 px-4 rounded-md transition duration-300"
>
Help
</button>
<span className="w-[65px]"></span>
<button
onClick={() => {
setFullAutoInterval((prev) => {
if (prev) {
clearInterval(prev);
cardInputParams.setFullAuto(false);
}
return 0;
});
if (!helpClick) {
checkAnswer(inputValue);
}
}}
className="w-[100px] theme-card-button py-2 px-4 rounded-md transition duration-300"
>
Enter
</button>
</div>
)}
</div>
</div>
</div>
<audio
ref={audioRef_happy}
src="/116779__domrodrig-bell-happy-trim.mp3"
/>
<audio ref={audioRef_fail} src="/342756__rhodesmas__failure-01.mp3" />
</div>
);
} else {
return null;
}
}
function trimJunk(inputStr: string) {
if (!inputStr) {
return "";
}
let i = inputStr.length - 1;
while (i > 0 && !/[a-zA-Z0-9]/.test(inputStr[i])) {
--i;
}
return inputStr.substring(0, i + 1).toLowerCase();
}
function toAscii(input: string) {
if (!input) return "";
return input.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}

View File

@ -0,0 +1,48 @@
// islands/ClearCheckedButton.// islands/ClearCheckedButton.tsx
interface ClearCheckedButtonProps {
clearSig: any;
setCanStudy: any;
}
export default function ClearCheckedButton(
{ clearSig, setCanStudy }: ClearCheckedButtonProps,
) {
function handleClearCheckedClick(): void {
// Use a monotonically-changing value so repeated clicks always notify.
if (!setCanStudy.value) {
clearSig.value = clearSig.value >= 0 ? -1 : (clearSig.value - 1);
} else {
clearSig.value = clearSig.value <= 0 ? 1 : (clearSig.value + 1);
}
}
const isAllMode = !setCanStudy.value;
return (
<button
class={`
w-8 h-8
${
isAllMode
? "bg-blue-600 hover:bg-blue-700"
: "bg-red-600 hover:bg-red-700"
}
text-white font-bold
rounded-full
shadow-lg
transition-all duration-300 ease-in-out
transform hover:scale-110
focus:outline-none focus:ring-2 ${
isAllMode ? "focus:ring-blue-500" : "focus:ring-red-500"
} focus:ring-opacity-50
flex flex-col justify-center items-center
`}
onClick={handleClearCheckedClick}
>
<span class="text-[10px] leading-none">
{isAllMode ? "All" : "Clear"}
</span>
</button>
);
}

File diff suppressed because it is too large Load Diff

288
islands/GuestHome.tsx Executable file
View File

@ -0,0 +1,288 @@
import { useState } from "preact/hooks";
export default function GuestHome() {
const [spinDirection, setSpinDirection] = useState<"cw" | "ccw">("cw");
const handleLogoMouseEnter = () => {
setSpinDirection(Math.random() < 0.5 ? "cw" : "ccw");
};
const startOnboarding = (role: "landlord" | "tenant") => {
try {
localStorage.setItem("login-purpose", role);
localStorage.removeItem("lock-route");
} catch (_error) {
void _error;
}
globalThis.location.href = "/auth/login";
};
return (
<div class="min-h-screen bg-black flex items-start justify-center relative overflow-hidden w-full pt-4">
{/* Animated background grid */}
<div class="absolute inset-0 opacity-20">
<div
class="absolute inset-0"
style={{
backgroundImage:
`linear-gradient(rgba(107, 206, 220, 0.1) 1px, transparent 1px), linear-gradient(90deg, rgba(107, 206, 220, 0.1) 1px, transparent 1px)`,
backgroundSize: "50px 50px",
}}
>
</div>
</div>
{/* Three colored orbs matching text highlights */}
<div class="absolute w-48 h-48 bg-cyan-400 rounded-full filter blur-3xl opacity-20 animate-float-cyan">
</div>
<div class="absolute w-56 h-56 bg-purple-400 rounded-full filter blur-3xl opacity-20 animate-float-purple">
</div>
<div class="absolute w-52 h-52 bg-pink-400 rounded-full filter blur-3xl opacity-20 animate-float-pink">
</div>
<div class="relative z-10 flex flex-col items-center text-center px-6 w-full max-w-none mt-4">
{/* Large Logo */}
<div class="mb-4 transform hover:scale-105 transition-transform duration-300">
<img
src="/logo.svg"
alt="Cyber Hybrid Hub Logo"
width="150"
height="150"
class={`drop-shadow-2xl filter drop-shadow-[0_0_20px_rgba(107,206,220,0.5)] spin-on-hover ${
spinDirection === "cw"
? "spin-direction-cw"
: "spin-direction-ccw"
}`}
onMouseEnter={handleLogoMouseEnter}
/>
</div>
{/* Cyberpunk Message */}
<div class="max-w-none w-full px-4">
<p class="text-lg md:text-xl text-gray-300 mb-6 leading-relaxed">
However you found <span class="text-pink-400">us</span>, you are
{" "}
<span class="text-cyan-400 font-bold">lucky</span>.
<br class="hidden md:block" />
<span class="text-purple-400">Click</span> to start your journey.
</p>
{/* Enter button form */}
<div class="mt-10 flex justify-center">
<form method="get" action="/auth/login">
<button
type="submit"
class="
group relative isolate overflow-hidden
w-36 h-36 md:w-44 md:h-44 rounded-full border-2 border-red-500/20
bg-linear-to-br from-red-300/10 via-red-500/5 to-red-800/15
shadow-[0_8px_32px_8px_rgba(220,38,38,0.25),0_20px_60px_rgba(127,29,29,0.45),inset_0_2px_4px_rgba(255,255,255,0.1)]
flex items-center justify-center
text-xl md:text-2xl font-bold uppercase tracking-widest text-white
transition-all duration-300 ease-out
hover:shadow-[0_12px_48px_12px_rgba(239,68,68,0.6),0_24px_80px_rgba(185,28,28,0.7),inset_0_2px_8px_rgba(255,255,255,0.3)]
hover:scale-105 hover:border-red-400/90
active:shadow-[0_4px_16px_4px_rgba(185,28,28,0.8),0_8px_24px_rgba(127,29,29,0.9),inset_0_1px_2px_rgba(0,0,0,0.3)]
active:scale-95
focus:ring-4 focus:ring-red-400/50 focus:outline-none focus:ring-offset-2 focus:ring-offset-black/50
backdrop-blur-md
before:absolute before:inset-0 before:rounded-full before:bg-linear-to-t from-transparent via-white/10 to-white/20 before:opacity-0 hover:before:opacity-100 before:transition-opacity before:duration-300
after:absolute after:inset-0 after:rounded-full after:bg-linear-to-br from-transparent via-red-600/20 to-red-900/40 after:opacity-50
"
>
<span
aria-hidden="true"
class="absolute inset-0 rounded-full bg-linear-to-b from-transparent via-red-400/8 to-black/25 opacity-40 pointer-events-none group-hover:opacity-70 transition-opacity duration-300"
>
</span>
<span
aria-hidden="true"
class="absolute inset-[12px] rounded-full border border-red-300/20 bg-linear-to-b from-red-200/20 via-transparent to-red-800/30 shadow-inner shadow-black/40 pointer-events-none"
>
</span>
<span
aria-hidden="true"
class="absolute top-[18px] left-10 right-10 h-12 rounded-full bg-linear-to-r from-red-400/12 via-red-500/10 to-red-400/12 blur-[2px] opacity-40 group-hover:opacity-80 transition-all duration-300 group-hover:blur-[3px] pointer-events-none"
>
</span>
<span class="relative z-10 drop-shadow-[0_4px_12px_rgba(0,0,0,0.85)] group-hover:drop-shadow-[0_6px_16px_rgba(0,0,0,0.95)] transition-all duration-300 tracking-[0.15em]">
ENTER
</span>
</button>
</form>
</div>
<div class="mb-8 flex flex-col items-center gap-3">
<p class="text-xs uppercase tracking-[0.35em] text-gray-400">
Start Real Estate Onboarding
</p>
<div class="flex flex-col sm:flex-row gap-3">
<button
type="button"
onClick={() => startOnboarding("landlord")}
class="rounded-full border border-cyan-400/40 px-6 py-2 text-sm font-semibold text-cyan-200 hover:border-cyan-300/80 hover:text-white transition"
>
Landlord Setup
</button>
<button
type="button"
onClick={() => startOnboarding("tenant")}
class="rounded-full border border-pink-400/40 px-6 py-2 text-sm font-semibold text-pink-200 hover:border-pink-300/80 hover:text-white transition"
>
Tenant Setup
</button>
</div>
</div>
{/* Glitch effect container */}
<div class="relative inline-block">
<div class="absolute inset-0 bg-cyan-400 opacity-20 blur-xl animate-pulse">
</div>
<div class="relative bg-linear-to-r from-cyan-500/20 to-purple-500/20 border border-cyan-500/50 rounded-lg px-8 py-4 backdrop-blur-sm">
<p class="text-cyan-300 text-sm font-mono">
&gt; System ready for authentication...
</p>
</div>
</div>
</div>
{/* Animated scanlines */}
<div class="absolute inset-0 pointer-events-none">
<div
class="absolute inset-0 bg-linear-to-b from-transparent via-cyan-500/5 to-transparent animate-pulse"
style={{
animation: "scan 3s linear infinite",
backgroundSize: "100% 3px",
}}
>
</div>
</div>
</div>
<style jsx>
{`
@keyframes scan {
0% { transform: translateY(-100%); }
100% { transform: translateY(100vh); }
}
@keyframes spiral-cyan {
0% {
transform: translate(-300px, -300px) rotate(0deg) scale(0.4);
opacity: 0;
}
25% {
transform: translate(-150px, -150px) rotate(90deg) scale(0.7);
opacity: 0.3;
}
50% {
transform: translate(0px, 0px) rotate(180deg) scale(1.1);
opacity: 0.5;
}
75% {
transform: translate(150px, 150px) rotate(270deg) scale(0.7);
opacity: 0.3;
}
100% {
transform: translate(300px, 300px) rotate(360deg) scale(0.4);
opacity: 0;
}
}
@keyframes spiral-purple {
0% {
transform: translate(300px, -300px) rotate(0deg) scale(0.4);
opacity: 0;
}
25% {
transform: translate(150px, -150px) rotate(-90deg) scale(0.7);
opacity: 0.3;
}
50% {
transform: translate(0px, 0px) rotate(-180deg) scale(1.1);
opacity: 0.5;
}
75% {
transform: translate(-150px, 150px) rotate(-270deg) scale(0.7);
opacity: 0.3;
}
100% {
transform: translate(-300px, 300px) rotate(-360deg) scale(0.4);
opacity: 0;
}
}
@keyframes spiral-pink {
0% {
transform: translate(0px, 300px) rotate(0deg) scale(0.4);
opacity: 0;
}
25% {
transform: translate(0px, 150px) rotate(180deg) scale(0.7);
opacity: 0.3;
}
50% {
transform: translate(0px, 0px) rotate(360deg) scale(1.1);
opacity: 0.5;
}
75% {
transform: translate(0px, -150px) rotate(540deg) scale(0.7);
opacity: 0.3;
}
100% {
transform: translate(0px, -300px) rotate(720deg) scale(0.4);
opacity: 0;
}
}
.animate-float-cyan {
animation: spiral-cyan 12s ease-in-out infinite;
top: 50%;
left: 50%;
margin-top: -24px;
margin-left: -24px;
}
.animate-float-purple {
animation: spiral-purple 15s ease-in-out infinite;
top: 50%;
left: 50%;
margin-top: -28px;
margin-left: -28px;
}
.animate-float-pink {
animation: spiral-pink 18s ease-in-out infinite;
top: 50%;
left: 50%;
margin-top: -26px;
margin-left: -26px;
}
.spin-on-hover {
transition: transform 0.3s ease;
animation-play-state: paused;
}
.spin-direction-cw:hover {
animation: spin-cw 4s linear infinite;
}
.spin-direction-ccw:hover {
animation: spin-ccw 4s linear infinite;
}
@keyframes spin-cw {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes spin-ccw {
from { transform: rotate(0deg); }
to { transform: rotate(-360deg); }
}
`}
</style>
</div>
);
}

View File

@ -0,0 +1,120 @@
import { useEffect, useMemo, useState } from "preact/hooks";
interface PropertyItem {
id: string;
title?: string | null;
address_1?: string | null;
city?: string | null;
state?: string | null;
}
interface GuestOnboardingDialogProps {
properties: PropertyItem[];
onClose?: () => void;
defaultRole?: "tenant" | "landlord";
}
export default function GuestOnboardingDialog(
{ properties, onClose, defaultRole }: GuestOnboardingDialogProps,
) {
const [role, setRole] = useState<"tenant" | "landlord" | "">(
defaultRole ?? "",
);
const [propertyId, setPropertyId] = useState<string>("");
const canSubmit = useMemo(() => {
if (role === "landlord") return true;
if (role === "tenant") return !!propertyId;
return false;
}, [role, propertyId]);
useEffect(() => {
if (role !== "tenant") setPropertyId("");
}, [role]);
return (
<div class="fixed inset-0 flex items-center justify-center bg-black bg-opacity-30 z-50">
<form
method="POST"
class="bg-white p-6 rounded shadow-lg w-full max-w-md space-y-4"
>
<h3 class="text-xl font-bold">Welcome! Let's get you set up</h3>
<p class="text-sm text-gray-600">
Please select how you'll use the app.
</p>
<div class="space-y-2">
<label class="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="role"
value="tenant"
checked={role === "tenant"}
onChange={() => setRole("tenant")}
/>
<span>I'm a Tenant</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="role"
value="landlord"
checked={role === "landlord"}
onChange={() => setRole("landlord")}
/>
<span>I'm a Landlord</span>
</label>
</div>
{role === "tenant" && (
<div class="space-y-1">
<label for="property_id" class="text-sm font-medium text-gray-700">
Select your property
</label>
<select
id="property_id"
name="property_id"
required
class="border p-2 rounded w-full"
value={propertyId}
onChange={(e) =>
setPropertyId((e.target as HTMLSelectElement).value)}
>
<option value="" disabled>Select a property…</option>
{properties?.map((p) => (
<option key={p.id} value={p.id}>
{p.title || p.address_1 || "Property"}
{p.city ? `, ${p.city}` : ""}
{p.state ? `, ${p.state}` : ""}
</option>
))}
</select>
</div>
)}
<div class="flex justify-end gap-2 pt-2">
<button
type="button"
class="px-4 py-2 rounded border"
onClick={onClose}
>
Cancel
</button>
<button
type="submit"
name="sender"
value="guest_onboarding"
class={`px-4 py-2 rounded text-white ${
canSubmit
? "bg-blue-600 hover:bg-blue-700"
: "bg-gray-400 cursor-not-allowed"
}`}
disabled={!canSubmit}
>
Continue
</button>
</div>
</form>
</div>
);
}

217
islands/ImageGenerater.tsx Normal file
View File

@ -0,0 +1,217 @@
import { useState } from "preact/hooks";
import { ImageFile } from "../utils/types.ts";
import { decodeData } from "../utils/urlEncoder.ts";
import PhotoCropperModal from "./PhotoCropperModal.tsx";
interface ImageGeneraterProps {
text_1: string;
image_data: string;
role: string;
recordingCallback: (data: ImageFile | null) => void;
}
export default function ImageGenerater(
{ text_1, image_data, role, recordingCallback }: ImageGeneraterProps,
) {
const [isVisible, setIsVisible] = useState(true);
const [displayImage, setDisplayImage] = useState(image_data);
// User-editable description shown in the input field
const [nexAIDescription, setNextAiDescription] = useState(
removeDashAndNumbers(
removePngExtension(text_1),
),
);
// Actual saved filename from AI generation (with timestamp hash)
// Falls back to the initial text_1 if no AI generation occurred
const [actualFileName, setActualFileName] = useState(text_1);
const [ai_nameError, setAiGenerateNameError] = useState(false);
const [showCropperModal, setShowCropperModal] = useState(false);
const [isAiGenerating, setIsAiGenerating] = useState(false);
const handleSave = () => {
setIsVisible(false);
// image_file: the actual saved filename (with timestamp hash for AI-generated)
// image_text: the user-editable description (preserves spaces, casing)
// image_data: the URL or base64 data
recordingCallback({
image_text: nexAIDescription,
image_file: actualFileName,
image_data: displayImage,
});
};
const handleCancel = () => {
setIsVisible(false);
recordingCallback(null);
};
if (!isVisible) return null;
const handleAiGenerate = async () => {
if (isAiGenerating) return;
const preloadImage = (src: string) =>
new Promise<void>((resolve) => {
const img = new Image();
img.onload = () => resolve();
img.onerror = () => resolve();
img.src = src;
});
setIsAiGenerating(true);
try {
const id = nexAIDescription.trim().slice(0, 120);
const apiResponse = await fetch(
`/api/ai-images/${encodeURIComponent(id)}`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
},
},
);
if (!apiResponse.ok) {
throw new Error(
`Failed to fetch image from local API: ${apiResponse.statusText}`,
);
}
const newImageData = await apiResponse.json() as ImageFile;
const rawImageData = newImageData.image_data ?? "";
const nextSrc = rawImageData &&
(rawImageData.startsWith("http") || rawImageData.startsWith("/"))
? decodeData(rawImageData)
: `data:image/png;base64,${rawImageData}`;
await preloadImage(nextSrc);
// Update display image with the URL/data from API
setDisplayImage(newImageData.image_data);
// Preserve the actual saved filename (with timestamp) from the API response
// This is the real file on disk, e.g., "a_person_running-20251212123456.png"
if (newImageData.image_file) {
setActualFileName(newImageData.image_file);
}
} catch (e) {
console.error("Error generating AI image:", e);
} finally {
setIsAiGenerating(false);
}
};
return (
<div className="theme-image-overlay">
<div
className="theme-image-container"
style={{
backgroundImage: `${
displayImage &&
(displayImage.startsWith("http") || displayImage.startsWith("/"))
? "url(" +
CSS.escape(
decodeData(displayImage),
) + ")"
: "url(data:image/png;base64," + displayImage + ")"
}`,
backgroundSize: "cover",
backgroundPosition: "center",
width: "512px",
height: "512px",
}}
>
<p>
<span className="strokelittle">
{role === "admin" && (
<input
type="text"
value={nexAIDescription}
onChange={(e) => setNextAiDescription(e.currentTarget.value)}
onKeyDown={() => setAiGenerateNameError(false)}
placeholder="Quiz Name"
className={`theme-image-input ${
ai_nameError ? "border-red-500" : ""
}`}
/>
)}
</span>
</p>
<span className="absolute bottom-0 left-0 right-0 flex justify-center gap-2 pb-4 px-6">
{role === "admin" && (
<button
className={`theme-image-button ${
isAiGenerating ? "opacity-60 cursor-not-allowed" : ""
} px-0 py-1 text-xs`}
onClick={handleAiGenerate}
disabled={isAiGenerating}
>
{isAiGenerating
? (
<span className="flex items-center gap-2">
<span
className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent"
aria-hidden="true"
/>
Generating
</span>
)
: "AI Generate"}
</button>
)}
{role === "admin" && (
<button
type="button"
className="theme-image-button px-2 py-1 text-xs"
onClick={() => setShowCropperModal(true)}
>
Upload
</button>
)}
{role === "admin" && (
<button
className="theme-image-button px-2 py-1 text-xs"
onClick={handleSave}
>
Save
</button>
)}
<button
className="theme-image-button px-2 py-1 text-xs"
onClick={handleCancel}
>
{role === "admin" ? "Cancel" : "Close"}
</button>
</span>
</div>
{showCropperModal && (
<PhotoCropperModal
onComplete={(imageData) => {
setDisplayImage(imageData);
// For uploaded images (base64), use the description as filename
// This will be hashed when uploaded via /api/image-blob/
setActualFileName(nexAIDescription);
setShowCropperModal(false);
}}
onCancel={() => setShowCropperModal(false)}
/>
)}
</div>
);
}
export const removePngExtension = (str: string) => {
const pngExtension = ".png";
if (str.toLowerCase().endsWith(pngExtension)) {
return str.slice(0, -pngExtension.length);
}
return str;
};
export const removeDashAndNumbers = (str: string) => {
const pattern = /-\d{14}$/;
if (pattern.test(str)) {
return str.slice(0, -15); // Remove the dash and 14 numeric characters
}
return str;
};

283
islands/IncomeList.tsx Normal file
View File

@ -0,0 +1,283 @@
import {
decryptDataWithStoredPassword,
encryptDataWithStoredPassword,
getEncryptionPassword,
MakeNewDate,
} from "../helpers.ts";
import { Income } from "../services/financhalServices.ts";
import { useEffect, useState } from "preact/hooks";
import { PopAddIncomeDialog } from "./dialogs/PopAddIncomeDialog.tsx";
interface Props {
incomes: Income[];
userId?: string;
openModal?: boolean;
}
export default function IncomeList(
{ incomes, userId, openModal = false }: Props,
) {
// Theme classes are defined in styles.css with @media (prefers-color-scheme: dark)
const [popAddIncome, popupAddIncomeDialog] = PopAddIncomeDialog({
userId,
defaultOpen: openModal,
});
const [processedIncomes, setProcessedIncomes] = useState<Income[]>(incomes);
const [isProcessing, setIsProcessing] = useState(false);
const [isOpeningModal, setIsOpeningModal] = useState(openModal);
// Process incomes on component mount and when incomes or userId change
useEffect(() => {
setTimeout(() => {
const lockRoute = localStorage.getItem("lock-route");
const url = new URL(globalThis.location.href);
const currentRoute = url.pathname + url.search;
if (lockRoute && lockRoute === currentRoute) {
localStorage.removeItem("lock-route");
}
}, 100);
if (!userId) {
setProcessedIncomes(incomes);
return;
}
const processIncomes = async () => {
setIsProcessing(true);
const encryptionPassword = getEncryptionPassword(userId);
if (!encryptionPassword) {
// No encryption key available, use incomes as-is
setProcessedIncomes(incomes);
setIsProcessing(false);
return;
}
const updatedIncomes: Income[] = [];
for (const income of incomes) {
const processedIncome = { ...income };
if (income.encrypted_data) {
// Try to decrypt existing encrypted data
try {
const decryptedData = await decryptDataWithStoredPassword(
userId,
income.encrypted_data,
);
if (decryptedData) {
const parsed = JSON.parse(decryptedData);
if (parsed.amount !== undefined) {
processedIncome.amount = parsed.amount;
}
if (parsed.source !== undefined) {
processedIncome.source = parsed.source;
}
}
} catch (error) {
console.error("Failed to decrypt income data:", error);
}
} else if (income.amount !== null || income.source !== null) {
// No encrypted data but has plaintext data - encrypt it and clear plaintext
try {
const dataToEncrypt = {
amount: income.amount,
source: income.source,
};
const encryptedData = await encryptDataWithStoredPassword(
userId,
JSON.stringify(dataToEncrypt),
);
if (encryptedData) {
// Update the income in the database
const response = await fetch("/api/update/income", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
income_id: income.id,
encrypted_data: encryptedData,
source: null, // Clear plaintext source
amount: null, // Clear plaintext amount
}),
});
if (response.ok) {
// Update local state
processedIncome.encrypted_data = encryptedData;
processedIncome.source = income.source; // Keep for display
processedIncome.amount = income.amount; // Keep for display
}
}
} catch (error) {
console.error("Failed to encrypt income data:", error);
}
}
updatedIncomes.push(processedIncome);
}
setProcessedIncomes(updatedIncomes);
setIsProcessing(false);
};
processIncomes();
}, []);
return (
<>
{popupAddIncomeDialog}
<div class="flex flex-col mt-14 gap-6">
<div
style={{
position: "relative",
marginBottom: "1rem",
minHeight: "56px",
}}
>
<h2
class="theme-text"
style={{
position: "absolute",
top: "50%",
left: "5%",
fontSize: "1.5rem",
margin: 0,
}}
>
Your Incomes
</h2>
<button
type="button"
style={{
position: "absolute",
top: "50%",
right: "5%",
backgroundColor: "#16a34a",
color: "#fff",
padding: "0.5rem 1.25rem",
borderRadius: "0.5rem",
boxShadow: "0 10px 15px -3px rgba(16, 185, 129, 0.4)",
border: "none",
cursor: "pointer",
fontSize: "1.4rem",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.backgroundColor =
"#15803d";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.backgroundColor =
"#16a34a";
}}
onClick={() => {
if (isOpeningModal) return;
localStorage.setItem(
"lock-route",
"/calendar/every-income?modal=on",
);
setIsOpeningModal(true);
globalThis.location.href = "/calendar/every-income?modal=on";
}}
disabled={isOpeningModal}
>
+ Add Income
</button>
</div>
<ul class="space-y-2">
{processedIncomes.map((inc) => (
<li
key={inc.id}
class="border rounded p-4 shadow relative theme-container"
>
<form
method="POST"
action="/api/delete/income"
className="absolute top-1/2 right-0 transform -translate-y-1/2"
style={{ display: "inline" }}
onSubmit={(e) => {
// Set sender input to current page
const form = e.currentTarget;
form.sender.value = location.pathname;
if (
!confirm(
`Are you sure you want to delete "${
inc.source || "this income"
}"?`,
)
) {
e.preventDefault();
return false;
}
}}
>
<input type="hidden" name="income_id" value={inc.id} />
<input
type="hidden"
name="sender"
value={typeof location !== "undefined"
? location.pathname
: ""}
/>
<button
type="submit"
aria-label="Delete income"
className="theme-icon-button hover:text-red-500 transition-colors"
style={{ background: "none", border: "none", padding: 0 }}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</form>
<div class="font-semibold text-lg theme-text">
{inc.source || ""}
</div>
<div class="text-sm theme-calendar-list-frequency">
Amount: ${inc.amount}
</div>
<div class="text-sm theme-calendar-list-frequency">
Frequency: {inc.frequency}
</div>
<div class="text-sm theme-calendar-list-frequency">
Received: {inc.received_date
? MakeNewDate(inc.received_date).toISOString().split(
"T",
)[0]
: ""}
</div>
</li>
))}
</ul>
</div>
<div aria-hidden="true" class="h-28"></div>
<div class="fixed bottom-4 inset-x-0 flex justify-center z-40 pointer-events-none">
<a
href={`/calendar/financial-calendar/${new Date().getFullYear()}/${
(new Date().getMonth() + 1).toString().padStart(2, "0")
}?`}
class="w-11/12 max-w-md bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 px-10 rounded-full shadow-lg pointer-events-auto text-center"
>
Back to Financial Calendar
</a>
</div>
</>
);
}

View File

@ -0,0 +1,563 @@
import { useEffect, useRef, useState } from "preact/hooks";
type InviteProperty = {
id: string;
title?: string | null;
address_1?: string | null;
address_2?: string | null;
city?: string | null;
state?: string | null;
zip?: string | null;
rental_status?: string | null;
monthly_rent?: number | null;
};
interface LandlordInviteTenantProps {
properties: InviteProperty[];
landlordName?: string | null;
inviteId?: string;
invitePropertyId?: string;
inviteAvailableOn?: number | null;
inviteEmails?: string[];
}
type GeneratedInvite = {
template: string;
recipient: string;
subject: string;
body: string;
inviteUrl: string;
};
const DEFAULT_SUBJECT = "You're invited to join your rental portal";
const EMAIL_TEMPLATE_LINES = [
"To: {{tenantEmail}}",
"Subject: {{subject}}",
"",
"Hi {{tenantName}},",
"",
"{{landlordName}} invited you to join the tenant portal for {{propertyLabel}}.",
"{{propertyAddressLine}}",
"{{rentLine}}",
"{{availableOnLine}}",
"",
"Review the property and start your lease:",
"{{inviteUrl}}",
"",
"{{phoneLine}}",
"",
"If you have any questions, reply to this email.",
];
const DEFAULT_TEMPLATE_PREVIEW = `To: tenant@example.com
Subject: ${DEFAULT_SUBJECT}
Hi Tenant Name,
Your landlord invited you to join the tenant portal for Property Name.
Rental: 123 Main St, City, ST
Monthly rent: $2,000
Available on: 2024-04-01
Review the property and start your lease:
https://your-domain.com/tenant/invite/INVITE_ID
We have this phone number on file for you: (555) 555-1234
If you have any questions, reply to this email.`;
function buildPropertyLabel(property: InviteProperty) {
const name = property.title?.trim() || property.address_1?.trim() ||
"Property";
const location = [property.city, property.state].filter(Boolean).join(", ");
return location ? `${name} (${location})` : name;
}
function buildPropertyAddress(property: InviteProperty) {
return [
property.address_1,
property.address_2,
property.city,
property.state,
property.zip,
].filter(Boolean).join(", ");
}
function formatCurrency(amount?: number | null) {
if (amount === null || amount === undefined) return "";
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
}).format(amount);
}
function parseEmailList(value: string) {
return value.split(/[\n,]+/)
.map((email) => email.trim())
.filter(Boolean);
}
function formatDateInput(value?: number | null) {
if (!value) return "";
return new Date(value).toISOString().slice(0, 10);
}
function parseDateInput(value: string) {
const [year, month, day] = value.split("-").map(Number);
if (!year || !month || !day) return null;
return Date.UTC(year, month - 1, day);
}
function fillTemplate(
lines: string[],
replacements: Record<string, string>,
) {
const filled = lines.map((line) =>
line.replace(
/\{\{(\w+)\}\}/g,
(_match, key: string) => replacements[key] ?? "",
)
);
const normalized: string[] = [];
let emptyCount = 0;
for (const line of filled) {
if (line.trim() === "") {
emptyCount += 1;
if (emptyCount <= 2) {
normalized.push("");
}
} else {
emptyCount = 0;
normalized.push(line);
}
}
return normalized;
}
export default function LandlordInviteTenant(
{
properties,
landlordName,
inviteId,
invitePropertyId,
inviteAvailableOn,
inviteEmails,
}: LandlordInviteTenantProps,
) {
const [inviteIdState, setInviteIdState] = useState(inviteId?.trim() || "");
const [invitePropertyIdState, setInvitePropertyIdState] = useState(
invitePropertyId?.trim() || "",
);
const [tenantName, setTenantName] = useState("");
const [tenantEmail, setTenantEmail] = useState(
inviteEmails?.length ? inviteEmails.join(", ") : "",
);
const [tenantPhone, setTenantPhone] = useState("");
const [availableOn, setAvailableOn] = useState(
formatDateInput(inviteAvailableOn),
);
const [selectedPropertyId, setSelectedPropertyId] = useState(
invitePropertyId?.trim()
? invitePropertyId
: properties.length === 1
? properties[0].id
: "",
);
const [generatedInvite, setGeneratedInvite] = useState<
GeneratedInvite | null
>(
null,
);
const [showErrors, setShowErrors] = useState(false);
const [copyStatus, setCopyStatus] = useState<"idle" | "copied" | "error">(
"idle",
);
const [saveError, setSaveError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const templateRef = useRef<HTMLTextAreaElement>(null);
const nameInputRef = useRef<HTMLInputElement>(null);
const emailInputRef = useRef<HTMLInputElement>(null);
const propertySelectRef = useRef<HTMLSelectElement>(null);
useEffect(() => {
setGeneratedInvite(null);
setCopyStatus("idle");
}, [tenantName, tenantEmail, tenantPhone, availableOn, selectedPropertyId]);
useEffect(() => {
if (
invitePropertyIdState &&
selectedPropertyId &&
invitePropertyIdState !== selectedPropertyId
) {
setInviteIdState("");
setInvitePropertyIdState("");
}
}, [invitePropertyIdState, selectedPropertyId]);
const selectedProperty = properties.find((property) =>
property.id === selectedPropertyId
);
const parsedEmails = parseEmailList(tenantEmail);
const missingName = tenantName.trim().length === 0;
const missingEmail = parsedEmails.length === 0;
const missingProperty = selectedPropertyId.trim().length === 0 ||
!selectedProperty;
const canGenerate = Boolean(
!missingName && !missingEmail && !missingProperty && !isSaving,
);
const handleGenerate = async () => {
setShowErrors(true);
setSaveError(null);
if (missingName) {
nameInputRef.current?.focus();
return;
}
if (missingEmail) {
emailInputRef.current?.focus();
return;
}
if (missingProperty) {
propertySelectRef.current?.focus();
return;
}
if (!canGenerate || !selectedProperty) return;
setIsSaving(true);
const availableOnTimestamp = availableOn
? parseDateInput(availableOn)
: null;
let inviteIdForUrl = inviteIdState;
try {
const response = await fetch("/api/update/tenant_invite", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
invite_id: inviteIdState || undefined,
property_id: selectedPropertyId,
available_on: availableOnTimestamp,
invited_emails: parsedEmails,
}),
});
if (!response.ok) {
throw new Error("Invite update failed");
}
const payload = await response.json().catch(() => null);
const returnedId = payload?.invite?.id;
if (returnedId) {
inviteIdForUrl = returnedId;
setInviteIdState(returnedId);
setInvitePropertyIdState(selectedPropertyId);
}
if (!inviteIdForUrl) {
throw new Error("Invite missing");
}
} catch (_error) {
setSaveError(
"Unable to save the invite details. Please try again.",
);
setIsSaving(false);
return;
}
setIsSaving(false);
const resolvedBaseUrl = typeof window === "undefined"
? ""
: window.location.origin;
const inviteUrl = `${resolvedBaseUrl}/tenant/invite/${
encodeURIComponent(inviteIdForUrl)
}`;
const displayName = tenantName.trim();
const email = parsedEmails.join(", ");
const phone = tenantPhone.trim();
const senderName = landlordName?.trim() || "Your landlord";
const propertyLabel = buildPropertyLabel(selectedProperty);
const propertyAddress = buildPropertyAddress(selectedProperty);
const rent = formatCurrency(selectedProperty.monthly_rent);
const availableOnLine = availableOn ? `Available on: ${availableOn}` : "";
const subject = DEFAULT_SUBJECT;
const templateLines = fillTemplate(EMAIL_TEMPLATE_LINES, {
tenantEmail: email,
subject,
tenantName: displayName,
landlordName: senderName,
propertyLabel,
propertyAddressLine: propertyAddress ? `Rental: ${propertyAddress}` : "",
rentLine: rent ? `Monthly rent: ${rent}` : "",
availableOnLine,
inviteUrl,
phoneLine: phone
? `We have this phone number on file for you: ${phone}`
: "",
});
const template = templateLines.join("\n");
const body = templateLines.slice(3).join("\n");
setGeneratedInvite({
template,
recipient: email,
subject,
body,
inviteUrl,
});
};
const handleCopy = async () => {
if (!generatedInvite?.template) return;
try {
await navigator.clipboard.writeText(generatedInvite.template);
setCopyStatus("copied");
} catch (_error) {
const textArea = templateRef.current;
if (textArea) {
textArea.focus();
textArea.select();
const success = document.execCommand("copy");
setCopyStatus(success ? "copied" : "error");
} else {
setCopyStatus("error");
}
}
window.setTimeout(() => setCopyStatus("idle"), 2000);
};
const mailtoHref = generatedInvite
? `mailto:${encodeURIComponent(generatedInvite.recipient)}?subject=${
encodeURIComponent(generatedInvite.subject)
}&body=${encodeURIComponent(generatedInvite.body)}`
: "";
const emptyTemplateMessage = DEFAULT_TEMPLATE_PREVIEW;
const hasTemplate = Boolean(generatedInvite?.template);
return (
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div class="bg-white shadow rounded-lg p-6 space-y-6">
<div>
<h2 class="text-lg font-semibold text-gray-900">
Invite Details
</h2>
<p class="text-sm text-gray-500">
Enter the tenant info and choose which rental to connect.
</p>
</div>
<div class="space-y-4">
<div>
<label
for="tenant_name"
class="block text-sm font-medium text-gray-700"
>
Tenant name
</label>
<input
id="tenant_name"
ref={nameInputRef}
type="text"
value={tenantName}
onInput={(event) =>
setTenantName((event.currentTarget as HTMLInputElement).value)}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
placeholder="Avery Johnson"
autoComplete="name"
/>
{showErrors && missingName && (
<p class="mt-2 text-xs text-rose-600">
Tenant name is required.
</p>
)}
</div>
<div>
<label
for="tenant_email"
class="block text-sm font-medium text-gray-700"
>
Tenant email addresses
</label>
<input
id="tenant_email"
ref={emailInputRef}
type="email"
multiple
value={tenantEmail}
onInput={(event) =>
setTenantEmail((event.currentTarget as HTMLInputElement).value)}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
placeholder="tenant@example.com, roommate@example.com"
autoComplete="email"
/>
{showErrors && missingEmail && (
<p class="mt-2 text-xs text-rose-600">
At least one tenant email is required.
</p>
)}
</div>
<div>
<label
for="available_on"
class="block text-sm font-medium text-gray-700"
>
Available on date (optional)
</label>
<input
id="available_on"
type="date"
value={availableOn}
onInput={(event) =>
setAvailableOn(
(event.currentTarget as HTMLInputElement).value,
)}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
</div>
<div>
<label
for="tenant_phone"
class="block text-sm font-medium text-gray-700"
>
Tenant phone (optional)
</label>
<input
id="tenant_phone"
type="tel"
value={tenantPhone}
onInput={(event) =>
setTenantPhone((event.currentTarget as HTMLInputElement).value)}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
placeholder="(555) 555-1234"
autoComplete="tel"
/>
</div>
<div>
<label
for="property_id"
class="block text-sm font-medium text-gray-700"
>
Select a property
</label>
<select
id="property_id"
ref={propertySelectRef}
value={selectedPropertyId}
onChange={(event) =>
setSelectedPropertyId(
(event.currentTarget as HTMLSelectElement).value,
)}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
>
<option value="" disabled>
Choose a rental property
</option>
{properties.map((property) => (
<option key={property.id} value={property.id}>
{buildPropertyLabel(property)}
</option>
))}
</select>
{selectedProperty && (
<p class="mt-2 text-xs text-gray-500">
Selected: {buildPropertyLabel(selectedProperty)}
{selectedProperty.rental_status
? ` (${selectedProperty.rental_status})`
: ""}
</p>
)}
{showErrors && missingProperty && (
<p class="mt-2 text-xs text-rose-600">
Choose a property to link to the invite.
</p>
)}
</div>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<button
type="button"
onClick={handleGenerate}
aria-disabled={!canGenerate}
disabled={isSaving}
class={`inline-flex items-center justify-center px-4 py-2 rounded-md text-sm font-medium transition ${
canGenerate
? "bg-emerald-600 text-white hover:bg-emerald-700"
: "bg-white border border-amber-300 text-amber-700 hover:bg-amber-50"
}`}
>
{isSaving ? "Saving Invite..." : "Generate Email Template"}
</button>
<p class="text-xs text-gray-500">
{canGenerate
? "Click generate to lock in the invite email."
: "Fill out the tenant and property details first."}
</p>
</div>
{saveError && (
<p class="text-xs text-rose-600">
{saveError}
</p>
)}
</div>
<div class="bg-white shadow rounded-lg p-6 space-y-4">
<div class="flex items-start justify-between gap-4">
<div>
<h2 class="text-lg font-semibold text-gray-900">
Copyable Email Template
</h2>
<p class="text-sm text-gray-500">
Paste this directly into your email client.
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<a
href={mailtoHref}
aria-disabled={!hasTemplate}
class={`inline-flex items-center px-4 py-2 rounded-md text-sm font-medium ${
hasTemplate
? "bg-indigo-600 text-white hover:bg-indigo-700"
: "bg-gray-200 text-gray-400 pointer-events-none"
}`}
>
Open Email App
</a>
<button
type="button"
onClick={handleCopy}
disabled={!hasTemplate}
class={`inline-flex items-center px-4 py-2 rounded-md text-sm font-medium ${
hasTemplate
? "bg-blue-600 text-white hover:bg-blue-700"
: "bg-gray-200 text-gray-400 cursor-not-allowed"
}`}
>
{copyStatus === "copied" ? "Copied" : "Copy"}
</button>
</div>
</div>
<textarea
ref={templateRef}
readOnly
rows={14}
class="w-full rounded-md border border-gray-200 bg-gray-50 p-3 text-xs font-mono text-gray-700"
value={generatedInvite?.template || emptyTemplateMessage}
/>
<div class="text-xs text-gray-500">
{copyStatus === "error"
? "Unable to copy automatically. Select the text and copy manually."
: hasTemplate
? "The invite link opens the property preview so the tenant can start the lease."
: "Generate the template to personalize this draft and enable copy."}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,66 @@
import { useEffect } from "preact/hooks";
export default function LandlordMessageSelection() {
useEffect(() => {
const container = document.querySelector<HTMLElement>(
"[data-message-form]",
);
if (!container) return;
const selectAll = container.querySelector<HTMLInputElement>(
"[data-select-all]",
);
const deleteButton = container.querySelector<HTMLButtonElement>(
"[data-delete-button]",
);
const checkboxes = Array.from(
container.querySelectorAll<HTMLInputElement>("[data-message-checkbox]"),
);
const labelBase = "Delete selected";
const updateState = () => {
const checkedCount = checkboxes.filter((box) => box.checked).length;
const totalCount = checkboxes.length;
if (selectAll) {
selectAll.indeterminate = checkedCount > 0 &&
checkedCount < totalCount;
selectAll.checked = totalCount > 0 && checkedCount === totalCount;
}
if (deleteButton) {
deleteButton.disabled = checkedCount === 0;
deleteButton.textContent = checkedCount > 0
? `${labelBase} (${checkedCount})`
: labelBase;
}
};
const handleSelectAllChange = (event: Event) => {
const target = event.target as HTMLInputElement | null;
const nextChecked = Boolean(target?.checked);
checkboxes.forEach((box) => {
box.checked = nextChecked;
});
updateState();
};
const handleCheckboxChange = () => updateState();
selectAll?.addEventListener("change", handleSelectAllChange);
checkboxes.forEach((box) =>
box.addEventListener("change", handleCheckboxChange)
);
updateState();
return () => {
selectAll?.removeEventListener("change", handleSelectAllChange);
checkboxes.forEach((box) =>
box.removeEventListener("change", handleCheckboxChange)
);
};
}, []);
return <span class="hidden" data-message-selection aria-hidden="true" />;
}

View File

@ -0,0 +1,122 @@
import { useEffect } from "preact/hooks";
export default function LandlordMessageThreads() {
useEffect(() => {
const container = document.querySelector<HTMLElement>(
"[data-message-form]",
);
if (!container) return;
const statusFilter = container.getAttribute("data-status-filter") || "all";
const e2eUser = container.getAttribute("data-e2e-user") || "";
const toggles = Array.from(
container.querySelectorAll<HTMLElement>("[data-message-toggle]"),
);
const handleToggle = async (event: Event) => {
const button = event.currentTarget as HTMLElement | null;
if (!button) return;
const row = button.closest<HTMLElement>("[data-message-row]");
const panel = row?.querySelector<HTMLElement>("[data-message-panel]");
if (!panel) return;
const subject = row?.querySelector<HTMLElement>(
"[data-message-subject]",
);
const preview = row?.querySelector<HTMLElement>(
"[data-message-preview]",
);
const updateTextStyles = (isRead: boolean) => {
if (subject) {
subject.classList.toggle("font-semibold", !isRead);
subject.classList.toggle("text-gray-900", !isRead);
subject.classList.toggle("font-medium", isRead);
subject.classList.toggle("text-gray-700", isRead);
}
if (preview) {
preview.classList.toggle("text-gray-700", !isRead);
preview.classList.toggle("text-gray-500", isRead);
}
};
const nextOpen = panel.classList.contains("hidden");
panel.classList.toggle("hidden", !nextOpen);
button.setAttribute("aria-expanded", String(nextOpen));
if (nextOpen) {
const messageId = button.getAttribute("data-message-id");
const isUnread = button.getAttribute("data-message-unread") === "true";
if (messageId && isUnread) {
button.setAttribute("data-message-unread", "false");
if (row) {
row.setAttribute("data-message-status", "read");
}
const badge = row?.querySelector<HTMLElement>("[data-unread-pill]");
if (badge) badge.classList.add("hidden");
const dot = row?.querySelector<HTMLElement>("[data-unread-dot]");
if (dot) dot.classList.add("hidden");
updateTextStyles(true);
const url = new URL("/api/update/message", window.location.origin);
if (e2eUser) {
url.searchParams.set("e2e-user", e2eUser);
}
const response = await fetch(url.toString(), {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
},
credentials: "include",
cache: "no-store",
body: JSON.stringify({ id: messageId }),
});
let updateSucceeded = response.ok;
if (response.ok) {
try {
const payload = await response.json();
if (
payload && typeof payload.updated_count === "number" &&
payload.updated_count === 0
) {
updateSucceeded = false;
}
} catch (_) {
updateSucceeded = false;
}
}
if (!updateSucceeded) {
button.setAttribute("data-message-unread", "true");
if (row) {
row.setAttribute("data-message-status", "unread");
}
badge?.classList.remove("hidden");
dot?.classList.remove("hidden");
updateTextStyles(false);
} else if (statusFilter === "unread" && row) {
row.classList.add("hidden");
}
}
const textarea = row?.querySelector<HTMLTextAreaElement>(
"textarea[name='content']",
);
textarea?.focus();
}
};
toggles.forEach((toggle) => {
toggle.addEventListener("click", handleToggle);
});
return () => {
toggles.forEach((toggle) => {
toggle.removeEventListener("click", handleToggle);
});
};
}, []);
return <span class="hidden" data-message-threads aria-hidden="true" />;
}

149
islands/LeasedHome.tsx Normal file
View File

@ -0,0 +1,149 @@
import { Lease, Property } from "../db/interfaces.ts";
import { PopAddLeaseDialog } from "./dialogs/PopAddLeaseDialog.tsx";
interface LeasedHomeProps {
leases: Lease[];
properties: Property[];
}
import { useState } from "preact/hooks";
export default function LeasedHome({ leases, properties }: LeasedHomeProps) {
const [selectedPropertyId, setSelectedPropertyId] = useState(
properties[0]?.id || "",
);
const [popAddLease, popupAddLeaseDialog] = PopAddLeaseDialog(
leases,
properties,
);
return (
<>
{popupAddLeaseDialog}
<div class="mb-4">
<label htmlFor="property-select" class="block mb-1 font-semibold">
Select Property:
</label>
<select
id="property-select"
class="border rounded px-3 py-2 w-full"
value={selectedPropertyId}
onChange={(e) =>
setSelectedPropertyId((e.target as HTMLSelectElement).value)}
>
{properties.map((property) => (
<option key={property.id} value={property.id}>
{property.title} — {property.address_1}
</option>
))}
</select>
</div>
<button
type="button"
aria-label="Go back"
onClick={() => globalThis.location.href = "/"}
style={{
position: "fixed",
bottom: 300,
left: 4,
background: "none",
border: "none",
zIndex: 50,
cursor: "pointer",
}}
>
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
<div class="flex flex-col gap-4">
<div class="flex justify-between items-center mb-4">
<h2 class="text-2xl font-bold">Leased Homes</h2>
<button
type="button"
class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded shadow"
onClick={() => popAddLease(selectedPropertyId, Date.now())}
>
Add Lease
</button>
</div>
{leases.length === 0
? <div class="text-gray-500">No leases found.</div>
: (
<ul class="space-y-2">
{leases.map((lease) => (
<li
key={lease.id}
class="border rounded p-4 bg-white shadow relative group"
>
<form
method="POST"
action="/api/delete/lease"
className="absolute top-1/2 -right-0 transform -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity duration-200"
style={{ display: "inline" }}
onSubmit={(e) => {
// Set sender input to current page
const form = e.currentTarget;
form.sender.value = location.pathname;
}}
>
<input type="hidden" name="lease_id" value={lease.id} />
<input
type="hidden"
name="sender"
value={typeof location !== "undefined"
? location.pathname
: ""}
/>
<button
type="submit"
aria-label="Delete lease"
className="text-red-700"
style={{ background: "none", border: "none", padding: 0 }}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</form>
<div class="font-semibold">Title: {lease.title}</div>
<div>Rent: {lease.rent}</div>
<div>Deposit: {lease.security_deposit}</div>
<div>
Start: {lease.start_date
? new Date(lease.start_date).toLocaleDateString()
: ""}
</div>
<div>
End: {lease.end_date
? new Date(lease.end_date).toLocaleDateString()
: ""}
</div>
</li>
))}
</ul>
)}
</div>
</>
);
}

View File

@ -0,0 +1,33 @@
import { ComponentChildren } from "preact";
interface LoginPurposeButtonProps {
purpose: "landlord" | "tenant";
className?: string;
lockRoute?: string | null;
children: ComponentChildren;
}
export default function LoginPurposeButton(
{ purpose, className, lockRoute, children }: LoginPurposeButtonProps,
) {
const handleClick = () => {
try {
if (lockRoute) {
localStorage.setItem("lock-route", lockRoute);
localStorage.removeItem("login-purpose");
} else {
localStorage.setItem("login-purpose", purpose);
localStorage.removeItem("lock-route");
}
} catch (_error) {
// Best-effort only.
}
globalThis.location.href = "/auth/login";
};
return (
<button type="button" onClick={handleClick} class={className}>
{children}
</button>
);
}

543
islands/MemberHome.tsx Normal file
View File

@ -0,0 +1,543 @@
import { useEffect, useState } from "preact/hooks";
import FloatingTileButtons from "./tiles/FloatingTileButtons.tsx";
import type { OnboardingStatus } from "../services/onboardingService.ts";
interface MemberHomeProps {
onboardingStatus?: OnboardingStatus | null;
}
export default function MemberHome({ onboardingStatus }: MemberHomeProps) {
const [spinDirection, setSpinDirection] = useState<"cw" | "ccw">("cw");
const handleLogoMouseEnter = () => {
setSpinDirection(Math.random() < 0.5 ? "cw" : "ccw");
};
useEffect(() => {
const lockRoute = localStorage.getItem("lock-route");
if (lockRoute) {
localStorage.removeItem("lock-route");
globalThis.location.href = lockRoute;
return;
}
const loginPurpose = localStorage.getItem("login-purpose");
if (loginPurpose) {
localStorage.removeItem("login-purpose");
const destination = loginPurpose === "landlord"
? "/landlord/dashboard"
: loginPurpose === "tenant"
? "/tenant/dashboard"
: "";
if (destination) {
globalThis.location.href = destination;
}
}
}, []);
// Sample tile data for FloatingTileButtons
const tileIds = [
{ id: "landlord-hub", label: "Landlord" },
{ id: "tenant-hub", label: "Tenant" },
{ id: "admin-center", label: "Admin" },
{ id: "user-profile", label: "Profile" },
{ id: "dashboard", label: "Dashboard" },
{ id: "financial-calendar", label: "Financial Calendar" },
];
const tenantOnboarded = Boolean(onboardingStatus?.onboarded_tenant);
const landlordStep = onboardingStatus?.onboarding_step ?? 0;
const landlordStarted = Boolean(onboardingStatus?.onboarded_landlord) ||
landlordStep > 0;
const landlordReady = landlordStarted && landlordStep === 0;
const landlordInProgress = landlordStep > 0;
const landlordProgressHref = landlordStep >= 2
? "/landlord/onboarding/property-setup"
: "/landlord/onboarding/profile";
const landlordPrimary = landlordReady
? { label: "Landlord Dashboard", href: "/landlord/dashboard" }
: landlordInProgress
? { label: "Continue Landlord Setup", href: landlordProgressHref }
: { label: "Start Landlord Setup", href: "/onboarding#landlord" };
const tenantPrimary = tenantOnboarded
? { label: "Tenant Dashboard", href: "/tenant/dashboard" }
: { label: "Start Tenant Setup", href: "/onboarding#tenant" };
return (
<div class="min-h-screen bg-black flex items-start justify-center relative overflow-hidden w-full pt-16">
{/* Animated background grid */}
<div class="absolute inset-0 opacity-20">
<div
class="absolute inset-0"
style={{
backgroundImage:
`linear-gradient(rgba(107, 206, 220, 0.1) 1px, transparent 1px), linear-gradient(90deg, rgba(107, 206, 220, 0.1) 1px, transparent 1px)`,
backgroundSize: "50px 50px",
}}
>
</div>
</div>
{/* Teal colored orbs */}
<div class="absolute w-48 h-48 bg-teal-400 rounded-full filter blur-3xl opacity-20 animate-float-teal-1">
</div>
<div class="absolute w-56 h-56 bg-cyan-400 rounded-full filter blur-3xl opacity-20 animate-float-teal-2">
</div>
<div class="absolute w-52 h-52 bg-emerald-400 rounded-full filter blur-3xl opacity-20 animate-float-teal-3">
</div>
<div class="relative z-10 flex flex-col items-center text-center px-6 w-full max-w-none mt-4">
{/* Large Logo */}
<div class="mb-4 transform hover:scale-105 transition-transform duration-300">
<img
src="/logo.svg"
alt="Cyber Hybrid Hub Logo"
width="150"
height="150"
class={`drop-shadow-2xl filter drop-shadow-[0_0_20px_rgba(107,206,220,0.5)] spin-on-hover ${
spinDirection === "cw"
? "spin-direction-cw"
: "spin-direction-ccw"
}`}
onMouseEnter={handleLogoMouseEnter}
/>
</div>
{/* Main message */}
<div class="max-w-none w-full px-4">
<h1 class="text-3xl md:text-5xl text-transparent bg-clip-text bg-linear-to-r from-teal-400 via-cyan-400 to-emerald-400 mb-6 animate-pulse">
Apps available to you!
</h1>
{/* Real Estate Hub */}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 w-full max-w-6xl mx-auto mb-10">
<div
id="landlord-hub"
class="group relative overflow-hidden bg-linear-to-br from-emerald-900/20 via-teal-900/25 to-cyan-900/20 border border-emerald-500/30 rounded-2xl p-6 backdrop-blur-sm hover:border-emerald-400/60 transition-all duration-300 hover:shadow-[0_0_40px_rgba(52,211,153,0.25)]"
>
<div class="absolute inset-0 bg-emerald-400/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
</div>
<div class="relative z-10">
<div class="flex items-start justify-between gap-4">
<div>
<h2 class="text-2xl font-bold text-emerald-200">
Landlord HQ
</h2>
<p class="text-gray-400 text-sm">
Launch new listings, manage leases, and track revenue.
</p>
</div>
<span
class={`text-xs uppercase tracking-widest px-3 py-1 rounded-full border ${
landlordReady
? "border-emerald-400/40 text-emerald-200 bg-emerald-500/15"
: landlordInProgress
? "border-amber-400/40 text-amber-200 bg-amber-500/10"
: "border-slate-400/40 text-slate-200 bg-slate-500/10"
}`}
>
{landlordReady
? "Onboarded"
: landlordInProgress
? "In Progress"
: "Not Set Up"}
</span>
</div>
<div class="mt-5 grid grid-cols-2 gap-4 text-left text-sm text-gray-300">
<div>
<p class="text-emerald-200 font-semibold">
Ownership Tools
</p>
<p class="text-xs text-gray-400">
Properties, applications, rent schedules.
</p>
</div>
<div>
<p class="text-emerald-200 font-semibold">
Tenant Pipeline
</p>
<p class="text-xs text-gray-400">
Review applications and coordinate repairs.
</p>
</div>
</div>
<div class="mt-6 flex flex-col sm:flex-row gap-3">
<a
href={landlordPrimary.href}
class="inline-flex items-center justify-center rounded-full px-5 py-2 text-sm font-semibold text-white bg-emerald-500/80 hover:bg-emerald-400/90 transition"
>
{landlordPrimary.label}
</a>
<a
href={landlordReady
? "/landlord/properties/add"
: "/onboarding#landlord"}
class="inline-flex items-center justify-center rounded-full px-5 py-2 text-sm font-semibold text-emerald-100 border border-emerald-400/40 hover:border-emerald-300/80 hover:text-white transition"
>
Add New Property
</a>
</div>
</div>
</div>
<div
id="tenant-hub"
class="group relative overflow-hidden bg-linear-to-br from-cyan-900/20 via-teal-900/15 to-sky-900/20 border border-cyan-500/30 rounded-2xl p-6 backdrop-blur-sm hover:border-cyan-400/60 transition-all duration-300 hover:shadow-[0_0_40px_rgba(56,189,248,0.25)]"
>
<div class="absolute inset-0 bg-cyan-400/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
</div>
<div class="relative z-10">
<div class="flex items-start justify-between gap-4">
<div>
<h2 class="text-2xl font-bold text-cyan-200">
Tenant Command
</h2>
<p class="text-gray-400 text-sm">
Pay rent, request maintenance, and stay in sync.
</p>
</div>
<span
class={`text-xs uppercase tracking-widest px-3 py-1 rounded-full border ${
tenantOnboarded
? "border-cyan-400/40 text-cyan-200 bg-cyan-500/15"
: "border-slate-400/40 text-slate-200 bg-slate-500/10"
}`}
>
{tenantOnboarded ? "Active" : "Not Set Up"}
</span>
</div>
<div class="mt-5 grid grid-cols-2 gap-4 text-left text-sm text-gray-300">
<div>
<p class="text-cyan-200 font-semibold">Payment Hub</p>
<p class="text-xs text-gray-400">
Auto-pay options and transaction history.
</p>
</div>
<div>
<p class="text-cyan-200 font-semibold">Service Requests</p>
<p class="text-xs text-gray-400">
Track repairs and chat with your landlord.
</p>
</div>
</div>
<div class="mt-6 flex flex-col sm:flex-row gap-3">
<a
href={tenantPrimary.href}
class="inline-flex items-center justify-center rounded-full px-5 py-2 text-sm font-semibold text-white bg-cyan-500/80 hover:bg-cyan-400/90 transition"
>
{tenantPrimary.label}
</a>
<a
href="/onboarding#tenant"
class="inline-flex items-center justify-center rounded-full px-5 py-2 text-sm font-semibold text-cyan-100 border border-cyan-400/40 hover:border-cyan-300/80 hover:text-white transition"
>
Apply to Property
</a>
</div>
</div>
</div>
</div>
{/* App tiles container */}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 w-full max-w-6xl mx-auto">
{/* Admin Center Tile */}
<div
id="admin-center"
class="group relative overflow-hidden bg-linear-to-br from-teal-900/20 to-cyan-900/20 border border-teal-500/30 rounded-xl p-6 backdrop-blur-sm hover:border-teal-400/60 transition-all duration-300 hover:shadow-[0_0_30px_rgba(107,206,220,0.3)]"
>
<div class="absolute inset-0 bg-teal-400/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
</div>
<div class="relative z-10">
<div class="w-12 h-12 bg-teal-400/20 rounded-lg flex items-center justify-center mb-4 group-hover:bg-teal-400/30 transition-colors">
<svg
class="w-6 h-6 text-teal-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
>
</path>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
>
</path>
</svg>
</div>
<h3 class="text-xl font-bold text-teal-400 mb-2">
Admin Center
</h3>
<p class="text-gray-400 text-sm">
Manage system settings and user permissions
</p>
</div>
</div>
{/* User Profile Tile */}
<div
id="user-profile"
class="group relative overflow-hidden bg-linear-to-br from-teal-900/20 to-cyan-900/20 border border-teal-500/30 rounded-xl p-6 backdrop-blur-sm hover:border-teal-400/60 transition-all duration-300 hover:shadow-[0_0_30px_rgba(107,206,220,0.3)]"
>
<div class="absolute inset-0 bg-teal-400/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
</div>
<div class="relative z-10">
<div class="w-12 h-12 bg-teal-400/20 rounded-lg flex items-center justify-center mb-4 group-hover:bg-teal-400/30 transition-colors">
<svg
class="w-6 h-6 text-teal-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
>
</path>
</svg>
</div>
<h3 class="text-xl font-bold text-teal-400 mb-2">
User Profile
</h3>
<p class="text-gray-400 text-sm">
View and edit your personal information
</p>
</div>
</div>
{/* Dashboard Tile */}
<button
onClick={() => {
localStorage.setItem("lock-route", "/language/dashboard");
globalThis.location.href = "/language/dashboard";
}}
type="button"
id="dashboard"
class="group relative overflow-hidden bg-gradient-to-br from-teal-900/20 to-cyan-900/20 border border-teal-500/30 rounded-xl p-6 backdrop-blur-sm hover:border-teal-400/60 transition-all duration-300 hover:shadow-[0_0_30px_rgba(107,206,220,0.3)]"
>
<div class="absolute inset-0 bg-teal-400/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
</div>
<div class="relative z-10">
<div class="w-12 h-12 bg-teal-400/20 rounded-lg flex items-center justify-center mb-4 group-hover:bg-teal-400/30 transition-colors">
<svg
class="w-6 h-6 text-teal-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
>
</path>
</svg>
</div>
<h3 class="text-xl font-bold text-teal-400 mb-2">Dashboard</h3>
<p class="text-gray-400 text-sm">
View analytics and system overview
</p>
</div>
</button>
{/* Financial Calendar Tile */}
<a
href={`/calendar/financial-calendar/${new Date().getFullYear()}/${
(new Date().getMonth() + 1).toString().padStart(2, "0")
}?`}
id="financial-calendar"
class="group relative overflow-hidden bg-linear-to-br from-teal-900/20 to-cyan-900/20 border border-teal-500/30 rounded-xl p-6 backdrop-blur-sm hover:border-teal-400/60 transition-all duration-300 hover:shadow-[0_0_30px_rgba(107,206,220,0.3)]"
>
<div class="absolute inset-0 bg-teal-400/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
</div>
<div class="relative z-10">
<div class="w-12 h-12 bg-teal-400/20 rounded-lg flex items-center justify-center mb-4 group-hover:bg-teal-400/30 transition-colors">
<svg
class="w-6 h-6 text-teal-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3M5 11h14M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5A2 2 0 003 7v12a2 2 0 002 2z"
>
</path>
</svg>
</div>
<h3 class="text-xl font-bold text-teal-400 mb-2">
Financial Calendar
</h3>
<p class="text-gray-400 text-sm">
See your incomes and obligations by day
</p>
</div>
</a>
</div>
</div>
{/* Glitch effect container */}
<div class="relative inline-block mb-8">
<div class="absolute inset-0 bg-teal-400 opacity-20 blur-xl animate-pulse">
</div>
<div class="relative bg-linear-to-r from-teal-500/20 to-cyan-500/20 border border-teal-500/50 rounded-lg px-8 py-4 backdrop-blur-sm">
<p class="text-teal-300 text-sm font-mono">
&gt; Loading applications...
</p>
</div>
</div>
{/* Animated scanlines */}
<div class="absolute inset-0 pointer-events-none">
<div
class="absolute inset-0 bg-linear-to-b from-transparent via-teal-500/5 to-transparent animate-pulse"
style={{
animation: "scan 3s linear infinite",
backgroundSize: "100% 3px",
}}
>
</div>
</div>
</div>
{/* Floating Tile Buttons */}
<FloatingTileButtons ids={tileIds} />
<style jsx>
{`
@keyframes scan {
0% { transform: translateY(-100%); }
100% { transform: translateY(100vh); }
}
@keyframes float-teal-1 {
0% {
transform: translate(-300px, -300px) rotate(0deg) scale(0.4);
opacity: 0;
}
25% {
transform: translate(-150px, -150px) rotate(90deg) scale(0.7);
opacity: 0.3;
}
50% {
transform: translate(0px, 0px) rotate(180deg) scale(1.1);
opacity: 0.5;
}
75% {
transform: translate(150px, 150px) rotate(270deg) scale(0.7);
opacity: 0.3;
}
100% {
transform: translate(300px, 300px) rotate(360deg) scale(0.4);
opacity: 0;
}
}
@keyframes float-teal-2 {
0% {
transform: translate(300px, -300px) rotate(0deg) scale(0.4);
opacity: 0;
}
25% {
transform: translate(150px, -150px) rotate(-90deg) scale(0.7);
opacity: 0.3;
}
50% {
transform: translate(0px, 0px) rotate(-180deg) scale(1.1);
opacity: 0.5;
}
75% {
transform: translate(-150px, 150px) rotate(-270deg) scale(0.7);
opacity: 0.3;
}
100% {
transform: translate(-300px, 300px) rotate(-360deg) scale(0.4);
opacity: 0;
}
}
@keyframes float-teal-3 {
0% {
transform: translate(0px, 300px) rotate(0deg) scale(0.4);
opacity: 0;
}
25% {
transform: translate(0px, 150px) rotate(180deg) scale(0.7);
opacity: 0.3;
}
50% {
transform: translate(0px, 0px) rotate(360deg) scale(1.1);
opacity: 0.5;
}
75% {
transform: translate(0px, -150px) rotate(540deg) scale(0.7);
opacity: 0.3;
}
100% {
transform: translate(0px, -300px) rotate(720deg) scale(0.4);
opacity: 0;
}
}
.animate-float-teal-1 {
animation: float-teal-1 12s ease-in-out infinite;
top: 50%;
left: 50%;
margin-top: -24px;
margin-left: -24px;
}
.animate-float-teal-2 {
animation: float-teal-2 15s ease-in-out infinite;
top: 50%;
left: 50%;
margin-top: -28px;
margin-left: -28px;
}
.animate-float-teal-3 {
animation: float-teal-3 18s ease-in-out infinite;
top: 50%;
left: 50%;
margin-top: -26px;
margin-left: -26px;
}
.spin-on-hover {
transition: transform 0.3s ease;
animation-play-state: paused;
}
.spin-direction-cw:hover {
animation: spin-cw 4s linear infinite;
}
.spin-direction-ccw:hover {
animation: spin-ccw 4s linear infinite;
}
@keyframes spin-cw {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes spin-ccw {
from { transform: rotate(0deg); }
to { transform: rotate(-360deg); }
}
`}
</style>
</div>
);
}

321
islands/ObligationList.tsx Normal file
View File

@ -0,0 +1,321 @@
import {
decryptDataWithStoredPassword,
encryptDataWithStoredPassword,
getEncryptionPassword,
MakeNewDate,
} from "../helpers.ts";
import { Obligation } from "../services/financhalServices.ts";
import { useEffect, useRef, useState } from "preact/hooks";
import { PopAddObligationDialog } from "./dialogs/PopAddObligationDialog.tsx";
interface Props {
obligations: Obligation[];
userId?: string;
openModal?: boolean;
}
export default function ObligationList(
{ obligations, userId, openModal = false }: Props,
) {
// Theme classes are defined in styles.css with @media (prefers-color-scheme: dark)
const [popAddObligation, popupAddObligationDialog] = PopAddObligationDialog({
userId,
defaultOpen: openModal,
});
const [processedObligations, setProcessedObligations] = useState<
Obligation[]
>(obligations);
const [isProcessing, setIsProcessing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isOpeningModal, setIsOpeningModal] = useState(openModal);
const processingRef = useRef(false);
// Process obligations on component mount and when obligations or userId change
useEffect(() => {
setTimeout(() => {
const lockRoute = localStorage.getItem("lock-route");
const url = new URL(globalThis.location.href);
const currentRoute = url.pathname + url.search;
if (lockRoute && lockRoute === currentRoute) {
localStorage.removeItem("lock-route");
}
}, 100);
// Prevent concurrent processing
if (processingRef.current) {
return;
}
if (!userId) {
setProcessedObligations(obligations);
setError(null);
return;
}
const processObligations = async () => {
processingRef.current = true;
setIsProcessing(true);
setError(null);
try {
const encryptionPassword = getEncryptionPassword(userId);
if (!encryptionPassword) {
// No encryption key available, use obligations as-is
setProcessedObligations(obligations);
return;
}
const updatedObligations: Obligation[] = [];
let hasEncryptionUpdates = false;
for (const obligation of obligations) {
const processedObligation = { ...obligation };
if (obligation.encrypted_data) {
// Try to decrypt existing encrypted data
try {
const decryptedData = await decryptDataWithStoredPassword(
userId,
obligation.encrypted_data,
);
if (decryptedData) {
const parsed = JSON.parse(decryptedData);
if (parsed.amount !== undefined) {
processedObligation.amount = parsed.amount;
}
if (parsed.description !== undefined) {
processedObligation.description = parsed.description;
}
}
} catch (error) {
console.error("Failed to decrypt obligation data:", error);
setError("Failed to decrypt some obligation data");
}
} else if (
obligation.amount !== null || obligation.description !== null
) {
// No encrypted data but has plaintext data - encrypt it and clear plaintext
try {
const dataToEncrypt = {
amount: obligation.amount,
description: obligation.description,
};
const encryptedData = await encryptDataWithStoredPassword(
userId,
JSON.stringify(dataToEncrypt),
);
if (encryptedData) {
// Update the obligation in the database
const response = await fetch("/api/update/obligation", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
obligation_id: obligation.id,
encrypted_data: encryptedData,
description: null, // Clear plaintext description
amount: null, // Clear plaintext amount
}),
});
if (response.ok) {
// Update local state
processedObligation.encrypted_data = encryptedData;
processedObligation.description = obligation.description; // Keep for display
processedObligation.amount = obligation.amount; // Keep for display
hasEncryptionUpdates = true;
} else {
console.error("Failed to update obligation encryption");
setError("Failed to encrypt some obligation data");
}
}
} catch (error) {
console.error("Failed to encrypt obligation data:", error);
setError("Failed to encrypt some obligation data");
}
}
updatedObligations.push(processedObligation);
}
setProcessedObligations(updatedObligations);
if (hasEncryptionUpdates) {
console.log("Successfully encrypted plaintext obligation data");
}
} catch (error) {
console.error("Unexpected error during obligation processing:", error);
setError("An unexpected error occurred while processing obligations");
setProcessedObligations(obligations); // Fallback to original data
} finally {
setIsProcessing(false);
processingRef.current = false;
}
};
processObligations();
// Cleanup function to handle component unmount
return () => {
processingRef.current = false;
};
}, []);
return (
<>
{popupAddObligationDialog}
<div class="flex flex-col mt-14 gap-6">
<div
style={{
position: "relative",
marginBottom: "1rem",
minHeight: "56px",
}}
>
<h2
class="theme-text"
style={{
position: "absolute",
top: "50%",
left: "5%",
fontSize: "1.5rem",
margin: 0,
}}
>
Your Obligations
</h2>
<button
type="button"
style={{
position: "absolute",
top: "50%",
right: "5%",
backgroundColor: "#16a34a",
color: "#fff",
padding: "0.5rem 1.25rem",
borderRadius: "0.5rem",
boxShadow: "0 10px 15px -3px rgba(16, 185, 129, 0.4)",
border: "none",
cursor: "pointer",
fontSize: "1.4rem",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.backgroundColor =
"#15803d";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.backgroundColor =
"#16a34a";
}}
onClick={() => {
if (isOpeningModal) return;
localStorage.setItem(
"lock-route",
"/calendar/budget-obligations?modal=on",
);
setIsOpeningModal(true);
globalThis.location.href =
"/calendar/budget-obligations?modal=on";
}}
disabled={isOpeningModal}
>
+ Add Obligation
</button>
</div>
<ul class="space-y-2">
{processedObligations.map((ob) => (
<li
key={ob.id}
class="border rounded p-4 shadow relative theme-container"
>
<form
method="POST"
action="/api/delete/obligation"
class="absolute top-1/2 right-0 transform -translate-y-1/2"
style={{ display: "inline" }}
onSubmit={(e) => {
// Set sender input to current page
const form = e.currentTarget;
form.sender.value = location.pathname;
// Add confirmation for delete
if (
!confirm(
`Are you sure you want to delete "${
ob.description || "this obligation"
}"?`,
)
) {
e.preventDefault();
return false;
}
}}
>
<input type="hidden" name="obligation_id" value={ob.id} />
<input
type="hidden"
name="sender"
value={typeof location !== "undefined"
? location.pathname
: ""}
/>
<button
type="submit"
aria-label="Delete obligation"
class="theme-icon-button hover:text-red-500 transition-colors"
style={{ background: "none", border: "none", padding: 0 }}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</form>
<div class="font-semibold text-lg theme-text">
{ob.description || ""}
</div>
<div class="text-sm theme-calendar-list-frequency">
Amount: ${ob.amount?.toFixed(2) || "0.00"}
</div>
<div class="text-sm theme-calendar-list-frequency">
Frequency: {ob.frequency}
</div>
<div class="text-sm theme-calendar-list-frequency">
Due: {ob.due_date
? MakeNewDate(ob.due_date).toISOString().split("T")[0]
: ""}
</div>
</li>
))}
</ul>
</div>
<div aria-hidden="true" class="h-28"></div>
<div class="fixed bottom-4 inset-x-0 flex justify-center z-40 pointer-events-none">
<a
href={`/calendar/financial-calendar/${new Date().getFullYear()}/${
(new Date().getMonth() + 1).toString().padStart(2, "0")
}?`}
class="w-11/12 max-w-md bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 px-10 rounded-full shadow-lg pointer-events-auto text-center"
>
Back to Financial Calendar
</a>
</div>
</>
);
}

View File

@ -0,0 +1,23 @@
import { useEffect } from "preact/hooks";
export default function OnboardingHashGate() {
useEffect(() => {
const landlordSection = document.getElementById("landlord");
const tenantSection = document.getElementById("tenant");
if (!landlordSection || !tenantSection) return;
const applyHashState = () => {
const tenantOnly = window.location.hash === "#tenant";
landlordSection.classList.toggle("hidden", tenantOnly);
tenantSection.style.gridColumn = tenantOnly ? "1 / -1" : "";
};
applyHashState();
window.addEventListener("hashchange", applyHashState);
return () => {
window.removeEventListener("hashchange", applyHashState);
};
}, []);
return null;
}

View File

@ -0,0 +1,894 @@
import { useEffect, useRef } from "preact/hooks";
import "../photo-editor/types.ts";
import {
useCropCalculator,
usePhotoEditorState,
} from "../photo-editor/hooks.ts";
import { applyOrientation, getOrientation } from "../photo-editor/utils.ts";
interface PhotoCropperModalProps {
onComplete: (imageDataUrl: string) => void;
onCancel: () => void;
}
export default function PhotoCropperModal(
{ onComplete, onCancel }: PhotoCropperModalProps,
) {
const { state, updateState } = usePhotoEditorState();
const { getPixelDimensions } = useCropCalculator(state, updateState);
const imgRef = useRef<HTMLImageElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleResize = () => {
const img = imgRef.current;
if (!img || !img.complete) return;
const currentWidth = img.style.width;
const currentHeight = img.style.height;
img.style.height = "8000px";
img.style.width = "auto";
void img.offsetHeight;
const achievedWidth = img.getBoundingClientRect().width;
updateState({ maxWidth: achievedWidth });
const displayRatio = img.naturalHeight / img.naturalWidth;
const maxHeight = displayRatio * achievedWidth;
updateState({ maxHeight });
img.style.width = currentWidth;
img.style.height = currentHeight;
updateState({ cropSize: 256, zoom: Math.max(1, state.minZoom) });
void img.offsetHeight;
};
useEffect(() => {
if (state.previewUrl && imgRef.current && imgRef.current.parentElement) {
const img = imgRef.current;
const centerCrop = () => {
if (!img.parentElement) return;
const imgRect = img.getBoundingClientRect();
const baseWidth = imgRect.width / state.zoom;
const baseHeight = imgRect.height / state.zoom;
const landscape = baseWidth >= baseHeight;
updateState({
baseDimensions: { width: baseWidth, height: baseHeight },
});
updateState({ isLandscape: landscape });
const longestDimension = Math.max(baseWidth, baseHeight);
const calculatedMinZoom = longestDimension > 0
? 256 / longestDimension
: 0.2;
updateState({ minZoom: calculatedMinZoom });
const containerRect = img.parentElement.getBoundingClientRect();
const imgOffsetLeft = imgRect.left - containerRect.left;
const imgOffsetTop = imgRect.top - containerRect.top;
let centerX = imgOffsetLeft + (imgRect.width - 256) / 2;
let centerY = imgOffsetTop + (imgRect.height - 256) / 2;
if (imgRect.width < 256) {
console.log(
`Width ${imgRect.width}px < crop ${256}px, allowing overflow`,
);
} else {
centerX = Math.max(imgOffsetLeft, centerX);
}
if (imgRect.height < 256) {
console.log(
`Height ${imgRect.height}px < crop ${256}px, allowing overflow`,
);
} else {
centerY = Math.max(imgOffsetTop, centerY);
}
updateState({
crop: {
x: centerX,
y: centerY,
size: 256,
},
});
};
if (img.complete) {
centerCrop();
} else {
img.addEventListener("load", centerCrop, { once: true });
return () => img.removeEventListener("load", centerCrop);
}
}
}, [state.previewUrl]);
useEffect(() => {
globalThis.addEventListener("resize", handleResize);
handleResize();
return () => globalThis.removeEventListener("resize", handleResize);
}, [state.previewUrl]);
useEffect(() => {
if (!state.previewUrl) return;
const longerDimension = state.isLandscape
? state.baseDimensions.width
: state.baseDimensions.height;
if (longerDimension <= 0) return;
const calculatedDimension = Math.round(longerDimension * state.zoom);
let clampedDimension = Math.max(calculatedDimension, state.cropSize);
let constrainedByMax = false;
if (
!state.isLandscape && state.maxHeight > 0 &&
clampedDimension > state.maxHeight
) {
clampedDimension = state.maxHeight;
constrainedByMax = true;
} else if (
state.isLandscape && state.maxWidth > 0 &&
clampedDimension > state.maxWidth
) {
clampedDimension = state.maxWidth;
constrainedByMax = true;
}
const actualZoom = clampedDimension / longerDimension;
let overRunPercent = constrainedByMax && actualZoom > 0
? ((state.zoom - actualZoom) / actualZoom)
: 0;
if (!isFinite(overRunPercent)) {
overRunPercent = 0;
}
const timer = setTimeout(() => {
const maxOverrunPercent = 0.9;
if (overRunPercent > 0.0 && overRunPercent <= maxOverrunPercent) {
const newCropSize = Math.max(25.6, 256 - (256 * overRunPercent));
if (Math.abs(newCropSize - state.cropSize) > 1) {
updateState({ cropSize: newCropSize, zoomInDisabled: false });
}
} else if (overRunPercent > maxOverrunPercent) {
const targetZoom = actualZoom * (1 + maxOverrunPercent);
const clampedZoom = Math.max(state.minZoom, targetZoom);
if (Math.abs(clampedZoom - state.zoom) > 0.01) {
updateState({ zoom: clampedZoom, zoomInDisabled: true });
} else {
updateState({ zoomInDisabled: true });
}
}
}, 100);
return () => clearTimeout(timer);
}, [
state.zoom,
state.cropSize,
state.baseDimensions,
state.isLandscape,
state.maxWidth,
state.maxHeight,
]);
useEffect(() => {
if (
typeof globalThis.heicTo !== "undefined" &&
typeof globalThis.isHeic !== "undefined"
) {
updateState({ heicToLoaded: true });
return;
}
const script = document.createElement("script");
script.type = "module";
script.textContent = `
import { heicTo, isHeic } from '/heic-to.min.js';
globalThis.heicTo = heicTo;
globalThis.isHeic = isHeic;
globalThis.dispatchEvent(new CustomEvent('heicToLoaded'));
`;
const handleLoaded = () => {
updateState({ heicToLoaded: true });
console.log("heic-to library loaded successfully");
};
globalThis.addEventListener("heicToLoaded", handleLoaded, { once: true });
document.head.appendChild(script);
return () => {
globalThis.removeEventListener("heicToLoaded", handleLoaded);
if (script.parentNode) {
document.head.removeChild(script);
}
};
}, []);
// Auto-open file picker on mount
useEffect(() => {
setTimeout(() => {
fileInputRef.current?.click();
}, 100);
}, []);
async function handleFile(e: Event) {
const originalFile = (e.target as HTMLInputElement).files?.[0];
if (!originalFile) {
onCancel();
return;
}
const validImageTypes = [
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/webp",
"image/bmp",
"image/svg+xml",
"image/heic",
"image/heif",
"image/tiff",
"image/avif",
"image/x-icon",
"image/vnd.microsoft.icon",
"image/ico",
"image/jfif",
"image/pjpeg",
"image/pjp",
];
const fileExtension = originalFile.name.toLowerCase().split(".").pop();
const isImageByType = originalFile.type.startsWith("image/") ||
validImageTypes.includes(originalFile.type);
const isImageByExtension = [
"jpg",
"jpeg",
"png",
"gif",
"webp",
"bmp",
"svg",
"heic",
"heif",
"tiff",
"tif",
"avif",
"ico",
"jfif",
"pjpeg",
"pjp",
].includes(fileExtension || "");
if (!isImageByType && !isImageByExtension) {
alert(
`Unsupported file type: ${
originalFile.type || "unknown"
}\n\nPlease select an image file.`,
);
return;
}
const maxRecommendedSize = 20 * 1024 * 1024;
if (originalFile.size > maxRecommendedSize) {
const sizeMB = (originalFile.size / (1024 * 1024)).toFixed(1);
const proceed = confirm(
`Large file detected (${sizeMB}MB).\n\nProcessing large images may be slow or cause the browser to become unresponsive.\n\nDo you want to continue?`,
);
if (!proceed) {
if (fileInputRef.current) fileInputRef.current.value = "";
return;
}
}
let file: Blob = originalFile;
// Check if file might be HEIC/HEIF format
const isHeicExtension = ["heic", "heif"].includes(fileExtension || "");
const isHeicMime = ["image/heic", "image/heif"].includes(originalFile.type);
const mightBeHeic = isHeicExtension || isHeicMime;
// Only require HEIC library for HEIC files
if (mightBeHeic) {
if (
!state.heicToLoaded || typeof globalThis.isHeic === "undefined" ||
typeof globalThis.heicTo === "undefined"
) {
alert(
"HEIC converter is still loading. Please wait a moment and try again.",
);
return;
}
try {
const isHeicFile = await globalThis.isHeic(originalFile);
if (isHeicFile) {
console.log("HEIC/HEIF file detected, converting to BMP...");
const convertedBlob = await globalThis.heicTo({
blob: originalFile,
type: "image/bmp",
quality: 1.0,
});
file = convertedBlob;
console.log("HEIC converted successfully to BMP:", {
originalSize: originalFile.size,
convertedSize: file.size,
type: file.type,
});
}
} catch (error) {
alert("Failed to convert HEIC image. Please try another file.");
console.error("HEIC processing error:", error);
return;
}
} else {
console.log(
`Processing ${
originalFile.type || fileExtension?.toUpperCase()
} image:`,
{
name: originalFile.name,
size: originalFile.size,
type: originalFile.type,
},
);
}
const orientationReader = new FileReader();
orientationReader.onload = (orientEvent) => {
const arrayBuffer = orientEvent.target?.result as ArrayBuffer;
const orientation = getOrientation(arrayBuffer);
const reader = new FileReader();
reader.onload = async (event) => {
try {
const response = await fetch(event.target?.result as string);
const blob = await response.blob();
const imageBitmap = await createImageBitmap(blob, {
colorSpaceConversion: "none",
imageOrientation: "none",
premultiplyAlpha: "default",
resizeQuality: "high",
}).catch((err) => {
console.error("ImageBitmap creation failed:", err);
throw new Error(
"Unable to decode image. The file may be corrupted or in an unsupported format.",
);
});
let canvasWidth = imageBitmap.width;
let canvasHeight = imageBitmap.height;
if (orientation >= 5 && orientation <= 8) {
[canvasWidth, canvasHeight] = [canvasHeight, canvasWidth];
}
const canvas = document.createElement("canvas");
canvas.width = canvasWidth;
canvas.height = canvasHeight;
let ctx: CanvasRenderingContext2D | null = null;
try {
ctx = canvas.getContext("2d", {
colorSpace: "display-p3",
}) as CanvasRenderingContext2D;
} catch (_e) {
ctx = canvas.getContext("2d");
}
if (!ctx) {
alert("Failed to create canvas context");
return;
}
applyOrientation(ctx, orientation, canvasWidth, canvasHeight);
ctx.drawImage(imageBitmap, 0, 0);
imageBitmap.close();
canvas.toBlob(
(blob) => {
if (blob) {
const url = URL.createObjectURL(blob);
updateState({ previewUrl: url });
updateState({ zoom: 1 });
setTimeout(() => {
handleResize();
}, 100);
} else {
alert("Failed to convert image");
}
},
"image/png",
);
} catch (error) {
console.error("Image processing error:", error);
const errorMessage = error instanceof Error
? error.message
: "Unknown error occurred";
alert(
`Failed to process image: ${errorMessage}\n\nPlease try a different file.`,
);
}
};
reader.onerror = () => {
alert(
"Failed to read file. The file may be corrupted or inaccessible.",
);
};
reader.readAsDataURL(file);
};
orientationReader.onerror = () => {
alert("Failed to read file metadata. The file may be corrupted.");
};
orientationReader.readAsArrayBuffer(file);
}
function centerCropBox(resetZoom = false) {
let thisCropSize = 256;
if (!resetZoom) {
thisCropSize = state.cropSize;
}
if (!imgRef.current || !imgRef.current.parentElement) return;
setTimeout(() => {
if (!imgRef.current || !imgRef.current.parentElement) return;
const containerRect = imgRef.current.parentElement
.getBoundingClientRect();
const imgRect = imgRef.current.getBoundingClientRect();
const imgOffsetLeft = imgRect.left - containerRect.left;
const imgOffsetTop = imgRect.top - containerRect.top;
let centerX = imgOffsetLeft + (imgRect.width - thisCropSize) / 2;
let centerY = imgOffsetTop + (imgRect.height - thisCropSize) / 2;
if (imgRect.width >= thisCropSize) {
centerX = Math.max(imgOffsetLeft, centerX);
}
if (imgRect.height >= thisCropSize) {
centerY = Math.max(imgOffsetTop, centerY);
}
updateState({ crop: { x: centerX, y: centerY, size: thisCropSize } });
}, 50);
}
function zoomIn() {
if (state.zoomInDisabled) return;
updateState({ zoom: Math.min(state.zoom + 0.1, 10.0) });
centerCropBox();
}
function zoomOut() {
updateState({
zoom: Math.max(state.zoom - 0.1, state.minZoom),
zoomInDisabled: false,
});
centerCropBox();
}
function zoomIn1() {
if (state.zoomInDisabled) return;
updateState({ zoom: Math.min(state.zoom + 0.01, 10.0) });
centerCropBox();
}
function zoomOut1() {
updateState({
zoom: Math.max(state.zoom - 0.01, state.minZoom),
zoomInDisabled: false,
});
centerCropBox();
}
function resetZoom() {
const safeMinZoom = isFinite(state.minZoom) && state.minZoom > 0
? state.minZoom
: 0.2;
updateState({
cropSize: 256,
zoom: Math.max(1, safeMinZoom),
zoomInDisabled: false,
});
centerCropBox(true);
}
function startDrag(_e: MouseEvent) {
updateState({ dragging: true });
}
function stopDrag() {
updateState({ dragging: false });
}
function drag(e: MouseEvent) {
const thisCropSize = state.cropSize;
if (!state.dragging || !imgRef.current || !imgRef.current.parentElement) {
return;
}
const containerRect = imgRef.current.parentElement.getBoundingClientRect();
const imgRect = imgRef.current.getBoundingClientRect();
const containerX = e.clientX - containerRect.left - thisCropSize / 2;
const containerY = e.clientY - containerRect.top - thisCropSize / 2;
const imgOffsetLeft = imgRect.left - containerRect.left;
const imgOffsetTop = imgRect.top - containerRect.top;
let x: number;
let y: number;
if (imgRect.width < thisCropSize) {
x = imgOffsetLeft + (imgRect.width - thisCropSize) / 2;
} else {
x = Math.max(
imgOffsetLeft,
Math.min(containerX, imgOffsetLeft + imgRect.width - thisCropSize),
);
}
if (imgRect.height < thisCropSize) {
y = imgOffsetTop + (imgRect.height - thisCropSize) / 2;
} else {
y = Math.max(
imgOffsetTop,
Math.min(containerY, imgOffsetTop + imgRect.height - thisCropSize),
);
}
updateState({
crop: {
x,
y,
size: thisCropSize,
},
});
}
function handleUseCropped() {
if (!imgRef.current || !imgRef.current.parentElement) return;
const containerRect = imgRef.current.parentElement.getBoundingClientRect();
const imgRect = imgRef.current.getBoundingClientRect();
const imgOffsetLeft = imgRect.left - containerRect.left;
const imgOffsetTop = imgRect.top - containerRect.top;
const imgX = state.crop.x - imgOffsetLeft;
const imgY = state.crop.y - imgOffsetTop;
const canvas = document.createElement("canvas");
canvas.width = 512;
canvas.height = 512;
const ctx = canvas.getContext("2d")!;
ctx.drawImage(
imgRef.current,
imgX / imgRef.current.width * imgRef.current.naturalWidth,
imgY / imgRef.current.height * imgRef.current.naturalHeight,
state.crop.size / imgRef.current.width * imgRef.current.naturalWidth,
state.crop.size / imgRef.current.height * imgRef.current.naturalHeight,
0,
0,
512,
512,
);
// Get base64 data URL
const dataUrl = canvas.toDataURL("image/png");
// Extract just the base64 part (remove "data:image/png;base64,")
const base64Data = dataUrl.split(",")[1];
onComplete(base64Data);
}
return (
<div
style={{
borderRadius: "12px",
position: "fixed",
top: 0,
left: 0,
width: "100%",
height: "100%",
zIndex: 1001,
background: "rgba(0, 0, 0, 0.9)",
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
onMouseMove={drag}
onMouseUp={stopDrag}
onMouseLeave={stopDrag}
>
<input
type="file"
accept="image/*"
onChange={handleFile}
ref={fileInputRef}
style={{ display: "none" }}
/>
<div
style={{
position: "relative",
overflow: "auto",
flex: "1",
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
{state.previewUrl
? (
<div
style={{
position: "relative",
display: "inline-block",
minWidth: "fit-content",
minHeight: "fit-content",
}}
>
<img
src={state.previewUrl}
ref={imgRef}
style={{
display: "block",
width: state.isLandscape
? `${getPixelDimensions().width}px`
: "auto",
height: !state.isLandscape
? `${getPixelDimensions().height}px`
: "auto",
margin: "0 auto",
}}
/>
<div
style={{
position: "absolute",
left: state.crop.x,
top: state.crop.y,
border: "2px solid #00ffcc",
width: state.crop.size,
height: state.crop.size,
cursor: "move",
boxShadow: "0 0 0 9999px rgba(0,0,0,0.5)",
}}
onMouseDown={startDrag}
>
</div>
</div>
)
: (
<div
style={{
textAlign: "center",
color: "#999",
}}
>
<div
style={{
fontSize: "18px",
marginBottom: "8px",
}}
>
Select a photo to crop
</div>
<div
style={{
fontSize: "14px",
color: "#666",
}}
>
Adjust the crop before using
</div>
</div>
)}
</div>
<div
style={{
height: "100px",
display: "flex",
gap: "6px",
alignItems: "center",
justifyContent: "center",
width: "100%",
padding: "0 16px",
flexShrink: 0,
background: "rgba(255, 255, 255, 0.1)",
}}
>
<button
type="button"
style={{
background: "white",
border: "1px solid #ccc",
borderRadius: "4px",
width: "32px",
height: "32px",
cursor: "pointer",
fontSize: "16px",
padding: "0",
transition: "all 0.2s",
}}
onMouseOver={(e) => e.currentTarget.style.background = "#f0f0f0"}
onMouseOut={(e) => e.currentTarget.style.background = "white"}
onMouseDown={(e) => e.currentTarget.style.transform = "scale(0.95)"}
onMouseUp={(e) => e.currentTarget.style.transform = "scale(1)"}
onClick={zoomOut}
disabled={!state.previewUrl}
>
−10
</button>
<button
type="button"
style={{
background: "white",
border: "1px solid #ccc",
borderRadius: "4px",
width: "32px",
height: "32px",
cursor: "pointer",
fontSize: "16px",
padding: "0",
transition: "all 0.2s",
}}
onMouseOver={(e) => e.currentTarget.style.background = "#f0f0f0"}
onMouseOut={(e) => e.currentTarget.style.background = "white"}
onMouseDown={(e) => e.currentTarget.style.transform = "scale(0.95)"}
onMouseUp={(e) => e.currentTarget.style.transform = "scale(1)"}
onClick={zoomOut1}
disabled={!state.previewUrl}
>
−1
</button>
<button
type="button"
style={{
background: "white",
border: "1px solid #ccc",
borderRadius: "4px",
padding: "6px 16px",
cursor: "pointer",
fontSize: "14px",
transition: "all 0.2s",
}}
onMouseOver={(e) => e.currentTarget.style.background = "#f0f0f0"}
onMouseOut={(e) => e.currentTarget.style.background = "white"}
onMouseDown={(e) => e.currentTarget.style.transform = "scale(0.95)"}
onMouseUp={(e) => e.currentTarget.style.transform = "scale(1)"}
onClick={resetZoom}
disabled={!state.previewUrl}
>
Reset
</button>
<button
type="button"
style={{
background: "white",
border: "1px solid #ccc",
borderRadius: "4px",
width: "32px",
height: "32px",
cursor: "pointer",
fontSize: "16px",
padding: "0",
transition: "all 0.2s",
}}
onMouseOver={(e) => e.currentTarget.style.background = "#f0f0f0"}
onMouseOut={(e) => e.currentTarget.style.background = "white"}
onMouseDown={(e) => e.currentTarget.style.transform = "scale(0.95)"}
onMouseUp={(e) => e.currentTarget.style.transform = "scale(1)"}
onClick={zoomIn1}
disabled={!state.previewUrl || state.zoomInDisabled}
>
+1
</button>
<button
type="button"
style={{
background: "white",
border: "1px solid #ccc",
borderRadius: "4px",
width: "32px",
height: "32px",
cursor: "pointer",
fontSize: "16px",
padding: "0",
transition: "all 0.2s",
}}
onMouseOver={(e) => e.currentTarget.style.background = "#f0f0f0"}
onMouseOut={(e) => e.currentTarget.style.background = "white"}
onMouseDown={(e) => e.currentTarget.style.transform = "scale(0.95)"}
onMouseUp={(e) => e.currentTarget.style.transform = "scale(1)"}
onClick={zoomIn}
disabled={!state.previewUrl || state.zoomInDisabled}
>
+10
</button>
<div style={{ width: "16px" }}></div>
<button
type="button"
style={{
padding: "8px 16px",
cursor: "pointer",
transition: "all 0.2s",
background: "white",
border: "1px solid #ccc",
borderRadius: "4px",
}}
onMouseOver={(e) => e.currentTarget.style.background = "#f0f0f0"}
onMouseOut={(e) => e.currentTarget.style.background = "white"}
onMouseDown={(e) => e.currentTarget.style.transform = "scale(0.95)"}
onMouseUp={(e) => e.currentTarget.style.transform = "scale(1)"}
onClick={() => fileInputRef.current?.click()}
>
Choose File
</button>
<button
type="button"
style={{
padding: "8px 16px",
cursor: "pointer",
transition: "all 0.2s",
background: "#4CAF50",
color: "white",
border: "none",
borderRadius: "4px",
}}
onMouseOver={(e) =>
!e.currentTarget.disabled &&
(e.currentTarget.style.background = "#45a049")}
onMouseOut={(e) => e.currentTarget.style.background = "#4CAF50"}
onMouseDown={(e) =>
!e.currentTarget.disabled &&
(e.currentTarget.style.transform = "scale(0.95)")}
onMouseUp={(e) => e.currentTarget.style.transform = "scale(1)"}
onClick={handleUseCropped}
disabled={!state.previewUrl}
>
Use Image
</button>
<button
type="button"
style={{
padding: "8px 16px",
cursor: "pointer",
transition: "all 0.2s",
background: "#f44336",
color: "white",
border: "none",
borderRadius: "4px",
}}
onMouseOver={(e) => e.currentTarget.style.background = "#d32f2f"}
onMouseOut={(e) => e.currentTarget.style.background = "#f44336"}
onMouseDown={(e) => e.currentTarget.style.transform = "scale(0.95)"}
onMouseUp={(e) => e.currentTarget.style.transform = "scale(1)"}
onClick={onCancel}
>
Cancel
</button>
</div>
</div>
);
}

851
islands/PhotoUpdate.tsx Normal file
View File

@ -0,0 +1,851 @@
import { useEffect, useRef } from "preact/hooks";
import "../photo-editor/types.ts";
import {
useCropCalculator,
usePhotoEditorState,
} from "../photo-editor/hooks.ts";
import { applyOrientation, getOrientation } from "../photo-editor/utils.ts";
export default function PhotoUpdateIsland() {
const { state, updateState } = usePhotoEditorState();
const { getPixelDimensions } = useCropCalculator(state, updateState);
const imgRef = useRef<HTMLImageElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleResize = () => {
const img = imgRef.current;
if (!img || !img.complete) return;
const currentWidth = img.style.width;
const currentHeight = img.style.height;
img.style.height = "8000px";
img.style.width = "auto";
void img.offsetHeight;
const achievedWidth = img.getBoundingClientRect().width;
updateState({ maxWidth: achievedWidth });
const displayRatio = img.naturalHeight / img.naturalWidth;
const maxHeight = displayRatio * achievedWidth;
updateState({ maxHeight });
img.style.width = currentWidth;
img.style.height = currentHeight;
updateState({ cropSize: 256, zoom: Math.max(1, state.minZoom) });
void img.offsetHeight;
};
useEffect(() => {
if (state.previewUrl && imgRef.current && imgRef.current.parentElement) {
const img = imgRef.current;
const centerCrop = () => {
if (!img.parentElement) return;
const imgRect = img.getBoundingClientRect();
const baseWidth = imgRect.width / state.zoom;
const baseHeight = imgRect.height / state.zoom;
const landscape = baseWidth >= baseHeight;
updateState({
baseDimensions: { width: baseWidth, height: baseHeight },
});
updateState({ isLandscape: landscape });
const longestDimension = Math.max(baseWidth, baseHeight);
const calculatedMinZoom = longestDimension > 0
? 256 / longestDimension
: 0.2;
updateState({ minZoom: calculatedMinZoom });
const containerRect = img.parentElement.getBoundingClientRect();
const imgOffsetLeft = imgRect.left - containerRect.left;
const imgOffsetTop = imgRect.top - containerRect.top;
let centerX = imgOffsetLeft + (imgRect.width - 256) / 2;
let centerY = imgOffsetTop + (imgRect.height - 256) / 2;
if (imgRect.width < 256) {
console.log(
`Width ${imgRect.width}px < crop ${256}px, allowing overflow`,
);
} else {
centerX = Math.max(imgOffsetLeft, centerX);
}
if (imgRect.height < 256) {
console.log(
`Height ${imgRect.height}px < crop ${256}px, allowing overflow`,
);
} else {
centerY = Math.max(imgOffsetTop, centerY);
}
updateState({
crop: {
x: centerX,
y: centerY,
size: 256,
},
});
};
if (img.complete) {
centerCrop();
} else {
img.addEventListener("load", centerCrop, { once: true });
return () => img.removeEventListener("load", centerCrop);
}
}
}, [state.previewUrl]);
useEffect(() => {
globalThis.addEventListener("resize", handleResize);
handleResize();
return () => globalThis.removeEventListener("resize", handleResize);
}, [state.previewUrl]);
useEffect(() => {
if (!state.previewUrl) return;
const longerDimension = state.isLandscape
? state.baseDimensions.width
: state.baseDimensions.height;
if (longerDimension <= 0) return;
const calculatedDimension = Math.round(longerDimension * state.zoom);
let clampedDimension = Math.max(calculatedDimension, state.cropSize);
let constrainedByMax = false;
if (
!state.isLandscape && state.maxHeight > 0 &&
clampedDimension > state.maxHeight
) {
clampedDimension = state.maxHeight;
constrainedByMax = true;
} else if (
state.isLandscape && state.maxWidth > 0 &&
clampedDimension > state.maxWidth
) {
clampedDimension = state.maxWidth;
constrainedByMax = true;
}
const actualZoom = clampedDimension / longerDimension;
let overRunPercent = constrainedByMax && actualZoom > 0
? ((state.zoom - actualZoom) / actualZoom)
: 0;
if (!isFinite(overRunPercent)) {
overRunPercent = 0;
}
const timer = setTimeout(() => {
const maxOverrunPercent = 0.9;
if (overRunPercent > 0.0 && overRunPercent <= maxOverrunPercent) {
const newCropSize = Math.max(25.6, 256 - (256 * overRunPercent));
if (Math.abs(newCropSize - state.cropSize) > 1) {
updateState({ cropSize: newCropSize, zoomInDisabled: false });
}
} else if (overRunPercent > maxOverrunPercent) {
const targetZoom = actualZoom * (1 + maxOverrunPercent);
const clampedZoom = Math.max(state.minZoom, targetZoom);
if (Math.abs(clampedZoom - state.zoom) > 0.01) {
updateState({ zoom: clampedZoom, zoomInDisabled: true });
} else {
updateState({ zoomInDisabled: true });
}
}
}, 100);
return () => clearTimeout(timer);
}, [
state.zoom,
state.cropSize,
state.baseDimensions,
state.isLandscape,
state.maxWidth,
state.maxHeight,
]);
useEffect(() => {
if (
typeof globalThis.heicTo !== "undefined" &&
typeof globalThis.isHeic !== "undefined"
) {
updateState({ heicToLoaded: true });
return;
}
const script = document.createElement("script");
script.type = "module";
script.textContent = `
import { heicTo, isHeic } from '/heic-to.min.js';
globalThis.heicTo = heicTo;
globalThis.isHeic = isHeic;
globalThis.dispatchEvent(new CustomEvent('heicToLoaded'));
`;
const handleLoaded = () => {
updateState({ heicToLoaded: true });
console.log("heic-to library loaded successfully");
};
globalThis.addEventListener("heicToLoaded", handleLoaded, { once: true });
document.head.appendChild(script);
return () => {
globalThis.removeEventListener("heicToLoaded", handleLoaded);
if (script.parentNode) {
document.head.removeChild(script);
}
};
}, []);
async function handleFile(e: Event) {
const originalFile = (e.target as HTMLInputElement).files?.[0];
if (!originalFile) return;
const validImageTypes = [
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/webp",
"image/bmp",
"image/svg+xml",
"image/heic",
"image/heif",
];
const fileExtension = originalFile.name.toLowerCase().split(".").pop();
const isImageByType = originalFile.type.startsWith("image/") ||
validImageTypes.includes(originalFile.type);
const isImageByExtension = [
"jpg",
"jpeg",
"png",
"gif",
"webp",
"bmp",
"svg",
"heic",
"heif",
].includes(fileExtension || "");
if (!isImageByType && !isImageByExtension) {
alert(
`Unsupported file type: ${
originalFile.type || "unknown"
}\n\nPlease select an image file.`,
);
return;
}
const maxRecommendedSize = 20 * 1024 * 1024;
if (originalFile.size > maxRecommendedSize) {
const sizeMB = (originalFile.size / (1024 * 1024)).toFixed(1);
const proceed = confirm(
`Large file detected (${sizeMB}MB).\n\nProcessing large images may be slow or cause the browser to become unresponsive.\n\nDo you want to continue?`,
);
if (!proceed) {
if (fileInputRef.current) fileInputRef.current.value = "";
return;
}
}
let file: Blob = originalFile;
if (
!state.heicToLoaded || typeof globalThis.isHeic === "undefined" ||
typeof globalThis.heicTo === "undefined"
) {
alert(
"Image converter is still loading. Please wait a moment and try again.",
);
return;
}
try {
const isHeicFile = await globalThis.isHeic(originalFile);
if (isHeicFile) {
console.log("HEIC/HEIF file detected, converting to BMP...");
const convertedBlob = await globalThis.heicTo({
blob: originalFile,
type: "image/bmp",
quality: 1.0,
});
file = convertedBlob;
console.log("HEIC converted successfully to BMP:", {
originalSize: originalFile.size,
convertedSize: file.size,
type: file.type,
});
} else {
console.log(
`Processing ${
originalFile.type || fileExtension?.toUpperCase()
} image:`,
{
name: originalFile.name,
size: originalFile.size,
type: originalFile.type,
},
);
}
} catch (error) {
alert("Failed to process image. Please try another file.");
console.error("Image processing error:", error);
return;
}
const orientationReader = new FileReader();
orientationReader.onload = (orientEvent) => {
const arrayBuffer = orientEvent.target?.result as ArrayBuffer;
const orientation = getOrientation(arrayBuffer);
const reader = new FileReader();
reader.onload = async (event) => {
try {
const response = await fetch(event.target?.result as string);
const blob = await response.blob();
const imageBitmap = await createImageBitmap(blob, {
colorSpaceConversion: "none",
imageOrientation: "none",
premultiplyAlpha: "default",
resizeQuality: "high",
}).catch((err) => {
console.error("ImageBitmap creation failed:", err);
throw new Error(
"Unable to decode image. The file may be corrupted or in an unsupported format.",
);
});
let canvasWidth = imageBitmap.width;
let canvasHeight = imageBitmap.height;
if (orientation >= 5 && orientation <= 8) {
[canvasWidth, canvasHeight] = [canvasHeight, canvasWidth];
}
const canvas = document.createElement("canvas");
canvas.width = canvasWidth;
canvas.height = canvasHeight;
let ctx: CanvasRenderingContext2D | null = null;
let colorSpaceUsed = "sRGB";
try {
ctx = canvas.getContext("2d", {
colorSpace: "display-p3",
}) as CanvasRenderingContext2D;
if (ctx) {
colorSpaceUsed = "display-p3";
}
} catch (_e) {
ctx = canvas.getContext("2d");
}
if (!ctx) {
alert("Failed to create canvas context");
return;
}
applyOrientation(ctx, orientation, canvasWidth, canvasHeight);
ctx.drawImage(imageBitmap, 0, 0);
imageBitmap.close();
canvas.toBlob(
(blob) => {
if (blob) {
const url = URL.createObjectURL(blob);
updateState({ previewUrl: url });
updateState({ zoom: 1 });
setTimeout(() => {
handleResize();
}, 100);
} else {
alert("Failed to convert image");
}
},
"image/png",
);
} catch (error) {
console.error("Image processing error:", error);
const errorMessage = error instanceof Error
? error.message
: "Unknown error occurred";
alert(
`Failed to process image: ${errorMessage}\n\nPlease try a different file.`,
);
}
};
reader.onerror = () => {
alert(
"Failed to read file. The file may be corrupted or inaccessible.",
);
};
reader.readAsDataURL(file);
};
orientationReader.onerror = () => {
alert("Failed to read file metadata. The file may be corrupted.");
};
orientationReader.readAsArrayBuffer(file);
}
function centerCropBox(resetZoom = false) {
let thisCropSize = 256;
if (!resetZoom) {
thisCropSize = state.cropSize;
}
if (!imgRef.current || !imgRef.current.parentElement) return;
setTimeout(() => {
if (!imgRef.current || !imgRef.current.parentElement) return;
const containerRect = imgRef.current.parentElement
.getBoundingClientRect();
const imgRect = imgRef.current.getBoundingClientRect();
const imgOffsetLeft = imgRect.left - containerRect.left;
const imgOffsetTop = imgRect.top - containerRect.top;
let centerX = imgOffsetLeft + (imgRect.width - thisCropSize) / 2;
let centerY = imgOffsetTop + (imgRect.height - thisCropSize) / 2;
if (imgRect.width >= thisCropSize) {
centerX = Math.max(imgOffsetLeft, centerX);
}
if (imgRect.height >= thisCropSize) {
centerY = Math.max(imgOffsetTop, centerY);
}
updateState({ crop: { x: centerX, y: centerY, size: thisCropSize } });
}, 50);
}
function zoomIn() {
if (state.zoomInDisabled) return;
updateState({ zoom: Math.min(state.zoom + 0.1, 10.0) });
centerCropBox();
}
function zoomOut() {
updateState({
zoom: Math.max(state.zoom - 0.1, state.minZoom),
zoomInDisabled: false,
});
centerCropBox();
}
function zoomIn1() {
if (state.zoomInDisabled) return;
updateState({ zoom: Math.min(state.zoom + 0.01, 10.0) });
centerCropBox();
}
function zoomOut1() {
updateState({
zoom: Math.max(state.zoom - 0.01, state.minZoom),
zoomInDisabled: false,
});
centerCropBox();
}
function resetZoom() {
const safeMinZoom = isFinite(state.minZoom) && state.minZoom > 0
? state.minZoom
: 0.2;
updateState({
cropSize: 256,
zoom: Math.max(1, safeMinZoom),
zoomInDisabled: false,
});
centerCropBox(true);
}
function handleZoomInput(e: Event) {
const input = e.target as HTMLInputElement;
const value = parseFloat(input.value);
if (!isNaN(value) && isFinite(value)) {
const safeMinZoom = isFinite(state.minZoom) && state.minZoom > 0
? state.minZoom
: 0.2;
const clampedValue = Math.max(safeMinZoom, Math.min(value, 10.0));
updateState({ zoom: clampedValue });
centerCropBox();
}
}
function startDrag(_e: MouseEvent) {
updateState({ dragging: true });
}
function stopDrag() {
updateState({ dragging: false });
}
function drag(e: MouseEvent) {
let thisCropSize = state.cropSize;
if (!state.dragging || !imgRef.current || !imgRef.current.parentElement) {
return;
}
const containerRect = imgRef.current.parentElement.getBoundingClientRect();
const imgRect = imgRef.current.getBoundingClientRect();
const containerX = e.clientX - containerRect.left - thisCropSize / 2;
const containerY = e.clientY - containerRect.top - thisCropSize / 2;
const imgOffsetLeft = imgRect.left - containerRect.left;
const imgOffsetTop = imgRect.top - containerRect.top;
let x: number;
let y: number;
if (imgRect.width < thisCropSize) {
x = imgOffsetLeft + (imgRect.width - thisCropSize) / 2;
} else {
x = Math.max(
imgOffsetLeft,
Math.min(containerX, imgOffsetLeft + imgRect.width - thisCropSize),
);
}
if (imgRect.height < thisCropSize) {
y = imgOffsetTop + (imgRect.height - thisCropSize) / 2;
} else {
y = Math.max(
imgOffsetTop,
Math.min(containerY, imgOffsetTop + imgRect.height - thisCropSize),
);
}
updateState({
crop: {
x,
y,
size: thisCropSize,
},
});
}
async function uploadCropped() {
if (!imgRef.current || !imgRef.current.parentElement) return;
const containerRect = imgRef.current.parentElement.getBoundingClientRect();
const imgRect = imgRef.current.getBoundingClientRect();
const imgOffsetLeft = imgRect.left - containerRect.left;
const imgOffsetTop = imgRect.top - containerRect.top;
const imgX = state.crop.x - imgOffsetLeft;
const imgY = state.crop.y - imgOffsetTop;
const canvas = document.createElement("canvas");
canvas.width = state.crop.size;
canvas.height = state.crop.size;
const ctx = canvas.getContext("2d")!;
ctx.drawImage(
imgRef.current,
imgX / imgRef.current.width * imgRef.current.naturalWidth,
imgY / imgRef.current.height * imgRef.current.naturalHeight,
state.crop.size / imgRef.current.width * imgRef.current.naturalWidth,
state.crop.size / imgRef.current.height * imgRef.current.naturalHeight,
0,
0,
state.crop.size,
state.crop.size,
);
const blob = await new Promise<Blob>((res) =>
canvas.toBlob((b) => res(b!), "image/jpeg", 0.92)
);
const form = new FormData();
form.append("photo", blob, "photo.jpg");
const token = localStorage.getItem("accessToken");
await fetch("/api/photo-upload", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
body: form,
});
alert("Uploaded!");
}
return (
<div
style={{
borderRadius: "12px",
position: "fixed",
width: "100%",
height: "100%",
zIndex: 1000,
background: "rgba(255, 255, 255, 1)",
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
onMouseMove={drag}
onMouseUp={stopDrag}
onMouseLeave={stopDrag}
>
<input
type="file"
accept="image/*"
onChange={handleFile}
ref={fileInputRef}
style={{ display: "none" }}
/>
<div
style={{
position: "relative",
overflow: "auto",
flex: "1",
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
{state.previewUrl
? (
<div
style={{
position: "relative",
display: "inline-block",
minWidth: "fit-content",
minHeight: "fit-content",
}}
>
<img
src={state.previewUrl}
ref={imgRef}
style={{
display: "block",
width: state.isLandscape
? `${getPixelDimensions().width}px`
: "auto",
height: !state.isLandscape
? `${getPixelDimensions().height}px`
: "auto",
margin: "0 auto",
}}
/>
<div
style={{
position: "absolute",
left: state.crop.x,
top: state.crop.y,
border: "2px solid #00ffcc",
width: state.crop.size,
height: state.crop.size,
cursor: "move",
boxShadow: "0 0 0 9999px rgba(0,0,0,0.25)",
}}
onMouseDown={startDrag}
>
</div>
</div>
)
: (
<div
style={{
textAlign: "center",
color: "#666",
}}
>
<div
style={{
fontSize: "18px",
marginBottom: "8px",
}}
>
Click Add to select a photo
</div>
<div
style={{
fontSize: "14px",
color: "#999",
}}
>
Adjust the crop before saving
</div>
</div>
)}
</div>
<div
style={{
height: "100px",
display: "flex",
gap: "6px",
alignItems: "center",
justifyContent: "center",
width: "100%",
padding: "0 16px",
flexShrink: 0,
}}
>
<button
type="button"
style={{
background: "white",
border: "1px solid #ccc",
borderRadius: "4px",
width: "32px",
height: "32px",
cursor: "pointer",
fontSize: "16px",
padding: "0",
transition: "all 0.2s",
}}
onMouseOver={(e) => e.currentTarget.style.background = "#f0f0f0"}
onMouseOut={(e) => e.currentTarget.style.background = "white"}
onMouseDown={(e) => e.currentTarget.style.transform = "scale(0.95)"}
onMouseUp={(e) => e.currentTarget.style.transform = "scale(1)"}
onClick={zoomOut}
disabled={!state.previewUrl}
>
−10
</button>
<button
type="button"
style={{
background: "white",
border: "1px solid #ccc",
borderRadius: "4px",
width: "32px",
height: "32px",
cursor: "pointer",
fontSize: "16px",
padding: "0",
transition: "all 0.2s",
}}
onMouseOver={(e) => e.currentTarget.style.background = "#f0f0f0"}
onMouseOut={(e) => e.currentTarget.style.background = "white"}
onMouseDown={(e) => e.currentTarget.style.transform = "scale(0.95)"}
onMouseUp={(e) => e.currentTarget.style.transform = "scale(1)"}
onClick={zoomOut1}
disabled={!state.previewUrl}
>
−1
</button>
<button
type="button"
style={{
background: "white",
border: "1px solid #ccc",
borderRadius: "4px",
padding: "6px 16px",
cursor: "pointer",
fontSize: "14px",
transition: "all 0.2s",
}}
onMouseOver={(e) => e.currentTarget.style.background = "#f0f0f0"}
onMouseOut={(e) => e.currentTarget.style.background = "white"}
onMouseDown={(e) => e.currentTarget.style.transform = "scale(0.95)"}
onMouseUp={(e) => e.currentTarget.style.transform = "scale(1)"}
onClick={resetZoom}
disabled={!state.previewUrl}
>
Reset
</button>
<button
type="button"
style={{
background: "white",
border: "1px solid #ccc",
borderRadius: "4px",
width: "32px",
height: "32px",
cursor: "pointer",
fontSize: "16px",
padding: "0",
transition: "all 0.2s",
}}
onMouseOver={(e) => e.currentTarget.style.background = "#f0f0f0"}
onMouseOut={(e) => e.currentTarget.style.background = "white"}
onMouseDown={(e) => e.currentTarget.style.transform = "scale(0.95)"}
onMouseUp={(e) => e.currentTarget.style.transform = "scale(1)"}
onClick={zoomIn1}
disabled={!state.previewUrl || state.zoomInDisabled}
>
+1
</button>
<button
type="button"
style={{
background: "white",
border: "1px solid #ccc",
borderRadius: "4px",
width: "32px",
height: "32px",
cursor: "pointer",
fontSize: "16px",
padding: "0",
transition: "all 0.2s",
}}
onMouseOver={(e) => e.currentTarget.style.background = "#f0f0f0"}
onMouseOut={(e) => e.currentTarget.style.background = "white"}
onMouseDown={(e) => e.currentTarget.style.transform = "scale(0.95)"}
onMouseUp={(e) => e.currentTarget.style.transform = "scale(1)"}
onClick={zoomIn}
disabled={!state.previewUrl || state.zoomInDisabled}
>
+10
</button>
<div style={{ width: "16px" }}></div>
<button
type="button"
style={{
padding: "8px 16px",
cursor: "pointer",
transition: "all 0.2s",
}}
onMouseOver={(e) => e.currentTarget.style.background = "#f0f0f0"}
onMouseOut={(e) => e.currentTarget.style.background = ""}
onMouseDown={(e) => e.currentTarget.style.transform = "scale(0.95)"}
onMouseUp={(e) => e.currentTarget.style.transform = "scale(1)"}
onClick={() => fileInputRef.current?.click()}
>
Add
</button>
<button
type="button"
style={{
padding: "8px 16px",
cursor: "pointer",
transition: "all 0.2s",
}}
onMouseOver={(e) =>
!e.currentTarget.disabled &&
(e.currentTarget.style.background = "#f0f0f0")}
onMouseOut={(e) => e.currentTarget.style.background = ""}
onMouseDown={(e) =>
!e.currentTarget.disabled &&
(e.currentTarget.style.transform = "scale(0.95)")}
onMouseUp={(e) => e.currentTarget.style.transform = "scale(1)"}
onClick={uploadCropped}
disabled={!state.previewUrl}
>
Save
</button>
</div>
</div>
);
}

229
islands/QuizConfigure.tsx Normal file
View File

@ -0,0 +1,229 @@
// islands/QuizConfigure.tsx
import { useEffect, useState } from "preact/hooks";
import { QuizConfig } from "../utils/types.ts";
import { User } from "../db/interfaces.ts";
export interface QuizConfigProps {
quizConfig: QuizConfig;
// kvObj: KvObj<QuizConfig>;
quizOrder: { index: number; slots: string[] };
quizMode: string;
}
export default function QuizConfigure(
{ quizConfig, quizOrder, quizMode }: QuizConfigProps,
) {
// Theme classes are defined in styles.css with @media (prefers-color-scheme: dark)
const [thisQuizConfig, setQuizConfig] = useState<QuizConfig>(quizConfig);
const [orderedQuizStr, setOrderQuizStr] = useState<string>(
JSON.stringify(thisQuizConfig),
);
const [thisQuizConfigStr, setQuizConfigStr] = useState<string>(
JSON.stringify({
theUpdate: null,
quiz: quizConfig,
//kvObj: kvObj,
quizOrder: quizOrder,
}),
);
const [selectedSlot, setSelectedSlot] = useState(0);
//const [changeMode, setChangeMode] = useState<{ mode: string } | null>(null);
const [nameError, setNameError] = useState(false);
const [thisQuizOder, setThisQuizOrder] = useState(quizOrder);
const [randomOrder, setRandomOrder] = useState(true);
const [modeType, setModeType] = useState(quizMode);
//const [isLoading, setIsLoading] = useState(true);
const [count, setCount] = useState(0);
const handleClick = () => {
setCount((prev) => {
const newCount = prev + 1;
return newCount;
});
};
// const handleQuizNameChange = (e: Event) => {
// const target = e.target as HTMLInputElement;
// if (!target.value) {
// setNameError(true);
// } else {
// thisQuizConfig.quiz_name = target.value;
// setQuizConfig(thisQuizConfig);
// setChangeMode((prev) => {
// if (!prev) {
// return { mode: "quiz" };
// }
//
//
//
// return prev;
// });
// }
// setQuizConfigStr(
// JSON.stringify({
// theUpdate: changeMode?.mode,
// quiz: thisQuizConfig,
// kvObj: kvObj,
// quizOrder: thisQuizOder,
// }),
// );
// };
const handleQuizModeChange = (e: Event) => {
const target = e.target as HTMLSelectElement;
// setQuizConfig((prev) => {
// if (!prev) return null;
// const updated = { ...prev, quiz_mode: target.value };
// //saveQuizConfig(updated); // Save the updated config
// return updated;
// });
};
if (!thisQuizConfig) {
return <div>Error: Unable to load quiz configuration</div>;
}
function handleOwnerChange(
event: Event,
selectedUser: User | undefined,
): void {
if (selectedUser) {
//("selected ", selectedUser.id);
thisQuizConfig.owner_id = selectedUser.id;
setQuizConfig(thisQuizConfig);
//console.log("thisQuizConfig ", thisQuizConfig.owner_id);
}
//console.log("on user change ", JSON.stringify(event));
//throw new Error("Function not implemented.");
}
useEffect(() => {
//console.log("setting mode!!",quizConfig.quiz_mode)
if (quizConfig.quiz_mode) {
setModeType(quizConfig.quiz_mode);
}
}, []);
return (
<>
<div
className={`theme-container p-2 sm:p-3 md:p-4 rounded-lg shadow-md max-w-md mx-auto w-full overflow-auto`}
>
<div hidden>
<button onClick={handleClick}>Increment</button>
<p>Count: {count}</p>
</div>
<div className="flex space-x-2 pb-[10px]">
<form
action="/language/quizzing"
method="POST"
//onSubmit={handleLaunchQuiz}
className="w-[300px] mx-0 px-0"
>
<input
type="hidden"
name="quizConfigData"
value={thisQuizConfigStr}
/>
<input
type="hidden"
name="quizData"
value={orderedQuizStr}
/>
<input
type="hidden"
name="randomValue"
value={`${randomOrder}`}
/>
<input
type="hidden"
name="modeValue"
value={modeType}
/>
<div className="fixed right-10 top-10 ">
<div className="mb-4 flex justify-end items-center">
<div className="flex flex-col items-end">
<button
type="submit"
className={`theme-button px-4 py-2 rounded`}
title="Start"
>
Launch
</button>
<label
htmlFor="randomOrderId"
className={`theme-text mb-1 pt-3 text-sm`}
>
Random Order
</label>
<input
id="randomOrderId"
type="checkbox"
checked={randomOrder}
onChange={(e) =>
setRandomOrder((e.target as HTMLInputElement).checked)}
className="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
</div>
</div>
</div>
<div className="flex items-center ">
<label htmlFor="modeSelect" className={`block mr-2 theme-text`}>
Mode
</label>
<select
id="modeSelect"
value={modeType}
onChange={(e) =>
setModeType((e.target as HTMLSelectElement).value)}
className={`theme-select w-36`}
>
<option className="text-black" value="photo-see-bisaya">
Photo, See Bisaya
</option>
<option className="text-black" value="photo-see-english">
Photo, See English
</option>
<option className="text-black" value="photo-hide-bisaya">
Photo, Hide Bisaya
</option>
<option className="text-black" value="photo-hide-english">
Photo, Hide English
</option>
<option className="text-black" value="blank-see-bisaya">
Blank, See Bisaya
</option>
<option className="text-black" value="blank-see-english">
Blank, See English
</option>
</select>
</div>
</form>
</div>
<h2 className={`text-2xl font-bold mb-4 theme-text`}>Study Quiz</h2>
<div className="w-[300px] mx-0 px-0 pb-[12px] mb-[12px]">
<label
className={`font-semibold text-left truncate w-full theme-text`}
>
{"Question count: " + thisQuizConfig.questions.length}
</label>
</div>
</div>
<div class="fixed bottom-4 inset-x-0 flex justify-center z-40 pointer-events-none">
<button
onClick={() => {
localStorage.setItem("lock-route", "/language/dashboard");
globalThis.location.href = "/language/dashboard";
}}
type="button"
class={`w-11/12 max-w-md theme-button font-semibold py-3 px-10 rounded-full shadow-lg pointer-events-auto text-center`}
>
Back to dashboard
</button>
</div>
</>
);
}

416
islands/Quizzing.tsx Normal file
View File

@ -0,0 +1,416 @@
// islands/Quizzing.tsx
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
//import { QuizData } from "../utils/database.ts";
import { QuizConfig } from "../utils/types.ts";
import { Signal, signal } from "@preact/signals";
import Card from "./Card.tsx";
import { stringify } from "node:querystring";
interface QuizzingIProps {
initialQuizData: QuizConfig;
runData: string;
}
export const STORAGE_KEY = "QUIZ_STATE";
const useAudio1 = signal(true);
const useAudio2 = signal(true);
const useInputHint = signal(false);
export default function Quizzing({ initialQuizData, runData }: QuizzingIProps) {
const hideText = initialQuizData &&
(initialQuizData.quiz_mode === "photo-hide-english" ||
initialQuizData.quiz_mode === "photo-hide-bisaya");
const [quizData, setQuizData] = useState<QuizConfig>(initialQuizData);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [questionText, setQuextionText] = useState(
quizData ? quizData.questions[0].text_1 : "",
);
const [meaningText, setMeaningText] = useState("");
const [backgroundUrl, setCurrentBackgroundUrl] = useState("");
const [quizCompleted, setQuizCompleted] = useState(false);
const [runFullAuto, setRunFullAuto] = useState(false);
const [isPausedDisplay, setIsPausedDisplay] = useState(false);
const togglePauseRef = useRef<() => void>(() => {});
const [countdown, setCountdown] = useState(3);
const postButtonRef = useRef<HTMLButtonElement>(null);
const [count, setCount] = useState(0);
const [cardKey, setCardKey] = useState(0);
const runFullAutoRef = useRef(runFullAuto);
const meaningTextRef = useRef(meaningText);
const quizDataRef = useRef(quizData);
useEffect(() => {
runFullAutoRef.current = runFullAuto;
}, [runFullAuto]);
useEffect(() => {
meaningTextRef.current = meaningText;
}, [meaningText]);
useEffect(() => {
quizDataRef.current = quizData;
}, [quizData]);
const [cardInputParams, seCardInputParams] = useState({
setFullAuto: setRunFullAuto,
runFullAuto: false,
setTogglePause: (fn: () => void) => {
togglePauseRef.current = fn;
},
setIsPausedDisplay: setIsPausedDisplay,
questionPhrase: quizData ? quizData.questions[0].text_1 : "",
answerPhrase: "",
nextButton: () => {},
audio1: "",
audio2: "",
onHelpUsed: () => {},
flipped: false,
});
const handleClick = () => {
setCount((prev) => {
const newCount = prev + 1;
//eventEmitter.emit("countUpdated", newCount);
return newCount;
});
};
// Ref to track current index synchronously (state updates are async)
const currentIndexRef = useRef(0);
const incTheIndex = (auto: boolean): number => {
let nextIndex = currentIndexRef.current + 1;
if (auto && nextIndex >= initialQuizData.questions.length) {
nextIndex = 0;
}
currentIndexRef.current = nextIndex;
setCurrentQuestionIndex(nextIndex);
return nextIndex;
};
useEffect(() => {
if (quizCompleted && countdown > 0) {
const timer = setTimeout(() => {
setCountdown(countdown - 1);
}, 1000);
return () => clearTimeout(timer);
} else if (quizCompleted && countdown === 0) {
if (postButtonRef.current) {
//console.log(runData);
postButtonRef.current.click();
}
//wind ow.location.href = "/language/dashboard";
}
}, [quizCompleted, countdown]);
const handleNextQuestionInternal = useCallback((forceAuto?: boolean) => {
if (forceAuto === true && !runFullAutoRef.current) {
runFullAutoRef.current = true;
setRunFullAuto(true);
}
const thisAuto = typeof forceAuto === "boolean"
? forceAuto
: runFullAutoRef.current;
const quizDataNow = quizDataRef.current;
const meaningTextNow = meaningTextRef.current;
// Determine the next index synchronously
// On first call (meaningText === ""), stay at index 0
// On subsequent calls, increment
const isFirstCall = meaningTextNow === "";
const thisIndex = isFirstCall ? 0 : incTheIndex(thisAuto);
if (thisIndex < quizDataNow.questions.length) {
// Get ALL data from the SAME question object
const thisQuestion = quizDataNow.questions[thisIndex];
const text_1 = thisQuestion.text_1;
const text_2 = thisQuestion.text_2;
const audio1 = useAudio1.value ? thisQuestion.audio_file_1 : "";
const audio2 = useAudio2.value ? thisQuestion.audio_file_2 : "";
const thisDataUrl = thisQuestion.background_image_data || "";
// Update all state together
setQuextionText(text_1);
setMeaningText(text_2);
meaningTextRef.current = text_2;
setCurrentBackgroundUrl(thisDataUrl);
const cardParams = {
setFullAuto: setRunFullAuto,
runFullAuto: thisAuto,
setTogglePause: (fn: () => void) => {
togglePauseRef.current = fn;
},
setIsPausedDisplay: setIsPausedDisplay,
questionPhrase: text_1,
answerPhrase: text_2,
nextButton: handleNextQuestion,
audio1: audio1,
audio2: audio2,
onHelpUsed: () => {},
flipped: initialQuizData.quiz_mode === "photo-hide-english" ||
initialQuizData.quiz_mode === "photo-see-english" ||
initialQuizData.quiz_mode === "blank-see-english",
};
seCardInputParams(cardParams);
setCardKey((prev) => prev + 1); // Force Card remount
handleClick();
} else {
//localStorage.removeItem("QUIZ_STATE");
setQuizCompleted(true);
}
}, [initialQuizData]);
const handleNextQuestion = useCallback(() => {
handleNextQuestionInternal();
}, [handleNextQuestionInternal]);
if (!initialQuizData || !runData) {
return <div>Unable to load quiz</div>;
}
if (!quizData) {
return <div>Loading...</div>;
}
if (quizCompleted) {
return (
<div className="flex flex-col items-center justify-start pt-20 min-h-screen">
<h2 className="text-2xl font-bold mb-4 theme-text">Quiz Completed!</h2>
<div className="relative w-24 h-24">
<svg
className="animate-spin -ml-1 mr-3 h-24 w-24 text-blue-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
>
</circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
>
</path>
</svg>
<span className="absolute inset-0 flex items-center justify-center text-2xl font-bold">
{countdown}
</span>
</div>
<p className="mt-4 theme-text">Redirecting ...</p>
<form
hidden
action="/language/set-quiz"
method="POST"
>
<input
type="hidden"
name="quizConfigData"
value={runData}
/>
<input
type="hidden"
name="quizMode"
value={initialQuizData.quiz_mode}
/>
<button hidden ref={postButtonRef} type="submit">
</button>
</form>
</div>
);
}
//const currentQuestion = quizData.questions[currentQuestionIndex];
//if (quizStarted){
return (
<div
className={`theme-container p-2 sm:p-3 md:p-4 rounded-lg shadow-md max-w-md mx-auto w-full overflow-clip`}
>
<div hidden>
<button onClick={handleClick}>Increment</button>
<p>Count: {count}</p>
</div>
{meaningText !== ""
? (
<Card
key={cardKey}
cardInputParams={cardInputParams}
backgroundUrl={backgroundUrl}
hideText={hideText}
useInputHint={useInputHint}
/>
)
: (
<div className="text-center p-0">
<p className="theme-text text-xl mb-4">Ready to start quiz</p>
<p className="theme-text text-sm">
Questions: {quizData.questions.length}
</p>
</div>
)}
{meaningText === ""
? (
<div className="mt-0 flex justify-center w-full px-2">
<div className="theme-card w-80 p-4 rounded-lg shadow-md theme-text">
<div className="text-sm opacity-90 mb-3">
Keyboard: shift + return = help
</div>
<div className="space-y-3">
<label className="flex items-center justify-between gap-3">
<span>Play recording 1</span>
<input
type="checkbox"
checked={useAudio1.value}
onChange={(
e,
) => (useAudio1.value = e.currentTarget.checked)}
/>
</label>
<label className="flex items-center justify-between gap-3">
<span>Play recording 2</span>
<input
type="checkbox"
checked={useAudio2.value}
onChange={(
e,
) => (useAudio2.value = e.currentTarget.checked)}
/>
</label>
<label className="flex items-center justify-between gap-3">
<span>Input hint on</span>
<input
type="checkbox"
checked={useInputHint.value}
onChange={(
e,
) => (useInputHint.value = e.currentTarget.checked)}
/>
</label>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-4">
<button
onClick={() => {
setRunFullAuto(true);
handleNextQuestionInternal(true);
}}
className="bg-blue-500 text-white px-4 py-2 rounded-xl hover:bg-blue-600 w-full whitespace-nowrap"
title={"Listen Through"}
>
Listen Through
</button>
<button
onClick={handleNextQuestion}
className="bg-blue-500 text-white px-4 py-2 rounded-xl hover:bg-blue-600 w-full whitespace-nowrap"
title={"Start"}
>
Start Quiz
</button>
</div>
</div>
</div>
)
: runFullAuto
? (
<button
type="button"
id="auto-pause-play-button"
onClick={() => togglePauseRef.current()}
style={{ top: "320px", left: "50%", marginLeft: "-12px" }}
className="bg-opacity-50 fixed bg-blue-500 hover:bg-blue-500 hover:bg-opacity-50 transition duration-300 text-white font-bold p-1 rounded-full"
aria-label={isPausedDisplay ? "Resume auto" : "Pause auto"}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
{isPausedDisplay
? (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
/>
)
: (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 9v6m4-6v6"
/>
)}
</svg>
</button>
)
: (
<button
type="button"
id="next-question-button"
onClick={handleNextQuestion}
style={{ top: "305px", left: "50%", marginLeft: "-12px" }}
className="bg-opacity-50 fixed bg-blue-500 hover:bg-blue-500 hover:bg-opacity-50 transition duration-300 text-white font-bold p-1 rounded-full"
aria-label="Next question"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</button>
)}
</div>
);
// }
// else{
// return (
// <div className=" bg-opacity-10 backdrop-filter backdrop-blur-lg rounded-xl shadow-2xl p-8 max-w-md w-full">
// <button
// //type="submit"
// onClick={() => {setQuizStarted(true)}}
// className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 w-full"
// >
// Start
// </button>
// </div>
// );
// }
// Placeholder function for fetching quiz data
// async function fetchQuizData(quiz_id: number): Promise<{}> {
// // Implement the actual data fetching logic here
// // This is just a placeholder
// return { quiz_id: quiz_id, questions: [] };
// }
}

108
islands/RentalsOwned.tsx Normal file
View File

@ -0,0 +1,108 @@
interface RentalsOwnedProps {
properties: Property[];
}
import { Property } from "../db/interfaces.ts";
import { PopAddRentalDialog } from "./dialogs/PopAddRentalDialog.tsx";
export default function RentalsOwned({ properties }: RentalsOwnedProps) {
const [popAddRental, popupAddRentalDialog] = PopAddRentalDialog(properties);
return (
<>
{popupAddRentalDialog}
<button
type="button"
aria-label="Go back"
onClick={() => globalThis.location.href = "/"}
style={{
position: "fixed",
bottom: 300,
left: 4,
background: "none",
border: "none",
zIndex: 50,
cursor: "pointer",
}}
>
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
<div class="flex flex-col gap-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-2xl font-bold">Your Rentals</h2>
<button
type="button"
class="bg-blue-600 hover:bg-blue-700 text-white px-5 py-2 rounded-lg font-semibold shadow-lg transition-colors duration-200"
onClick={() => popAddRental("Add a new rental")}
>
+ Add Rental
</button>
</div>
{properties.length === 0
? (
<div class="text-gray-500 text-center">
You do not own any rental properties yet.
</div>
)
: (
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{properties.map((property: Property) => (
<div
key={property.id}
class="rounded-xl shadow-lg bg-white hover:bg-blue-50 transition flex flex-col p-4 border border-gray-100 relative"
>
{
/* <button
type="button"
class={`absolute top-3 right-3 px-4 py-1 rounded-full font-semibold shadow transition-colors duration-200 text-xs z-10
${
property.lease_count
? "bg-green-600 hover:bg-green-700 text-white"
: "bg-gray-300 text-gray-500 cursor-not-allowed"
}`}
style={{ minWidth: 120 }}
disabled={!property?.lease_count}
>
Manage Leases ({property?.lease_count ?? 0})
</button> */
}
{property.image_url && (
<img
src={property.image_url}
alt={property.title || "Property Image"}
class="w-full h-40 object-cover rounded-md mb-2"
/>
)}
<div class="font-semibold text-lg mb-1">
{property.title || "Untitled Property"}
</div>
<div class="text-gray-600 mb-1">
{property.address_1} {property.city}, {property.state}
</div>
<div class="text-blue-700 font-bold">
${property.price?.toLocaleString() || "-"}
</div>
<a
href={`/rental-property/${property.id}`}
class="mt-2 text-blue-600 hover:underline text-sm"
>
View Details
</a>
</div>
))}
</div>
)}
</div>
</>
);
}

39
islands/StudyButton.tsx Normal file
View File

@ -0,0 +1,39 @@
// islands/StudyButton.tsx
import { useState } from "preact/hooks";
import { JSX } from "preact/jsx-runtime";
interface StudyButtonProps {
setCanStudy: any;
}
export default function StudyButton(
{ setCanStudy }: StudyButtonProps,
) {
//const [clicked, setClicked] = useState(true);
const handleClick = () => {
//setClicked(true);
//onClick();
};
const handleStudyClick = (
event: JSX.TargetedMouseEvent<HTMLButtonElement>,
) => {
//console.log("clicked");
if (!setCanStudy.value) {
event.preventDefault();
}
};
return (
<button
type="submit"
form="studyrun"
class={`w-16 h-16 bg-green-600 hover:bg-green-700 text-white font-bold rounded-full shadow-lg transition-all duration-300 ease-in-out transform hover:scale-110 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 ${
!setCanStudy.value ? "opacity-40" : ""
}`}
onClick={handleStudyClick}
>
Study
</button>
);
}

77
islands/SyncButton.tsx Normal file
View File

@ -0,0 +1,77 @@
// islands/StudyButton.tsx
import { useState } from "preact/hooks";
import { JSX } from "preact/jsx-runtime";
interface SyncButtonProps {
setCanStudy: any;
}
export default function SyncButton(
{ setCanStudy }: SyncButtonProps,
) {
//const [clicked, setClicked] = useState(true);
const handleClick = () => {
//setClicked(true);
//onClick();
};
const handleStudyClick = (
event: JSX.TargetedMouseEvent<HTMLButtonElement>,
) => {
//console.log("clicked");
if (!setCanStudy.value) {
event.preventDefault();
}
};
return (
<a href="/resync">
<button
className={`w-8 h-8 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-full shadow-lg transition-all duration-300 ease-in-out transform hover:scale-110 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 flex flex-col justify-center items-center ${
setCanStudy.value ? "opacity-0" : ""
}`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
d="M3 12a9 9 0 0115.07-6.36"
stroke="currentColor"
strokeWidth={2}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M21 3v5h-5"
stroke="currentColor"
strokeWidth={2}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M21 12a9 9 0 01-15.07 6.36"
stroke="currentColor"
strokeWidth={2}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M3 21v-5h5"
stroke="currentColor"
strokeWidth={2}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</a>
);
}

View File

@ -0,0 +1,77 @@
import { useEffect } from "preact/hooks";
export default function TenantMessageSelection() {
useEffect(() => {
const container = document.querySelector<HTMLElement>(
"[data-message-form]",
);
if (!container) return;
const selectAll = container.querySelector<HTMLInputElement>(
"[data-select-all]",
);
const deleteButton = container.querySelector<HTMLButtonElement>(
"[data-delete-button]",
);
const markReadButton = container.querySelector<HTMLButtonElement>(
"[data-mark-read-button]",
);
const checkboxes = Array.from(
container.querySelectorAll<HTMLInputElement>("[data-message-checkbox]"),
);
const deleteLabelBase = "Delete selected";
const markReadLabelBase = "Mark selected as read";
const updateState = () => {
const checkedCount = checkboxes.filter((box) => box.checked).length;
const totalCount = checkboxes.length;
if (selectAll) {
selectAll.indeterminate = checkedCount > 0 &&
checkedCount < totalCount;
selectAll.checked = totalCount > 0 && checkedCount === totalCount;
}
if (deleteButton) {
deleteButton.disabled = checkedCount === 0;
deleteButton.textContent = checkedCount > 0
? `${deleteLabelBase} (${checkedCount})`
: deleteLabelBase;
}
if (markReadButton) {
markReadButton.disabled = checkedCount === 0;
markReadButton.textContent = checkedCount > 0
? `${markReadLabelBase} (${checkedCount})`
: markReadLabelBase;
}
};
const handleSelectAllChange = (event: Event) => {
const target = event.target as HTMLInputElement | null;
const nextChecked = Boolean(target?.checked);
checkboxes.forEach((box) => {
box.checked = nextChecked;
});
updateState();
};
const handleCheckboxChange = () => updateState();
selectAll?.addEventListener("change", handleSelectAllChange);
checkboxes.forEach((box) =>
box.addEventListener("change", handleCheckboxChange)
);
updateState();
return () => {
selectAll?.removeEventListener("change", handleSelectAllChange);
checkboxes.forEach((box) =>
box.removeEventListener("change", handleCheckboxChange)
);
};
}, []);
return <span class="hidden" data-message-selection aria-hidden="true" />;
}

View File

@ -0,0 +1,129 @@
import { useEffect } from "preact/hooks";
export default function TenantMessageThreads() {
useEffect(() => {
const container = document.querySelector<HTMLElement>(
"[data-message-form]",
);
if (!container) return;
const statusFilter = container.getAttribute("data-status-filter") || "all";
const e2eUser = container.getAttribute("data-e2e-user") || "";
const toggles = Array.from(
container.querySelectorAll<HTMLElement>("[data-message-toggle]"),
);
const handleToggle = async (event: Event) => {
const button = event.currentTarget as HTMLElement | null;
if (!button) return;
const row = button.closest<HTMLElement>("[data-message-row]");
const panel = row?.querySelector<HTMLElement>("[data-message-panel]");
if (!panel) return;
const subject = row?.querySelector<HTMLElement>(
"[data-message-subject]",
);
const preview = row?.querySelector<HTMLElement>(
"[data-message-preview]",
);
const updateTextStyles = (isRead: boolean) => {
if (subject) {
subject.classList.toggle("font-semibold", !isRead);
subject.classList.toggle("text-gray-900", !isRead);
subject.classList.toggle("font-medium", isRead);
subject.classList.toggle("text-gray-700", isRead);
}
if (preview) {
preview.classList.toggle("text-gray-700", !isRead);
preview.classList.toggle("text-gray-500", isRead);
}
};
const nextOpen = panel.classList.contains("hidden");
panel.classList.toggle("hidden", !nextOpen);
button.setAttribute("aria-expanded", String(nextOpen));
if (nextOpen) {
const messageId = button.getAttribute("data-message-id");
const isUnread = button.getAttribute("data-message-unread") === "true";
if (messageId && isUnread) {
button.setAttribute("data-message-unread", "false");
if (row) {
row.setAttribute("data-message-status", "read");
}
const badge = row?.querySelector<HTMLElement>("[data-unread-pill]");
if (badge) badge.classList.add("hidden");
const dot = row?.querySelector<HTMLElement>("[data-unread-dot]");
if (dot) dot.classList.add("hidden");
updateTextStyles(true);
// API call to mark as read
const url = new URL("/api/update/message", window.location.origin);
if (e2eUser) {
url.searchParams.set("e2e-user", e2eUser);
}
const response = await fetch(url.toString(), {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
},
credentials: "include",
cache: "no-store",
body: JSON.stringify({ id: messageId, action: "mark_read" }), // Explicitly pass action
});
let updateSucceeded = response.ok;
if (response.ok) {
try {
const payload = await response.json();
if (
payload && typeof payload.updated_count === "number" &&
payload.updated_count === 0
) {
updateSucceeded = false;
}
} catch (_) {
updateSucceeded = false;
}
}
if (!updateSucceeded) {
// Revert state if update failed
button.setAttribute("data-message-unread", "true");
if (row) {
row.setAttribute("data-message-status", "unread");
}
badge?.classList.remove("hidden");
dot?.classList.remove("hidden");
updateTextStyles(false);
} else if (statusFilter === "unread" && row) {
// Hide the row if we are on the 'unread' filter view
row.classList.add("hidden");
}
}
// Focus on the reply textarea if it exists
const textarea = row?.querySelector<HTMLTextAreaElement>(
"textarea[name='content']",
);
textarea?.focus();
}
};
toggles.forEach((toggle) => {
toggle.addEventListener("click", handleToggle);
});
return () => {
toggles.forEach((toggle) => {
toggle.removeEventListener("click", handleToggle);
});
};
}, []);
return <span class="hidden" data-message-threads aria-hidden="true" />;
}

206
islands/TenantSettings.tsx Normal file
View File

@ -0,0 +1,206 @@
// islands/TenantSettings.tsx
import { useState } from "preact/hooks";
import { User } from "../db/interfaces.ts";
// Define the structure for the preference objects
interface NotificationPreferences {
email_messages: boolean;
email_reminders: boolean;
email_payments: boolean;
}
interface PaymentPreferences {
payment_reminders: boolean;
// Note: default_auto_pay is typically lease-specific, this is for general preference
}
interface TenantSettingsProps {
user: User;
}
function parsePreferences<T extends Record<string, boolean>>(
raw: unknown,
fallback: T,
): T {
if (!raw) return fallback;
if (typeof raw === "string") {
try {
const parsed = JSON.parse(raw) as Partial<T>;
return { ...fallback, ...parsed };
} catch {
return fallback;
}
}
if (typeof raw === "object") {
return { ...fallback, ...(raw as Partial<T>) };
}
return fallback;
}
export default function TenantSettings({ user }: TenantSettingsProps) {
const initialNotifPrefs: NotificationPreferences = parsePreferences(
user.notification_preferences_json as unknown,
{
email_messages: true,
email_reminders: true,
email_payments: true,
},
);
const initialPaymentPrefs: PaymentPreferences = parsePreferences(
user.payment_preferences_json as unknown,
{
payment_reminders: true,
},
);
const [notificationPrefs, setNotificationPrefs] = useState(initialNotifPrefs);
const [paymentPrefs, setPaymentPrefs] = useState(initialPaymentPrefs);
const [status, setStatus] = useState<"idle" | "saving" | "success" | "error">(
"idle",
);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const handleNotificationChange = (key: keyof NotificationPreferences) => {
setNotificationPrefs((prev) => ({ ...prev, [key]: !prev[key] }));
};
const handlePaymentChange = (key: keyof PaymentPreferences) => {
setPaymentPrefs((prev) => ({ ...prev, [key]: !prev[key] }));
};
const handleSubmit = async (e: Event) => {
e.preventDefault();
setStatus("saving");
setErrorMessage(null);
try {
const response = await fetch("/api/user/preferences", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
notification_preferences: notificationPrefs,
payment_preferences: paymentPrefs,
}),
});
if (response.ok) {
setStatus("success");
setTimeout(() => setStatus("idle"), 3000);
} else {
const errorData = await response.json();
setErrorMessage(errorData.error || "Failed to save preferences.");
setStatus("error");
}
} catch (e) {
console.error("Submission error:", e);
setErrorMessage("An unexpected error occurred.");
setStatus("error");
}
};
const StatusMessage = () => {
switch (status) {
case "saving":
return <div class="text-blue-600 text-sm">Saving...</div>;
case "success":
return (
<div class="text-green-600 text-sm">
Preferences saved successfully!
</div>
);
case "error":
return <div class="text-red-600 text-sm">Error: {errorMessage}</div>;
default:
return null;
}
};
const PreferenceToggle = ({ label, checked, onChange }: {
label: string;
checked: boolean;
onChange: () => void;
}) => (
<div class="flex items-center justify-between py-3 border-b border-gray-100 last:border-b-0">
<span class="text-sm font-medium text-gray-700">{label}</span>
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={checked}
onChange={onChange}
class="sr-only peer"
disabled={status === "saving"}
/>
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600">
</div>
</label>
</div>
);
return (
<form onSubmit={handleSubmit} class="space-y-8">
{/* Notification Preferences */}
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h2 class="text-lg leading-6 font-medium text-gray-900">
Notification Preferences
</h2>
<p class="mt-1 text-sm text-gray-500">
Control how you receive alerts about your tenancy.
</p>
<div class="mt-6 space-y-4">
<PreferenceToggle
label="Email for New Messages"
checked={notificationPrefs.email_messages}
onChange={() => handleNotificationChange("email_messages")}
/>
<PreferenceToggle
label="Email for Reminders (Rent, Tasks)"
checked={notificationPrefs.email_reminders}
onChange={() => handleNotificationChange("email_reminders")}
/>
<PreferenceToggle
label="Email for Payment Confirmations"
checked={notificationPrefs.email_payments}
onChange={() => handleNotificationChange("email_payments")}
/>
</div>
</div>
</div>
{/* Payment Preferences */}
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h2 class="text-lg leading-6 font-medium text-gray-900">
Payment Preferences
</h2>
<p class="mt-1 text-sm text-gray-500">
Manage settings related to your rent payments.
</p>
<div class="mt-6 space-y-4">
<PreferenceToggle
label="Receive Payment Due Reminders"
checked={paymentPrefs.payment_reminders}
onChange={() => handlePaymentChange("payment_reminders")}
/>
{/* Auto-pay setup is handled on the Payment Methods page, not here */}
</div>
</div>
</div>
{/* Save Button and Status */}
<div class="flex justify-end items-center space-x-4">
<StatusMessage />
<button
type="submit"
disabled={status === "saving"}
class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
{status === "saving" ? "Saving..." : "Save Changes"}
</button>
</div>
</form>
);
}

407
islands/TranslationList.tsx Normal file
View File

@ -0,0 +1,407 @@
// islands/TranslationList.tsx
import { useEffect, useState } from "preact/hooks";
import { QuizToTextFile } from "../utils/QuizToTextFile.ts";
import { TextToQuizTranslations } from "../utils/TextToQuizTranslations.ts";
import { QuizConfig } from "../utils/types.ts";
//import { QuizData } from "../utils/database.ts";
import { AsyncYesNoPop } from "./AsyncYesNoPop.tsx";
export interface Translation {
id: number;
quiz_id: number;
owner_id: string;
question_text: string;
meaning_text: string;
order_num: number;
}
interface TranslationListProps {
quiz: QuizConfig;
userName: string;
}
export default function TranslationList(
{ quiz, userName }: TranslationListProps,
) {
// Theme classes are defined in styles.css with @media (prefers-color-scheme: dark)
const [ask, popupElement] = AsyncYesNoPop();
const [quizData, setQuizData] = useState<QuizConfig>(quiz);
const [quizConfigDataStr, setQuizConfigDataStr] = useState(
JSON.stringify(quiz),
);
const [quizNameStr, setQuizNameStr] = useState(quiz.quiz_name);
const [text1Str, setText1Str] = useState("");
const [text2Str, setText2Str] = useState("");
const [deleteIndexStr, setDeleteIndexStr] = useState("");
//const [fileContent, setFileContent] = useState("");
const [quiz_nameError, setquiz_nameError] = useState(false);
const [questionsError, setQuestionsError] = useState(false);
useEffect(() => {
const lockRoute = localStorage.getItem("lock-route");
if (lockRoute) {
globalThis.location.href = lockRoute;
}
//fetchQuizData();
}, []);
const justClear = async () => {
const clearQuiz = {
active_date: Date.now(),
quiz_mode: "",
owner_id: quiz.owner_id,
quiz_config_id: crypto.randomUUID(),
quiz_name: "",
questions: [],
results: [],
};
setQuizData(clearQuiz);
setQuizConfigDataStr(JSON.stringify(clearQuiz));
setQuizNameStr("");
setText1Str("");
setText2Str("");
setDeleteIndexStr("");
};
const handleSaveNewQuiz = async () => {
};
const handleUpdateTranslation = async (translation: Translation) => {
};
const handleAddTranslation = async () => {
};
const handleDeleteTranslation = async (translationId: number) => {
};
const uploadFile = () => {
setquiz_nameError(false);
const input = document.createElement("input");
input.type = "file";
input.accept = ".txt";
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
const newQuizName = (file.name as string)?.split(".txt")[0]
?.trim();
if (!quizNameStr && !newQuizName) {
setquiz_nameError(true);
return;
}
if (
!quizNameStr && newQuizName && newQuizName.length > 0
) {
setQuizNameStr(newQuizName);
}
const text = (await file.text())?.trim();
if (text && text.length > 0) {
const textToQuiz = new TextToQuizTranslations(quizData);
const parsedQuizData = textToQuiz.parseText(text);
setQuizData(parsedQuizData);
setQuizConfigDataStr(JSON.stringify(parsedQuizData));
}
}
};
input.click();
};
const downloadFile = async () => {
if (quizData.questions.length === 0) {
setQuestionsError(true);
return;
}
if (!quizNameStr || quizNameStr.length === 0) {
setquiz_nameError(true);
return;
}
setQuestionsError(false);
const quizToText = new QuizToTextFile(quizData);
const content = quizToText.generateTextContent();
const blob = new Blob([content], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${quizNameStr}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const handleSaveAddQuiz = async (event: Event) => {
event.preventDefault();
const form = event.currentTarget as HTMLFormElement;
if (deleteIndexStr) {
const userConfirmed = await ask(
"Are you sure you want to delete?",
Date.now(),
);
if (userConfirmed) {
form.submit();
}
} else {
if (!quizNameStr || quizNameStr.length === 0) {
setquiz_nameError(true);
} else {
form.submit();
}
}
};
return (
<>
{popupElement}
<div
className={`theme-container p-2 sm:p-3 md:p-4 rounded-lg shadow-md max-w-md mx-auto w-full overflow-auto`}
>
<div className="flex space-x-2 pb-[10px]">
<button
onClick={uploadFile}
className={`flex-1 theme-button py-2 px-2 rounded-md transition duration-300`}
>
Add from file
</button>
<button
onClick={downloadFile}
className={`flex-1 theme-button py-2 px-2 rounded-md transition duration-300`}
>
Out to file
</button>
<button
onClick={justClear}
className={`flex-1 theme-button rounded-md transition duration-300`}
>
Clear
</button>
</div>
<form
action="/language/lists"
method="POST"
onSubmit={handleSaveAddQuiz}
>
<input
type="hidden"
name="quizConfigData"
value={quizConfigDataStr}
/>
<input
type="hidden"
name="quizNameStrData"
value={quizNameStr}
/>
<input
type="hidden"
name="text1StrData"
value={text1Str}
/>
<input
type="hidden"
name="text2StrData"
value={text2Str}
/>
<input
type="hidden"
name="deleteIndexStrData"
value={deleteIndexStr}
/>
<div className="mb-4 sm:mb-6">
<input
type="text"
value={quizNameStr}
onChange={(e) => {
const target = (e.target as HTMLInputElement).value.trim();
if (!target) {
setQuizNameStr(quizNameStr);
} else {
setQuizNameStr(target);
}
}}
onKeyDown={() => setquiz_nameError(false)}
placeholder="Quiz Name"
className={`w-full p-2 border rounded theme-input ${
quiz_nameError ? "border-red-500" : ""
}`}
/>
</div>
<div className="mb-4 sm:mb-6">
<h3 className={`text-lg sm:text-xl font-semibold mb-2 theme-text`}>
Add New Translation
</h3>
<div className="flex flex-col space-y-2">
<input
type="text"
value={text1Str}
onChange={(e) => {
const target = (e.target as HTMLInputElement).value.trim();
setText1Str(target);
}}
placeholder="Bisaya"
onKeyDown={() => setQuestionsError(false)}
className={`flex-1 p-2 border rounded theme-input ${
questionsError ? "border-red-500" : ""
}`}
/>
<input
type="text"
value={text2Str}
onChange={(e) => {
const target = (e.target as HTMLInputElement).value.trim();
setText2Str(target);
}}
placeholder="English"
onKeyDown={() => setQuestionsError(false)}
className={`flex-1 p-2 border rounded theme-input ${
questionsError ? "border-red-500" : ""
}`}
/>
<button
type="submit"
// onClick={handleAddTranslation}
className={`theme-button px-4 py-2 rounded w-full`}
>
Add and Save
</button>
</div>
</div>
<ul className="space-y-2">
{quizData.questions.map((translation: any, index: number) => (
<li
key={translation.id}
className={`theme-secondary-container p-1 rounded relative group`}
>
<div className="flex flex-col scroll pr-2">
<div className={`w-full p-2 border rounded mb-2 theme-input`}>
{translation.text_1}
</div>
<div className={`w-full p-2 border rounded theme-input`}>
{translation.text_2}
</div>
</div>
{!(!!translation.audio_file_1 || !!translation.audio_file_2 ||
!!translation.video_file || !!translation.video_file_data ||
!!translation.background_file ||
!!translation.background_image_data)
? (
<button
type="submit"
onClick={() => {
setDeleteIndexStr(`${index}`);
}}
className="absolute top-1/2 -right-2 transform -translate-y-1/2 text-red-700 opacity-0 group-hover:opacity-100 transition-opacity duration-200"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
)
: (
<button
type="submit"
onClick={(e) => {
e.preventDefault();
}}
className="absolute top-1/2 -right-2 transform -translate-y-1/2 text-red-700 opacity-0 group-hover:opacity-100 transition-opacity duration-200"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<rect
x="5"
y="11"
width="14"
height="8"
rx="2"
stroke="currentColor"
strokeWidth={2}
fill="none"
/>
<path
d="M7 11V7a5 5 0 0110 0v4"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
<circle
cx="12"
cy="15"
r="1"
fill="currentColor"
/>
</svg>
</button>
)}
</li>
))}
</ul>
</form>
</div>
<div class="fixed bottom-4 inset-x-0 flex justify-center z-40 pointer-events-none">
<div class="flex gap-2 w-11/12 max-w-md pointer-events-auto">
<span class={`w-[200px] py-3 px-6`}></span>
<span class={`w-[16px]`}></span>
<button
type="button"
onClick={() => {
globalThis.location.href = "/language/dashboard";
}}
class={`w-[200px] theme-button font-semibold py-3 px-6 rounded-full shadow-lg text-center`}
>
Dashboard
</button>
</div>
</div>
</>
);
}
async function makeQuizWithName(
owner_id: string,
quiz_name: string,
): Promise<number> {
try {
const response = await fetch("/api/quiz", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
owner_id,
quiz_name,
}),
});
if (!response.ok) {
const errorData = await response.json();
console.error("Error creating quiz:", errorData.error);
return 0;
}
const result = await response.json();
return result.quiz_id;
} catch (error) {
console.error("Network error when creating quiz:", error);
return 0;
}
}

1360
islands/UserDashboard.tsx Normal file

File diff suppressed because it is too large Load Diff

329
islands/UserProfileEdit.tsx Normal file
View File

@ -0,0 +1,329 @@
import { useEffect, useRef, useState } from "preact/hooks";
import {
decryptDataWithStoredPassword,
encryptDataWithStoredPassword,
getEncryptionPassword,
} from "../helpers.ts";
import { User } from "../db/interfaces.ts";
interface UserProfileEditProps {
user: User;
isEncrypted: boolean;
}
export default function UserProfileEdit(
{ user, isEncrypted }: UserProfileEditProps,
) {
const [form, setForm] = useState<User>({ ...user });
const [errors, setErrors] = useState<{ name?: string; email?: string }>({});
const [isEncryptionEnabled, setIsEncryptionEnabled] = useState(isEncrypted);
const formRef = useRef<HTMLFormElement>(null);
// Check if encryption is available and decrypt data on component mount
useEffect(() => {
const initializeComponent = async () => {
const encryptionEnabled = getEncryptionPassword(user.id);
setIsEncryptionEnabled(encryptionEnabled !== null);
// If encryption is enabled and user has encrypted data, decrypt and populate fields
if (encryptionEnabled && user.encrypted_data) {
try {
const decryptedData = await decryptDataWithStoredPassword(
user.id,
user.encrypted_data,
);
if (decryptedData) {
const encryptedDataObj = JSON.parse(decryptedData);
// Only update form state with fields that have actual data from decrypted object
const updatedFields: Partial<User> = {};
if (encryptedDataObj.address_1) {
updatedFields.address_1 = encryptedDataObj.address_1;
}
if (encryptedDataObj.address_2) {
updatedFields.address_2 = encryptedDataObj.address_2;
}
if (encryptedDataObj.city) {
updatedFields.city = encryptedDataObj.city;
}
if (encryptedDataObj.state) {
updatedFields.state = encryptedDataObj.state;
}
if (encryptedDataObj.zip) updatedFields.zip = encryptedDataObj.zip;
if (encryptedDataObj.country) {
updatedFields.country = encryptedDataObj.country;
}
setForm((prevForm) => ({
...prevForm,
...updatedFields,
}));
}
} catch (error) {
console.error("Failed to decrypt user data:", error);
// Continue with original user data if decryption fails
}
}
};
initializeComponent();
}, [user.id, user.encrypted_data]);
function handleChange(e: Event) {
const target = e.target as HTMLInputElement;
setForm({ ...form, [target.name]: target.value });
setErrors({ ...errors, [target.name]: undefined });
}
async function handleSave(e: Event) {
e.preventDefault();
// Validate required fields
const newErrors: { [key: string]: string } = {};
if (!form.name?.trim()) newErrors.name = "Name is required";
if (!form.email?.trim()) newErrors.email = "Email is required";
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
setErrors({});
// Prepare data for submission
const submissionData = { ...form };
// Handle encryption if enabled
if (isEncryptionEnabled) {
const dataToEncrypt = {
user_id: user.id,
address_1: form.address_1 || "",
address_2: form.address_2 || "",
city: form.city || "",
state: form.state || "",
zip: form.zip || "",
country: form.country || "",
};
try {
const encryptedData = await encryptDataWithStoredPassword(
user.id,
JSON.stringify(dataToEncrypt),
);
if (encryptedData) {
submissionData.encrypted_data = encryptedData;
// Clear address fields when encryption is used
submissionData.address_1 = "";
submissionData.address_2 = "";
submissionData.city = "";
submissionData.state = "";
submissionData.zip = "";
submissionData.country = "";
}
} catch (error) {
console.error("Encryption failed:", error);
alert("Failed to encrypt data. Please try again.");
return;
}
}
// Get the form element using the ref
const formElement = formRef.current;
if (!formElement) return;
// Update form field values with submission data (including encrypted data)
Object.entries(submissionData).forEach(([key, value]) => {
const input = formElement.querySelector(
`[name="${key}"]`,
) as HTMLInputElement;
if (input && value !== null && value !== undefined) {
input.value = String(value);
}
});
// Submit the existing form
formElement.submit();
}
return (
<>
<button
type="button"
aria-label="Go back"
onClick={() => globalThis.location.href = "/"}
style={{
position: "fixed",
bottom: 300,
left: 4,
background: "none",
border: "none",
zIndex: 50,
cursor: "pointer",
}}
>
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
<div class="w-full max-w-xl mx-auto mt-8 bg-white shadow-lg rounded-xl p-6 sm:p-8 flex flex-col items-center">
<form
ref={formRef}
class="flex flex-col gap-4 w-full pb-14"
onSubmit={handleSave}
method="POST"
action="/api/user/update"
>
<h2 class="text-lg font-semibold mb-2 mt-2">Profile</h2>
<label class="flex flex-col text-sm font-medium gap-1">
Name:
<input
name="name"
value={form.name || ""}
onInput={handleChange}
required
minLength={1}
class="input input-bordered rounded px-3 py-2 bg-slate-100 focus:bg-white transition-colors"
/>
{errors.name && (
<span class="text-red-600 text-xs mt-1">{errors.name}</span>
)}
</label>
<label class="flex flex-col text-sm font-medium gap-1">
Email:
<input
name="email"
value={form.email || ""}
onInput={handleChange}
required
type="email"
class="input input-bordered rounded px-3 py-2 bg-slate-100 focus:bg-white transition-colors"
/>
{errors.email && (
<span class="text-red-600 text-xs mt-1">{errors.email}</span>
)}
</label>
<label class="flex flex-col text-sm font-medium gap-1 mb-4">
Phone:
<input
name="phone"
value={form.phone || ""}
onInput={handleChange}
class="input input-bordered rounded px-3 py-2 bg-slate-100 focus:bg-white transition-colors"
/>
</label>
<input
type="hidden"
name="sender"
value={typeof location !== "undefined" ? location.pathname : ""}
/>
<input
type="hidden"
name="user_id"
value={user.id}
/>
{isEncryptionEnabled && (
<div class="bg-green-50 border border-green-200 rounded-lg p-4 mb-4">
<div class="flex items-center gap-2 mb-2">
<svg
class="w-5 h-5 text-green-600"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z"
clipRule="evenodd"
/>
</svg>
<span class="text-sm font-medium text-green-800">
Encryption Enabled
</span>
</div>
<p class="text-xs text-green-700">
Address information will be encrypted before saving.
</p>
</div>
)}
<input
type="hidden"
name="encrypted_data"
value={form.encrypted_data || ""}
/>
<h2 class="text-lg font-semibold mb-2 mt-4">Mailing Address</h2>
<label class="flex flex-col text-sm font-medium gap-1">
Address 1:
<input
name="address_1"
value={form.address_1 || ""}
onInput={handleChange}
class="input input-bordered rounded px-3 py-2 bg-slate-100 focus:bg-white transition-colors"
/>
</label>
<label class="flex flex-col text-sm font-medium gap-1">
Address 2:
<input
name="address_2"
value={form.address_2 || ""}
onInput={handleChange}
class="input input-bordered rounded px-3 py-2 bg-slate-100 focus:bg-white transition-colors"
/>
</label>
<label class="flex flex-col text-sm font-medium gap-1">
City:
<input
name="city"
value={form.city || ""}
onInput={handleChange}
class="input input-bordered rounded px-3 py-2 bg-slate-100 focus:bg-white transition-colors"
/>
</label>
<label class="flex flex-col text-sm font-medium gap-1">
State:
<input
name="state"
value={form.state || ""}
onInput={handleChange}
class="input input-bordered rounded px-3 py-2 bg-slate-100 focus:bg-white transition-colors"
/>
</label>
<label class="flex flex-col text-sm font-medium gap-1">
ZIP:
<input
name="zip"
value={form.zip || ""}
onInput={handleChange}
class="input input-bordered rounded px-3 py-2 bg-slate-100 focus:bg-white transition-colors"
/>
</label>
<label class="flex flex-col text-sm font-medium gap-1">
Country:
<input
name="country"
value={form.country || ""}
onInput={handleChange}
class="input input-bordered rounded px-3 py-2 bg-slate-100 focus:bg-white transition-colors"
/>
</label>
<div class="fixed bottom-4 inset-x-0 flex justify-center z-40 pointer-events-none">
<button
type="submit"
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 px-10 rounded-full shadow-lg pointer-events-auto w-11/12 max-w-md"
>
Save
</button>
</div>
</form>
</div>
</>
);
}

263
islands/VoiceRecorder.tsx Normal file
View File

@ -0,0 +1,263 @@
import { useEffect, useRef, useState } from "preact/hooks";
interface VoiceRecorderProps {
text: string;
audio_file: string;
role: string;
recordingCallback: (data: Blob | null) => void;
}
export default function VoiceRecorder(
{ text, audio_file, role, recordingCallback }: VoiceRecorderProps,
) {
const [recording, setRecording] = useState(false);
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [initialAudioUrl, setInitialAudioUrl] = useState<string | null>(null);
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const selectedMimeTypeRef = useRef<string | undefined>(undefined);
const initialAudioRef = useRef<HTMLAudioElement>(null);
const pickBestAudioMimeType = (): string | undefined => {
if (typeof MediaRecorder === "undefined") return undefined;
if (typeof MediaRecorder.isTypeSupported !== "function") return undefined;
const candidates = [
"audio/mp4",
"audio/mp4;codecs=mp4a.40.2",
"audio/webm;codecs=opus",
"audio/webm",
"audio/ogg;codecs=opus",
"audio/ogg",
];
return candidates.find((t) => MediaRecorder.isTypeSupported(t));
};
// Fetch and play the initial audio_file when the component mounts
useEffect(() => {
if (!audio_file) return;
const fetchAudio = async () => {
try {
const isDirectUrl = audio_file.startsWith("blob:") ||
audio_file.startsWith("http") ||
audio_file.startsWith("/");
if (isDirectUrl) {
setInitialAudioUrl(audio_file);
return;
}
const response = await fetch(
`/api/audio-blob/${encodeURIComponent(audio_file)}`,
{ credentials: "include" },
);
if (!response.ok) {
console.error("Failed to fetch audio file:", response.statusText);
return;
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
setInitialAudioUrl(url);
} catch (err) {
console.error("Error fetching audio file:", err);
}
};
fetchAudio();
// Cleanup blob URL on unmount
return () => {
if (initialAudioUrl) {
URL.revokeObjectURL(initialAudioUrl);
}
};
}, [audio_file]);
// Auto-play when initial audio is loaded
useEffect(() => {
if (initialAudioUrl && initialAudioRef.current) {
initialAudioRef.current.play().catch(() => {
// Autoplay might be blocked, user must interact
});
}
}, [initialAudioUrl]);
const startRecording = async () => {
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
}
setAudioUrl(null);
setAudioBlob(null);
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
if (typeof MediaRecorder === "undefined") {
alert("Audio recording is not supported in this browser.");
stream.getTracks().forEach((t) => t.stop());
return;
}
const bestMimeType = pickBestAudioMimeType();
const mimeTypeCandidates = [
bestMimeType,
"audio/mp4",
"audio/mp4;codecs=mp4a.40.2",
"audio/webm;codecs=opus",
"audio/webm",
"audio/ogg;codecs=opus",
"audio/ogg",
undefined,
].filter((t, i, arr) => arr.indexOf(t) === i);
let mediaRecorder: MediaRecorder | null = null;
let lastError: unknown = undefined;
for (const candidate of mimeTypeCandidates) {
try {
mediaRecorder = candidate
? new MediaRecorder(stream, { mimeType: candidate })
: new MediaRecorder(stream);
selectedMimeTypeRef.current = candidate;
break;
} catch (err) {
lastError = err;
mediaRecorder = null;
continue;
}
}
if (!mediaRecorder) {
console.error("Failed to create MediaRecorder", lastError);
alert("Audio recording is not supported in this browser.");
stream.getTracks().forEach((t) => t.stop());
return;
}
mediaRecorderRef.current = mediaRecorder;
mediaRecorder.onerror = (e) => {
const recErr = (e as ErrorEvent | Event) as unknown as {
error?: DOMException;
};
const err = recErr?.error;
console.error("MediaRecorder error", err || e);
stream.getTracks().forEach((t) => t.stop());
setRecording(false);
alert(
"Recording failed. This Safari version may not support MediaRecorder with audio-only streams.",
);
};
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) {
chunksRef.current.push(e.data);
}
};
mediaRecorder.onstop = () => {
const fallbackType = chunksRef.current[0]?.type ||
mediaRecorder.mimeType ||
selectedMimeTypeRef.current ||
"";
const blob = new Blob(
chunksRef.current,
fallbackType ? { type: fallbackType } : undefined,
);
chunksRef.current = [];
setAudioBlob(blob);
setAudioUrl(URL.createObjectURL(blob));
stream.getTracks().forEach((t) => t.stop());
};
mediaRecorder.start();
setRecording(true);
} catch (err) {
alert("Microphone access denied or not supported.");
}
};
const stopRecording = () => {
mediaRecorderRef.current?.stop();
setRecording(false);
};
const handleSave = () => {
if (audioBlob) {
recordingCallback(audioBlob);
}
};
const handleClose = () => {
recordingCallback(null);
};
return (
<div className="theme-popup-overlay">
<div
className="theme-recorder-container"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={handleClose}
aria-label="Close"
className="theme-recorder-close"
>
×
</button>
<div className="flex-1 flex items-center justify-center w-full mb-6">
<div className="text-center w-full">{text}</div>
</div>
{/* Initial audio playback */}
<div className="mb-4 w-full flex justify-center">
{initialAudioUrl && (
<audio
ref={initialAudioRef}
src={initialAudioUrl}
autoPlay
controls
className="w-full"
/>
)}
</div>
{role === "admin" && (
<div className="flex gap-4 justify-center w-full mb-4">
<button
className={`theme-recorder-button theme-recorder-start ${
recording ? "opacity-60 pointer-events-none" : ""
}`}
onClick={startRecording}
disabled={recording}
>
🎤 Start
</button>
<button
className={`theme-recorder-button theme-recorder-stop ${
!recording ? "opacity-50 cursor-not-allowed" : ""
}`}
onClick={stopRecording}
disabled={!recording}
>
⏹ Stop
</button>
<button
className={`theme-recorder-button theme-recorder-save ${
!audioUrl ? "opacity-50 cursor-not-allowed" : ""
}`}
onClick={handleSave}
disabled={!audioUrl}
>
💾 Save
</button>
</div>
)}
<div className="mt-4 h-[30px] w-full flex justify-center items-center">
{audioUrl && <audio controls src={audioUrl}></audio>}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,276 @@
import { TargetedEvent, TargetedKeyboardEvent } from "preact";
import { useEffect, useRef } from "preact/hooks";
import { EncryptionModalProps } from "./welcome-types.ts";
/**
* EncryptionModal Component
* Modal banner for setting up encryption password or changing PIN
* Appears at top of screen until password is set or skipped
*
* @param showModal - Whether modal is visible
* @param tempPassword - Current password input value
* @param passwordError - Error message to display
* @param isChangeMode - Whether modal is in change PIN mode
* @param onPasswordChange - Handler for password input changes
* @param onSetPassword - Handler to validate and set password
* @param onSkip - Handler to skip encryption setup
* @param onClose - Handler to close change PIN modal
*
* TODO: Future enhancements
* - Add password strength indicator
* - Add password visibility toggle
* - Add "Remember this device" checkbox
* - Add password requirements tooltip
* - Add biometric authentication option
* - Add password confirmation field
*/
export default function EncryptionModal({
showModal,
tempPassword,
passwordError,
isChangeMode = false,
showRemovePinConfirm = false,
persistAcrossDevices,
onPasswordChange,
onSetPassword,
onSkip,
onClose,
onRemovePinConfirm,
onPersistAcrossDevicesChange,
}: EncryptionModalProps) {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (showModal && inputRef.current) {
inputRef.current.focus();
}
}, [showModal]);
if (!showModal) return null;
const handleCheckboxChange = (e: TargetedEvent<HTMLInputElement>) => {
onPersistAcrossDevicesChange(e.currentTarget.checked);
};
const handlePasswordInput = (
e: TargetedEvent<HTMLInputElement, InputEvent>,
) => {
onPasswordChange(e.currentTarget.value);
};
const handleKeyDown = (e: TargetedKeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
if (tempPassword.length >= 4) {
onSetPassword();
}
}
};
const handleSubmit = (e: TargetedEvent<HTMLFormElement>) => {
e.preventDefault();
if (tempPassword.length >= 4) {
onSetPassword();
}
};
const handleCancel = () => {
if (isChangeMode) {
onClose?.();
} else {
// Clear the input when skipping initial encryption setup
onPasswordChange("");
onSkip();
}
};
const handleBackdropClick = (
e: TargetedEvent<HTMLDivElement, MouseEvent>,
) => {
if (e.currentTarget === e.target) {
if (isChangeMode) {
onClose?.();
} else {
onSkip();
}
}
};
const handleInputBlur = () => {
if (tempPassword.length === 0 && !isChangeMode) {
onSkip();
}
};
const isPasswordValid = tempPassword.length >= 4;
return (
<>
{/* Main Change PIN Modal */}
<div
class="fixed inset-0 z-50 flex items-start justify-center pt-2 p-4 bg-transparent"
role="dialog"
aria-labelledby="encryption-modal-title"
onClick={handleBackdropClick}
>
<div class="bg-white/65 rounded-2xl shadow-2xl p-6 w-full max-w-md mx-auto transform transition-all duration-200 scale-100">
<div class="flex flex-col bg-opacity-10 items-center text-center space-y-4">
<div class="flex-1 bg-opacity-10 min-w-0">
<h3
id="encryption-modal-title"
class="text-lg font-semibold text-gray-800"
>
{isChangeMode ? "Change PIN" : "Enter PIN"}
</h3>
<p class="text-sm text-gray-700 mt-1">
{isChangeMode
? "Enter your new PIN to update your encryption"
: "Enter your PIN to access your encrypted data"}
</p>
</div>
</div>
<div class="mt-6 space-y-4">
<form onSubmit={handleSubmit} class="flex flex-col space-y-2">
<input
ref={inputRef}
id="encryption-password"
name="encryption-password"
type="password"
value={tempPassword}
onInput={handlePasswordInput}
onKeyDown={handleKeyDown}
onBlur={handleInputBlur}
placeholder={isChangeMode
? "Enter new PIN (min 4 digits)"
: "Enter PIN (min 4 digits)"}
autocomplete="current-password"
aria-invalid={!!passwordError}
aria-describedby={passwordError
? "password-error"
: "password-hint"}
class={`w-full px-4 py-3 border rounded-xl shadow-sm focus:outline-none focus:ring-2 focus:border-blue-500 text-base text-gray-800 placeholder-gray-600 text-center tracking-widest bg-white ${
passwordError
? "border-red-300 focus:ring-red-500"
: "border-gray-300 focus:ring-blue-500"
}`}
/>
{passwordError && (
<div
id="password-error"
class="text-red-600 text-base font-medium"
role="alert"
>
{passwordError}
</div>
)}
{tempPassword.length > 0 && tempPassword.length < 4 && (
<p id="password-hint" class="text-sm text-red-700">
PIN must be at least 4 characters
</p>
)}
{isPasswordValid && (
<p id="password-hint" class="text-sm text-green-700">
✓ PIN valid - press Enter or click{" "}
{isChangeMode ? "Update" : "Continue"}
</p>
)}
{/* Persist across devices checkbox - only show in initial setup, not change mode */}
{!isChangeMode && (
<div class="flex items-center space-x-2 mt-3">
<input
id="persist-across-devices"
type="checkbox"
checked={persistAcrossDevices}
onChange={handleCheckboxChange}
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 focus:ring-2"
/>
<label
htmlFor="persist-across-devices"
class="text-sm text-gray-700 cursor-pointer select-none"
>
Persist across devices
</label>
</div>
)}
</form>
<div class="flex gap-3">
<button
type="button"
onClick={onSetPassword}
disabled={!isPasswordValid && !isChangeMode}
aria-label={isChangeMode
? "Update encryption PIN"
: "Set encryption PIN"}
class={`flex-1 px-4 py-3 rounded-xl text-base font-medium transition-colors duration-200 ${
(isPasswordValid ||
(isChangeMode && tempPassword.length === 0))
? "bg-blue-600 text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 hover:bg-gray-100"
: "bg-gray-300 text-gray-500 cursor-not-allowed"
}`}
>
{isChangeMode ? "Update" : "Continue"}
</button>
<button
type="button"
onClick={handleCancel}
class="px-6 py-3 text-base bg-gray-200 text-gray-500 hover:text-gray-700 font-medium transition-colors duration-200 rounded-xl hover:bg-gray-100"
aria-label={isChangeMode
? "Cancel PIN change"
: "Skip encryption setup"}
>
{isChangeMode ? "Cancel" : "Skip"}
</button>
</div>
</div>
</div>
</div>
{/* Confirmation Dialog for PIN Removal */}
{showRemovePinConfirm && onRemovePinConfirm && onSkip && (
<div
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50"
role="dialog"
aria-labelledby="remove-pin-confirm-title"
>
<div class="bg-white rounded-2xl shadow-2xl p-6 w-full max-w-sm mx-auto transform transition-all duration-200 scale-100">
<div class="flex flex-col items-center text-center space-y-4">
<div class="flex-1 min-w-0">
<h3
id="remove-pin-confirm-title"
class="text-lg font-semibold text-gray-800"
>
Remove PIN?
</h3>
<p class="text-sm text-gray-700 mt-2">
This will remove the previously saved PIN
</p>
</div>
</div>
<div class="mt-6 flex gap-3">
<button
type="button"
onClick={onRemovePinConfirm}
class="flex-1 px-4 py-3 bg-red-600 text-white rounded-xl text-base font-medium transition-colors duration-200 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500"
aria-label="Remove PIN"
>
OK
</button>
<button
type="button"
onClick={onSkip}
class="flex-1 px-4 py-3 bg-gray-200 text-gray-700 rounded-xl text-base font-medium transition-colors duration-200 hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-gray-500"
aria-label="Cancel PIN removal"
>
Cancel
</button>
</div>
</div>
</div>
)}
</>
);
}

View File

@ -0,0 +1,126 @@
import { LockIconsProps } from "./welcome-types.ts";
/**
* LockIcons Component
* Displays encryption status icons (green locked or red unlocked)
* Centered absolutely within parent container
*
* @param isPasswordSet - Whether encryption password is set
* @param isSkipped - Whether user skipped encryption setup
* @param onClearSkip - Handler to re-enable encryption setup
*/
export default function LockIcon(
{
isPasswordSet,
isSkipped,
onClearSkip,
onChangePin,
isEncrypted,
onRequestPin,
}: LockIconsProps,
) {
return (
<>
{/* Green closed lock icon when encryption is enabled */}
{isPasswordSet && (
<div class="absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2">
<button
type="button"
onClick={onChangePin}
class="hover:scale-110 transition-transform duration-200 focus:outline-none focus:ring-2 focus:ring-green-300 rounded"
title="Click to change your PIN"
aria-label="Change encryption PIN"
>
<svg
class="w-5 h-5 text-green-600"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
aria-label="Encryption enabled"
>
<path d="M10 2a4 4 0 00-4 4v2H5a2 2 0 00-2 2v6a2 2 0 002 2h10a2 2 0 002-2v-6a2 2 0 00-2-2h-1V6a4 4 0 00-4-4zM8 6a2 2 0 114 0v2H8V6zm2 6a1 1 0 011 1v2a1 1 0 11-2 0v-2a1 1 0 011-1z" />
</svg>
</button>
</div>
)}
{/* Red open lock button when encryption is skipped */}
{isSkipped && (
<div class="absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2">
<button
type="button"
onClick={onClearSkip}
class="hover:scale-110 transition-transform duration-200 focus:outline-none focus:ring-2 focus:ring-red-300 rounded"
title="Click to enable encryption setup"
aria-label="Enable encryption"
>
<svg
class="w-5 h-5 text-red-600"
fill="none"
viewBox="0 0 20 20"
stroke="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7 8V5a3 3 0 116 0"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<rect
x="4"
y="9"
width="13"
height="9"
rx="1.5"
fill="currentColor"
/>
<rect
x="9.5"
y="14.5"
width="1"
height="2"
rx="0.5"
fill="white"
/>
<rect
x="9"
y="12"
width="3"
height="5"
rx="2"
fill="white"
/>
</svg>
</button>
</div>
)}
{/* Yellow caution icon when account is encrypted but this device has no PIN yet */}
{isEncrypted && !isPasswordSet && !isSkipped && (
<div class="absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2">
<button
type="button"
onClick={onRequestPin}
class="hover:scale-110 transition-transform duration-200 focus:outline-none focus:ring-2 focus:ring-yellow-300 rounded"
title="Encryption active - click to enter your PIN on this device"
aria-label="Enter encryption PIN on this device"
>
<svg
class="w-5 h-5 text-yellow-400"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
aria-label="Encryption requires PIN on this device"
>
<path d="M8.257 3.099c.765-1.36 2.681-1.36 3.446 0l6.518 11.59C19.002 16.37 18.135 18 16.708 18H3.292c-1.427 0-2.294-1.63-1.513-3.31l6.478-11.59z" />
<path d="M11 14a1 1 0 11-2 0 1 1 0 012 0z" />
<path d="M10 7a1 1 0 00-1 1v3a1 1 0 102 0V8a1 1 0 00-1-1z" />
</svg>
</button>
</div>
)}
</>
);
}

View File

@ -0,0 +1,226 @@
import { useEffect, useState } from "preact/hooks";
import { createPortal } from "preact/compat";
import { NavigationItem, NavigationMenuProps } from "./welcome-types.ts";
import { getNavigationGroups } from "./navigationConfig.ts";
/**
* NavigationMenu Component
* Dropdown menu with grouped navigation links
* Positioned absolutely relative to the hamburger button
* Closes when clicking outside
*
* @param showMenu - Whether menu is visible
* @param menuButtonRef - Reference to the hamburger button for positioning
* @param onClose - Handler to close the menu
* @param isLoggedIn - User authentication status
* @param currentRoute - Current route path
* @param navigationGroups - Optional custom navigation groups
*/
export default function NavigationMenu(
{
showMenu,
menuButtonRef,
onClose,
isLoggedIn,
currentRoute,
navigationGroups,
}: NavigationMenuProps,
) {
// Close menu when clicking outside - simplified to prevent issues
useEffect(() => {
if (!showMenu) return;
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (!target) return;
// Simple check: if click is outside menu container and button
if (
!target.closest(".navigation-menu") &&
!target.closest(".menu-button")
) {
onClose();
}
};
// Handle escape key
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
// Add listeners immediately without delay
document.addEventListener("click", handleClickOutside, true);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("click", handleClickOutside, true);
document.removeEventListener("keydown", handleEscape);
};
}, [showMenu, onClose]);
/**
* Render a single navigation item
*/
function renderNavigationItem(item: NavigationItem) {
const baseClasses =
"block w-full text-left px-4 py-2 transition-all duration-200";
const stateClasses = (() => {
if (item.disabled) {
return "text-gray-500 cursor-not-allowed";
}
if (item.path === "/auth/logout") {
return "text-red-400 hover:bg-red-400/10 hover:text-red-300 hover:shadow-[0_0_15px_rgba(248,113,113,0.3)]";
}
if (item.path === "/auth/login") {
return "text-purple-400 hover:bg-purple-400/10 hover:text-purple-300 hover:shadow-[0_0_15px_rgba(192,132,252,0.3)]";
}
return "text-teal-400 hover:bg-teal-400/10 hover:text-teal-300 hover:shadow-[0_0_15px_rgba(20,184,166,0.3)]";
})();
// Skip current route items (except auth forms)
if (!item.isForm && item.path === currentRoute) {
return null;
}
// Handle form items (login/logout)
if (item.isForm && item.formAction) {
return (
<form method={item.formMethod || "post"} action={item.formAction}>
<button
type="submit"
onClick={item.onClick}
class={`${baseClasses} ${stateClasses}`}
role="menuitem"
disabled={item.disabled}
>
{item.label}
</button>
</form>
);
}
// Handle regular links
return (
<a
href={item.path}
class={`${baseClasses} ${stateClasses}`}
onClick={(e) => {
if (item.disabled) {
e.preventDefault();
return;
}
onClose();
}}
role="menuitem"
aria-disabled={item.disabled}
>
{item.label}
</a>
);
}
/**
* Render a navigation group with separator
*/
function renderNavigationGroup(
group: { id: string; label: string; items: NavigationItem[] },
) {
if (group.items.length === 0) return null;
return (
<div key={group.id}>
{group.id !== "auth" && (
<div class="border-t border-teal-400/20 my-1"></div>
)}
{group.items.map(renderNavigationItem)}
</div>
);
}
// Track if we're in browser for portal rendering
const [isBrowser, setIsBrowser] = useState(false);
const [menuPosition, setMenuPosition] = useState({ top: 0, right: 0 });
useEffect(() => {
setIsBrowser(true);
}, []);
// Calculate menu position based on button location
useEffect(() => {
if (showMenu && menuButtonRef && isBrowser) {
const rect = menuButtonRef.getBoundingClientRect();
setMenuPosition({
top: rect.bottom + 8, // 8px below the button
right: window.innerWidth - rect.right, // Align right edge with button
});
}
}, [showMenu, menuButtonRef, isBrowser]);
if (!showMenu || !menuButtonRef) {
return null;
}
// Position menu as fixed overlay using calculated coordinates
const menuStyle = {
position: "fixed" as const,
top: `${menuPosition.top}px`,
right: `${menuPosition.right}px`,
zIndex: 9999,
minWidth: "192px",
boxShadow:
"0 10px 15px -3px rgba(20, 184, 166, 0.3), 0 4px 6px -2px rgba(20, 184, 166, 0.2)",
borderRadius: "0.5rem",
border: "1px solid rgb(20, 184, 166)",
backgroundColor: "black",
};
// Backdrop element to be portaled to body
const backdrop = (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.5)",
backdropFilter: "blur(4px)",
WebkitBackdropFilter: "blur(4px)",
zIndex: 9998,
}}
onClick={onClose}
aria-hidden="true"
/>
);
// Menu dropdown element to be portaled to body
const menu = (
<div
class="w-48 bg-black rounded-lg shadow-xl border border-teal-400 py-2 navigation-menu transform transition-all duration-150 ease-out origin-top-right"
style={menuStyle}
role="menu"
aria-label="Navigation menu"
onClick={(e) => e.stopPropagation()}
>
{getNavigationGroups(isLoggedIn, currentRoute, navigationGroups).map(
renderNavigationGroup,
)}
</div>
);
if (!isBrowser) {
return null;
}
return createPortal(
<>
{/* Overlay backdrop */}
{backdrop}
{/* Menu dropdown */}
{menu}
</>,
document.body,
);
}

301
islands/WelcomeBar/WelcomeBar.tsx Executable file
View File

@ -0,0 +1,301 @@
import { useEffect, useRef, useState } from "preact/hooks";
import { WelcomeBarProps, WelcomeBarState } from "./welcome-types.ts";
import { useEncryption } from "./useEncryption.ts";
import { useLogoutCleanup, useServiceWorker } from "./useServiceWorker.ts";
import EncryptionModal from "./EncryptionModal.tsx";
import NavigationMenu from "./NavigationMenu.tsx";
import LockIcon from "./LockIcon.tsx";
/**
* WelcomeBar Component
* Main navigation bar with user info, encryption status, and menu
* Refactored with consolidated state for V8 optimization
*
* Features:
* - Consolidated state management (single state object)
* - Modular component architecture
* - Custom hooks for reusable logic
* - Service worker management
* - Encryption status display
*
* @param stateData - Session cookie data with user info
* @param currentRoute - Current route path
*/
export default function WelcomeBar(
{ publicId, user_name, currentRoute, isEncrypted, stateId }: WelcomeBarProps,
) {
// Use ref for menu button to avoid state-based re-renders
const menuButtonRef = useRef<HTMLButtonElement>(null);
// Track current route dynamically (props only set on initial hydration)
const [activeRoute, setActiveRoute] = useState(currentRoute);
// Consolidated state for better V8 optimization
const [state, setState] = useState<Omit<WelcomeBarState, "menuButtonRef">>({
showPasswordModal: false,
isPasswordSet: false,
isSkipped: false,
tempPassword: "",
passwordError: "",
showMenu: false,
photoUrl: "",
});
// Helper function for partial state updates
const updateState = (
partial: Partial<Omit<WelcomeBarState, "menuButtonRef">>,
) => {
setState((prev) => ({ ...prev, ...partial }));
};
// Function to manage localStorage stateId tracking
const manageStateIdInStorage = () => {
try {
const storedStateId = localStorage.getItem("mystate");
if (storedStateId === null) {
// No existing stateId, save the current one
localStorage.setItem("mystate", stateId);
} else if (storedStateId !== stateId) {
// Existing stateId doesn't match, log it before saving
//console.log(`StateId changed from ${storedStateId} to ${stateId}`);
localStorage.setItem("mystate", stateId);
}
// If they match, no action needed
} catch (error) {
console.error("Error managing stateId in localStorage:", error);
}
};
// Custom hooks for modular logic
//useServiceWorker();
useLogoutCleanup();
// Update activeRoute when browser location changes (client-side navigation)
useEffect(() => {
// Set initial route from globalThis.location if different from prop
if (
typeof globalThis !== "undefined" &&
globalThis.location?.pathname !== activeRoute
) {
setActiveRoute(globalThis.location.pathname);
}
// Listen for popstate (back/forward navigation)
const handlePopState = () => {
setActiveRoute(globalThis.location.pathname);
};
// Listen for custom navigation events (if using client-side routing)
const handleNavigation = () => {
setActiveRoute(globalThis.location.pathname);
};
globalThis.addEventListener("popstate", handlePopState);
// Also check on click events that might trigger navigation
document.addEventListener("click", handleNavigation);
return () => {
globalThis.removeEventListener("popstate", handlePopState);
document.removeEventListener("click", handleNavigation);
};
}, []);
// useEffect to run localStorage management only when stateId changes
useEffect(() => {
manageStateIdInStorage();
}, [stateId]);
// Only use encryption hook if user is logged in (has valid user_id)
const hasValidUserId = publicId && publicId.length > 0;
const encryption = useEncryption(
hasValidUserId ? publicId : "",
isEncrypted,
);
// Menu handlers with error boundaries
const handleMenuToggle = (event?: Event) => {
try {
// Prevent event bubbling to avoid triggering click outside handlers
if (event && typeof event.stopPropagation === "function") {
event.stopPropagation();
}
updateState({ showMenu: !state.showMenu });
} catch (error) {
console.error("Error toggling menu:", error);
}
};
const handleMenuClose = () => {
try {
updateState({ showMenu: false });
} catch (error) {
console.error("Error closing menu:", error);
}
};
// Allow rendering even for guest users - don't block on missing stateData
// If stateData is completely missing, create a safe default for guest state
return (
<>
<div class="fixed top-0 left-0 right-0 w-full theme-navbar px-6 py-0 min-h-[56px] z-50">
<div class="relative min-h-[56px]">
<div class="absolute top-1/2 left-[5%] -translate-y-1/2 flex items-center">
{user_name
? (
<div class="flex items-center gap-2">
{state.photoUrl
? (
<img
src={state.photoUrl}
alt="User profile"
class="w-8 h-8 rounded-full object-cover border-2 border-cyan-400"
onError={(e) => {
// Fallback to initials if image fails to load
(e.target as HTMLImageElement).style.display = "none";
const fallback = document.getElementById(
`initials-${user_name || "default"}`,
);
if (fallback) fallback.style.display = "flex";
}}
/>
)
: null}
<div
id={`initials-${user_name || "default"}`}
class={`w-8 h-8 rounded-full bg-cyan-500 text-white flex items-center justify-center text-sm font-semibold ${
state.photoUrl ? "hidden" : ""
}`}
>
{user_name.substring(0, 2).toUpperCase()}
</div>
</div>
)
: (
<form method="get" action="/auth/login">
<button
type="submit"
class="theme-navbar-button px-4 py-1 rounded-lg shadow font-medium transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-cyan-500"
>
Sign in or Register
</button>
</form>
)}
</div>
{/* Lock icons showing encryption status - only for logged in users */}
{hasValidUserId && (
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex items-center">
<LockIcon
isPasswordSet={encryption.isPasswordSet}
isSkipped={encryption.isSkipped}
onClearSkip={encryption.handleClearSkip}
onChangePin={encryption.handleChangePin}
isEncrypted={isEncrypted}
onRequestPin={encryption.handleOpenPasswordModal}
/>
</div>
)}
{/* Logo positioned by percentage across the bar */}
{activeRoute !== "/" && (
<div
class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2"
style={{ left: `70%` }}
>
<div class="relative w-12 h-12">
<img
src="/logo.svg"
alt="Logo"
class="w-full h-full object-contain"
/>
<a
href="/"
title="Home"
class="absolute inset-0 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-500 transition-colors duration-200 flex items-center"
aria-label="Home"
>
<span class="sr-only">Home</span>
</a>
</div>
</div>
)}
<div class="absolute top-1/2 left-[95%] -translate-y-1/2">
<div class="flex items-center gap-3 relative hamburger-container">
{/* Hamburger Menu - always show */}
<button
ref={menuButtonRef}
type="button"
onClick={handleMenuToggle}
class="theme-navbar-accent transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-cyan-500 rounded p-1 menu-button relative z-[9999]"
title="Menu"
aria-label="Toggle navigation menu"
aria-expanded={state.showMenu}
>
<svg
class="w-8 h-8"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
{/* Navigation Menu Component - positioned relative to button */}
<NavigationMenu
showMenu={state.showMenu}
menuButtonRef={menuButtonRef.current}
onClose={handleMenuClose}
isLoggedIn={!!user_name}
currentRoute={activeRoute}
/>
</div>
</div>
</div>
</div>
{/* Encryption Modal Component - only for logged in users */}
{hasValidUserId && encryption.showPasswordModal && (
<EncryptionModal
showModal={encryption.showPasswordModal}
tempPassword={encryption.tempPassword}
passwordError={encryption.passwordError}
persistAcrossDevices={encryption.persistAcrossDevices}
onPasswordChange={encryption.handlePasswordChange}
onSetPassword={encryption.handleSetPassword}
onSkip={encryption.handleSkipEncryption}
onPersistAcrossDevicesChange={encryption
.handlePersistAcrossDevicesChange}
/>
)}
{/* Change PIN Modal Component - only for logged in users */}
{hasValidUserId && (
<EncryptionModal
showModal={encryption.showChangePinModal}
tempPassword={encryption.tempPassword}
passwordError={encryption.passwordError}
isChangeMode
showRemovePinConfirm={encryption.showRemovePinConfirm}
persistAcrossDevices={encryption.persistAcrossDevices}
onPasswordChange={encryption.handlePasswordChange}
onSetPassword={encryption.handleChangePinSubmit}
onSkip={encryption.handleCancelRemovePin}
onClose={encryption.handleCloseChangePin}
onRemovePinConfirm={encryption.handleRemovePinConfirm}
onPersistAcrossDevicesChange={encryption
.handlePersistAcrossDevicesChange}
/>
)}
</>
);
}

View File

@ -0,0 +1,176 @@
import { NavigationGroup } from "./welcome-types.ts";
/**
* Default navigation groups configuration
*/
export const defaultNavigationGroups: NavigationGroup[] = [
{
id: "auth",
label: "Authentication",
items: [
{
path: "/auth/login",
label: "Login",
requiresAuth: false,
},
{
path: "/auth/logout",
label: "Logout",
requiresAuth: true,
isForm: true,
formAction: "/auth/logout",
formMethod: "post",
onClick: () => {
localStorage.setItem("lastClickedButton", "logout");
},
},
],
},
{
id: "financial-tools",
label: "Financial Tools",
items: [
{
path: `/calendar/financial-calendar/${new Date().getFullYear()}/${
(new Date().getMonth() + 1).toString().padStart(2, "0")
}?`,
label: "Financial Calendar",
requiresAuth: true,
},
{
path: "/calendar/every-income",
label: "Every Income",
requiresAuth: true,
},
{
path: "/calendar/budget-obligations",
label: "Budget Obligations",
requiresAuth: true,
},
],
},
{
id: "language",
label: "Language & Learning",
items: [
{
path: "/language/dashboard",
label: "Dashboard",
requiresAuth: true,
},
// {
// path: "/language/set-quiz",
// label: "Set Quiz",
// requiresAuth: true,
// },
{
path: "/language/lists",
label: "Lists",
requiresAuth: true,
},
{
path: "/language/arrange",
label: "Arrange",
requiresAuth: true,
},
],
},
{
id: "general",
label: "General",
items: [
{
path: "/",
label: "Home",
requiresAuth: false,
},
{
path: "/user-profile",
label: "Profile",
requiresAuth: true,
},
{
path: "#help",
label: "Help",
requiresAuth: false,
disabled: true,
},
{
path: "#about",
label: "About",
requiresAuth: false,
disabled: true,
},
],
},
];
/**
* Get navigation groups filtered by authentication status and current route
*
* When on /language/* routes, only show auth + language-specific navigation,
* excluding the current route from the language items.
*
* When on /calendar/* routes, only show auth + financial-tools navigation,
* excluding the current route from the financial-tools items.
*/
export function getNavigationGroups(
isLoggedIn: boolean,
currentRoute: string,
customGroups?: NavigationGroup[],
): NavigationGroup[] {
const groups = customGroups || defaultNavigationGroups;
const isLanguageRoute = currentRoute.startsWith("/language/");
const isCalendarRoute = currentRoute.startsWith("/calendar/");
return groups
.filter((group) => {
// On language routes, only show auth and language groups
if (isLanguageRoute) {
return group.id === "auth" || group.id === "language";
}
// On calendar routes, only show auth and financial-tools groups
if (isCalendarRoute) {
return group.id === "auth" || group.id === "financial-tools";
}
return true;
})
.map((group) => ({
...group,
items: group.items.filter((item) => {
// Filter out items that require authentication if user is not logged in
if (item.requiresAuth && !isLoggedIn) {
return false;
}
// Show login only when logged out, logout only when logged in
if (group.id === "auth") {
if (item.path === "/auth/login" && isLoggedIn) {
return false;
}
if (item.path === "/auth/logout" && !isLoggedIn) {
return false;
}
}
// On language routes, exclude the current route from language items
if (
isLanguageRoute && group.id === "language" &&
item.path === currentRoute
) {
return false;
}
// On calendar routes, exclude the current route from financial-tools items
if (isCalendarRoute && group.id === "financial-tools") {
// Check if current route matches or starts with the item path (for dynamic routes)
const itemBasePath = item.path.split("?")[0]; // Remove query params
if (
currentRoute === itemBasePath ||
currentRoute.startsWith(itemBasePath)
) {
return false;
}
}
return true;
}),
}))
.filter((group) => group.items.length > 0);
}

View File

@ -0,0 +1,653 @@
import { useEffect, useRef, useState } from "preact/hooks";
import { UseEncryptionReturn } from "./welcome-types.ts";
import { User } from "../../db/interfaces.ts";
import {
decryptDataWithStoredPassword,
encryptDataWithStoredPassword,
getEncryptionPassword,
getUserEncryptionKey,
getUserSkipKey,
tryDecryptDataWith,
} from "../../helpers.ts";
// Store encryption password in localStorage
function setEncryptionPassword(userId: string, password: string): boolean {
if (!userId) return false;
try {
const userKey = getUserEncryptionKey(userId);
globalThis.localStorage?.setItem(userKey, password);
return true;
} catch (error) {
console.error("Failed to store encryption password:", error);
return false;
}
}
// Set skip state for encryption setup
function setEncryptionSkip(userId: string, skip: boolean): boolean {
if (!userId) return false;
try {
const skipKey = getUserSkipKey(userId);
if (skip) {
globalThis.localStorage?.setItem(skipKey, "true");
} else {
globalThis.localStorage?.removeItem(skipKey);
}
return true;
} catch (error) {
console.error("Failed to set skip state:", error);
return false;
}
}
// Check if user has skipped encryption setup
function _hasEncryptionSkip(userId: string): boolean {
if (!userId) return false;
try {
const skipKey = getUserSkipKey(userId);
return globalThis.localStorage?.getItem(skipKey) === "true";
} catch (error) {
console.error("Failed to check skip state:", error);
return false;
}
}
// Clear skip state
function clearEncryptionSkip(userId: string): void {
if (!userId) return;
try {
const skipKey = getUserSkipKey(userId);
globalThis.localStorage?.removeItem(skipKey);
} catch (error) {
console.error("Failed to clear skip state:", error);
}
}
/**
* Custom hook to manage encryption state and operations
* Centralizes all encryption-related logic for reusability
*
* @param userId - The current user's ID
* @returns Encryption state and handlers
*
* @example
* ```tsx
* function MyComponent({ userId }) {
* const encryption = useEncryption(userId);
* return <EncryptionModal {...encryption} />;
* }
* ```
*/
export function useEncryption(
userId: string,
isEncrypted: boolean,
): UseEncryptionReturn {
// Track mounted state to prevent updates after unmount
const isMountedRef = useRef(true);
// AbortController ref for cancelling fetch operations on unmount
const abortControllerRef = useRef<AbortController | null>(null);
// Consolidated encryption state
const [state, setState] = useState({
isPasswordSet: false,
isSkipped: false,
showPasswordModal: false,
showChangePinModal: false,
showRemovePinConfirm: false,
tempPassword: "",
passwordError: "",
persistAcrossDevices: false,
});
// Helper for partial state updates - only updates if still mounted
const updateState = (partial: Partial<typeof state>) => {
if (isMountedRef.current) {
setState((prev) => ({ ...prev, ...partial }));
}
};
// Explicit handler to open the password modal (e.g. from LockIcon warning state)
const handleOpenPasswordModal = () => {
updateState({
showPasswordModal: true,
isSkipped: false,
});
};
// Check encryption password status on mount and when userId changes
useEffect(() => {
// Reset mounted state on each effect run
isMountedRef.current = true;
// Create new abort controller for this effect run
abortControllerRef.current?.abort();
const abortController = new AbortController();
abortControllerRef.current = abortController;
// Only run encryption logic if we have a valid userId (logged in user)
if (typeof globalThis !== "undefined" && userId && userId.length > 0) {
if (
(globalThis as { __E2E_SKIP_ENCRYPTION__?: boolean })
.__E2E_SKIP_ENCRYPTION__
) {
updateState({
isPasswordSet: false,
isSkipped: true,
showPasswordModal: false,
});
return;
}
// Check localStorage directly to see if password is already set
const existingPassword = getEncryptionPassword(userId);
if (existingPassword) {
updateState({
isPasswordSet: true,
isSkipped: false,
showPasswordModal: false,
});
return;
}
const hasSkip = _hasEncryptionSkip(userId);
if (hasSkip) {
updateState({
isPasswordSet: false,
isSkipped: true,
showPasswordModal: false,
});
return;
}
const checkEncryptionStatus = async () => {
try {
if (isEncrypted) {
const me = await fetch("/api/user/get", {
credentials: "include",
signal: abortController.signal,
});
// Check if aborted before processing response
if (abortController.signal.aborted) return;
if (me.ok) {
const userData = await me.json() as User;
if (userData.id && userData.encrypted_data) {
//console.log("User data found, checking local pin");
const localPin = getEncryptionPassword(userId);
if (localPin) {
//console.log("Local pin found, decrypting data");
const decryptedData = await decryptDataWithStoredPassword(
userId,
userData.encrypted_data,
);
if (decryptedData) {
const decryptedUserData = JSON.parse(decryptedData);
//console.log("Checking user data", decryptedUserData);
//console.log("user id ", userData.id);
if (decryptedUserData.id === userData.id) {
updateState({
isPasswordSet: true,
isSkipped: false,
showPasswordModal: false,
});
return;
}
}
} else {
//console.log("No local pin found, checking server");
const response = await fetch("/api/sync-pin/get", {
credentials: "include",
signal: abortController.signal,
});
// Check if aborted before processing response
if (abortController.signal.aborted) return;
if (response.ok) {
const serverPinData = await response.json();
//console.log("Server pin found, decrypting data", serverPinData);
// Server has a pin, store it in localStorage for local usage
if (serverPinData.pin) {
const decryptedData = await tryDecryptDataWith(
serverPinData.pin,
userData.encrypted_data,
);
if (decryptedData) {
const decryptedUserData = JSON.parse(decryptedData);
//console.log("Decrypted data found, checking user ID", decryptedUserData.id);
//console.log("user id ", userData.id);
if (decryptedUserData.id === userData.id) {
const localSetSuccess = setEncryptionPassword(
userId,
serverPinData.pin,
);
if (localSetSuccess) {
updateState({
isPasswordSet: true,
isSkipped: false,
showPasswordModal: false,
persistAcrossDevices: true,
});
return;
}
}
}
// Server pin was not usable for this encrypted data, fall back to asking user
updateState({
showPasswordModal: true,
isSkipped: false,
persistAcrossDevices: false,
});
} else {
updateState({
showPasswordModal: true,
isSkipped: false,
persistAcrossDevices: false,
});
}
return;
} else {
updateState({
showPasswordModal: true,
isSkipped: false,
persistAcrossDevices: false,
});
}
}
}
}
}
// Show modal for logged-in users without password ONLY if isEncrypted is falsy
if (!isEncrypted) {
updateState({
showPasswordModal: true,
persistAcrossDevices: false, // Default to local only
});
}
} catch (error) {
// Ignore abort errors - these are expected on cleanup
if (error instanceof Error && error.name === "AbortError") {
return;
}
console.error("Error during encryption password setup:", error);
updateState({ showPasswordModal: false });
}
};
checkEncryptionStatus();
} else {
//console.log("No user ID found, skipping encryption setup");
// For guest users (no userId), ensure modal is hidden
try {
localStorage.removeItem(
"realestate_app_skip_user_zZoSEsW5UYehfqDNkmflaM",
);
localStorage.removeItem("realestate_app_user_zZoSEsW5UYehfqDNkmflaM");
} catch {
// Ignore localStorage errors in SSR or private mode
}
updateState({
showPasswordModal: false,
isPasswordSet: false,
isSkipped: false,
});
}
// Cleanup: abort pending requests and mark as unmounted
return () => {
isMountedRef.current = false;
abortController.abort();
};
}, [userId, isEncrypted]);
// Handle encryption password input
const handlePasswordChange = (password: string) => {
updateState({ tempPassword: password });
// Clear error when user starts typing
if (state.passwordError) {
updateState({ passwordError: "" });
}
};
// Handle setting the password
const handleSetPassword = async () => {
if (!userId) return;
// If in change mode and password is empty, show confirmation dialog
if (state.showChangePinModal && state.tempPassword.length === 0) {
updateState({ showRemovePinConfirm: true });
return;
}
// Require minimum 4 characters for normal password setting
if (state.tempPassword.length < 4) return;
updateState({ passwordError: "" });
try {
// Fetch user data to support validation and/or initial encryption
//console.log(`[Encryption] Fetching user data for PIN handling`);
const userResponse = await fetch("/api/user/get", {
credentials: "include",
});
if (!userResponse.ok) {
throw new Error("Failed to fetch user data");
}
// At this point, either the account was not encrypted yet, or the PIN has been validated.
// Now persist the PIN locally.
const localSetSuccess = setEncryptionPassword(
userId,
state.tempPassword,
);
if (!localSetSuccess) {
throw new Error("Failed to store PIN locally");
}
const userData = await userResponse.json() as User;
// If the account is already encrypted, validate the entered PIN against existing encrypted_data
if (isEncrypted && userData.encrypted_data) {
const decrypted = await decryptDataWithStoredPassword(
userId,
userData.encrypted_data,
);
if (!decrypted) {
console.warn(
`[Encryption] Entered PIN failed to decrypt existing data`,
);
updateState({
passwordError: "Incorrect PIN. Please try again.",
});
return;
}
}
// For first-time encryption (isEncrypted is falsy), encrypt and push encrypted_data to server
if (!isEncrypted) {
try {
//console.log(`[Encryption] Encrypting user data with new PIN`);
const encryptedData = await encryptDataWithStoredPassword(
userId,
JSON.stringify(userData),
);
if (encryptedData) {
// console.log(`[Encryption] User data encrypted successfully`);
// console.log(`[Encryption] Encrypted data:`, encryptedData);
const response = await fetch("/api/user/set", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
encrypted_data: encryptedData,
}),
});
if (!response.ok) {
throw new Error("Failed to update user data");
}
const result = await response.json();
//console.log(`[Encryption] User data updated successfully`);
//console.log(`[Encryption] Result:`, result);
}
} catch (encryptionError) {
console.error(
`[Encryption] Error during user data encryption:`,
encryptionError,
);
// Continue with password setup even if encryption fails
}
}
// If persistAcrossDevices is true, sync to server
if (state.persistAcrossDevices) {
try {
const response = await fetch("/api/sync-pin/set", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
pin: state.tempPassword,
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || "Failed to sync PIN to server");
}
} catch (_serverError) {
// Still allow local usage even if server sync fails
updateState({
passwordError: "PIN set locally but failed to sync across devices",
persistAcrossDevices: false,
});
return;
}
}
updateState({
isPasswordSet: true,
showPasswordModal: false,
showChangePinModal: false,
});
// Only redirect if everything succeeded and component is still mounted
if (
isMountedRef.current && typeof globalThis !== "undefined" &&
globalThis.location
) {
globalThis.location.href = "/";
}
} catch (error) {
console.error("Error setting password:", error);
updateState({ passwordError: "Failed to set PIN. Please try again." });
}
};
// Handle skip encryption
const handleSkipEncryption = () => {
if (userId) {
setEncryptionSkip(userId, true);
updateState({
isSkipped: true,
isPasswordSet: false,
showPasswordModal: false,
});
}
};
// Handle clearing skip state
const handleClearSkip = () => {
if (userId) {
clearEncryptionSkip(userId);
updateState({
isSkipped: false,
isPasswordSet: false,
showPasswordModal: true,
});
}
};
// Handle opening change PIN modal
const handleChangePin = () => {
updateState({
showChangePinModal: true,
showRemovePinConfirm: false,
tempPassword: "",
passwordError: "",
});
};
// Handle closing change PIN modal
const handleCloseChangePin = () => {
updateState({
showChangePinModal: false,
showRemovePinConfirm: false,
tempPassword: "",
passwordError: "",
});
};
// Handle remove PIN confirmation
const handleRemovePinConfirm = async () => {
if (!userId) return;
try {
// Remove the encryption password from localStorage
const storageKey = getUserEncryptionKey(userId);
globalThis.localStorage?.removeItem(storageKey);
// If sync was enabled, also remove from server
if (state.persistAcrossDevices) {
try {
const response = await fetch("/api/sync-pin/delete", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
} catch (serverError) {
console.warn(
"[Pin Sync] Failed to remove PIN from server:",
serverError,
);
}
}
// Set skip state instead of clearing it
setEncryptionSkip(userId, true);
updateState({
isPasswordSet: false,
isSkipped: true,
showChangePinModal: false,
showRemovePinConfirm: false,
tempPassword: "",
passwordError: "",
persistAcrossDevices: false,
});
} catch (error) {
console.error("Error removing PIN:", error);
updateState({ passwordError: "Failed to remove PIN. Please try again." });
}
};
// Handle cancel remove PIN
const handleCancelRemovePin = () => {
updateState({ showRemovePinConfirm: false });
};
// Handle changing the PIN
const handleChangePinSubmit = async () => {
if (!userId) return;
// If empty PIN, show confirmation dialog to remove existing PIN
if (state.tempPassword.length === 0) {
updateState({ showRemovePinConfirm: true });
return;
}
if (state.tempPassword.length < 4) return;
updateState({ passwordError: "" });
try {
// Set the new password locally first
const localSetSuccess = setEncryptionPassword(
userId,
state.tempPassword,
);
if (!localSetSuccess) {
throw new Error("Failed to store PIN locally");
}
// If persistAcrossDevices is true, sync to server
if (state.persistAcrossDevices) {
try {
const response = await fetch("/api/sync-pin/set", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
pin: state.tempPassword,
}),
});
if (!response.ok) {
const errorData = await response.json();
console.error(
`[Pin Sync] Server sync failed during change:`,
errorData,
);
throw new Error(errorData.error || "Failed to sync PIN to server");
}
} catch (serverError) {
// Still allow local usage even if server sync fails
updateState({
passwordError:
"PIN updated locally but failed to sync across devices",
persistAcrossDevices: false,
});
return;
}
}
updateState({
isPasswordSet: true,
showChangePinModal: false,
tempPassword: "",
});
} catch (error) {
console.error("Error changing PIN:", error);
updateState({ passwordError: "Failed to update PIN. Please try again." });
}
};
// Handle persist across devices checkbox change
const handlePersistAcrossDevicesChange = (persist: boolean) => {
updateState({ persistAcrossDevices: persist });
};
return {
isPasswordSet: state.isPasswordSet,
isSkipped: state.isSkipped,
showPasswordModal: state.showPasswordModal,
showChangePinModal: state.showChangePinModal,
showRemovePinConfirm: state.showRemovePinConfirm,
tempPassword: state.tempPassword,
passwordError: state.passwordError,
persistAcrossDevices: state.persistAcrossDevices,
handlePasswordChange,
handleSetPassword,
handleSkipEncryption,
handleClearSkip,
handleChangePin,
handleCloseChangePin,
handleChangePinSubmit,
handleRemovePinConfirm,
handleCancelRemovePin,
handlePersistAcrossDevicesChange,
handleOpenPasswordModal,
};
}

View File

@ -0,0 +1,154 @@
import { useEffect } from "preact/hooks";
// Simple safe storage functions to avoid errorHandling.ts dependency
function safeLocalStorage() {
try {
if (typeof localStorage === "undefined") {
return null;
}
// Test if localStorage is accessible
const testKey = "__test__";
localStorage.setItem(testKey, "test");
localStorage.removeItem(testKey);
return localStorage;
} catch {
return null;
}
}
function safeSessionStorage() {
try {
if (typeof sessionStorage === "undefined") {
return null;
}
// Test if sessionStorage is accessible
const testKey = "__test__";
sessionStorage.setItem(testKey, "test");
sessionStorage.removeItem(testKey);
return sessionStorage;
} catch {
return null;
}
}
/**
* Custom hook to manage Service Worker registration
* Handles registration, unregistration, and session-based updates
* Includes robust error handling for network issues and broken pipes
*
* @example
* ```tsx
* function MyComponent() {
* useServiceWorker();
* return <div>Component content</div>;
* }
* ```
*/
export function useServiceWorker() {
useEffect(() => {
if ("serviceWorker" in navigator) {
// Check if this is a new user experience (session start)
const storage = safeSessionStorage();
const isNewSession = !storage?.getItem("sw_registered");
// Add timeout for service worker operations
// const registrationTimeout = setTimeout(() => {
// console.warn("Service Worker registration taking longer than expected");
// }, 10000);
navigator.serviceWorker.getRegistrations().then((registrations) => {
const activeSW = registrations.find((reg) =>
reg.active?.scriptURL.includes("fetch_sw.js")
);
if (activeSW && !isNewSession) {
// Service worker already active and not a new session - skip registration
// console.log("Service Worker already active.");
// clearTimeout(registrationTimeout);
return;
}
// New session or no active SW - unregister old versions and register new
const unregisterPromises = registrations.map((registration) =>
registration.unregister().catch((err) => {
// Log but don't fail if unregistration fails
// console.warn("Failed to unregister old service worker:", err);
return Promise.resolve();
})
);
return Promise.all(unregisterPromises).then(() => {
// Register the new service worker after unregistering old ones
return navigator.serviceWorker.register("/fetch_sw.js");
}).then(() => {
// console.log("Service Worker registered.");
const swStorage = safeSessionStorage();
swStorage?.setItem("sw_registered", "true");
// clearTimeout(registrationTimeout);
}).catch((regError) => {
// Handle registration errors gracefully
// console.error("Service Worker registration failed:", regError);
// clearTimeout(registrationTimeout);
// Don't throw - allow app to continue without service worker
});
}).catch((error) => {
//clearTimeout(registrationTimeout);
// Handle different error types
// const errorMessage = error instanceof Error ? error.message : String(error);
// if (error instanceof TypeError || errorMessage.includes("network")) {
// console.error("Network error during service worker setup:", error);
// } else if (errorMessage.includes("broken pipe")) {
// console.error("Connection broken during service worker setup:", error);
// } else {
// console.error("Service Worker registration failed:", error);
// }
// App should continue functioning without service worker
});
}
// else {
// console.log("Service Worker not supported in this browser.");
// }
// Cleanup function
return () => {
// No cleanup needed for service worker registration
};
}, []);
}
/**
* Custom hook to manage logout button state
* Handles post-logout redirect cleanup
* Includes error handling for localStorage access issues
*/
export function useLogoutCleanup() {
useEffect(() => {
try {
// Use safe localStorage access
const storage = safeLocalStorage();
if (!storage) {
console.warn("localStorage not available");
return;
}
const lastClickedButton = storage.getItem("lastClickedButton");
if (lastClickedButton === "logout") {
storage.removeItem("lastClickedButton");
// Add try-catch for URL manipulation and redirect
try {
const url = new URL(globalThis.location.href);
globalThis.location.href = url.origin + "?popstate=true";
} catch (urlError) {
console.error("Error during logout redirect:", urlError);
// Fallback to simple redirect
globalThis.location.href = "/";
}
}
} catch (error) {
// Handle localStorage access errors (quota exceeded, privacy mode, etc.)
console.error("Error during logout cleanup:", error);
}
}, []);
}

View File

@ -0,0 +1,117 @@
import { SessionCookie as _SessionCookie } from "../../routes/auth/callback.ts";
/**
* Props for the main WelcomeBar component
*/
export interface WelcomeBarProps {
publicId: string;
user_name: string;
currentRoute: string;
isEncrypted: boolean;
stateId: string;
}
/**
* Consolidated state interface for WelcomeBar
* Single state object optimized for V8 hidden class caching
*/
export interface WelcomeBarState {
showPasswordModal: boolean;
isPasswordSet: boolean;
isSkipped: boolean;
tempPassword: string;
passwordError: string;
showMenu: boolean;
menuButtonRef: HTMLButtonElement | null;
photoUrl: string;
}
/**
* Props for the EncryptionModal component
*/
export interface EncryptionModalProps {
showModal: boolean;
tempPassword: string;
passwordError: string;
isChangeMode?: boolean;
showRemovePinConfirm?: boolean;
persistAcrossDevices: boolean;
onPasswordChange: (password: string) => void;
onSetPassword: () => Promise<void>;
onSkip: () => void;
onClose?: () => void;
onRemovePinConfirm?: () => void;
onPersistAcrossDevicesChange: (persist: boolean) => void;
}
/**
* Navigation item configuration
*/
export interface NavigationItem {
path: string;
label: string;
requiresAuth?: boolean;
isForm?: boolean;
formAction?: string;
formMethod?: string;
onClick?: () => void;
disabled?: boolean;
}
/**
* Navigation group configuration
*/
export interface NavigationGroup {
id: string;
label: string;
items: NavigationItem[];
}
/**
* Props for the NavigationMenu component
*/
export interface NavigationMenuProps {
showMenu: boolean;
menuButtonRef: HTMLButtonElement | null;
onClose: () => void;
isLoggedIn: boolean;
currentRoute: string;
navigationGroups?: NavigationGroup[];
}
/**
* Props for the LockIcons component
*/
export interface LockIconsProps {
isPasswordSet: boolean;
isSkipped: boolean;
onClearSkip: () => void;
onChangePin: () => void;
isEncrypted: boolean;
onRequestPin: () => void;
}
/**
* Return type for useEncryption hook
*/
export interface UseEncryptionReturn {
isPasswordSet: boolean;
isSkipped: boolean;
showPasswordModal: boolean;
showChangePinModal: boolean;
showRemovePinConfirm: boolean;
tempPassword: string;
passwordError: string;
persistAcrossDevices: boolean;
handlePasswordChange: (password: string) => void;
handleSetPassword: () => Promise<void>;
handleSkipEncryption: () => void;
handleClearSkip: () => void;
handleChangePin: () => void;
handleCloseChangePin: () => void;
handleChangePinSubmit: () => Promise<void>;
handleRemovePinConfirm: () => void;
handleCancelRemovePin: () => void;
handlePersistAcrossDevicesChange: (persist: boolean) => void;
handleOpenPasswordModal: () => void;
}

35
islands/YesNoPopup.tsx Normal file
View File

@ -0,0 +1,35 @@
interface YesNoPopupProps {
question: string;
onAnswer: (answer: boolean) => void;
}
export default function YesNoPopup({ question, onAnswer }: YesNoPopupProps) {
const handleAnswer = (answer: boolean) => {
onAnswer(answer);
};
return (
<div className="theme-popup-overlay">
<div
className="theme-popup-container"
onClick={(e) => e.stopPropagation()}
>
<p>{question}</p>
<div className="flex gap-16 mt-4 justify-center">
<button
onClick={() => handleAnswer(true)}
className="theme-popup-button"
>
Yes
</button>
<button
onClick={() => handleAnswer(false)}
className="theme-popup-button"
>
No
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,229 @@
import { useEffect, useRef, useState } from "preact/hooks";
import {
encryptDataWithStoredPassword,
getEncryptionPassword,
} from "../../helpers.ts";
interface AddIncomeDialogProps {
onClose: () => void;
userId?: string;
}
type FieldErrorState = {
source: boolean;
amount: boolean;
};
export default function AddIncomeDialog(
{ onClose, userId }: AddIncomeDialogProps,
) {
// Theme classes are defined in styles.css with @media (prefers-color-scheme: dark)
const formRef = useRef<HTMLFormElement>(null);
const firstInputRef = useRef<HTMLInputElement>(null);
const [loading, setLoading] = useState(false);
const [fieldErrors, setFieldErrors] = useState<FieldErrorState>({
source: false,
amount: false,
});
const today = new Date().toISOString().split("T")[0];
const addNewIncome = async () => {
if (!formRef.current) return;
// Collect form data
const formData = new FormData(formRef.current);
const source = formData.get("source")?.toString().trim() || "";
const amount = formData.get("amount")?.toString().trim() || "";
const frequency = formData.get("frequency")?.toString() || "Monthly";
const receivedDate = formData.get("received_date")?.toString() || today;
const sender = typeof location !== "undefined" ? location.pathname : "/";
const validation = {
source: !source,
amount: !amount,
};
setFieldErrors(validation);
if (validation.source || validation.amount) {
return;
}
setLoading(true);
localStorage.setItem("lock-route", "/calendar/every-income?modal=off");
// Build submission data
const submitData = new FormData();
submitData.set("sender", sender);
submitData.set("frequency", frequency);
submitData.set("received_date", receivedDate);
// Encrypt if password is available
const encryptionPassword = userId ? getEncryptionPassword(userId) : null;
if (encryptionPassword && (source || amount)) {
try {
const dataToEncrypt = {
source,
amount: parseFloat(amount) || 0,
};
const encryptedData = await encryptDataWithStoredPassword(
userId!,
JSON.stringify(dataToEncrypt),
);
if (encryptedData) {
submitData.set("encrypted_data", encryptedData);
// Send empty plaintext when encrypted
submitData.set("source", "");
submitData.set("amount", "0");
} else {
throw new Error("Encryption returned empty result");
}
} catch (err) {
setLoading(false);
return;
}
} else {
submitData.set("source", source);
submitData.set("amount", amount);
submitData.set("encrypted_data", "");
}
// Submit via fetch - let redirects follow automatically
try {
const response = await fetch("/api/create/income", {
method: "POST",
body: submitData,
credentials: "same-origin",
});
// Fetch followed the redirect, now navigate to final URL or sender
if (response.ok) {
globalThis.location.href = "/calendar/every-income?modal=off";
} else {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || "Failed to create income");
}
} catch (err) {
setLoading(false);
}
};
const clearFieldError = (field: keyof FieldErrorState) => {
setFieldErrors((prev) => prev[field] ? { ...prev, [field]: false } : prev);
};
const getInputErrorClass = (hasError: boolean) =>
hasError ? "border-red-500 ring-1 ring-red-300" : "";
useEffect(() => {
firstInputRef.current?.focus();
}, []);
return (
<div
class="theme-dialog-overlay"
style={{ paddingTop: "calc(50vh - 400px)" }}
>
<div class="theme-dialog-container flex flex-col gap-2">
<form ref={formRef}>
<h2 class="theme-dialog-title">Add Income</h2>
<div class="flex flex-col gap-1 my-4">
<input
ref={firstInputRef}
class={`theme-dialog-input ${
getInputErrorClass(fieldErrors.source)
}`}
name="source"
placeholder="Source"
required
onInput={() => clearFieldError("source")}
/>
<div
class={`text-red-500 text-xs min-h-4 ${
fieldErrors.source ? "visible" : "invisible"
}`}
aria-live="polite"
>
Source name is required.
</div>
</div>
<div class="flex flex-col gap-1 my-4">
<input
class={`theme-dialog-input ${
getInputErrorClass(fieldErrors.amount)
}`}
name="amount"
placeholder="Amount"
type="number"
step="0.01"
required
onInput={() => clearFieldError("amount")}
/>
<div
class={`text-red-500 text-xs min-h-4 ${
fieldErrors.amount ? "visible" : "invisible"
}`}
aria-live="polite"
>
Amount is required.
</div>
</div>
<div class="flex flex-col gap-1 my-4">
<select
class="theme-dialog-select"
name="frequency"
defaultValue="Monthly"
>
<option value="Biweekly">Biweekly</option>
<option value="Weekly">Weekly</option>
<option value="Monthly">Monthly</option>
<option value="Quarterly">Quarterly</option>
<option value="Yearly">Yearly</option>
</select>
</div>
<div class="flex flex-col gap-1 my-4">
<input
class="theme-dialog-input"
name="received_date"
type="date"
defaultValue={today}
required
/>
</div>
</form>
<div class="flex justify-end gap-2 mt-2">
<button
type="button"
class={`theme-dialog-cancel-button w-40 ${
loading ? "opacity-70" : ""
}`}
onClick={() => {
localStorage.setItem(
"lock-route",
"/calendar/every-income?modal=off",
);
setLoading(true);
onClose();
}}
disabled={loading}
>
Cancel
</button>
<button
type="button"
class={`theme-dialog-submit-button w-40 ${
loading ? "opacity-70" : ""
}`}
disabled={loading}
onClick={addNewIncome}
>
{loading ? "Adding..." : "Add"}
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,131 @@
import { useState } from "preact/hooks";
interface AddLeaseDialogProps {
leases: any[];
propertyId: string;
propertyTitle: string;
propertyAddress: string;
propertyCost?: number; // New prop for default rent
popupKey?: number;
onClose?: () => void;
}
export default function AddLeaseDialog(
{
leases,
propertyId,
propertyTitle,
propertyAddress,
propertyCost,
onClose = () => {},
}: AddLeaseDialogProps,
) {
const [form, setForm] = useState({
property_id: propertyId,
title: propertyTitle + " " + propertyAddress,
start_date: new Date().toISOString().split("T")[0],
end_date: new Date(new Date().getTime() + 12 * 30 * 24 * 60 * 60 * 1000)
.toISOString().split("T")[0],
rent: propertyCost !== undefined ? String(propertyCost) : "",
security_deposit: "",
lease_status: "",
});
return (
<div class="fixed inset-0 flex items-center justify-center bg-black bg-opacity-30 z-50">
<form
method="POST"
action="/api/create/lease"
class="bg-white p-6 rounded shadow-lg w-full max-w-md space-y-4"
>
<h3 class="text-xl font-bold mb-2">Add Lease</h3>
<div class="bg-gray-100 rounded p-2 mb-2 text-sm">
<div>
<span class="font-semibold">{propertyTitle}</span>
</div>
<div>{propertyAddress}</div>
</div>
<input
name="sender"
type="hidden"
value={typeof location !== "undefined" ? location.pathname : ""}
/>
<input
name="property_id"
type="hidden"
value={form.property_id}
readOnly
/>
<label class="block text-sm font-medium mb-1">Lease Title</label>
<input
name="title"
class="border p-2 rounded w-full mb-2"
value={form.title}
onInput={(e) =>
setForm({ ...form, title: (e.target as HTMLInputElement).value })}
required
/>
<input
name="start_date"
type="date"
placeholder="Start Date"
required
class="border p-2 rounded w-full"
value={form.start_date}
onInput={(e) =>
setForm({ ...form, start_date: e.currentTarget.value })}
/>
<input
name="end_date"
type="date"
placeholder="End Date"
class="border p-2 rounded w-full"
value={form.end_date}
onInput={(e) => setForm({ ...form, end_date: e.currentTarget.value })}
/>
<input
name="rent"
type="number"
placeholder="Rent"
required
class="border p-2 rounded w-full"
value={form.rent}
onInput={(e) => setForm({ ...form, rent: e.currentTarget.value })}
/>
<input
name="security_deposit"
type="number"
placeholder="Security Deposit"
class="border p-2 rounded w-full"
value={form.security_deposit}
onInput={(e) =>
setForm({ ...form, security_deposit: e.currentTarget.value })}
/>
<input
name="lease_status"
placeholder="Lease Status"
class="border p-2 rounded w-full"
value={form.lease_status}
onInput={(e) =>
setForm({ ...form, lease_status: e.currentTarget.value })}
/>
<div class="flex justify-end gap-2 mt-4">
<button
type="button"
class="px-4 py-2 rounded border"
onClick={onClose}
>
Cancel
</button>
<button
type="submit"
class="px-4 py-2 rounded bg-blue-600 text-white"
>
Create Lease
</button>
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,233 @@
import { useEffect, useRef, useState } from "preact/hooks";
import {
encryptDataWithStoredPassword,
getEncryptionPassword,
} from "../../helpers.ts";
interface AddObligationDialogProps {
onClose: () => void;
userId?: string;
}
type FieldErrorState = {
description: boolean;
amount: boolean;
};
export default function AddObligationDialog(
{ onClose, userId }: AddObligationDialogProps,
) {
// Theme classes are defined in styles.css with @media (prefers-color-scheme: dark)
const formRef = useRef<HTMLFormElement>(null);
const firstInputRef = useRef<HTMLInputElement>(null);
const [loading, setLoading] = useState(false);
const [fieldErrors, setFieldErrors] = useState<FieldErrorState>({
description: false,
amount: false,
});
const today = new Date().toISOString().split("T")[0];
const addNewObligation = async () => {
if (!formRef.current) return;
// Collect form data
const formData = new FormData(formRef.current);
const description = formData.get("description")?.toString().trim() || "";
const amount = formData.get("amount")?.toString().trim() || "";
const frequency = formData.get("frequency")?.toString() || "Monthly";
const dueDate = formData.get("due_date")?.toString() || today;
const sender = typeof location !== "undefined" ? location.pathname : "/";
const validation = {
description: !description,
amount: !amount,
};
setFieldErrors(validation);
if (validation.description || validation.amount) {
return;
}
setLoading(true);
localStorage.setItem(
"lock-route",
"/calendar/budget-obligations?modal=off",
);
// Build submission data
const submitData = new FormData();
submitData.set("sender", sender);
submitData.set("frequency", frequency);
submitData.set("due_date", dueDate);
// Encrypt if password is available
const encryptionPassword = userId ? getEncryptionPassword(userId) : null;
if (encryptionPassword && (description || amount)) {
try {
const dataToEncrypt = {
description,
amount: parseFloat(amount) || 0,
};
const encryptedData = await encryptDataWithStoredPassword(
userId!,
JSON.stringify(dataToEncrypt),
);
if (encryptedData) {
submitData.set("encrypted_data", encryptedData);
// Send empty plaintext when encrypted
submitData.set("description", "");
submitData.set("amount", "0");
} else {
throw new Error("Encryption returned empty result");
}
} catch (_err) {
setLoading(false);
return;
}
} else {
submitData.set("description", description);
submitData.set("amount", amount);
submitData.set("encrypted_data", "");
}
// Submit via fetch - let redirects follow automatically
try {
const response = await fetch("/api/create/obligation", {
method: "POST",
body: submitData,
credentials: "same-origin",
});
// Fetch followed the redirect, now navigate to final URL or sender
if (response.ok) {
globalThis.location.href = "/calendar/budget-obligations?modal=off";
} else {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || "Failed to create obligation");
}
} catch (_err) {
setLoading(false);
}
};
const clearFieldError = (field: keyof FieldErrorState) => {
setFieldErrors((prev) => prev[field] ? { ...prev, [field]: false } : prev);
};
const getInputErrorClass = (hasError: boolean) =>
hasError ? "border-red-500 ring-1 ring-red-300" : "";
useEffect(() => {
firstInputRef.current?.focus();
}, []);
return (
<div
class="theme-dialog-overlay"
style={{ paddingTop: "calc(50vh - 400px)" }}
>
<div class="theme-dialog-container flex flex-col gap-2">
<form ref={formRef}>
<h2 class="theme-dialog-title">Add Obligation</h2>
<div class="flex flex-col gap-1 my-4">
<input
ref={firstInputRef}
class={`theme-dialog-input ${
getInputErrorClass(fieldErrors.description)
}`}
name="description"
placeholder="Description"
required
onInput={() => clearFieldError("description")}
/>
<div
class={`text-red-500 text-xs min-h-4 ${
fieldErrors.description ? "visible" : "invisible"
}`}
aria-live="polite"
>
Description is required.
</div>
</div>
<div class="flex flex-col gap-1 my-4">
<input
class={`theme-dialog-input ${
getInputErrorClass(fieldErrors.amount)
}`}
name="amount"
placeholder="Amount"
type="number"
step="0.01"
required
onInput={() => clearFieldError("amount")}
/>
<div
class={`text-red-500 text-xs min-h-4 ${
fieldErrors.amount ? "visible" : "invisible"
}`}
aria-live="polite"
>
Amount is required.
</div>
</div>
<div class="flex flex-col gap-1 my-4">
<select
class="theme-dialog-select"
name="frequency"
defaultValue="Monthly"
>
<option value="One-time">One-time</option>
<option value="Biweekly">Biweekly</option>
<option value="Weekly">Weekly</option>
<option value="Monthly">Monthly</option>
<option value="Quarterly">Quarterly</option>
<option value="Yearly">Yearly</option>
</select>
</div>
<div class="flex flex-col gap-1 my-4">
<input
class="theme-dialog-input"
name="due_date"
type="date"
defaultValue={today}
required
/>
</div>
</form>
<div class="flex justify-end gap-2 mt-2">
<button
type="button"
class={`theme-dialog-cancel-button w-40 ${
loading ? "opacity-70" : ""
}`}
onClick={() => {
localStorage.setItem(
"lock-route",
"/calendar/budget-obligations?modal=off",
);
setLoading(true);
onClose();
}}
disabled={loading}
>
Cancel
</button>
<button
type="button"
class={`theme-dialog-submit-button w-40 ${
loading ? "opacity-70" : ""
}`}
disabled={loading}
onClick={addNewObligation}
>
{loading ? "Adding..." : "Add"}
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,110 @@
import { useState } from "preact/hooks";
interface AddRentalDialogProps {
properties: any[];
onClose?: () => void;
}
export default function AddRentalDialog(
{ properties, onClose }: AddRentalDialogProps,
) {
const [form, setForm] = useState({
title: "",
address_1: "",
city: "",
state: "",
price: "",
image_url: "",
description: "",
});
return (
<div class="fixed inset-0 flex items-center justify-center bg-black bg-opacity-30 z-50">
<form
method="POST"
action="/rental-property"
class="bg-white p-6 rounded shadow-lg w-full max-w-md space-y-4"
>
<h3 class="text-xl font-bold mb-2">Add Rental Property</h3>
<input
name="properties"
type="hidden"
value={JSON.stringify(properties)}
/>
<input
name="title"
placeholder="Title"
required
class="border p-2 rounded w-full"
value={form.title}
onInput={(e) => setForm({ ...form, title: e.currentTarget.value })}
/>
<input
name="address_1"
placeholder="Address"
required
class="border p-2 rounded w-full"
value={form.address_1}
onInput={(e) =>
setForm({ ...form, address_1: e.currentTarget.value })}
/>
<input
name="city"
placeholder="City"
required
class="border p-2 rounded w-full"
value={form.city}
onInput={(e) => setForm({ ...form, city: e.currentTarget.value })}
/>
<input
name="state"
placeholder="State"
required
class="border p-2 rounded w-full"
value={form.state}
onInput={(e) => setForm({ ...form, state: e.currentTarget.value })}
/>
<input
name="price"
type="number"
placeholder="Price"
required
class="border p-2 rounded w-full"
value={form.price}
onInput={(e) => setForm({ ...form, price: e.currentTarget.value })}
/>
<input
name="image_url"
placeholder="Image URL"
class="border p-2 rounded w-full"
value={form.image_url}
onInput={(e) =>
setForm({ ...form, image_url: e.currentTarget.value })}
/>
<textarea
name="description"
placeholder="Description"
class="border p-2 rounded w-full"
value={form.description}
onInput={(e) =>
setForm({ ...form, description: e.currentTarget.value })}
/>
<div class="flex justify-end gap-2 mt-4">
<button
type="button"
class="px-4 py-2 rounded border"
onClick={onClose}
>
Cancel
</button>
<button
type="submit"
class="px-4 py-2 rounded bg-blue-600 text-white"
>
Create Rental
</button>
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,121 @@
import { useEffect, useMemo, useState } from "preact/hooks";
interface PropertyItem {
id: string;
title?: string | null;
address_1?: string | null;
city?: string | null;
state?: string | null;
}
interface GuestOnboardingDialogProps {
properties: PropertyItem[];
onClose?: () => void;
defaultRole?: "tenant" | "landlord";
}
export default function GuestOnboardingDialog(
{ properties, onClose, defaultRole }: GuestOnboardingDialogProps,
) {
const [role, setRole] = useState<"tenant" | "landlord" | "">(
defaultRole ?? "",
);
const [propertyId, setPropertyId] = useState<string>("");
const canSubmit = useMemo(() => {
if (role === "landlord") return true;
if (role === "tenant") return !!propertyId;
return false;
}, [role, propertyId]);
useEffect(() => {
// Reset property selection if switching away from tenant
if (role !== "tenant") setPropertyId("");
}, [role]);
return (
<div class="fixed inset-0 flex items-center justify-center bg-black bg-opacity-30 z-50">
<form
method="POST"
class="bg-white p-6 rounded shadow-lg w-full max-w-md space-y-4"
>
<h3 class="text-xl font-bold">Welcome! Let's get you set up</h3>
<p class="text-sm text-gray-600">
Please select how you'll use the app.
</p>
<div class="space-y-2">
<label class="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="role"
value="tenant"
checked={role === "tenant"}
onChange={() => setRole("tenant")}
/>
<span>I'm a Tenant</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="role"
value="landlord"
checked={role === "landlord"}
onChange={() => setRole("landlord")}
/>
<span>I'm a Landlord</span>
</label>
</div>
{role === "tenant" && (
<div class="space-y-1">
<label for="property_id" class="text-sm font-medium text-gray-700">
Select your property
</label>
<select
id="property_id"
name="property_id"
required
class="border p-2 rounded w-full"
value={propertyId}
onChange={(e) =>
setPropertyId((e.target as HTMLSelectElement).value)}
>
<option value="" disabled>Select a property…</option>
{properties?.map((p) => (
<option key={p.id} value={p.id}>
{p.title || p.address_1 || "Property"}
{p.city ? `, ${p.city}` : ""}
{p.state ? `, ${p.state}` : ""}
</option>
))}
</select>
</div>
)}
<div class="flex justify-end gap-2 pt-2">
<button
type="button"
class="px-4 py-2 rounded border"
onClick={onClose}
>
Cancel
</button>
<button
type="submit"
name="sender"
value="guest_onboarding"
class={`px-4 py-2 rounded text-white ${
canSubmit
? "bg-blue-600 hover:bg-blue-700"
: "bg-gray-400 cursor-not-allowed"
}`}
disabled={!canSubmit}
>
Continue
</button>
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,40 @@
import { useState } from "preact/hooks";
import AddIncomeDialog from "./AddIncomeDialog.tsx";
interface PopAddIncomeDialogOptions {
userId?: string;
defaultOpen?: boolean;
}
interface PopupController {
resolve?: (value: boolean) => void;
}
export function PopAddIncomeDialog(options: PopAddIncomeDialogOptions = {}) {
const [popup, setPopup] = useState<PopupController | null>(() => {
return options.defaultOpen ? { resolve: () => {} } : null;
});
// Show the popup and return a Promise for the answer
const popAddIncome = (): Promise<boolean> => {
return new Promise((resolve) => {
setPopup({ resolve });
});
};
// Render the popup if needed
const popupAddIncomeDialog = popup
? (
<AddIncomeDialog
onClose={() => {
globalThis.location.href = "/calendar/every-income?modal=off";
// popup?.resolve?.(false);
// setPopup(null);
}}
userId={options.userId}
/>
)
: null;
return [popAddIncome, popupAddIncomeDialog] as const;
}

View File

@ -0,0 +1,58 @@
// islands/AsyncYesNoPop.ts
import { useState } from "preact/hooks";
import AddLeaseDialog from "./AddLeaseDialog.tsx";
import { Lease, Property } from "../../db/interfaces.ts";
export function PopAddLeaseDialog(leases: Lease[], properties: Property[]) {
const [popup, setPopup] = useState<
{
propertyId: string;
popupKey: number;
resolve: (value: boolean) => void;
} | null
>(null);
function getPropertyInfo(propertyId: string) {
const property = properties.find((p) => p.id === propertyId);
return property
? {
title: property.title,
address: property.address_1,
price: property.price,
}
: { title: "", address: "", price: undefined };
}
// Show the popup and return a Promise for the answer
const popAddLease = (
propertyId: string,
popupKey: number,
): Promise<boolean> => {
return new Promise((resolve) => {
setPopup({ propertyId, popupKey, resolve });
});
};
// Render the popup if needed
const popupAddLeaseDialog = popup
? (() => {
const { title, address, price } = getPropertyInfo(popup.propertyId);
return (
<AddLeaseDialog
leases={leases}
propertyId={popup.propertyId}
propertyTitle={title || ""}
propertyAddress={address || ""}
propertyCost={price || undefined}
popupKey={popup.popupKey}
onClose={() => {
popup.resolve(false);
setPopup(null);
}}
/>
);
})()
: null;
return [popAddLease, popupAddLeaseDialog] as const;
}

View File

@ -0,0 +1,42 @@
import { useState } from "preact/hooks";
import AddObligationDialog from "./AddObligationDialog.tsx";
interface PopAddObligationDialogOptions {
userId?: string;
defaultOpen?: boolean;
}
interface PopupController {
resolve?: (value: boolean) => void;
}
export function PopAddObligationDialog(
options: PopAddObligationDialogOptions = {},
) {
const [popup, setPopup] = useState<PopupController | null>(() => {
return options.defaultOpen ? { resolve: () => {} } : null;
});
// Show the popup and return a Promise for the answer
const popAddObligation = (): Promise<boolean> => {
return new Promise((resolve) => {
setPopup({ resolve });
});
};
// Render the popup if needed
const popupAddObligationDialog = popup
? (
<AddObligationDialog
onClose={() => {
globalThis.location.href = "/calendar/budget-obligations?modal=off";
// popup?.resolve?.(false);
// setPopup(null);
}}
userId={options.userId}
/>
)
: null;
return [popAddObligation, popupAddObligationDialog] as const;
}

View File

@ -0,0 +1,31 @@
import { useState } from "preact/hooks";
import AddRentalDialog from "./AddRentalDialog.tsx";
import { Property } from "../../db/interfaces.ts";
export function PopAddRentalDialog(properties: Property[]) {
const [popup, setPopup] = useState<
{
question: string;
resolve: (value: boolean) => void;
} | null
>(null);
// Show the popup and return a Promise for the answer
const popAddRental = (question: string): Promise<boolean> => {
return new Promise((resolve) => {
setPopup({ question, resolve });
});
};
// Render the popup if needed
const popupAddRentalDialog = popup
? (
<AddRentalDialog
properties={properties}
onClose={() => setPopup(null)}
/>
)
: null;
return [popAddRental, popupAddRentalDialog] as const;
}

View File

@ -0,0 +1,17 @@
export default function AdminCenterTile() {
return (
<button
type="button"
class="rounded-xl shadow-lg bg-white hover:bg-blue-50 transition flex flex-col items-center p-6 w-full h-48 focus:outline-none focus:ring-2 focus:ring-blue-400"
onClick={() => {
globalThis.location.href = "/admin-center";
}}
>
<span class="tile-icon text-4xl mb-2">🔐</span>
<span class="font-semibold text-lg mb-1">Admin Center</span>
<span class="text-gray-500 text-sm text-center">
Access administrative tools and system settings.
</span>
</button>
);
}

View File

@ -0,0 +1,15 @@
export default function BudgetObligationsTile() {
return (
<button
type="button"
class="rounded-xl shadow-lg bg-white hover:bg-blue-50 transition flex flex-col items-center p-6 w-full h-48 focus:outline-none focus:ring-2 focus:ring-blue-400"
onClick={() => {
globalThis.location.href = "/calendar/budget-obligations";
}}
>
<span class="tile-icon text-4xl mb-2">📄</span>
<span class="font-semibold text-lg mb-1">Budget Obligations</span>
<span class="text-gray-500 text-sm text-center">Budget obligations.</span>
</button>
);
}

View File

@ -0,0 +1,15 @@
export default function EveryIncomeTile() {
return (
<button
type="button"
class="rounded-xl shadow-lg bg-white hover:bg-blue-50 transition flex flex-col items-center p-6 w-full h-48 focus:outline-none focus:ring-2 focus:ring-blue-400"
onClick={() => {
globalThis.location.href = "/calendar/every-income";
}}
>
<span class="tile-icon text-4xl mb-2">💰</span>
<span class="font-semibold text-lg mb-1">Income Sources</span>
<span class="text-gray-500 text-sm text-center">Sources of income.</span>
</button>
);
}

View File

@ -0,0 +1,19 @@
export default function FinancialCalendarTile() {
return (
<button
type="button"
class="rounded-xl shadow-lg bg-white hover:bg-blue-50 transition flex flex-col items-center p-6 w-full h-48 focus:outline-none focus:ring-2 focus:ring-blue-400"
onClick={() => {
globalThis.location.href = `/calendar/financial-calendar/${
new Date().getFullYear()
}/${(new Date().getMonth() + 1).toString().padStart(2, "0")}?`;
}}
>
<span class="tile-icon text-4xl mb-2">📅</span>
<span class="font-semibold text-lg mb-1">Financial Calendar</span>
<span class="text-gray-500 text-sm text-center">
View upcoming financial events and deadlines.
</span>
</button>
);
}

View File

@ -0,0 +1,98 @@
import { FunctionalComponent } from "preact";
import { useEffect, useState } from "preact/hooks";
interface FloatingTileButtonsProps {
ids: { id: string; label: string }[];
}
const isNotVisibleTop = (elem: HTMLElement | null) => {
if (!elem) return false;
const rect = elem.getBoundingClientRect();
// If the bottom is above the top of the viewport (with threshold), it's not visible at the top
return rect.bottom - 100 < 0;
};
const isNotVisibleBottom = (elem: HTMLElement | null) => {
if (!elem) return false;
const rect = elem.getBoundingClientRect();
// If the top is below the bottom of the viewport (with threshold), it's not visible at the bottom
return rect.top + 100 >
(globalThis.innerHeight ||
globalThis.document.documentElement.clientHeight);
};
const FloatingTileButtons: FunctionalComponent<FloatingTileButtonsProps> = (
{ ids },
) => {
const [notVisibleTop, setNotVisibleTop] = useState<string[]>([]);
const [notVisibleBottom, setNotVisibleBottom] = useState<string[]>([]);
useEffect(() => {
const checkTiles = () => {
const topList = ids.filter(({ id }) => {
const elem = globalThis.document.getElementById(id);
return isNotVisibleTop(elem);
}).map(({ id }) => id);
const bottomList = ids.filter(({ id }) => {
const elem = globalThis.document.getElementById(id);
return !topList.includes(id) && isNotVisibleBottom(elem);
}).map(({ id }) => id);
setNotVisibleTop(topList);
setNotVisibleBottom(bottomList);
};
checkTiles();
globalThis.addEventListener("scroll", checkTiles, { passive: true });
globalThis.addEventListener("resize", checkTiles);
return () => {
globalThis.removeEventListener("scroll", checkTiles);
globalThis.removeEventListener("resize", checkTiles);
};
}, [ids]);
return (
<>
{/* Top IDs Section */}
{notVisibleTop.length > 0 && (
<div class="fixed top-6 right-4 z-50 flex flex-col gap-2 sm:hidden">
{ids.filter(({ id }) => notVisibleTop.includes(id)).map((
{ id, label },
) => (
<button
key={id}
type="button"
class="bg-blue-500 hover:bg-blue-600 text-white rounded-full shadow-lg p-3 transition-all"
onClick={() =>
globalThis.document.getElementById(id)?.scrollIntoView({
behavior: "smooth",
})}
>
{label}
</button>
))}
</div>
)}
{/* Bottom IDs Section */}
{notVisibleBottom.length > 0 && (
<div class="fixed bottom-6 right-4 z-50 flex flex-col gap-2 sm:hidden">
{ids.filter(({ id }) => notVisibleBottom.includes(id)).map((
{ id, label },
) => (
<button
key={id}
type="button"
class="bg-blue-500 hover:bg-blue-600 text-white rounded-full shadow-lg p-3 transition-all"
onClick={() =>
globalThis.document.getElementById(id)?.scrollIntoView({
behavior: "smooth",
})}
>
{label}
</button>
))}
</div>
)}
</>
);
};
export default FloatingTileButtons;

View File

@ -0,0 +1,17 @@
export default function GuestDemoTile() {
return (
<button
type="button"
class="rounded-xl shadow-lg bg-white hover:bg-blue-50 transition flex flex-col items-center p-6 w-full h-48 focus:outline-none focus:ring-2 focus:ring-blue-400"
onClick={() => {
globalThis.location.href = "/guest-demo";
}}
>
<span class="tile-icon text-4xl mb-2">🎟️</span>
<span class="font-semibold text-lg mb-1">Guest Demo</span>
<span class="text-gray-500 text-sm text-center">
Explore the platform as a guest user.
</span>
</button>
);
}

View File

@ -0,0 +1,17 @@
export default function LeasedHomeTile() {
return (
<button
type="button"
class="rounded-xl shadow-lg bg-white hover:bg-blue-50 transition flex flex-col items-center p-6 w-full h-48 focus:outline-none focus:ring-2 focus:ring-blue-400"
onClick={() => {
globalThis.location.href = "/leased-home";
}}
>
<span class="tile-icon text-4xl mb-2">🏠</span>
<span class="font-semibold text-lg mb-1">Leased Home</span>
<span class="text-gray-500 text-sm text-center">
View and manage your leased properties.
</span>
</button>
);
}

View File

@ -0,0 +1,17 @@
export default function RentalPropertyTile() {
return (
<button
type="button"
class="rounded-xl shadow-lg bg-white hover:bg-blue-50 transition flex flex-col items-center p-6 w-full h-48 focus:outline-none focus:ring-2 focus:ring-blue-400"
onClick={() => {
globalThis.location.href = "/rental-property";
}}
>
<span class="tile-icon text-4xl mb-2">🏢</span>
<span class="font-semibold text-lg mb-1">Rental Property</span>
<span class="text-gray-500 text-sm text-center">
Manage rental listings and tenant information.
</span>
</button>
);
}

View File

@ -0,0 +1,17 @@
export default function UserProfileTile() {
return (
<button
type="button"
class="rounded-xl shadow-lg bg-white hover:bg-blue-50 transition flex flex-col items-center p-6 w-full h-48 focus:outline-none focus:ring-2 focus:ring-blue-400"
onClick={() => {
globalThis.location.href = "/user-profile";
}}
>
<span class="tile-icon text-4xl mb-2">👤</span>
<span class="font-semibold text-lg mb-1">User Profile</span>
<span class="text-gray-500 text-sm text-center">
Manage your personal information and account settings.
</span>
</button>
);
}

203
main.ts Executable file
View File

@ -0,0 +1,203 @@
import { App, Context, staticFiles } from "fresh";
import { type State } from "./utils.ts";
import "$std/dotenv/load.ts";
import { checkDatabaseConnection } from "./db/db.ts";
const isDevelopment = Deno.env.get("DEVELOPMENT") === "true";
//console.log("Development mode:", isDevelopment);
/** Error page HTML for service unavailability */
const SERVICE_UNAVAILABLE_HTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Service Unavailable</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: white;
border-radius: 16px;
padding: 48px;
max-width: 500px;
text-align: center;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
.icon {
font-size: 64px;
margin-bottom: 24px;
}
h1 {
color: #1a202c;
font-size: 24px;
margin-bottom: 16px;
}
p {
color: #4a5568;
font-size: 16px;
line-height: 1.6;
}
</style>
</head>
<body>
<div class="container">
<div class="icon">🔧</div>
<h1>The problem is on our side!</h1>
<p>We will fix this as soon as we can.</p>
</div>
</body>
</html>`;
/** Check if required KV cache files exist */
function checkKvCacheFiles(): boolean {
// const requiredFiles = ["./data/kv-cache-shm", "./data/kv-cache-wal"];
// for (const file of requiredFiles) {
// try {
// Deno.statSync(file);
// } catch {
// console.error(`⚠️ Required file missing: ${file}`);
// return false;
// }
// }
return true;
}
/** Perform all startup checks */
async function performStartupChecks(): Promise<boolean> {
//console.log("Checking startup requirements...");
// Check KV cache files
const kvFilesExist = checkKvCacheFiles();
if (!kvFilesExist) {
console.error(
"⚠️ KV cache files missing - app will display maintenance page",
);
return false;
}
//console.log("✅ KV cache files found");
// Check database connection
//console.log("Checking database connection...");
const dbAvailable = await checkDatabaseConnection();
if (!dbAvailable) {
console.error(
"⚠️ Database connection failed - app will display maintenance page",
);
return false;
}
//console.log("✅ Database connection successful");
return true;
}
// Run startup checks first
const isAppReady = await performStartupChecks();
// Create app based on startup check result
export const app = new App<State>();
if (isAppReady) {
// Full app with all routes and middleware
const { InitiPGSessionMiddleware } = await import(
"./services/stateMiddleware.ts"
);
app.use(staticFiles());
app.get("/health", (_ctx: Context<State>) => {
console.log("Health check");
return new Response("OK");
});
// app.post("/graph-update", async (ctx: Context<State>) => {
// console.log("Graph update");
// const body = await ctx.req.json();
// //console.log(body);
// //const { userId, roles, organization } = body;
// // await inMemoryCacheKv.set(["ciam", userId], {
// // userId,
// // roles,
// // organization,
// // lastSynced: Date.now(),
// // });
// //console.log(`Graph update applied for user ${userId}`);
// return new Response("Graph update stored in cache", { status: 202 });
// });
// TODO: add a well-known endpoint
// app.get("/.well-known/acme-challenge/:token", (ctx: Context<State>) => {
// const token = ctx.params.token;
// const content = `${token}.EXAMPLE_ACCOUNT_THUMBPRINT`;
// return new Response(content, {
// status: 200,
// headers: {
// "content-type": "text/plain; charset=utf-8",
// "cache-control": "public, max-age=0, must-revalidate",
// },
// });
// });
app.use(InitiPGSessionMiddleware());
app.fsRoutes();
//console.log("✅ Full application loaded");
} else {
// Maintenance mode - serve error page for all routes
app.use(staticFiles());
app.get("/health", () => {
return new Response("Service Unavailable", { status: 503 });
});
app.use(() => {
return new Response(SERVICE_UNAVAILABLE_HTML, {
status: 503,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
});
console.log("⚠️ Maintenance mode - serving error page only");
// Start periodic database connection check (every 60 seconds)
setInterval(async () => {
console.log("🔄 Checking if database connection is restored...");
const dbAvailable = await checkDatabaseConnection();
if (dbAvailable) {
console.log("✅ Database connection restored! Restarting app...");
// Check if running in development mode
const restartCmd = isDevelopment ? "exec deno task dev" : "exec ./server";
// Use exec to replace shell with new process, keeping terminal connection
const command = new Deno.Command("sh", {
args: [
"-c",
`lsof -ti:8080 | xargs kill -9 2>/dev/null || true; ${restartCmd}`,
],
cwd: Deno.cwd(),
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
});
command.spawn();
Deno.exit(0);
} else {
console.log("❌ Database still unavailable, will retry in 60 seconds");
}
}, 60_000);
}
// if (import.meta.main) {
// await app.listen();
// }

1011
modules/KvCacheStore.ts Executable file

File diff suppressed because it is too large Load Diff

192
photo-editor/hooks.ts Normal file
View File

@ -0,0 +1,192 @@
import { useCallback, useState } from "preact/hooks";
export function usePhotoEditorState() {
const [state, setState] = useState({
previewUrl: null as string | null,
cropSize: 256,
crop: { x: 0, y: 0, size: 256 },
zoom: 1.0,
minZoom: 0.2,
maxWidth: 0,
maxHeight: 0,
baseDimensions: { width: 0, height: 0 },
isLandscape: true,
dragging: false,
heicToLoaded: false,
zoomInDisabled: false,
});
const updateState = useCallback((updates: Partial<typeof state>) => {
setState((prev) => ({ ...prev, ...updates }));
}, []);
const resetCropState = useCallback(() => {
setState((prev) => ({
...prev,
cropSize: 256,
crop: { x: 0, y: 0, size: 256 },
zoom: Math.max(1, prev.minZoom),
}));
}, []);
return { state, updateState, resetCropState };
}
export function useImageProcessor(state: { heicToLoaded: boolean }) {
const [processing, setProcessing] = useState(false);
const processImageFile = useCallback(
async (file: File): Promise<string | null> => {
setProcessing(true);
try {
const validImageTypes = [
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/webp",
"image/bmp",
"image/svg+xml",
"image/heic",
"image/heif",
];
const fileExtension = file.name.toLowerCase().split(".").pop();
const isImageByType = file.type.startsWith("image/") ||
validImageTypes.includes(file.type);
const isImageByExtension = [
"jpg",
"jpeg",
"png",
"gif",
"webp",
"bmp",
"svg",
"heic",
"heif",
]
.includes(fileExtension || "");
if (!isImageByType && !isImageByExtension) {
alert(
`Unsupported file type: ${
file.type || "unknown"
}\n\nPlease select an image file.`,
);
return null;
}
const maxRecommendedSize = 20 * 1024 * 1024;
if (file.size > maxRecommendedSize) {
const sizeMB = (file.size / (1024 * 1024)).toFixed(1);
const proceed = confirm(
`Large file detected (${sizeMB}MB).\n\nProcessing large images may be slow or cause the browser to become unresponsive.\n\nDo you want to continue?`,
);
if (!proceed) return null;
}
let processedFile: Blob = file;
if (state.heicToLoaded && globalThis.isHeic && globalThis.heicTo) {
try {
const isHeicFile = await globalThis.isHeic(file);
if (isHeicFile) {
processedFile = await globalThis.heicTo({
blob: file,
type: "image/bmp",
quality: 1.0,
});
}
} catch (error) {
console.error("HEIC conversion failed:", error);
}
}
const dataUrl = URL.createObjectURL(processedFile);
return Promise.resolve(dataUrl);
} finally {
setProcessing(false);
}
},
[state.heicToLoaded],
);
return { processImageFile, processing };
}
export function useCropCalculator(
state: {
isLandscape: boolean;
baseDimensions: { width: number; height: number };
zoom: number;
cropSize: number;
maxHeight: number;
maxWidth: number;
},
updateState: (updates: Record<string, unknown>) => void,
) {
const getPixelDimensions = useCallback(() => {
function getPixelDimensions() {
const longerDimension = state.isLandscape
? state.baseDimensions.width
: state.baseDimensions.height;
if (longerDimension <= 0) {
return {
width: 256,
height: 256,
actualZoom: 1,
overRunPercent: 0,
};
}
const calculatedDimension = Math.round(longerDimension * state.zoom);
let clampedDimension = Math.max(calculatedDimension, state.cropSize);
let constrainedByMax = false;
if (
!state.isLandscape && state.maxHeight > 0 &&
clampedDimension > state.maxHeight
) {
clampedDimension = state.maxHeight;
constrainedByMax = true;
} else if (
state.isLandscape && state.maxWidth > 0 &&
clampedDimension > state.maxWidth
) {
clampedDimension = state.maxWidth;
constrainedByMax = true;
}
const actualZoom = clampedDimension / longerDimension;
let overRunPercent = constrainedByMax && actualZoom > 0
? ((state.zoom - actualZoom) / actualZoom)
: 0;
if (!isFinite(overRunPercent)) {
overRunPercent = 0;
}
if (state.isLandscape) {
return {
width: Math.max(256, clampedDimension),
height: "auto" as const,
actualZoom: Math.max(0.01, actualZoom),
overRunPercent,
};
} else {
return {
width: "auto" as const,
height: Math.max(256, clampedDimension),
actualZoom: Math.max(0.01, actualZoom),
overRunPercent,
};
}
}
return getPixelDimensions();
}, [state, updateState]);
return { getPixelDimensions };
}

Some files were not shown because too many files have changed in this diff Show More