initial
This commit is contained in:
commit
3593fee561
19
.gitignore
vendored
Executable file
19
.gitignore
vendored
Executable 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
6
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"denoland.vscode-deno",
|
||||||
|
"bradlc.vscode-tailwindcss"
|
||||||
|
]
|
||||||
|
}
|
||||||
20
.vscode/settings.json
vendored
Normal file
20
.vscode/settings.json
vendored
Normal 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
124
AGENTS.md
Normal 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
321
README.md
Executable 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
65
assets/kvWorker.ts
Executable 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
366
assets/styles.css
Executable 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
31
certs/isrg-root-x1.pem
Normal 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
79
certs/root.crt
Normal 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
48
certs/server.crt
Normal 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
49
certs/server2.crt
Normal 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
2
client.ts
Executable 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
17
components/Button.tsx
Normal 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
281
cyberhybridhub-web.sh
Executable 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
24
db/db.ts
Executable 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
275
db/interfaces.ts
Executable 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
390
db/schema.ts
Executable 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
1034
db/sqlUserRepository.ts
Normal file
File diff suppressed because it is too large
Load Diff
BIN
debug-invite-onboarding.png
Normal file
BIN
debug-invite-onboarding.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 243 KiB |
BIN
debug-onboarding.png
Normal file
BIN
debug-onboarding.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 330 KiB |
82
deno.json
Executable file
82
deno.json
Executable 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
10
drizzle.config.ts
Normal 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")!,
|
||||||
|
},
|
||||||
|
});
|
||||||
332
drizzle/0000_gifted_stepford_cuckoos.sql
Normal file
332
drizzle/0000_gifted_stepford_cuckoos.sql
Normal 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;
|
||||||
43
drizzle/0003_brain_mode_progression.sql
Normal file
43
drizzle/0003_brain_mode_progression.sql
Normal 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";
|
||||||
17
drizzle/0004_quiz_tab_fields.sql
Normal file
17
drizzle/0004_quiz_tab_fields.sql
Normal 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;
|
||||||
15
drizzle/0005_user_onboarding_flags.sql
Normal file
15
drizzle/0005_user_onboarding_flags.sql
Normal 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';
|
||||||
23
drizzle/0006_tenant_invites.sql
Normal file
23
drizzle/0006_tenant_invites.sql
Normal 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;
|
||||||
2151
drizzle/meta/0000_snapshot.json
Normal file
2151
drizzle/meta/0000_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
13
drizzle/meta/_journal.json
Normal file
13
drizzle/meta/_journal.json
Normal 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
227
drizzle/relations.ts
Normal 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
446
drizzle/schema.ts
Normal 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
257
helpers.ts
Executable 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
156
islands/AddIncomeModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
islands/AddObligationModal.tsx
Normal file
160
islands/AddObligationModal.tsx
Normal 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
121
islands/AdminCenter.tsx
Normal 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
249
islands/AdminPage.tsx
Normal 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
372
islands/ArrangeQuiz.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
islands/AsyncImageGenerater.tsx
Normal file
44
islands/AsyncImageGenerater.tsx
Normal 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;
|
||||||
|
}
|
||||||
61
islands/AsyncVoiceRecorder.tsx
Normal file
61
islands/AsyncVoiceRecorder.tsx
Normal 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
37
islands/AsyncYesNoPop.tsx
Normal 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
770
islands/BrainStudy.tsx
Normal 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
856
islands/Card.tsx
Normal 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, "");
|
||||||
|
}
|
||||||
48
islands/ClearCheckedButton.tsx
Normal file
48
islands/ClearCheckedButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1042
islands/FinancialCalendar.tsx
Normal file
1042
islands/FinancialCalendar.tsx
Normal file
File diff suppressed because it is too large
Load Diff
288
islands/GuestHome.tsx
Executable file
288
islands/GuestHome.tsx
Executable 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">
|
||||||
|
> 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
islands/GuestOnboardingDialog.tsx
Normal file
120
islands/GuestOnboardingDialog.tsx
Normal 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
217
islands/ImageGenerater.tsx
Normal 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
283
islands/IncomeList.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
563
islands/LandlordInviteTenant.tsx
Normal file
563
islands/LandlordInviteTenant.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
islands/LandlordMessageSelection.tsx
Normal file
66
islands/LandlordMessageSelection.tsx
Normal 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" />;
|
||||||
|
}
|
||||||
122
islands/LandlordMessageThreads.tsx
Normal file
122
islands/LandlordMessageThreads.tsx
Normal 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
149
islands/LeasedHome.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
islands/LoginPurposeButton.tsx
Normal file
33
islands/LoginPurposeButton.tsx
Normal 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
543
islands/MemberHome.tsx
Normal 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">
|
||||||
|
> 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
321
islands/ObligationList.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
islands/OnboardingHashGate.tsx
Normal file
23
islands/OnboardingHashGate.tsx
Normal 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;
|
||||||
|
}
|
||||||
894
islands/PhotoCropperModal.tsx
Normal file
894
islands/PhotoCropperModal.tsx
Normal 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
851
islands/PhotoUpdate.tsx
Normal 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
229
islands/QuizConfigure.tsx
Normal 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
416
islands/Quizzing.tsx
Normal 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
108
islands/RentalsOwned.tsx
Normal 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
39
islands/StudyButton.tsx
Normal 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
77
islands/SyncButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
islands/TenantMessageSelection.tsx
Normal file
77
islands/TenantMessageSelection.tsx
Normal 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" />;
|
||||||
|
}
|
||||||
129
islands/TenantMessageThreads.tsx
Normal file
129
islands/TenantMessageThreads.tsx
Normal 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
206
islands/TenantSettings.tsx
Normal 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
407
islands/TranslationList.tsx
Normal 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
1360
islands/UserDashboard.tsx
Normal file
File diff suppressed because it is too large
Load Diff
329
islands/UserProfileEdit.tsx
Normal file
329
islands/UserProfileEdit.tsx
Normal 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
263
islands/VoiceRecorder.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
276
islands/WelcomeBar/EncryptionModal.tsx
Normal file
276
islands/WelcomeBar/EncryptionModal.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
islands/WelcomeBar/LockIcon.tsx
Normal file
126
islands/WelcomeBar/LockIcon.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
226
islands/WelcomeBar/NavigationMenu.tsx
Normal file
226
islands/WelcomeBar/NavigationMenu.tsx
Normal 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
301
islands/WelcomeBar/WelcomeBar.tsx
Executable 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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
176
islands/WelcomeBar/navigationConfig.ts
Normal file
176
islands/WelcomeBar/navigationConfig.ts
Normal 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);
|
||||||
|
}
|
||||||
653
islands/WelcomeBar/useEncryption.ts
Normal file
653
islands/WelcomeBar/useEncryption.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
154
islands/WelcomeBar/useServiceWorker.ts
Normal file
154
islands/WelcomeBar/useServiceWorker.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
117
islands/WelcomeBar/welcome-types.ts
Normal file
117
islands/WelcomeBar/welcome-types.ts
Normal 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
35
islands/YesNoPopup.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
229
islands/dialogs/AddIncomeDialog.tsx
Normal file
229
islands/dialogs/AddIncomeDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
131
islands/dialogs/AddLeaseDialog.tsx
Normal file
131
islands/dialogs/AddLeaseDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
233
islands/dialogs/AddObligationDialog.tsx
Normal file
233
islands/dialogs/AddObligationDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
islands/dialogs/AddRentalDialog.tsx
Normal file
110
islands/dialogs/AddRentalDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
islands/dialogs/GuestOnboardingDialog.tsx
Normal file
121
islands/dialogs/GuestOnboardingDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
islands/dialogs/PopAddIncomeDialog.tsx
Normal file
40
islands/dialogs/PopAddIncomeDialog.tsx
Normal 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;
|
||||||
|
}
|
||||||
58
islands/dialogs/PopAddLeaseDialog.tsx
Normal file
58
islands/dialogs/PopAddLeaseDialog.tsx
Normal 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;
|
||||||
|
}
|
||||||
42
islands/dialogs/PopAddObligationDialog.tsx
Normal file
42
islands/dialogs/PopAddObligationDialog.tsx
Normal 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;
|
||||||
|
}
|
||||||
31
islands/dialogs/PopAddRentalDialog.tsx
Normal file
31
islands/dialogs/PopAddRentalDialog.tsx
Normal 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;
|
||||||
|
}
|
||||||
17
islands/tiles/AdminCenterTile.tsx
Normal file
17
islands/tiles/AdminCenterTile.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
islands/tiles/BudgetObligationsTile.tsx
Normal file
15
islands/tiles/BudgetObligationsTile.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
islands/tiles/EveryIncomeTile.tsx
Normal file
15
islands/tiles/EveryIncomeTile.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
islands/tiles/FinancialCalendarTile.tsx
Normal file
19
islands/tiles/FinancialCalendarTile.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
islands/tiles/FloatingTileButtons.tsx
Normal file
98
islands/tiles/FloatingTileButtons.tsx
Normal 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;
|
||||||
17
islands/tiles/GuestDemoTile.tsx
Normal file
17
islands/tiles/GuestDemoTile.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
islands/tiles/LeasedHomeTile.tsx
Normal file
17
islands/tiles/LeasedHomeTile.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
islands/tiles/RentalPropertyTile.tsx
Normal file
17
islands/tiles/RentalPropertyTile.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
islands/tiles/UserProfileTile.tsx
Normal file
17
islands/tiles/UserProfileTile.tsx
Normal 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
203
main.ts
Executable 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
1011
modules/KvCacheStore.ts
Executable file
File diff suppressed because it is too large
Load Diff
192
photo-editor/hooks.ts
Normal file
192
photo-editor/hooks.ts
Normal 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
Loading…
x
Reference in New Issue
Block a user