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