release/0.4.0 #60
153 changed files with 1303 additions and 14361 deletions
|
|
@ -3,6 +3,7 @@ vendor
|
||||||
.git
|
.git
|
||||||
.forgejo
|
.forgejo
|
||||||
docker/dev
|
docker/dev
|
||||||
|
!docker/dev/container-start.sh
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
.env.testing
|
.env.testing
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
APP_NAME=Laravel
|
APP_NAME=incr
|
||||||
APP_ENV=production
|
APP_ENV=production
|
||||||
APP_KEY=
|
APP_KEY=
|
||||||
APP_DEBUG=false
|
APP_DEBUG=false
|
||||||
|
|
@ -13,7 +13,6 @@ APP_MAINTENANCE_DRIVER=file
|
||||||
|
|
||||||
PHP_CLI_SERVER_WORKERS=4
|
PHP_CLI_SERVER_WORKERS=4
|
||||||
|
|
||||||
BCRYPT_ROUNDS=12
|
|
||||||
|
|
||||||
LOG_CHANNEL=stack
|
LOG_CHANNEL=stack
|
||||||
LOG_STACK=single
|
LOG_STACK=single
|
||||||
|
|
@ -27,7 +26,7 @@ DB_DATABASE=incr
|
||||||
DB_USERNAME=incr_user
|
DB_USERNAME=incr_user
|
||||||
DB_PASSWORD=change_me_in_production
|
DB_PASSWORD=change_me_in_production
|
||||||
|
|
||||||
SESSION_DRIVER=database
|
SESSION_DRIVER=cookie
|
||||||
SESSION_LIFETIME=120
|
SESSION_LIFETIME=120
|
||||||
SESSION_ENCRYPT=true
|
SESSION_ENCRYPT=true
|
||||||
SESSION_PATH=/
|
SESSION_PATH=/
|
||||||
|
|
@ -55,4 +54,3 @@ AWS_DEFAULT_REGION=us-east-1
|
||||||
AWS_BUCKET=
|
AWS_BUCKET=
|
||||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,8 @@
|
||||||
APP_ENV=testing
|
APP_ENV=testing
|
||||||
APP_KEY=base64:+7T2RuonhTIij1yLp3rTOv2uQlYJh0TQulu20MlCA+s=
|
APP_KEY=base64:+7T2RuonhTIij1yLp3rTOv2uQlYJh0TQulu20MlCA+s=
|
||||||
|
|
||||||
DB_CONNECTION=mysql
|
DB_CONNECTION=sqlite
|
||||||
DB_HOST=db
|
DB_DATABASE=:memory:
|
||||||
DB_DATABASE=testing
|
|
||||||
DB_USERNAME=incr_user
|
|
||||||
DB_PASSWORD=incr_password
|
|
||||||
|
|
||||||
SESSION_DRIVER=array
|
SESSION_DRIVER=array
|
||||||
CACHE_STORE=array
|
CACHE_STORE=array
|
||||||
|
|
|
||||||
|
|
@ -4,37 +4,24 @@ on:
|
||||||
push:
|
push:
|
||||||
branches: ['release/*']
|
branches: ['release/*']
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main, 'release/*']
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
ci:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
image: forge.lvl0.xyz/lvl0/incr-ci:php8.3-3
|
||||||
|
|
||||||
services:
|
|
||||||
db:
|
|
||||||
image: mysql:8.0
|
|
||||||
env:
|
|
||||||
MYSQL_DATABASE: testing
|
|
||||||
MYSQL_USER: incr_user
|
|
||||||
MYSQL_PASSWORD: incr_password
|
|
||||||
MYSQL_ROOT_PASSWORD: root_password
|
|
||||||
|
|
||||||
|
# No service container: on this runner a job container cannot reach one
|
||||||
|
# (the service starts fine but lands on a different network). Tests run
|
||||||
|
# against sqlite in memory instead — see .env.testing.
|
||||||
steps:
|
steps:
|
||||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up PHP
|
|
||||||
uses: https://github.com/shivammathur/setup-php@v2
|
|
||||||
with:
|
|
||||||
php-version: '8.3'
|
|
||||||
extensions: pdo_mysql, mbstring, xml, dom, bcmath, gd, exif, pcntl
|
|
||||||
coverage: pcov
|
|
||||||
|
|
||||||
- name: Cache Composer dependencies
|
- name: Cache Composer dependencies
|
||||||
uses: https://data.forgejo.org/actions/cache@v4
|
uses: https://data.forgejo.org/actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.composer/cache
|
path: ~/.cache/composer
|
||||||
key: composer-${{ hashFiles('composer.lock') }}
|
key: composer-${{ hashFiles('composer.lock') }}
|
||||||
restore-keys: composer-
|
restore-keys: composer-
|
||||||
|
|
||||||
|
|
@ -44,20 +31,12 @@ jobs:
|
||||||
- name: Prepare environment
|
- name: Prepare environment
|
||||||
run: cp .env.testing .env
|
run: cp .env.testing .env
|
||||||
|
|
||||||
- name: Wait for MySQL
|
|
||||||
run: |
|
|
||||||
for i in $(seq 1 30); do
|
|
||||||
nc -z db 3306 && echo "MySQL is up" && break
|
|
||||||
echo "Waiting for MySQL... ($i/30)"
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Run migrations
|
|
||||||
run: php artisan migrate --force
|
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: vendor/bin/pint --test
|
run: vendor/bin/pint --test
|
||||||
|
|
||||||
|
- name: Static analysis
|
||||||
|
run: vendor/bin/phpstan analyse --memory-limit=1G --no-progress --error-format=github
|
||||||
|
|
||||||
- name: Tests
|
- name: Tests
|
||||||
run: php artisan test --coverage-clover coverage.xml --coverage-text
|
run: php artisan test --coverage-clover coverage.xml --coverage-text
|
||||||
|
|
||||||
|
|
@ -80,6 +59,7 @@ jobs:
|
||||||
|
|
||||||
- name: Comment coverage on PR
|
- name: Comment coverage on PR
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
|
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
|
|
@ -130,9 +110,17 @@ jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
# Vite 8 needs node >= 22.12, so the CI image's bookworm nodejs is too old
|
||||||
|
# for this job and it gets its own image.
|
||||||
|
image: node:22-bookworm-slim
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
# checkout@v4 without git falls back to a REST API tarball download that
|
||||||
|
# Forgejo does not serve (404); without ca-certificates git cannot verify
|
||||||
|
# the forge's TLS certificate ("CAfile: none").
|
||||||
|
- name: Install git
|
||||||
|
run: apt-get update && apt-get install -y --no-install-recommends git ca-certificates
|
||||||
|
|
||||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
- name: Install JS dependencies
|
- name: Install JS dependencies
|
||||||
|
|
|
||||||
46
.forgejo/workflows/images.yml
Normal file
46
.forgejo/workflows/images.yml
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
name: Build and Push CI Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
# release/* included so a new CI image exists before the release PR is
|
||||||
|
# opened; ci.yml on that branch cannot pass until the image is published.
|
||||||
|
branches: [main, 'release/*']
|
||||||
|
paths:
|
||||||
|
- 'docker/build/**'
|
||||||
|
- '.forgejo/workflows/images.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
images:
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- name: incr-ci
|
||||||
|
file: docker/build/Dockerfile.ci
|
||||||
|
version: php8.3-3
|
||||||
|
steps:
|
||||||
|
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: https://data.forgejo.org/docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Forgejo Registry
|
||||||
|
uses: https://data.forgejo.org/docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: forge.lvl0.xyz
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: https://data.forgejo.org/docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ${{ matrix.file }}
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
forge.lvl0.xyz/lvl0/${{ matrix.name }}:${{ matrix.version }}
|
||||||
|
forge.lvl0.xyz/lvl0/${{ matrix.name }}:latest
|
||||||
|
forge.lvl0.xyz/lvl0/${{ matrix.name }}:${{ github.sha }}
|
||||||
45
.github/workflows/lint.yml
vendored
45
.github/workflows/lint.yml
vendored
|
|
@ -1,45 +0,0 @@
|
||||||
name: linter
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- develop
|
|
||||||
- main
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- develop
|
|
||||||
- main
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
quality:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup PHP
|
|
||||||
uses: shivammathur/setup-php@v2
|
|
||||||
with:
|
|
||||||
php-version: '8.4'
|
|
||||||
|
|
||||||
- name: Install Dependencies
|
|
||||||
run: |
|
|
||||||
composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
|
|
||||||
npm install
|
|
||||||
|
|
||||||
- name: Run Pint
|
|
||||||
run: vendor/bin/pint
|
|
||||||
|
|
||||||
- name: Format Frontend
|
|
||||||
run: npm run format
|
|
||||||
|
|
||||||
- name: Lint Frontend
|
|
||||||
run: npm run lint
|
|
||||||
|
|
||||||
# - name: Commit Changes
|
|
||||||
# uses: stefanzweifel/git-auto-commit-action@v5
|
|
||||||
# with:
|
|
||||||
# commit_message: fix code style
|
|
||||||
# commit_options: '--no-verify'
|
|
||||||
50
.github/workflows/tests.yml
vendored
50
.github/workflows/tests.yml
vendored
|
|
@ -1,50 +0,0 @@
|
||||||
name: tests
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- develop
|
|
||||||
- main
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- develop
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
ci:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup PHP
|
|
||||||
uses: shivammathur/setup-php@v2
|
|
||||||
with:
|
|
||||||
php-version: 8.4
|
|
||||||
tools: composer:v2
|
|
||||||
coverage: xdebug
|
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '22'
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install Node Dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Build Assets
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: Install Dependencies
|
|
||||||
run: composer install --no-interaction --prefer-dist --optimize-autoloader
|
|
||||||
|
|
||||||
- name: Copy Environment File
|
|
||||||
run: cp .env.example .env
|
|
||||||
|
|
||||||
- name: Generate Application Key
|
|
||||||
run: php artisan key:generate
|
|
||||||
|
|
||||||
- name: Tests
|
|
||||||
run: ./vendor/bin/phpunit
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
resources/js/components/ui/*
|
|
||||||
resources/js/ziggy.js
|
|
||||||
resources/views/mail/*
|
|
||||||
19
.prettierrc
19
.prettierrc
|
|
@ -1,19 +0,0 @@
|
||||||
{
|
|
||||||
"semi": true,
|
|
||||||
"singleQuote": true,
|
|
||||||
"singleAttributePerLine": false,
|
|
||||||
"htmlWhitespaceSensitivity": "css",
|
|
||||||
"printWidth": 150,
|
|
||||||
"plugins": ["prettier-plugin-organize-imports", "prettier-plugin-tailwindcss"],
|
|
||||||
"tailwindFunctions": ["clsx", "cn"],
|
|
||||||
"tailwindStylesheet": "resources/css/app.css",
|
|
||||||
"tabWidth": 4,
|
|
||||||
"overrides": [
|
|
||||||
{
|
|
||||||
"files": "**/*.yml",
|
|
||||||
"options": {
|
|
||||||
"tabWidth": 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
76
CHANGELOG.md
Normal file
76
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [0.4.0] - 2026-08-16
|
||||||
|
|
||||||
|
incr began as a tracker for VWCE shares and accumulated a ledger, asset prices,
|
||||||
|
milestones, an onboarding wizard and a React frontend on the way. This release
|
||||||
|
removes all of it. What remains is a counter: click the number to add one, or
|
||||||
|
open a dialog to set it directly.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **The frontend is Blade and Livewire 4 instead of React and Inertia** (#52)
|
||||||
|
- `wire:click="increment"` replaces a page component, a display component, a
|
||||||
|
dialog and the fetch layer between them.
|
||||||
|
- The build output went from 2264 modules and 311 kB of JavaScript to two
|
||||||
|
modules and 13 kB of CSS. No JavaScript is shipped to the browser.
|
||||||
|
- npm dependencies went from 33 to 4: vite, tailwindcss and two plugins.
|
||||||
|
- The page now renders server-side with the count already in the HTML. There
|
||||||
|
is no loading spinner and no fetch after paint.
|
||||||
|
- **The counter is a single integer** (#50)
|
||||||
|
- It was `SUM(quantity)` over an `entries` ledger. Per-increment history was
|
||||||
|
never displayed and is deliberately not kept.
|
||||||
|
- `POST /increment` and `PATCH /count` return JSON with real status codes. The
|
||||||
|
endpoints they replaced returned redirects, which `fetch()` followed — a
|
||||||
|
validation failure reported success.
|
||||||
|
- **Onboarding is a single input for the starting value** (#51)
|
||||||
|
- It was a three-screen wizard asking for a label, a unit, a date, a quantity
|
||||||
|
and a milestone.
|
||||||
|
- A counter sitting at zero used to be indistinguishable from an unconfigured
|
||||||
|
app and was sent back to setup. The gate is now whether a counter exists.
|
||||||
|
- **CI runs in about 40 seconds instead of two to forty minutes** (#46, #57)
|
||||||
|
- PHP is baked into a prebuilt image rather than installed on every run.
|
||||||
|
- Tests run against sqlite in memory rather than a MySQL service container,
|
||||||
|
taking about a second instead of 53.
|
||||||
|
- The Composer cache path was wrong, so no packages were ever cached.
|
||||||
|
- Thirteen migrations squashed into one (#46). The chain created and dropped
|
||||||
|
nine tables to arrive at a single table, and replaying it required
|
||||||
|
MySQL-specific DDL. Existing installations are unaffected.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- Asset and price tracking: the `Asset` and `AssetPrice` models, their
|
||||||
|
controllers, eight routes and two tables (#48)
|
||||||
|
- Milestones, the progress bar and the stats box (#49)
|
||||||
|
- The user layer. The app is single-user with no authentication, but carried a
|
||||||
|
fake user, an auth config, and `users`, `sessions` and `password_reset_tokens`
|
||||||
|
tables that existed only to be bypassed (#53)
|
||||||
|
- 51 unreachable frontend files left by the Laravel starter kit — a layout
|
||||||
|
system, a component library and an unrouted landing page, none of which any
|
||||||
|
page imported (#47)
|
||||||
|
- Stale CI: two GitHub Actions workflows targeting a branch that does not exist,
|
||||||
|
and a Jenkinsfile pointing at a registry the project left months ago (#56)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- PHPStan with larastan at level 7, clean with no baseline (#55)
|
||||||
|
- Test coverage for the counter, from 4 tests covering only milestones to 17
|
||||||
|
covering increment, set-value, validation and onboarding (#54)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The 7-segment display font never loaded in development. Vite serves assets
|
||||||
|
itself, so the absolute `/fonts/` path returned 404 and the browser silently
|
||||||
|
fell back to a monospace face (#59)
|
||||||
|
- The browser tab said "Laravel" and the favicon was the framework default (#59)
|
||||||
|
- `GET /register` returned an error rather than 404: the route and controller
|
||||||
|
outlived the page component they rendered (#53)
|
||||||
|
- `container_tinker` ran expressions but discarded their return values, and
|
||||||
|
`container_db_read` invoked a MariaDB client against a MySQL container (#58)
|
||||||
|
|
||||||
|
### Note
|
||||||
|
|
||||||
|
Price tracking (#38, #35) and a dropdown fix (#17) were closed as won't-fix.
|
||||||
|
They built or repaired subsystems this release removes.
|
||||||
97
CONTRIBUTING.md
Normal file
97
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
# Contributing
|
||||||
|
|
||||||
|
Thanks for your interest in incr. This is a small self-hosted project, issues and
|
||||||
|
pull requests are both welcome.
|
||||||
|
|
||||||
|
## Reporting issues
|
||||||
|
|
||||||
|
Use [Issues](https://forge.lvl0.xyz/lvl0/incr/issues).
|
||||||
|
|
||||||
|
For bugs, include what you expected, what happened, and enough detail to
|
||||||
|
reproduce it. `dev-logs` follows the application log.
|
||||||
|
|
||||||
|
Bear in mind what incr deliberately is: a single counter, incremented by clicking
|
||||||
|
it, with a dialog to set a value directly. Feature requests that add tracking,
|
||||||
|
history or goals are likely to be declined — a previous version had all three and
|
||||||
|
they were removed on purpose.
|
||||||
|
|
||||||
|
## Development setup
|
||||||
|
|
||||||
|
Requires PHP 8.2+ and Docker or Podman. The development environment runs in
|
||||||
|
containers defined by `docker/dev/docker-compose.yml`.
|
||||||
|
|
||||||
|
On NixOS, or anywhere with Nix installed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://forge.lvl0.xyz/lvl0/incr.git
|
||||||
|
cd incr
|
||||||
|
nix-shell
|
||||||
|
```
|
||||||
|
|
||||||
|
The shell prints the available commands on entry and can start the containers
|
||||||
|
for you:
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `dev-up` | Start the development environment |
|
||||||
|
| `dev-down` | Stop the development environment |
|
||||||
|
| `dev-restart` | Restart the containers |
|
||||||
|
| `dev-rebuild` | Rebuild the images |
|
||||||
|
| `dev-shell` | Enter the app container |
|
||||||
|
| `dev-artisan <cmd>` | Run an artisan command |
|
||||||
|
| `dev-composer <cmd>` | Run a composer command |
|
||||||
|
| `dev-logs` | Follow the application log |
|
||||||
|
| `dev-logs-db` | Follow the database log |
|
||||||
|
|
||||||
|
Once running:
|
||||||
|
|
||||||
|
| Service | URL |
|
||||||
|
|---------|-----|
|
||||||
|
| App | http://localhost:8000 |
|
||||||
|
| Vite | http://localhost:5173 |
|
||||||
|
| MySQL | localhost:3307 |
|
||||||
|
|
||||||
|
Without Nix, start the same containers directly from
|
||||||
|
`docker/dev/docker-compose.yml`. Contributions improving the setup instructions
|
||||||
|
for other platforms are welcome.
|
||||||
|
|
||||||
|
## Before opening a pull request
|
||||||
|
|
||||||
|
Three checks run in CI, and all three must pass. Run them locally first, from
|
||||||
|
inside the app container or anywhere the project's dependencies are available:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/pint --test # code style, Laravel preset
|
||||||
|
vendor/bin/phpstan analyse # static analysis, level 7
|
||||||
|
php artisan test # PHPUnit, Feature suite
|
||||||
|
```
|
||||||
|
|
||||||
|
Some conventions:
|
||||||
|
|
||||||
|
- **Static analysis.** PHPStan runs at level 7 with no baseline, and the project
|
||||||
|
is clean at that level. Keep it that way rather than introducing one. Inline
|
||||||
|
`@phpstan-ignore` comments aren't used either.
|
||||||
|
- **Tests.** New behaviour needs a test. Tests run against sqlite in memory and
|
||||||
|
must work offline, so use factories or fixtures rather than reaching for the
|
||||||
|
network or a real database.
|
||||||
|
- **Dependencies.** `composer.lock` and `package-lock.json` are both committed.
|
||||||
|
If you change dependencies, commit the updated lockfile alongside.
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
One commit does one thing. Keep each commit passing all three checks so history
|
||||||
|
stays bisectable. Separate renames from behaviour changes, and mechanical edits
|
||||||
|
from logic.
|
||||||
|
|
||||||
|
Commit messages are a single line, referencing the issue they belong to:
|
||||||
|
|
||||||
|
```
|
||||||
|
52 - Replace React/Inertia with Blade + Livewire 4
|
||||||
|
```
|
||||||
|
|
||||||
|
No body, no trailers.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
By contributing, you agree that your contributions are licensed under the
|
||||||
|
[GNU AGPL-3.0](LICENSE), the same license as the project.
|
||||||
63
Jenkinsfile
vendored
63
Jenkinsfile
vendored
|
|
@ -1,63 +0,0 @@
|
||||||
pipeline {
|
|
||||||
agent any
|
|
||||||
|
|
||||||
triggers {
|
|
||||||
GenericTrigger(
|
|
||||||
causeString: 'Triggered on tag push',
|
|
||||||
token: 'tag-trigger-secret',
|
|
||||||
printContributedVariables: true,
|
|
||||||
printPostContent: true,
|
|
||||||
regexpFilterExpression: 'ref=refs/tags/.*\nafter=(?!0{40}).*',
|
|
||||||
regexpFilterText: '$ref\n$after',
|
|
||||||
genericVariables: [
|
|
||||||
[key: 'ref', value: '$.ref'],
|
|
||||||
[key: 'after', value: '$.after']
|
|
||||||
]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
environment {
|
|
||||||
REGISTRY = 'codeberg.org'
|
|
||||||
IMAGE_NAME = "${REGISTRY}/lvl0/incr"
|
|
||||||
DOCKER_CREDENTIALS_ID = 'codeberg-registry'
|
|
||||||
}
|
|
||||||
|
|
||||||
stages {
|
|
||||||
stage('Tag Push Filter') {
|
|
||||||
steps {
|
|
||||||
script {
|
|
||||||
if (!env.ref?.startsWith('refs/tags/')) {
|
|
||||||
echo "Not a tag push (ref = ${env.ref}). Skipping build."
|
|
||||||
currentBuild.result = 'NOT_BUILT'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stage('Build & Push Docker Image') {
|
|
||||||
when {
|
|
||||||
expression {
|
|
||||||
return env.ref?.startsWith('refs/tags/') && env.after != null && env.after != "0000000000000000000000000000000000000000"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
steps {
|
|
||||||
script {
|
|
||||||
def tagName = env.ref.replaceFirst(/^refs\/tags\//, '')
|
|
||||||
def cleanedTag = tagName.replaceFirst(/^v/, '')
|
|
||||||
|
|
||||||
sh "docker build -t $IMAGE_NAME:$cleanedTag -f docker/Dockerfile ."
|
|
||||||
|
|
||||||
withCredentials([usernamePassword(credentialsId: "$DOCKER_CREDENTIALS_ID", usernameVariable: 'USERNAME', passwordVariable: 'PASSWORD')]) {
|
|
||||||
sh """
|
|
||||||
echo "$PASSWORD" | docker login $REGISTRY -u "$USERNAME" --password-stdin
|
|
||||||
docker push $IMAGE_NAME:$cleanedTag
|
|
||||||
docker tag $IMAGE_NAME:$cleanedTag $IMAGE_NAME:latest
|
|
||||||
docker push $IMAGE_NAME:latest
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
143
LICENSE
143
LICENSE
|
|
@ -1,5 +1,5 @@
|
||||||
GNU GENERAL PUBLIC LICENSE
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
Version 3, 29 June 2007
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
Everyone is permitted to copy and distribute verbatim copies
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
|
@ -7,17 +7,15 @@
|
||||||
|
|
||||||
Preamble
|
Preamble
|
||||||
|
|
||||||
The GNU General Public License is a free, copyleft license for
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
software and other kinds of works.
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
The licenses for most software and other practical works are designed
|
The licenses for most software and other practical works are designed
|
||||||
to take away your freedom to share and change the works. By contrast,
|
to take away your freedom to share and change the works. By contrast,
|
||||||
the GNU General Public License is intended to guarantee your freedom to
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
share and change all versions of a program--to make sure it remains free
|
share and change all versions of a program--to make sure it remains free
|
||||||
software for all its users. We, the Free Software Foundation, use the
|
software for all its users.
|
||||||
GNU General Public License for most of our software; it applies also to
|
|
||||||
any other work released this way by its authors. You can apply it to
|
|
||||||
your programs, too.
|
|
||||||
|
|
||||||
When we speak of free software, we are referring to freedom, not
|
When we speak of free software, we are referring to freedom, not
|
||||||
price. Our General Public Licenses are designed to make sure that you
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
|
@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
|
||||||
want it, that you can change the software or use pieces of it in new
|
want it, that you can change the software or use pieces of it in new
|
||||||
free programs, and that you know you can do these things.
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
To protect your rights, we need to prevent others from denying you
|
Developers that use our General Public Licenses protect your rights
|
||||||
these rights or asking you to surrender the rights. Therefore, you have
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
certain responsibilities if you distribute copies of the software, or if
|
you this License which gives you legal permission to copy, distribute
|
||||||
you modify it: responsibilities to respect the freedom of others.
|
and/or modify the software.
|
||||||
|
|
||||||
For example, if you distribute copies of such a program, whether
|
A secondary benefit of defending all users' freedom is that
|
||||||
gratis or for a fee, you must pass on to the recipients the same
|
improvements made in alternate versions of the program, if they
|
||||||
freedoms that you received. You must make sure that they, too, receive
|
receive widespread use, become available for other developers to
|
||||||
or can get the source code. And you must show them these terms so they
|
incorporate. Many developers of free software are heartened and
|
||||||
know their rights.
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
Developers that use the GNU GPL protect your rights with two steps:
|
The GNU Affero General Public License is designed specifically to
|
||||||
(1) assert copyright on the software, and (2) offer you this License
|
ensure that, in such cases, the modified source code becomes available
|
||||||
giving you legal permission to copy, distribute and/or modify it.
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
For the developers' and authors' protection, the GPL clearly explains
|
An older license, called the Affero General Public License and
|
||||||
that there is no warranty for this free software. For both users' and
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
authors' sake, the GPL requires that modified versions be marked as
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
changed, so that their problems will not be attributed erroneously to
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
authors of previous versions.
|
this license.
|
||||||
|
|
||||||
Some devices are designed to deny users access to install or run
|
|
||||||
modified versions of the software inside them, although the manufacturer
|
|
||||||
can do so. This is fundamentally incompatible with the aim of
|
|
||||||
protecting users' freedom to change the software. The systematic
|
|
||||||
pattern of such abuse occurs in the area of products for individuals to
|
|
||||||
use, which is precisely where it is most unacceptable. Therefore, we
|
|
||||||
have designed this version of the GPL to prohibit the practice for those
|
|
||||||
products. If such problems arise substantially in other domains, we
|
|
||||||
stand ready to extend this provision to those domains in future versions
|
|
||||||
of the GPL, as needed to protect the freedom of users.
|
|
||||||
|
|
||||||
Finally, every program is threatened constantly by software patents.
|
|
||||||
States should not allow patents to restrict development and use of
|
|
||||||
software on general-purpose computers, but in those that do, we wish to
|
|
||||||
avoid the special danger that patents applied to a free program could
|
|
||||||
make it effectively proprietary. To prevent this, the GPL assures that
|
|
||||||
patents cannot be used to render the program non-free.
|
|
||||||
|
|
||||||
The precise terms and conditions for copying, distribution and
|
The precise terms and conditions for copying, distribution and
|
||||||
modification follow.
|
modification follow.
|
||||||
|
|
@ -72,7 +60,7 @@ modification follow.
|
||||||
|
|
||||||
0. Definitions.
|
0. Definitions.
|
||||||
|
|
||||||
"This License" refers to version 3 of the GNU General Public License.
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
works, such as semiconductor masks.
|
works, such as semiconductor masks.
|
||||||
|
|
@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
|
||||||
the Program, the only way you could satisfy both those terms and this
|
the Program, the only way you could satisfy both those terms and this
|
||||||
License would be to refrain entirely from conveying the Program.
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
13. Use with the GNU Affero General Public License.
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, you have
|
Notwithstanding any other provision of this License, you have
|
||||||
permission to link or combine any covered work with a work licensed
|
permission to link or combine any covered work with a work licensed
|
||||||
under version 3 of the GNU Affero General Public License into a single
|
under version 3 of the GNU General Public License into a single
|
||||||
combined work, and to convey the resulting work. The terms of this
|
combined work, and to convey the resulting work. The terms of this
|
||||||
License will continue to apply to the part which is the covered work,
|
License will continue to apply to the part which is the covered work,
|
||||||
but the special requirements of the GNU Affero General Public License,
|
but the work with which it is combined will remain governed by version
|
||||||
section 13, concerning interaction through a network will apply to the
|
3 of the GNU General Public License.
|
||||||
combination as such.
|
|
||||||
|
|
||||||
14. Revised Versions of this License.
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
The Free Software Foundation may publish revised and/or new versions of
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
the GNU General Public License from time to time. Such new versions will
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
be similar in spirit to the present version, but may differ in detail to
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
address new problems or concerns.
|
address new problems or concerns.
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the
|
Each version is given a distinguishing version number. If the
|
||||||
Program specifies that a certain numbered version of the GNU General
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
Public License "or any later version" applies to it, you have the
|
Public License "or any later version" applies to it, you have the
|
||||||
option of following the terms and conditions either of that numbered
|
option of following the terms and conditions either of that numbered
|
||||||
version or of any later version published by the Free Software
|
version or of any later version published by the Free Software
|
||||||
Foundation. If the Program does not specify a version number of the
|
Foundation. If the Program does not specify a version number of the
|
||||||
GNU General Public License, you may choose any version ever published
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
by the Free Software Foundation.
|
by the Free Software Foundation.
|
||||||
|
|
||||||
If the Program specifies that a proxy can decide which future
|
If the Program specifies that a proxy can decide which future
|
||||||
versions of the GNU General Public License can be used, that proxy's
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
public statement of acceptance of a version permanently authorizes you
|
public statement of acceptance of a version permanently authorizes you
|
||||||
to choose that version for the Program.
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
|
@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
|
||||||
Copyright (C) <year> <name of author>
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
This program is free software: you can redistribute it and/or modify
|
||||||
it under the terms of the GNU General Public License as published by
|
it under the terms of the GNU Affero General Public License as published
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
by the Free Software Foundation, either version 3 of the License, or
|
||||||
(at your option) any later version.
|
(at your option) any later version.
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
This program is distributed in the hope that it will be useful,
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
GNU General Public License for more details.
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
You should have received a copy of the GNU Affero General Public License
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
Also add information on how to contact you by electronic and paper mail.
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
If the program does terminal interaction, make it output a short
|
If your software can interact with users remotely through a computer
|
||||||
notice like this when it starts in an interactive mode:
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
<program> Copyright (C) <year> <name of author>
|
interface could display a "Source" link that leads users to an archive
|
||||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
of the code. There are many ways you could offer source, and different
|
||||||
This is free software, and you are welcome to redistribute it
|
solutions will be better for different programs; see section 13 for the
|
||||||
under certain conditions; type `show c' for details.
|
specific requirements.
|
||||||
|
|
||||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
|
||||||
parts of the General Public License. Of course, your program's commands
|
|
||||||
might be different; for a GUI interface, you would use an "about box".
|
|
||||||
|
|
||||||
You should also get your employer (if you work as a programmer) or school,
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
For more information on this, and how to apply and follow the GNU GPL, see
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
<https://www.gnu.org/licenses/>.
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
The GNU General Public License does not permit incorporating your program
|
|
||||||
into proprietary programs. If your program is a subroutine library, you
|
|
||||||
may consider it more useful to permit linking proprietary applications with
|
|
||||||
the library. If this is what you want to do, use the GNU Lesser General
|
|
||||||
Public License instead of this License. But first, please read
|
|
||||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
|
||||||
|
|
|
||||||
42
README.md
42
README.md
|
|
@ -2,35 +2,41 @@
|
||||||
|
|
||||||
# incr
|
# incr
|
||||||
|
|
||||||
**A minimalist counter with milestone-driven progress**
|
**A minimalist counter**
|
||||||
|
|
||||||
*Track anything you accumulate — with visual progress and milestone reinforcement*
|
*Track anything you accumulate — click to increment*
|
||||||
|
|
||||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||||
[](https://laravel.com/)
|
[](https://laravel.com/)
|
||||||
[](https://reactjs.org/)
|
[](https://livewire.laravel.com/)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## About
|
## About
|
||||||
|
|
||||||
incr is a minimalist, self-hosted tracking app. Pick something you want to accumulate — shares, books, workouts, anything — log your progress, and watch milestones fall.
|
incr is a minimalist, self-hosted counter. Pick something you want to accumulate — books, workouts, anything — set a starting value, then click the number to count up.
|
||||||
|
|
||||||
It features a distinctive LED-style digital display, configurable milestones, and optional price tracking.
|
It features a distinctive LED-style digital display and nothing else.
|
||||||
|
|
||||||
|
## Screenshot
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The whole application. Click the number to add one; `[SET VALUE]` opens a dialog
|
||||||
|
for when clicking once at a time is not practical.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **LED-style display** — large red digital counter
|
- **LED-style display** — large red digital counter
|
||||||
- **Milestone tracking** — set targets, cycle through them as you progress
|
- **Click to increment** — click the number to add to it
|
||||||
- **Purchase logging** — add entries with date and optional price
|
- **Set a value directly** — for when you need to add more than one
|
||||||
- **Price tracking** — optional; log a current price to see portfolio value and P/L
|
|
||||||
- **Self-hosted** — your data stays on your server
|
- **Self-hosted** — your data stays on your server
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Backend**: Laravel 12 (PHP 8.3+) with MySQL
|
- **Backend**: Laravel 13 (PHP 8.3+) with MySQL
|
||||||
- **Frontend**: React 19 + TypeScript with Inertia.js
|
- **Frontend**: Livewire 4 with Blade
|
||||||
- **Styling**: Tailwind CSS 4 with shadcn/ui components
|
- **Styling**: Tailwind CSS 4
|
||||||
- **Deployment**: Docker / Podman with multi-stage builds
|
- **Deployment**: Docker / Podman with multi-stage builds
|
||||||
|
|
||||||
## Self-hosting
|
## Self-hosting
|
||||||
|
|
@ -101,8 +107,14 @@ ## Development
|
||||||
dev-up
|
dev-up
|
||||||
```
|
```
|
||||||
|
|
||||||
Available commands inside the shell: `dev-up`, `dev-down`, `dev-rebuild`, `dev-logs`, `dev-shell`, `dev-artisan`, `dev-composer`.
|
Available commands inside the shell: `dev-up`, `dev-down`, `dev-restart`, `dev-rebuild`, `dev-logs`, `dev-logs-db`, `dev-shell`, `dev-artisan`, `dev-composer`, `base-build`.
|
||||||
|
|
||||||
|
The dev stack binds host ports 8000, 5173 and 3307. Only one project using these can run at a time.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
GNU General Public License v3.0 — see [LICENSE](LICENSE).
|
incr is free software, licensed under the [GNU AGPL-3.0](LICENSE).
|
||||||
|
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Models\Asset;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class AssetController extends Controller
|
|
||||||
{
|
|
||||||
public function index(): JsonResponse
|
|
||||||
{
|
|
||||||
return response()->json(Asset::orderBy('symbol')->get());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function store(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$validated = $request->validate([
|
|
||||||
'symbol' => 'required|string|max:10|unique:assets,symbol',
|
|
||||||
'full_name' => 'nullable|string|max:255',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$asset = Asset::create([
|
|
||||||
'symbol' => strtoupper($validated['symbol']),
|
|
||||||
'full_name' => $validated['full_name'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'success' => true,
|
|
||||||
'message' => 'Asset created successfully!',
|
|
||||||
'asset' => $asset,
|
|
||||||
], 201);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function show(Asset $asset): JsonResponse
|
|
||||||
{
|
|
||||||
$asset->load('assetPrices');
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'asset' => $asset,
|
|
||||||
'current_price' => $asset->currentPrice(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function search(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$query = $request->get('q');
|
|
||||||
|
|
||||||
if (! $query) {
|
|
||||||
return response()->json([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$assets = Asset::where('symbol', 'like', "%{$query}%")
|
|
||||||
->orWhere('full_name', 'like', "%{$query}%")
|
|
||||||
->orderBy('symbol')
|
|
||||||
->limit(10)
|
|
||||||
->get();
|
|
||||||
|
|
||||||
return response()->json($assets);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Auth;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Auth\Events\Registered;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Illuminate\Validation\Rules;
|
|
||||||
use Illuminate\Validation\ValidationException;
|
|
||||||
use Inertia\Inertia;
|
|
||||||
use Inertia\Response;
|
|
||||||
|
|
||||||
class RegisteredUserController extends Controller
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Show the registration page.
|
|
||||||
*/
|
|
||||||
public function create(): Response
|
|
||||||
{
|
|
||||||
if (User::exists()) {
|
|
||||||
abort(403, 'Registration is disabled.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return Inertia::render('auth/register');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle an incoming registration request.
|
|
||||||
*
|
|
||||||
* @throws ValidationException
|
|
||||||
*/
|
|
||||||
public function store(Request $request): RedirectResponse
|
|
||||||
{
|
|
||||||
if (User::exists()) {
|
|
||||||
abort(403, 'Registration is disabled.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$validated = $request->validate([
|
|
||||||
'name' => 'required|string|max:255',
|
|
||||||
'email' => 'required|string|lowercase|email|max:255|unique:'.User::class,
|
|
||||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$user = User::forceCreate([
|
|
||||||
'name' => $validated['name'],
|
|
||||||
'email' => $validated['email'],
|
|
||||||
'password' => Hash::make($validated['password']),
|
|
||||||
]);
|
|
||||||
|
|
||||||
event(new Registered($user));
|
|
||||||
|
|
||||||
Auth::login($user);
|
|
||||||
|
|
||||||
return redirect()->intended(route('dashboard', absolute: false));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Milestones;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class MilestoneController extends Controller
|
|
||||||
{
|
|
||||||
public function store(Request $request): RedirectResponse
|
|
||||||
{
|
|
||||||
$validated = $request->validate([
|
|
||||||
'target' => 'required|integer|min:1',
|
|
||||||
'description' => 'required|string|max:255',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$tracker = User::default()->tracker;
|
|
||||||
|
|
||||||
if (! $tracker) {
|
|
||||||
return back()->withErrors(['tracker' => 'No tracker found. Please complete onboarding first.']);
|
|
||||||
}
|
|
||||||
|
|
||||||
$tracker->milestones()->create($validated);
|
|
||||||
|
|
||||||
return back()->with('success', 'Milestone created successfully');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function index(): JsonResponse
|
|
||||||
{
|
|
||||||
$tracker = User::default()->tracker;
|
|
||||||
|
|
||||||
if (! $tracker) {
|
|
||||||
return response()->json([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json($tracker->milestones()->orderBy('target')->get());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Pricing;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use App\Models\Pricing\AssetPrice;
|
|
||||||
use App\Models\Tracker;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class PricingController extends Controller
|
|
||||||
{
|
|
||||||
private ?Tracker $tracker;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->tracker = User::default()->tracker;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function current(): JsonResponse
|
|
||||||
{
|
|
||||||
return response()->json([
|
|
||||||
'current_price' => AssetPrice::current($this->tracker?->asset_id),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(Request $request)
|
|
||||||
{
|
|
||||||
$validated = $request->validate([
|
|
||||||
'date' => 'required|date|before_or_equal:today',
|
|
||||||
'price' => 'required|numeric|min:0.0001',
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (! $this->tracker?->asset_id) {
|
|
||||||
return back()->withErrors(['asset' => 'Please set an asset first.']);
|
|
||||||
}
|
|
||||||
|
|
||||||
AssetPrice::updatePrice($this->tracker->asset_id, $validated['date'], $validated['price']);
|
|
||||||
|
|
||||||
if (! $this->tracker->price_tracking_enabled) {
|
|
||||||
$this->tracker->update(['price_tracking_enabled' => true]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return back()->with('success', 'Asset price updated successfully!');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function history(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$limit = min(max(1, $request->integer('limit', 30)), 365);
|
|
||||||
|
|
||||||
return response()->json(AssetPrice::history($this->tracker?->asset_id, $limit));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function forDate(Request $request, string $date): JsonResponse
|
|
||||||
{
|
|
||||||
validator(['date' => $date], ['date' => 'required|date_format:Y-m-d'])->validate();
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'date' => $date,
|
|
||||||
'price' => AssetPrice::forDate($date, $this->tracker?->asset_id),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Models\Asset;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class TrackerController extends Controller
|
|
||||||
{
|
|
||||||
public function show(): JsonResponse
|
|
||||||
{
|
|
||||||
$tracker = User::default()->tracker;
|
|
||||||
|
|
||||||
if (! $tracker) {
|
|
||||||
return response()->json(['exists' => false]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json(['exists' => true, 'tracker' => $tracker->load('asset')]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function store(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$validated = $request->validate([
|
|
||||||
'label' => 'required|string|max:255',
|
|
||||||
'unit' => 'required|string|max:50',
|
|
||||||
'price_tracking_enabled' => 'boolean',
|
|
||||||
'symbol' => 'nullable|string|max:10',
|
|
||||||
'full_name' => 'nullable|string|max:255',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$user = User::default();
|
|
||||||
|
|
||||||
if ($user->tracker) {
|
|
||||||
return response()->json(['error' => 'Tracker already exists.'], 409);
|
|
||||||
}
|
|
||||||
|
|
||||||
$assetId = null;
|
|
||||||
if (! empty($validated['symbol'])) {
|
|
||||||
$asset = Asset::findOrCreateBySymbol($validated['symbol'], $validated['full_name'] ?? null);
|
|
||||||
$assetId = $asset->id;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tracker = $user->tracker()->create([
|
|
||||||
'label' => $validated['label'],
|
|
||||||
'unit' => $validated['unit'],
|
|
||||||
'price_tracking_enabled' => $validated['price_tracking_enabled'] ?? false,
|
|
||||||
'asset_id' => $assetId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return response()->json($tracker->load('asset'), 201);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(Request $request): RedirectResponse|JsonResponse
|
|
||||||
{
|
|
||||||
$validated = $request->validate([
|
|
||||||
'label' => 'sometimes|string|max:255',
|
|
||||||
'unit' => 'sometimes|string|max:50',
|
|
||||||
'price_tracking_enabled' => 'sometimes|boolean',
|
|
||||||
'symbol' => 'nullable|string|max:10',
|
|
||||||
'full_name' => 'nullable|string|max:255',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$tracker = User::default()->tracker;
|
|
||||||
|
|
||||||
if (! $tracker) {
|
|
||||||
return back()->withErrors(['tracker' => 'No tracker found.']);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (array_key_exists('symbol', $validated)) {
|
|
||||||
if ($validated['symbol']) {
|
|
||||||
$asset = Asset::findOrCreateBySymbol($validated['symbol'], $validated['full_name'] ?? null);
|
|
||||||
$tracker->asset_id = $asset->id;
|
|
||||||
} else {
|
|
||||||
$tracker->asset_id = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$update = [];
|
|
||||||
if (isset($validated['label'])) {
|
|
||||||
$update['label'] = $validated['label'];
|
|
||||||
}
|
|
||||||
if (isset($validated['unit'])) {
|
|
||||||
$update['unit'] = $validated['unit'];
|
|
||||||
}
|
|
||||||
if (array_key_exists('price_tracking_enabled', $validated)) {
|
|
||||||
$update['price_tracking_enabled'] = $validated['price_tracking_enabled'];
|
|
||||||
}
|
|
||||||
if (array_key_exists('symbol', $validated)) {
|
|
||||||
$update['asset_id'] = $tracker->asset_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tracker->update($update);
|
|
||||||
|
|
||||||
return back();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Transactions;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use App\Models\Transactions\Entry;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class EntryController extends Controller
|
|
||||||
{
|
|
||||||
public function index(): JsonResponse
|
|
||||||
{
|
|
||||||
$tracker = User::default()->tracker;
|
|
||||||
|
|
||||||
if (! $tracker) {
|
|
||||||
return response()->json([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json($tracker->entries()->orderBy('date', 'desc')->get());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function store(Request $request): RedirectResponse
|
|
||||||
{
|
|
||||||
$validated = $request->validate([
|
|
||||||
'date' => 'required|date|before_or_equal:today',
|
|
||||||
'quantity' => 'required|numeric|min:0.000001',
|
|
||||||
'unit_price' => 'nullable|numeric|min:0.01',
|
|
||||||
'total_cost' => 'nullable|numeric|min:0.01',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$tracker = User::default()->tracker;
|
|
||||||
|
|
||||||
if (! $tracker) {
|
|
||||||
return back()->withErrors(['tracker' => 'No tracker found. Please complete onboarding first.']);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If unit_price and total_cost provided, verify the calculation
|
|
||||||
if (isset($validated['unit_price'], $validated['total_cost'])) {
|
|
||||||
$calculatedTotal = $validated['quantity'] * $validated['unit_price'];
|
|
||||||
if (abs($calculatedTotal - $validated['total_cost']) > 0.01) {
|
|
||||||
return back()->withErrors([
|
|
||||||
'total_cost' => 'Total cost does not match quantity × unit price.',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$tracker->entries()->create($validated);
|
|
||||||
|
|
||||||
return back()->with('success', 'Entry added successfully!');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function summary(): JsonResponse
|
|
||||||
{
|
|
||||||
$tracker = User::default()->tracker;
|
|
||||||
|
|
||||||
if (! $tracker) {
|
|
||||||
return response()->json([
|
|
||||||
'total_quantity' => 0,
|
|
||||||
'total_cost' => 0,
|
|
||||||
'average_cost_per_unit' => 0,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'total_quantity' => Entry::totalQuantity($tracker->id),
|
|
||||||
'total_cost' => Entry::totalCost($tracker->id),
|
|
||||||
'average_cost_per_unit' => Entry::averageCostPerUnit($tracker->id),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function destroy(Entry $entry): JsonResponse
|
|
||||||
{
|
|
||||||
$tracker = User::default()->tracker;
|
|
||||||
|
|
||||||
if (! $tracker || $entry->tracker_id !== $tracker->id) {
|
|
||||||
return response()->json(['error' => 'Entry not found.'], 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$entry->delete();
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'success' => true,
|
|
||||||
'message' => 'Entry deleted successfully!',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Middleware;
|
|
||||||
|
|
||||||
use Closure;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\View;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
|
|
||||||
class HandleAppearance
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Handle an incoming request.
|
|
||||||
*
|
|
||||||
* @param Closure(Request): (Response) $next
|
|
||||||
*/
|
|
||||||
public function handle(Request $request, Closure $next): Response
|
|
||||||
{
|
|
||||||
View::share('appearance', $request->cookie('appearance') ?? 'system');
|
|
||||||
|
|
||||||
return $next($request);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Middleware;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Inspiring;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Inertia\Middleware;
|
|
||||||
use Tighten\Ziggy\Ziggy;
|
|
||||||
|
|
||||||
class HandleInertiaRequests extends Middleware
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* The root template that's loaded on the first page visit.
|
|
||||||
*
|
|
||||||
* @see https://inertiajs.com/server-side-setup#root-template
|
|
||||||
*
|
|
||||||
* @var string
|
|
||||||
*/
|
|
||||||
protected $rootView = 'app';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determines the current asset version.
|
|
||||||
*
|
|
||||||
* @see https://inertiajs.com/asset-versioning
|
|
||||||
*/
|
|
||||||
public function version(Request $request): ?string
|
|
||||||
{
|
|
||||||
return parent::version($request);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Define the props that are shared by default.
|
|
||||||
*
|
|
||||||
* @see https://inertiajs.com/shared-data
|
|
||||||
*
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function share(Request $request): array
|
|
||||||
{
|
|
||||||
[$message, $author] = str(Inspiring::quotes()->random())->explode('-');
|
|
||||||
|
|
||||||
return [
|
|
||||||
...parent::share($request),
|
|
||||||
'name' => config('app.name'),
|
|
||||||
'quote' => ['message' => trim($message), 'author' => trim($author)],
|
|
||||||
'auth' => [
|
|
||||||
'user' => $request->user()?->only(['id', 'name', 'email']),
|
|
||||||
],
|
|
||||||
'ziggy' => fn (): array => [
|
|
||||||
...(new Ziggy)->toArray(),
|
|
||||||
'location' => $request->url(),
|
|
||||||
],
|
|
||||||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Requests\Auth;
|
|
||||||
|
|
||||||
use Illuminate\Auth\Events\Lockout;
|
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Illuminate\Validation\ValidationException;
|
|
||||||
|
|
||||||
class LoginRequest extends FormRequest
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Determine if the user is authorized to make this request.
|
|
||||||
*/
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the validation rules that apply to the request.
|
|
||||||
*
|
|
||||||
* @return array<string, ValidationRule|array<mixed>|string>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'email' => ['required', 'string', 'email'],
|
|
||||||
'password' => ['required', 'string'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attempt to authenticate the request's credentials.
|
|
||||||
*
|
|
||||||
* @throws ValidationException
|
|
||||||
*/
|
|
||||||
public function authenticate(): void
|
|
||||||
{
|
|
||||||
$this->ensureIsNotRateLimited();
|
|
||||||
|
|
||||||
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
|
|
||||||
RateLimiter::hit($this->throttleKey());
|
|
||||||
|
|
||||||
throw ValidationException::withMessages([
|
|
||||||
'email' => __('auth.failed'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
RateLimiter::clear($this->throttleKey());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure the login request is not rate limited.
|
|
||||||
*
|
|
||||||
* @throws ValidationException
|
|
||||||
*/
|
|
||||||
public function ensureIsNotRateLimited(): void
|
|
||||||
{
|
|
||||||
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
event(new Lockout($this));
|
|
||||||
|
|
||||||
$seconds = RateLimiter::availableIn($this->throttleKey());
|
|
||||||
|
|
||||||
throw ValidationException::withMessages([
|
|
||||||
'email' => __('auth.throttle', [
|
|
||||||
'seconds' => $seconds,
|
|
||||||
'minutes' => ceil($seconds / 60),
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the rate limiting throttle key for the request.
|
|
||||||
*/
|
|
||||||
public function throttleKey(): string
|
|
||||||
{
|
|
||||||
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Requests\Settings;
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
|
|
||||||
class ProfileUpdateRequest extends FormRequest
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Get the validation rules that apply to the request.
|
|
||||||
*
|
|
||||||
* @return array<string, ValidationRule|array<mixed>|string>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'name' => ['required', 'string', 'max:255'],
|
|
||||||
|
|
||||||
'email' => [
|
|
||||||
'required',
|
|
||||||
'string',
|
|
||||||
'lowercase',
|
|
||||||
'email',
|
|
||||||
'max:255',
|
|
||||||
Rule::unique(User::class)->ignore($this->user()->id),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
101
app/Livewire/Counter.php
Normal file
101
app/Livewire/Counter.php
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Models\Tracker;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Livewire\Attributes\Locked;
|
||||||
|
use Livewire\Attributes\Validate;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class Counter extends Component
|
||||||
|
{
|
||||||
|
// Ceiling of the unsigned int column backing trackers.count.
|
||||||
|
public const MAX_COUNT = 4294967295;
|
||||||
|
|
||||||
|
#[Locked]
|
||||||
|
public int $count = 0;
|
||||||
|
|
||||||
|
#[Locked]
|
||||||
|
public bool $needsOnboarding = false;
|
||||||
|
|
||||||
|
public bool $editing = false;
|
||||||
|
|
||||||
|
#[Validate('required|integer|min:0|max:'.self::MAX_COUNT)]
|
||||||
|
public ?int $value = null;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$tracker = Tracker::current();
|
||||||
|
|
||||||
|
$this->needsOnboarding = $tracker === null;
|
||||||
|
$this->count = $tracker === null ? 0 : $tracker->count;
|
||||||
|
$this->value = $this->count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function initialise(): void
|
||||||
|
{
|
||||||
|
$this->validate();
|
||||||
|
|
||||||
|
$tracker = Tracker::current() ?? Tracker::create([
|
||||||
|
'label' => 'Counter',
|
||||||
|
'unit' => 'units',
|
||||||
|
'count' => $this->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->count = $tracker->count;
|
||||||
|
$this->needsOnboarding = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function increment(): void
|
||||||
|
{
|
||||||
|
$tracker = Tracker::current();
|
||||||
|
|
||||||
|
if (! $tracker || $tracker->count >= self::MAX_COUNT) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tracker->increment('count');
|
||||||
|
$tracker->refresh();
|
||||||
|
|
||||||
|
$this->count = $tracker->count;
|
||||||
|
$this->value = $this->count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(): void
|
||||||
|
{
|
||||||
|
$this->value = $this->count;
|
||||||
|
$this->resetValidation();
|
||||||
|
$this->editing = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
$this->validate();
|
||||||
|
|
||||||
|
$tracker = Tracker::current();
|
||||||
|
|
||||||
|
if (! $tracker) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tracker->update(['count' => $this->value]);
|
||||||
|
|
||||||
|
$this->count = (int) $this->value;
|
||||||
|
$this->editing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancel(): void
|
||||||
|
{
|
||||||
|
$this->value = $this->count;
|
||||||
|
$this->resetValidation();
|
||||||
|
$this->editing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
return view('livewire.counter')->layout('layouts.app');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @method static create(array $array)
|
|
||||||
* @method static where(string $string, string $value)
|
|
||||||
* @method static find(int $id)
|
|
||||||
* @method static orderBy(string $string)
|
|
||||||
*
|
|
||||||
* @property int $id
|
|
||||||
* @property string $symbol
|
|
||||||
* @property string|null $full_name
|
|
||||||
*/
|
|
||||||
class Asset extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $fillable = [
|
|
||||||
'symbol',
|
|
||||||
'full_name',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'symbol' => 'string',
|
|
||||||
'full_name' => 'string',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function assetPrices(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(Pricing\AssetPrice::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function currentPrice(): ?float
|
|
||||||
{
|
|
||||||
$latestPrice = $this->assetPrices()->latest('date')->first();
|
|
||||||
|
|
||||||
return $latestPrice ? $latestPrice->price : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function findBySymbol(string $symbol): ?self
|
|
||||||
{
|
|
||||||
return static::where('symbol', strtoupper($symbol))->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function findOrCreateBySymbol(string $symbol, ?string $fullName = null): self
|
|
||||||
{
|
|
||||||
$asset = static::findBySymbol($symbol);
|
|
||||||
|
|
||||||
if (! $asset) {
|
|
||||||
$asset = static::create([
|
|
||||||
'symbol' => strtoupper($symbol),
|
|
||||||
'full_name' => $fullName,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $asset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @method static create(array $array)
|
|
||||||
* @method static orderBy(string $string)
|
|
||||||
*/
|
|
||||||
class Milestone extends Model
|
|
||||||
{
|
|
||||||
protected $fillable = [
|
|
||||||
'tracker_id',
|
|
||||||
'target',
|
|
||||||
'description',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'target' => 'integer',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function tracker(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Tracker::class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models\Pricing;
|
|
||||||
|
|
||||||
use App\Models\Asset;
|
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @method static latest(string $string)
|
|
||||||
* @method static where(string $string, string $string1, string $date)
|
|
||||||
* @method static updateOrCreate(string[] $array, float[] $array1)
|
|
||||||
* @method static orderBy(string $string, string $string1)
|
|
||||||
*
|
|
||||||
* @property Carbon $date
|
|
||||||
* @property float $price
|
|
||||||
*/
|
|
||||||
class AssetPrice extends Model
|
|
||||||
{
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
protected $fillable = [
|
|
||||||
'asset_id',
|
|
||||||
'date',
|
|
||||||
'price',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'date' => 'date',
|
|
||||||
'price' => 'decimal:4',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function asset(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Asset::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function current(?int $assetId = null): ?float
|
|
||||||
{
|
|
||||||
$query = static::latest('date');
|
|
||||||
|
|
||||||
if ($assetId) {
|
|
||||||
$query->where('asset_id', $assetId);
|
|
||||||
}
|
|
||||||
|
|
||||||
$latestPrice = $query->first();
|
|
||||||
|
|
||||||
return $latestPrice ? $latestPrice->price : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function forDate(string $date, ?int $assetId = null): ?float
|
|
||||||
{
|
|
||||||
$query = static::where('date', '<=', $date)
|
|
||||||
->orderBy('date', 'desc');
|
|
||||||
|
|
||||||
if ($assetId) {
|
|
||||||
$query->where('asset_id', $assetId);
|
|
||||||
}
|
|
||||||
|
|
||||||
$price = $query->first();
|
|
||||||
|
|
||||||
return $price ? $price->price : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function updatePrice(int $assetId, string $date, float $price): self
|
|
||||||
{
|
|
||||||
return static::updateOrCreate(
|
|
||||||
['asset_id' => $assetId, 'date' => $date],
|
|
||||||
['price' => $price]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function history(?int $assetId = null, int $limit = 30): Collection
|
|
||||||
{
|
|
||||||
$query = static::orderBy('date', 'desc')->limit($limit);
|
|
||||||
|
|
||||||
if ($assetId) {
|
|
||||||
$query->where('asset_id', $assetId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $query->get();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -4,45 +4,30 @@
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Models\Transactions\Entry;
|
use Database\Factories\TrackerFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
|
|
||||||
class Tracker extends Model
|
class Tracker extends Model
|
||||||
{
|
{
|
||||||
|
/** @use HasFactory<TrackerFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'user_id',
|
|
||||||
'asset_id',
|
|
||||||
'label',
|
'label',
|
||||||
'unit',
|
'unit',
|
||||||
'price_tracking_enabled',
|
'count',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'price_tracking_enabled' => 'boolean',
|
'count' => 'integer',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function user(): BelongsTo
|
public static function current(): ?self
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class);
|
return self::orderBy('id')->first();
|
||||||
}
|
|
||||||
|
|
||||||
public function asset(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Asset::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function entries(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(Entry::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function milestones(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(Milestone::class);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Models\Transactions;
|
|
||||||
|
|
||||||
use App\Models\Tracker;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
|
|
||||||
class Entry extends Model
|
|
||||||
{
|
|
||||||
protected $fillable = [
|
|
||||||
'tracker_id',
|
|
||||||
'date',
|
|
||||||
'quantity',
|
|
||||||
'unit_price',
|
|
||||||
'total_cost',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'date' => 'date',
|
|
||||||
'quantity' => 'decimal:6',
|
|
||||||
'unit_price' => 'decimal:4',
|
|
||||||
'total_cost' => 'decimal:2',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function tracker(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Tracker::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function totalQuantity(int $trackerId): float
|
|
||||||
{
|
|
||||||
return (float) static::where('tracker_id', $trackerId)->sum('quantity');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function totalCost(int $trackerId): float
|
|
||||||
{
|
|
||||||
return (float) static::where('tracker_id', $trackerId)->sum('total_cost');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function averageCostPerUnit(int $trackerId): float
|
|
||||||
{
|
|
||||||
$totalQuantity = static::totalQuantity($trackerId);
|
|
||||||
$totalCost = static::totalCost($trackerId);
|
|
||||||
|
|
||||||
return $totalQuantity > 0 ? $totalCost / $totalQuantity : 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Database\Factories\UserFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
|
||||||
use Illuminate\Notifications\Notifiable;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
class User extends Authenticatable
|
|
||||||
{
|
|
||||||
/** @use HasFactory<UserFactory> */
|
|
||||||
use HasFactory, Notifiable;
|
|
||||||
|
|
||||||
protected $fillable = [
|
|
||||||
'name',
|
|
||||||
'email',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $hidden = [
|
|
||||||
'password',
|
|
||||||
'remember_token',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'email_verified_at' => 'datetime',
|
|
||||||
'password' => 'hashed',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function tracker(): HasOne
|
|
||||||
{
|
|
||||||
return $this->hasOne(Tracker::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function default(): self
|
|
||||||
{
|
|
||||||
return self::firstWhere('email', 'user@incr.local')
|
|
||||||
?? self::forceCreate([
|
|
||||||
'email' => 'user@incr.local',
|
|
||||||
'name' => 'Default User',
|
|
||||||
'password' => bcrypt(Str::random(32)),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function hasCompletedOnboarding(): bool
|
|
||||||
{
|
|
||||||
return $this->hasEntries() && $this->hasMilestones();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function hasEntries(): bool
|
|
||||||
{
|
|
||||||
return (bool) $this->tracker?->entries()->exists();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function hasMilestones(): bool
|
|
||||||
{
|
|
||||||
return (bool) $this->tracker?->milestones()->exists();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Middleware\HandleAppearance;
|
|
||||||
use App\Http\Middleware\HandleInertiaRequests;
|
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
|
|
@ -14,11 +12,7 @@
|
||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware) {
|
->withMiddleware(function (Middleware $middleware) {
|
||||||
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
|
|
||||||
|
|
||||||
$middleware->web(append: [
|
$middleware->web(append: [
|
||||||
HandleAppearance::class,
|
|
||||||
HandleInertiaRequests::class,
|
|
||||||
AddLinkHeadersForPreloadedAssets::class,
|
AddLinkHeadersForPreloadedAssets::class,
|
||||||
]);
|
]);
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
{
|
|
||||||
"$schema": "https://ui.shadcn.com/schema.json",
|
|
||||||
"style": "default",
|
|
||||||
"rsc": false,
|
|
||||||
"tsx": true,
|
|
||||||
"tailwind": {
|
|
||||||
"config": "tailwind.config.js",
|
|
||||||
"css": "resources/css/app.css",
|
|
||||||
"baseColor": "neutral",
|
|
||||||
"cssVariables": true,
|
|
||||||
"prefix": ""
|
|
||||||
},
|
|
||||||
"aliases": {
|
|
||||||
"components": "@/components",
|
|
||||||
"utils": "@/lib/utils",
|
|
||||||
"ui": "@/components/ui",
|
|
||||||
"lib": "@/lib",
|
|
||||||
"hooks": "@/hooks"
|
|
||||||
},
|
|
||||||
"iconLibrary": "lucide"
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +1,28 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://getcomposer.org/schema.json",
|
"$schema": "https://getcomposer.org/schema.json",
|
||||||
"name": "laravel/react-starter-kit",
|
"name": "lvl0/incr",
|
||||||
"type": "project",
|
"type": "project",
|
||||||
"description": "The skeleton application for the Laravel framework.",
|
"description": "A minimalist counter.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"laravel",
|
"laravel",
|
||||||
"framework"
|
"framework"
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "AGPL-3.0-only",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
"inertiajs/inertia-laravel": "^3.0",
|
|
||||||
"laravel/framework": "^13.0",
|
"laravel/framework": "^13.0",
|
||||||
"laravel/tinker": "^3.0",
|
"laravel/tinker": "^3.0",
|
||||||
"tightenco/ziggy": "^2.4"
|
"livewire/livewire": "^4.0"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
|
"larastan/larastan": "^3.5",
|
||||||
"laravel/pail": "^1.2.2",
|
"laravel/pail": "^1.2.2",
|
||||||
"laravel/pint": "^1.18",
|
"laravel/pint": "^1.18",
|
||||||
"laravel/sail": "^1.43",
|
"laravel/sail": "^1.43",
|
||||||
"mockery/mockery": "^1.6",
|
"mockery/mockery": "^1.6",
|
||||||
"nunomaduro/collision": "^8.6",
|
"nunomaduro/collision": "^8.6",
|
||||||
|
"phpstan/phpstan": "^2.1.32 <2.2",
|
||||||
"phpunit/phpunit": "^12.0"
|
"phpunit/phpunit": "^12.0"
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
|
|
@ -56,11 +57,6 @@
|
||||||
"Composer\\Config::disableProcessTimeout",
|
"Composer\\Config::disableProcessTimeout",
|
||||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
|
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
|
||||||
],
|
],
|
||||||
"dev:ssr": [
|
|
||||||
"npm run build:ssr",
|
|
||||||
"Composer\\Config::disableProcessTimeout",
|
|
||||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"php artisan inertia:start-ssr\" --names=server,queue,logs,ssr --kill-others"
|
|
||||||
],
|
|
||||||
"test": [
|
"test": [
|
||||||
"@php artisan config:clear --ansi",
|
"@php artisan config:clear --ansi",
|
||||||
"@php artisan test"
|
"@php artisan test"
|
||||||
|
|
|
||||||
405
composer.lock
generated
405
composer.lock
generated
|
|
@ -4,7 +4,7 @@
|
||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "a30f9c47c86600aafedfd3862ac984c5",
|
"content-hash": "9b1957cddc5253df62939093e476f2f8",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "brick/math",
|
"name": "brick/math",
|
||||||
|
|
@ -1053,79 +1053,6 @@
|
||||||
],
|
],
|
||||||
"time": "2025-08-22T14:27:06+00:00"
|
"time": "2025-08-22T14:27:06+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "inertiajs/inertia-laravel",
|
|
||||||
"version": "v3.0.6",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/inertiajs/inertia-laravel.git",
|
|
||||||
"reference": "c255b1ea050cf563b240542a76f7f756ccdb2d67"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/inertiajs/inertia-laravel/zipball/c255b1ea050cf563b240542a76f7f756ccdb2d67",
|
|
||||||
"reference": "c255b1ea050cf563b240542a76f7f756ccdb2d67",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"ext-json": "*",
|
|
||||||
"laravel/framework": "^11.0|^12.0|^13.0",
|
|
||||||
"php": "^8.2.0",
|
|
||||||
"symfony/console": "^7.0|^8.0"
|
|
||||||
},
|
|
||||||
"conflict": {
|
|
||||||
"laravel/boost": "<2.2.0"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"guzzlehttp/guzzle": "^7.2",
|
|
||||||
"larastan/larastan": "^3.0",
|
|
||||||
"laravel/pint": "^1.16",
|
|
||||||
"mockery/mockery": "^1.3.3",
|
|
||||||
"orchestra/testbench": "^9.2|^10.0|^11.0",
|
|
||||||
"phpunit/phpunit": "^11.5|^12.0",
|
|
||||||
"roave/security-advisories": "dev-master"
|
|
||||||
},
|
|
||||||
"suggest": {
|
|
||||||
"ext-pcntl": "Recommended when running the Inertia SSR server via the `inertia:start-ssr` artisan command."
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"extra": {
|
|
||||||
"laravel": {
|
|
||||||
"providers": [
|
|
||||||
"Inertia\\ServiceProvider"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"autoload": {
|
|
||||||
"files": [
|
|
||||||
"./helpers.php"
|
|
||||||
],
|
|
||||||
"psr-4": {
|
|
||||||
"Inertia\\": "src"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Jonathan Reinink",
|
|
||||||
"email": "jonathan@reinink.ca",
|
|
||||||
"homepage": "https://reinink.ca"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "The Laravel adapter for Inertia.js.",
|
|
||||||
"keywords": [
|
|
||||||
"inertia",
|
|
||||||
"laravel"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/inertiajs/inertia-laravel/issues",
|
|
||||||
"source": "https://github.com/inertiajs/inertia-laravel/tree/v3.0.6"
|
|
||||||
},
|
|
||||||
"time": "2026-04-10T14:29:45+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "laravel/framework",
|
"name": "laravel/framework",
|
||||||
"version": "v13.7.0",
|
"version": "v13.7.0",
|
||||||
|
|
@ -2098,6 +2025,82 @@
|
||||||
],
|
],
|
||||||
"time": "2026-03-08T20:05:35+00:00"
|
"time": "2026-03-08T20:05:35+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "livewire/livewire",
|
||||||
|
"version": "v4.4.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/livewire/livewire.git",
|
||||||
|
"reference": "514b29d5a23594d4e4846494f580268b20c2f11e"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/livewire/livewire/zipball/514b29d5a23594d4e4846494f580268b20c2f11e",
|
||||||
|
"reference": "514b29d5a23594d4e4846494f580268b20c2f11e",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"illuminate/database": "^10.0|^11.0|^12.0|^13.0",
|
||||||
|
"illuminate/routing": "^10.0|^11.0|^12.0|^13.0",
|
||||||
|
"illuminate/support": "^10.0|^11.0|^12.0|^13.0",
|
||||||
|
"illuminate/validation": "^10.0|^11.0|^12.0|^13.0",
|
||||||
|
"laravel/prompts": "^0.1.24|^0.2|^0.3",
|
||||||
|
"league/mime-type-detection": "^1.9",
|
||||||
|
"php": "^8.1",
|
||||||
|
"symfony/console": "^6.0|^7.0|^8.0",
|
||||||
|
"symfony/http-kernel": "^6.2|^7.0|^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"calebporzio/sushi": "^2.1",
|
||||||
|
"laravel/framework": "^10.15.0|^11.0|^12.0|^13.0",
|
||||||
|
"mockery/mockery": "^1.3.1",
|
||||||
|
"orchestra/testbench": "^8.21.0|^9.0|^10.0|^11.0",
|
||||||
|
"orchestra/testbench-dusk": "^8.24|^9.1|^10.0|^11.0",
|
||||||
|
"phpunit/phpunit": "^10.4|^11.5|^12.5",
|
||||||
|
"psy/psysh": "^0.11.22|^0.12"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"aliases": {
|
||||||
|
"Livewire": "Livewire\\Livewire"
|
||||||
|
},
|
||||||
|
"providers": [
|
||||||
|
"Livewire\\LivewireServiceProvider"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"src/helpers.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"Livewire\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Caleb Porzio",
|
||||||
|
"email": "calebporzio@gmail.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "A front-end framework for Laravel.",
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/livewire/livewire/issues",
|
||||||
|
"source": "https://github.com/livewire/livewire/tree/v4.4.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/livewire",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-08-10T15:24:22+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "monolog/monolog",
|
"name": "monolog/monolog",
|
||||||
"version": "3.10.0",
|
"version": "3.10.0",
|
||||||
|
|
@ -5954,76 +5957,6 @@
|
||||||
],
|
],
|
||||||
"time": "2026-03-30T13:44:50+00:00"
|
"time": "2026-03-30T13:44:50+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "tightenco/ziggy",
|
|
||||||
"version": "v2.6.2",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/tighten/ziggy.git",
|
|
||||||
"reference": "8a0b645921623f77dceaf543d61ecd51a391d96e"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/tighten/ziggy/zipball/8a0b645921623f77dceaf543d61ecd51a391d96e",
|
|
||||||
"reference": "8a0b645921623f77dceaf543d61ecd51a391d96e",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"ext-json": "*",
|
|
||||||
"laravel/framework": ">=9.0",
|
|
||||||
"php": ">=8.1"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"laravel/folio": "^1.1",
|
|
||||||
"orchestra/testbench": "^8.0 || ^9.0 || ^10.0",
|
|
||||||
"pestphp/pest": "^2.0 || ^3.0 || ^4.0",
|
|
||||||
"pestphp/pest-plugin-laravel": "^2.0 || ^3.0 || ^4.0"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"extra": {
|
|
||||||
"laravel": {
|
|
||||||
"providers": [
|
|
||||||
"Tighten\\Ziggy\\ZiggyServiceProvider"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Tighten\\Ziggy\\": "src/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Daniel Coulbourne",
|
|
||||||
"email": "daniel@tighten.co"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Jake Bathman",
|
|
||||||
"email": "jake@tighten.co"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Jacob Baker-Kretzmar",
|
|
||||||
"email": "jacob@tighten.co"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Use your Laravel named routes in JavaScript.",
|
|
||||||
"homepage": "https://github.com/tighten/ziggy",
|
|
||||||
"keywords": [
|
|
||||||
"Ziggy",
|
|
||||||
"javascript",
|
|
||||||
"laravel",
|
|
||||||
"routes"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/tighten/ziggy/issues",
|
|
||||||
"source": "https://github.com/tighten/ziggy/tree/v2.6.2"
|
|
||||||
},
|
|
||||||
"time": "2026-03-05T14:41:19+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "tijsverkoyen/css-to-inline-styles",
|
"name": "tijsverkoyen/css-to-inline-styles",
|
||||||
"version": "v2.4.0",
|
"version": "v2.4.0",
|
||||||
|
|
@ -6424,6 +6357,137 @@
|
||||||
},
|
},
|
||||||
"time": "2025-04-30T06:54:44+00:00"
|
"time": "2025-04-30T06:54:44+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "iamcal/sql-parser",
|
||||||
|
"version": "v0.7",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/iamcal/SQLParser.git",
|
||||||
|
"reference": "610392f38de49a44dab08dc1659960a29874c4b8"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/iamcal/SQLParser/zipball/610392f38de49a44dab08dc1659960a29874c4b8",
|
||||||
|
"reference": "610392f38de49a44dab08dc1659960a29874c4b8",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"php-coveralls/php-coveralls": "^1.0",
|
||||||
|
"phpunit/phpunit": "^5|^6|^7|^8|^9"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"iamcal\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Cal Henderson",
|
||||||
|
"email": "cal@iamcal.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "MySQL schema parser",
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/iamcal/SQLParser/issues",
|
||||||
|
"source": "https://github.com/iamcal/SQLParser/tree/v0.7"
|
||||||
|
},
|
||||||
|
"time": "2026-01-28T22:20:33+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "larastan/larastan",
|
||||||
|
"version": "v3.9.6",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/larastan/larastan.git",
|
||||||
|
"reference": "9ad17e83e96b63536cb6ac39c3d40d29ff9cf636"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/larastan/larastan/zipball/9ad17e83e96b63536cb6ac39c3d40d29ff9cf636",
|
||||||
|
"reference": "9ad17e83e96b63536cb6ac39c3d40d29ff9cf636",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-json": "*",
|
||||||
|
"iamcal/sql-parser": "^0.7.0",
|
||||||
|
"illuminate/console": "^11.44.2 || ^12.4.1 || ^13",
|
||||||
|
"illuminate/container": "^11.44.2 || ^12.4.1 || ^13",
|
||||||
|
"illuminate/contracts": "^11.44.2 || ^12.4.1 || ^13",
|
||||||
|
"illuminate/database": "^11.44.2 || ^12.4.1 || ^13",
|
||||||
|
"illuminate/http": "^11.44.2 || ^12.4.1 || ^13",
|
||||||
|
"illuminate/pipeline": "^11.44.2 || ^12.4.1 || ^13",
|
||||||
|
"illuminate/support": "^11.44.2 || ^12.4.1 || ^13",
|
||||||
|
"php": "^8.2",
|
||||||
|
"phpstan/phpstan": "^2.1.44"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"doctrine/coding-standard": "^13",
|
||||||
|
"laravel/framework": "^11.44.2 || ^12.7.2 || ^13",
|
||||||
|
"mockery/mockery": "^1.6.12",
|
||||||
|
"nikic/php-parser": "^5.4",
|
||||||
|
"orchestra/canvas": "^v9.2.2 || ^10.0.1 || ^11",
|
||||||
|
"orchestra/testbench-core": "^9.12.0 || ^10.1 || ^11",
|
||||||
|
"phpstan/phpstan-deprecation-rules": "^2.0.1",
|
||||||
|
"phpunit/phpunit": "^10.5.35 || ^11.5.15 || ^12.5.8"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"orchestra/testbench": "Using Larastan for analysing a package needs Testbench",
|
||||||
|
"phpmyadmin/sql-parser": "Install to enable Larastan's optional phpMyAdmin-based SQL parser automatically"
|
||||||
|
},
|
||||||
|
"type": "phpstan-extension",
|
||||||
|
"extra": {
|
||||||
|
"phpstan": {
|
||||||
|
"includes": [
|
||||||
|
"extension.neon"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "3.0-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Larastan\\Larastan\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Can Vural",
|
||||||
|
"email": "can9119@gmail.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel",
|
||||||
|
"keywords": [
|
||||||
|
"PHPStan",
|
||||||
|
"code analyse",
|
||||||
|
"code analysis",
|
||||||
|
"larastan",
|
||||||
|
"laravel",
|
||||||
|
"package",
|
||||||
|
"php",
|
||||||
|
"static analysis"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/larastan/larastan/issues",
|
||||||
|
"source": "https://github.com/larastan/larastan/tree/v3.9.6"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/canvural",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-04-16T10:02:43+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/pail",
|
"name": "laravel/pail",
|
||||||
"version": "v1.2.6",
|
"version": "v1.2.6",
|
||||||
|
|
@ -6992,6 +7056,59 @@
|
||||||
},
|
},
|
||||||
"time": "2022-02-21T01:04:05+00:00"
|
"time": "2022-02-21T01:04:05+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "phpstan/phpstan",
|
||||||
|
"version": "2.1.56",
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/93a603c9fc3be8c3c93bbc8d22170ad766685537",
|
||||||
|
"reference": "93a603c9fc3be8c3c93bbc8d22170ad766685537",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.4|^8.0"
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"phpstan/phpstan-shim": "*"
|
||||||
|
},
|
||||||
|
"bin": [
|
||||||
|
"phpstan",
|
||||||
|
"phpstan.phar"
|
||||||
|
],
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"bootstrap.php"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"description": "PHPStan - PHP Static Analysis Tool",
|
||||||
|
"keywords": [
|
||||||
|
"dev",
|
||||||
|
"static analysis"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"docs": "https://phpstan.org/user-guide/getting-started",
|
||||||
|
"forum": "https://github.com/phpstan/phpstan/discussions",
|
||||||
|
"issues": "https://github.com/phpstan/phpstan/issues",
|
||||||
|
"security": "https://github.com/phpstan/phpstan/security/policy",
|
||||||
|
"source": "https://github.com/phpstan/phpstan-src"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/ondrejmirtes",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/phpstan",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-05-26T17:04:57+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "phpunit/php-code-coverage",
|
"name": "phpunit/php-code-coverage",
|
||||||
"version": "12.5.6",
|
"version": "12.5.6",
|
||||||
|
|
|
||||||
117
config/auth.php
117
config/auth.php
|
|
@ -1,117 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
|
|
||||||
return [
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Authentication Defaults
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| This option defines the default authentication "guard" and password
|
|
||||||
| reset "broker" for your application. You may change these values
|
|
||||||
| as required, but they're a perfect start for most applications.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'defaults' => [
|
|
||||||
'guard' => env('AUTH_GUARD', 'web'),
|
|
||||||
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
|
|
||||||
],
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Authentication Guards
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| Next, you may define every authentication guard for your application.
|
|
||||||
| Of course, a great default configuration has been defined for you
|
|
||||||
| which utilizes session storage plus the Eloquent user provider.
|
|
||||||
|
|
|
||||||
| All authentication guards have a user provider, which defines how the
|
|
||||||
| users are actually retrieved out of your database or other storage
|
|
||||||
| system used by the application. Typically, Eloquent is utilized.
|
|
||||||
|
|
|
||||||
| Supported: "session"
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'guards' => [
|
|
||||||
'web' => [
|
|
||||||
'driver' => 'session',
|
|
||||||
'provider' => 'users',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| User Providers
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| All authentication guards have a user provider, which defines how the
|
|
||||||
| users are actually retrieved out of your database or other storage
|
|
||||||
| system used by the application. Typically, Eloquent is utilized.
|
|
||||||
|
|
|
||||||
| If you have multiple user tables or models you may configure multiple
|
|
||||||
| providers to represent the model / table. These providers may then
|
|
||||||
| be assigned to any extra authentication guards you have defined.
|
|
||||||
|
|
|
||||||
| Supported: "database", "eloquent"
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'providers' => [
|
|
||||||
'users' => [
|
|
||||||
'driver' => 'eloquent',
|
|
||||||
'model' => env('AUTH_MODEL', User::class),
|
|
||||||
],
|
|
||||||
|
|
||||||
// 'users' => [
|
|
||||||
// 'driver' => 'database',
|
|
||||||
// 'table' => 'users',
|
|
||||||
// ],
|
|
||||||
],
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Resetting Passwords
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| These configuration options specify the behavior of Laravel's password
|
|
||||||
| reset functionality, including the table utilized for token storage
|
|
||||||
| and the user provider that is invoked to actually retrieve users.
|
|
||||||
|
|
|
||||||
| The expiry time is the number of minutes that each reset token will be
|
|
||||||
| considered valid. This security feature keeps tokens short-lived so
|
|
||||||
| they have less time to be guessed. You may change this as needed.
|
|
||||||
|
|
|
||||||
| The throttle setting is the number of seconds a user must wait before
|
|
||||||
| generating more password reset tokens. This prevents the user from
|
|
||||||
| quickly generating a very large amount of password reset tokens.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'passwords' => [
|
|
||||||
'users' => [
|
|
||||||
'provider' => 'users',
|
|
||||||
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
|
|
||||||
'expire' => 60,
|
|
||||||
'throttle' => 60,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Password Confirmation Timeout
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| Here you may define the amount of seconds before a password confirmation
|
|
||||||
| window expires and users are asked to re-enter their password via the
|
|
||||||
| confirmation screen. By default, the timeout lasts for three hours.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
|
|
||||||
|
|
||||||
];
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
return [
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Server Side Rendering
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| These options configures if and how Inertia uses Server Side Rendering
|
|
||||||
| to pre-render each initial request made to your application's pages
|
|
||||||
| so that server rendered HTML is delivered for the user's browser.
|
|
||||||
|
|
|
||||||
| See: https://inertiajs.com/server-side-rendering
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'ssr' => [
|
|
||||||
'enabled' => true,
|
|
||||||
'url' => 'http://127.0.0.1:13714',
|
|
||||||
// 'bundle' => base_path('bootstrap/ssr/ssr.mjs'),
|
|
||||||
|
|
||||||
],
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Testing
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| The values described here are used to locate Inertia components on the
|
|
||||||
| filesystem. For instance, when using `assertInertia`, the assertion
|
|
||||||
| attempts to locate the component as a file relative to the paths.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'testing' => [
|
|
||||||
|
|
||||||
'ensure_pages_exist' => true,
|
|
||||||
|
|
||||||
'page_paths' => [
|
|
||||||
resource_path('js/pages'),
|
|
||||||
],
|
|
||||||
|
|
||||||
'page_extensions' => [
|
|
||||||
'js',
|
|
||||||
'jsx',
|
|
||||||
'svelte',
|
|
||||||
'ts',
|
|
||||||
'tsx',
|
|
||||||
'vue',
|
|
||||||
],
|
|
||||||
|
|
||||||
],
|
|
||||||
|
|
||||||
];
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'driver' => env('SESSION_DRIVER', 'database'),
|
'driver' => env('SESSION_DRIVER', 'cookie'),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
28
database/factories/TrackerFactory.php
Normal file
28
database/factories/TrackerFactory.php
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Tracker;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<Tracker>
|
||||||
|
*/
|
||||||
|
class TrackerFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = Tracker::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'label' => 'Counter',
|
||||||
|
'unit' => 'units',
|
||||||
|
'count' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Database\Factories;
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @extends Factory<User>
|
|
||||||
*/
|
|
||||||
class UserFactory extends Factory
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* The current password being used by the factory.
|
|
||||||
*/
|
|
||||||
protected static ?string $password;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Define the model's default state.
|
|
||||||
*
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function definition(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'name' => fake()->name(),
|
|
||||||
'email' => fake()->unique()->safeEmail(),
|
|
||||||
'email_verified_at' => now(),
|
|
||||||
'password' => static::$password ??= Hash::make('password'),
|
|
||||||
'remember_token' => Str::random(10),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Indicate that the model's email address should be unverified.
|
|
||||||
*/
|
|
||||||
public function unverified(): static
|
|
||||||
{
|
|
||||||
return $this->state(fn (array $attributes) => [
|
|
||||||
'email_verified_at' => null,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::create('assets', function (Blueprint $table) {
|
|
||||||
$table->id();
|
|
||||||
$table->string('symbol')->unique();
|
|
||||||
$table->string('full_name')->nullable();
|
|
||||||
$table->timestamps();
|
|
||||||
|
|
||||||
$table->index('symbol');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('assets');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Run the migrations.
|
|
||||||
*/
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::create('users', function (Blueprint $table) {
|
|
||||||
$table->id();
|
|
||||||
$table->string('name');
|
|
||||||
$table->string('email')->unique();
|
|
||||||
$table->timestamp('email_verified_at')->nullable();
|
|
||||||
$table->string('password');
|
|
||||||
$table->foreignId('asset_id')->nullable()->constrained()->onDelete('set null');
|
|
||||||
$table->boolean('price_tracking_enabled')->default(false);
|
|
||||||
$table->rememberToken();
|
|
||||||
$table->timestamps();
|
|
||||||
|
|
||||||
$table->index('asset_id');
|
|
||||||
});
|
|
||||||
|
|
||||||
Schema::create('password_reset_tokens', function (Blueprint $table) {
|
|
||||||
$table->string('email')->primary();
|
|
||||||
$table->string('token');
|
|
||||||
$table->timestamp('created_at')->nullable();
|
|
||||||
});
|
|
||||||
|
|
||||||
Schema::create('sessions', function (Blueprint $table) {
|
|
||||||
$table->string('id')->primary();
|
|
||||||
$table->foreignId('user_id')->nullable()->index();
|
|
||||||
$table->string('ip_address', 45)->nullable();
|
|
||||||
$table->text('user_agent')->nullable();
|
|
||||||
$table->longText('payload');
|
|
||||||
$table->integer('last_activity')->index();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('users');
|
|
||||||
Schema::dropIfExists('password_reset_tokens');
|
|
||||||
Schema::dropIfExists('sessions');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::create('asset_prices', function (Blueprint $table) {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('asset_id')->constrained()->onDelete('cascade');
|
|
||||||
$table->date('date');
|
|
||||||
$table->decimal('price', 10, 4);
|
|
||||||
$table->timestamps();
|
|
||||||
|
|
||||||
$table->unique(['asset_id', 'date']);
|
|
||||||
$table->index('asset_id');
|
|
||||||
$table->index('date');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('asset_prices');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Run the migrations.
|
|
||||||
*/
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::create('milestones', function (Blueprint $table) {
|
|
||||||
$table->id();
|
|
||||||
$table->integer('target');
|
|
||||||
$table->string('description');
|
|
||||||
$table->timestamps();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('milestones');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::create('trackers', function (Blueprint $table) {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
|
||||||
$table->foreignId('asset_id')->nullable()->constrained()->nullOnDelete();
|
|
||||||
$table->string('label');
|
|
||||||
$table->string('unit');
|
|
||||||
$table->boolean('price_tracking_enabled')->default(false);
|
|
||||||
$table->timestamps();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Migrate existing users: create one tracker per user from their current asset_id + price_tracking_enabled
|
|
||||||
DB::table('users')->orderBy('id')->each(function (object $user) {
|
|
||||||
DB::table('trackers')->insert([
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'asset_id' => $user->asset_id,
|
|
||||||
'label' => 'Portfolio',
|
|
||||||
'unit' => 'shares',
|
|
||||||
'price_tracking_enabled' => $user->price_tracking_enabled ?? false,
|
|
||||||
'created_at' => now(),
|
|
||||||
'updated_at' => now(),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
// Restore asset_id and price_tracking_enabled back onto users before dropping trackers
|
|
||||||
DB::table('trackers')->orderBy('id')->each(function (object $tracker) {
|
|
||||||
DB::table('users')
|
|
||||||
->where('id', $tracker->user_id)
|
|
||||||
->update([
|
|
||||||
'asset_id' => $tracker->asset_id,
|
|
||||||
'price_tracking_enabled' => $tracker->price_tracking_enabled,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
Schema::dropIfExists('trackers');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::create('entries', function (Blueprint $table): void {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('tracker_id')->constrained()->cascadeOnDelete();
|
|
||||||
$table->date('date');
|
|
||||||
$table->decimal('quantity', 12, 6);
|
|
||||||
$table->decimal('unit_price', 12, 4)->nullable();
|
|
||||||
$table->decimal('total_cost', 12, 2)->nullable();
|
|
||||||
$table->timestamps();
|
|
||||||
|
|
||||||
$table->index(['tracker_id', 'date']);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('entries');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::table('milestones', function (Blueprint $table) {
|
|
||||||
$table->foreignId('tracker_id')->nullable()->after('id')->constrained()->cascadeOnDelete();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Backfill tracker_id on milestones
|
|
||||||
$trackerId = DB::table('trackers')->value('id');
|
|
||||||
if ($trackerId) {
|
|
||||||
DB::table('milestones')->update(['tracker_id' => $trackerId]);
|
|
||||||
}
|
|
||||||
|
|
||||||
Schema::table('milestones', function (Blueprint $table) {
|
|
||||||
$table->unsignedBigInteger('tracker_id')->nullable(false)->change();
|
|
||||||
});
|
|
||||||
|
|
||||||
Schema::table('users', function (Blueprint $table) {
|
|
||||||
$table->dropForeign(['asset_id']);
|
|
||||||
$table->dropColumn(['asset_id', 'price_tracking_enabled']);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::table('users', function (Blueprint $table) {
|
|
||||||
$table->foreignId('asset_id')->nullable()->constrained()->nullOnDelete();
|
|
||||||
$table->boolean('price_tracking_enabled')->default(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
Schema::table('milestones', function (Blueprint $table) {
|
|
||||||
$table->dropForeign(['tracker_id']);
|
|
||||||
$table->dropColumn('tracker_id');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consolidates the thirteen migrations that preceded v0.4.0. Those created and
|
||||||
|
* then dropped assets, users, sessions, milestones and entries to arrive at a
|
||||||
|
* single table; replaying that chain required MySQL-specific DDL. Deployments
|
||||||
|
* migrated before this have the old entries in their migrations table and skip
|
||||||
|
* it via the guard below.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (Schema::hasTable('trackers')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Schema::create('trackers', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('label');
|
||||||
|
$table->string('unit');
|
||||||
|
$table->unsignedInteger('count')->default(0);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('trackers');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -2,8 +2,7 @@
|
||||||
|
|
||||||
namespace Database\Seeders;
|
namespace Database\Seeders;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\Tracker;
|
||||||
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
class DatabaseSeeder extends Seeder
|
class DatabaseSeeder extends Seeder
|
||||||
|
|
@ -13,11 +12,10 @@ class DatabaseSeeder extends Seeder
|
||||||
*/
|
*/
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
// User::factory(10)->create();
|
Tracker::firstOrCreate([], [
|
||||||
|
'label' => 'Counter',
|
||||||
User::factory()->create([
|
'unit' => 'units',
|
||||||
'name' => 'Test User',
|
'count' => 0,
|
||||||
'email' => 'test@example.com',
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
38
docker/build/Dockerfile.ci
Normal file
38
docker/build/Dockerfile.ci
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# Image for CI: PHP, Composer and the extensions the suite needs. No runtime
|
||||||
|
# server; the frontend build runs in its own job on a node image.
|
||||||
|
#
|
||||||
|
# Published as incr-ci:php<version>-<revision>, not :latest. Runners cache
|
||||||
|
# mutable tags and will not re-pull them, so bump the revision in the tag and
|
||||||
|
# in ci.yml whenever this file changes.
|
||||||
|
#
|
||||||
|
# Debian-based rather than alpine: ffr hit repeated DNS resolution timeouts
|
||||||
|
# against codeload.github.com on the alpine build.
|
||||||
|
#
|
||||||
|
# pdo_sqlite: tests run against sqlite in memory (see .env.testing). Service
|
||||||
|
# containers are not reachable from job containers on this runner, so CI must
|
||||||
|
# not depend on one. pdo_mysql is kept so artisan can talk to a real database
|
||||||
|
# when run manually inside this image.
|
||||||
|
FROM php:8.3-cli
|
||||||
|
|
||||||
|
COPY --from=mlocati/php-extension-installer:2 /usr/bin/install-php-extensions /usr/local/bin/
|
||||||
|
|
||||||
|
RUN install-php-extensions \
|
||||||
|
pdo_sqlite \
|
||||||
|
pdo_mysql \
|
||||||
|
mbstring \
|
||||||
|
dom \
|
||||||
|
xml \
|
||||||
|
bcmath \
|
||||||
|
fileinfo \
|
||||||
|
pcntl \
|
||||||
|
gd \
|
||||||
|
pcov
|
||||||
|
|
||||||
|
# nodejs is not used by the app's tests; the Forgejo JavaScript actions
|
||||||
|
# (checkout, cache) are executed with it inside this container.
|
||||||
|
# The MySQL readiness check in ci.yml uses PHP's fsockopen, so no netcat here.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends git unzip nodejs \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
name: incr
|
||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build:
|
build:
|
||||||
|
|
@ -10,7 +12,7 @@ services:
|
||||||
- DB_HOST=db
|
- DB_HOST=db
|
||||||
volumes:
|
volumes:
|
||||||
- ../../:/var/www/html:Z
|
- ../../:/var/www/html:Z
|
||||||
- app_node_modules:/var/www/html/node_modules
|
- incr_app_node_modules:/var/www/html/node_modules
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
- "5173:5173"
|
- "5173:5173"
|
||||||
|
|
@ -30,7 +32,7 @@ services:
|
||||||
- MYSQL_PASSWORD=incr_password
|
- MYSQL_PASSWORD=incr_password
|
||||||
- MYSQL_ROOT_PASSWORD=root_password
|
- MYSQL_ROOT_PASSWORD=root_password
|
||||||
volumes:
|
volumes:
|
||||||
- db_data:/var/lib/mysql
|
- incr_db_data:/var/lib/mysql
|
||||||
- ./mysql-init:/docker-entrypoint-initdb.d:ro
|
- ./mysql-init:/docker-entrypoint-initdb.d:ro
|
||||||
ports:
|
ports:
|
||||||
- "3307:3306"
|
- "3307:3306"
|
||||||
|
|
@ -48,7 +50,7 @@ networks:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
db_data:
|
incr_db_data:
|
||||||
driver: local
|
driver: local
|
||||||
app_node_modules:
|
incr_app_node_modules:
|
||||||
driver: local
|
driver: local
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Multi-stage build for Laravel + React application
|
# Multi-stage build for Laravel + Livewire application
|
||||||
FROM node:20-alpine AS frontend-builder
|
FROM node:20-alpine AS frontend-builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
@ -13,9 +13,6 @@ RUN npm ci
|
||||||
COPY resources/ resources/
|
COPY resources/ resources/
|
||||||
COPY public/ public/
|
COPY public/ public/
|
||||||
COPY vite.config.ts ./
|
COPY vite.config.ts ./
|
||||||
COPY tsconfig.json ./
|
|
||||||
COPY components.json ./
|
|
||||||
COPY eslint.config.js ./
|
|
||||||
|
|
||||||
# Build frontend assets
|
# Build frontend assets
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
|
||||||
0
docs/screenshots/.gitkeep
Normal file
0
docs/screenshots/.gitkeep
Normal file
BIN
docs/screenshots/counter.png
Normal file
BIN
docs/screenshots/counter.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
|
|
@ -1,44 +0,0 @@
|
||||||
import js from '@eslint/js';
|
|
||||||
import prettier from 'eslint-config-prettier';
|
|
||||||
import react from 'eslint-plugin-react';
|
|
||||||
import reactHooks from 'eslint-plugin-react-hooks';
|
|
||||||
import globals from 'globals';
|
|
||||||
import typescript from 'typescript-eslint';
|
|
||||||
|
|
||||||
/** @type {import('eslint').Linter.Config[]} */
|
|
||||||
export default [
|
|
||||||
js.configs.recommended,
|
|
||||||
...typescript.configs.recommended,
|
|
||||||
{
|
|
||||||
...react.configs.flat.recommended,
|
|
||||||
...react.configs.flat['jsx-runtime'], // Required for React 17+
|
|
||||||
languageOptions: {
|
|
||||||
globals: {
|
|
||||||
...globals.browser,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
'react/react-in-jsx-scope': 'off',
|
|
||||||
'react/prop-types': 'off',
|
|
||||||
'react/no-unescaped-entities': 'off',
|
|
||||||
},
|
|
||||||
settings: {
|
|
||||||
react: {
|
|
||||||
version: 'detect',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
plugins: {
|
|
||||||
'react-hooks': reactHooks,
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
'react-hooks/rules-of-hooks': 'error',
|
|
||||||
'react-hooks/exhaustive-deps': 'warn',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ignores: ['vendor', 'node_modules', 'public', 'bootstrap/ssr', 'tailwind.config.js'],
|
|
||||||
},
|
|
||||||
prettier, // Turn off all rules that might conflict with Prettier
|
|
||||||
];
|
|
||||||
6307
package-lock.json
generated
6307
package-lock.json
generated
File diff suppressed because it is too large
Load diff
47
package.json
47
package.json
|
|
@ -3,57 +3,12 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"build:ssr": "vite build && vite build --ssr",
|
"dev": "vite"
|
||||||
"dev": "vite",
|
|
||||||
"format": "prettier --write resources/",
|
|
||||||
"format:check": "prettier --check resources/",
|
|
||||||
"lint": "eslint . --fix",
|
|
||||||
"types": "tsc --noEmit"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.19.0",
|
|
||||||
"@types/node": "^22.13.5",
|
|
||||||
"eslint": "^9.39.4",
|
|
||||||
"eslint-config-prettier": "^10.0.1",
|
|
||||||
"eslint-plugin-react": "^7.37.3",
|
|
||||||
"eslint-plugin-react-hooks": "^5.1.0",
|
|
||||||
"prettier": "^3.4.2",
|
|
||||||
"prettier-plugin-organize-imports": "^4.1.0",
|
|
||||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
|
||||||
"typescript-eslint": "^8.23.0"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@headlessui/react": "^2.2.0",
|
|
||||||
"@inertiajs/react": "^3.0.3",
|
|
||||||
"@radix-ui/react-avatar": "^1.1.3",
|
|
||||||
"@radix-ui/react-checkbox": "^1.1.4",
|
|
||||||
"@radix-ui/react-collapsible": "^1.1.3",
|
|
||||||
"@radix-ui/react-dialog": "^1.1.6",
|
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
|
||||||
"@radix-ui/react-label": "^2.1.2",
|
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.5",
|
|
||||||
"@radix-ui/react-select": "^2.1.6",
|
|
||||||
"@radix-ui/react-separator": "^1.1.2",
|
|
||||||
"@radix-ui/react-slot": "^1.1.2",
|
|
||||||
"@radix-ui/react-toggle": "^1.1.2",
|
|
||||||
"@radix-ui/react-toggle-group": "^1.1.2",
|
|
||||||
"@radix-ui/react-tooltip": "^1.1.8",
|
|
||||||
"@tailwindcss/vite": "^4.0.6",
|
"@tailwindcss/vite": "^4.0.6",
|
||||||
"@types/react": "^19.0.3",
|
|
||||||
"@types/react-dom": "^19.0.2",
|
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"concurrently": "^9.0.1",
|
|
||||||
"globals": "^15.14.0",
|
|
||||||
"laravel-vite-plugin": "^3.1.0",
|
"laravel-vite-plugin": "^3.1.0",
|
||||||
"lucide-react": "^1.14.0",
|
|
||||||
"react": "^19.0.0",
|
|
||||||
"react-dom": "^19.0.0",
|
|
||||||
"tailwind-merge": "^3.0.1",
|
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
|
||||||
"typescript": "^6.0.3",
|
|
||||||
"vite": "^8.0.10"
|
"vite": "^8.0.10"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
|
|
|
||||||
13
phpstan.neon
Normal file
13
phpstan.neon
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
includes:
|
||||||
|
- vendor/larastan/larastan/extension.neon
|
||||||
|
|
||||||
|
parameters:
|
||||||
|
level: 7
|
||||||
|
paths:
|
||||||
|
- app/
|
||||||
|
- database/
|
||||||
|
- tests/
|
||||||
|
|
||||||
|
excludePaths:
|
||||||
|
- bootstrap/*.php
|
||||||
|
- storage/*
|
||||||
|
|
@ -22,7 +22,6 @@
|
||||||
<env name="APP_MAINTENANCE_DRIVER" value="file" force="true"/>
|
<env name="APP_MAINTENANCE_DRIVER" value="file" force="true"/>
|
||||||
<env name="BCRYPT_ROUNDS" value="4" force="true"/>
|
<env name="BCRYPT_ROUNDS" value="4" force="true"/>
|
||||||
<env name="CACHE_STORE" value="array" force="true"/>
|
<env name="CACHE_STORE" value="array" force="true"/>
|
||||||
<env name="DB_DATABASE" value="testing" force="true"/>
|
|
||||||
<env name="MAIL_MAILER" value="array" force="true"/>
|
<env name="MAIL_MAILER" value="array" force="true"/>
|
||||||
<env name="PULSE_ENABLED" value="false" force="true"/>
|
<env name="PULSE_ENABLED" value="false" force="true"/>
|
||||||
<env name="QUEUE_CONNECTION" value="sync" force="true"/>
|
<env name="QUEUE_CONNECTION" value="sync" force="true"/>
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB |
|
|
@ -1,3 +1,12 @@
|
||||||
<svg width="166" height="166" viewBox="0 0 166 166" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M162.041 38.7592C162.099 38.9767 162.129 39.201 162.13 39.4264V74.4524C162.13 74.9019 162.011 75.3435 161.786 75.7325C161.561 76.1216 161.237 76.4442 160.847 76.6678L131.462 93.5935V127.141C131.462 128.054 130.977 128.897 130.186 129.357L68.8474 164.683C68.707 164.763 68.5538 164.814 68.4007 164.868C68.3432 164.887 68.289 164.922 68.2284 164.938C67.7996 165.051 67.3489 165.051 66.9201 164.938C66.8499 164.919 66.7861 164.881 66.7191 164.855C66.5787 164.804 66.4319 164.76 66.2979 164.683L4.97219 129.357C4.58261 129.133 4.2589 128.81 4.0337 128.421C3.8085 128.032 3.68976 127.591 3.68945 127.141L3.68945 22.0634C3.68945 21.8336 3.72136 21.6101 3.7788 21.393C3.79794 21.3196 3.84262 21.2526 3.86814 21.1791C3.91601 21.0451 3.96068 20.9078 4.03088 20.7833C4.07874 20.7003 4.14894 20.6333 4.20638 20.5566C4.27977 20.4545 4.34678 20.3491 4.43293 20.2598C4.50632 20.1863 4.60205 20.1321 4.68501 20.0682C4.77755 19.9916 4.86051 19.9086 4.96581 19.848L35.6334 2.18492C36.0217 1.96139 36.4618 1.84375 36.9098 1.84375C37.3578 1.84375 37.7979 1.96139 38.1862 2.18492L68.8506 19.848H68.857C68.9591 19.9118 69.0452 19.9916 69.1378 20.065C69.2207 20.1289 69.3133 20.1863 69.3867 20.2566C69.476 20.3491 69.5398 20.4545 69.6164 20.5566C69.6707 20.6333 69.7441 20.7003 69.7887 20.7833C69.8621 20.911 69.9036 21.0451 69.9546 21.1791C69.9802 21.2526 70.0248 21.3196 70.044 21.3962C70.1027 21.6138 70.1328 21.8381 70.1333 22.0634V87.6941L95.686 72.9743V39.4232C95.686 39.1997 95.7179 38.9731 95.7753 38.7592C95.7977 38.6826 95.8391 38.6155 95.8647 38.5421C95.9157 38.408 95.9604 38.2708 96.0306 38.1463C96.0785 38.0633 96.1487 37.9962 96.2029 37.9196C96.2795 37.8175 96.3433 37.7121 96.4326 37.6227C96.506 37.5493 96.5986 37.495 96.6815 37.4312C96.7773 37.3546 96.8602 37.2716 96.9623 37.2109L127.633 19.5479C128.021 19.324 128.461 19.2062 128.91 19.2062C129.358 19.2062 129.798 19.324 130.186 19.5479L160.85 37.2109C160.959 37.2748 161.042 37.3546 161.137 37.428C161.217 37.4918 161.31 37.5493 161.383 37.6195C161.473 37.7121 161.536 37.8175 161.613 37.9196C161.67 37.9962 161.741 38.0633 161.785 38.1463C161.859 38.2708 161.9 38.408 161.951 38.5421C161.98 38.6155 162.021 38.6826 162.041 38.7592ZM157.018 72.9743V43.8477L146.287 50.028L131.462 58.5675V87.6941L157.021 72.9743H157.018ZM126.354 125.663V96.5176L111.771 104.85L70.1301 128.626V158.046L126.354 125.663ZM8.80126 26.4848V125.663L65.0183 158.043V128.629L35.6494 112L35.6398 111.994L35.6271 111.988C35.5281 111.93 35.4452 111.847 35.3526 111.777C35.2729 111.713 35.1803 111.662 35.1101 111.592L35.1038 111.582C35.0208 111.502 34.9634 111.403 34.8932 111.314C34.8293 111.228 34.7528 111.154 34.7017 111.065L34.6985 111.055C34.6411 110.96 34.606 110.845 34.5645 110.736C34.523 110.64 34.4688 110.551 34.4432 110.449C34.4113 110.328 34.4049 110.197 34.3922 110.072C34.3794 109.976 34.3539 109.881 34.3539 109.785V109.778V41.2045L19.5322 32.6619L8.80126 26.4848ZM36.913 7.35007L11.3635 22.0634L36.9066 36.7768L62.4529 22.0602L36.9066 7.35007H36.913ZM50.1999 99.1736L65.0215 90.6374V26.4848L54.2906 32.6651L39.4657 41.2045V105.357L50.1999 99.1736ZM128.91 24.713L103.363 39.4264L128.91 54.1397L154.453 39.4232L128.91 24.713ZM126.354 58.5675L111.529 50.028L100.798 43.8477V72.9743L115.619 81.5106L126.354 87.6941V58.5675ZM67.5711 124.205L105.042 102.803L123.772 92.109L98.2451 77.4053L68.8538 94.3341L42.0663 109.762L67.5711 124.205Z" fill="#FF2D20"/>
|
<rect width="64" height="64" fill="#000000"/>
|
||||||
|
<!-- 7-segment "0": the counter at rest -->
|
||||||
|
<g fill="#ef4444">
|
||||||
|
<polygon points="24,8 40,8 44,12 40,16 24,16 20,12"/>
|
||||||
|
<polygon points="18,14 22,18 22,30 18,34 14,30 14,18"/>
|
||||||
|
<polygon points="46,14 50,18 50,30 46,34 42,30 42,18"/>
|
||||||
|
<polygon points="18,36 22,40 22,52 18,56 14,52 14,40"/>
|
||||||
|
<polygon points="46,36 50,40 50,52 46,56 42,52 42,40"/>
|
||||||
|
<polygon points="24,54 40,54 44,58 40,62 24,62 20,58"/>
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 603 B |
|
|
@ -1,16 +0,0 @@
|
||||||
<svg width="1991" height="500" viewBox="0 0 1991 500" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<mask id="path-1-outside-1_1_7" maskUnits="userSpaceOnUse" x="765" y="175" width="253" height="226" fill="black">
|
|
||||||
<rect fill="white" x="765" y="175" width="253" height="226"/>
|
|
||||||
<path d="M971.537 248.894C968.887 247.988 966.217 247.145 963.529 246.365C963.975 244.551 964.386 242.726 964.763 240.892C970.827 211.403 966.862 187.646 953.33 179.825C940.374 172.321 919.125 180.141 897.69 198.84C895.582 200.685 893.518 202.585 891.496 204.541C890.149 203.242 888.767 201.97 887.35 200.727C864.884 180.732 842.365 172.31 828.849 180.157C815.893 187.677 812.042 210.009 817.499 237.958C818.045 240.722 818.658 243.474 819.339 246.214C816.152 247.121 813.073 248.09 810.13 249.117C783.823 258.316 767 272.739 767 287.696C767 303.15 785.056 318.646 812.488 328.042C814.713 328.799 816.959 329.502 819.225 330.152C818.489 333.085 817.838 336.051 817.271 339.05C812.089 366.518 816.131 388.321 829.062 395.795C842.417 403.511 864.822 395.582 886.661 376.458C888.389 374.945 890.116 373.347 891.844 371.664C894.027 373.775 896.273 375.824 898.581 377.811C919.726 396.043 940.607 403.408 953.517 395.914C966.862 388.171 971.2 364.746 965.566 336.246C965.134 334.07 964.637 331.848 964.074 329.582C965.649 329.115 967.192 328.633 968.702 328.136C997.206 318.667 1015.78 303.358 1015.78 287.711C1015.76 272.682 998.403 258.165 971.537 248.894ZM904.681 206.873C923.043 190.854 940.208 184.531 948.033 189.05C956.362 193.865 959.601 213.279 954.367 238.741C954.021 240.399 953.648 242.053 953.247 243.701C942.289 241.208 931.177 239.449 919.985 238.435C913.577 229.204 906.553 220.417 898.959 212.133C900.818 210.33 902.722 208.576 904.671 206.873H904.681ZM840.453 300.476C842.702 304.829 845.053 309.129 847.507 313.376C850.005 317.708 852.608 321.977 855.317 326.182C847.616 325.345 839.963 324.111 832.389 322.487C834.586 315.376 837.292 307.991 840.453 300.476ZM840.453 275.429C837.343 268.074 834.711 260.84 832.55 253.853C839.65 252.262 847.216 250.962 855.104 249.977C852.461 254.102 849.918 258.288 847.475 262.534C845.033 266.781 842.687 271.079 840.437 275.429H840.453ZM846.102 287.955C849.377 281.124 852.905 274.425 856.685 267.857C860.461 261.292 864.481 254.878 868.745 248.614C876.146 248.054 883.733 247.759 891.398 247.759C899.063 247.759 906.697 248.054 914.093 248.619C918.311 254.869 922.307 261.261 926.08 267.795C929.853 274.328 933.427 280.996 936.803 287.799C933.472 294.654 929.906 301.381 926.106 307.981C922.336 314.545 918.363 320.978 914.186 327.281C906.801 327.799 899.156 328.084 891.382 328.084C883.609 328.084 876.109 327.84 868.859 327.369C864.564 321.084 860.511 314.642 856.7 308.043C852.89 301.444 849.352 294.748 846.087 287.955H846.102ZM935.305 313.303C937.81 308.957 940.215 304.553 942.519 300.093C945.678 307.255 948.467 314.576 950.874 322.025C943.21 323.751 935.462 325.074 927.661 325.99C930.293 321.813 932.842 317.584 935.305 313.303ZM942.416 275.434C940.121 271.063 937.736 266.744 935.258 262.477C932.84 258.269 930.319 254.113 927.697 250.008C935.632 251.013 943.245 252.35 950.381 253.983C948.087 261.262 945.428 268.421 942.416 275.434ZM891.502 219.747C896.677 225.401 901.563 231.314 906.143 237.461C896.347 236.994 886.545 236.994 876.736 237.461C881.572 231.066 886.521 225.131 891.502 219.747ZM834.198 189.366C842.521 184.536 860.924 191.439 880.323 208.682C881.561 209.786 882.81 210.941 884.054 212.139C876.422 220.421 869.348 229.201 862.878 238.42C851.711 239.426 840.621 241.157 829.678 243.602C829.046 241.059 828.476 238.499 827.968 235.922C823.278 211.962 826.382 193.901 834.198 189.366ZM822.065 319.89C819.992 319.299 817.945 318.658 815.924 317.967C803.796 313.821 793.779 308.411 786.901 302.518C780.744 297.237 777.624 291.966 777.624 287.696C777.624 278.621 791.13 267.043 813.654 259.191C816.483 258.207 819.337 257.307 822.215 256.491C825.561 267.242 829.612 277.759 834.343 287.976C829.555 298.333 825.454 308.994 822.065 319.89ZM879.659 368.436C870.004 376.894 860.333 382.89 851.808 385.911C844.148 388.622 838.048 388.7 834.363 386.569C826.517 382.035 823.252 364.523 827.704 341.035C828.232 338.271 828.837 335.507 829.518 332.743C840.57 335.124 851.771 336.75 863.044 337.609C869.577 346.876 876.71 355.706 884.396 364.041C882.852 365.559 881.271 367.021 879.659 368.436ZM891.797 356.402C886.76 350.955 881.732 344.927 876.83 338.449C881.594 338.636 886.445 338.729 891.382 338.729C896.451 338.729 901.468 338.62 906.412 338.397C901.849 344.643 896.971 350.652 891.797 356.402ZM956.331 371.219C954.849 379.221 951.869 384.559 948.184 386.694C940.342 391.244 923.577 385.331 905.5 369.736C903.427 367.954 901.354 366.041 899.244 364.036C906.781 355.668 913.747 346.803 920.094 337.501C931.429 336.54 942.685 334.8 953.781 332.292C954.289 334.344 954.738 336.355 955.128 338.325C957.621 350.918 957.979 362.31 956.331 371.219ZM965.349 318.024C963.991 318.475 962.591 318.911 961.171 319.336C957.692 308.535 953.483 297.983 948.572 287.753C953.299 277.659 957.343 267.26 960.679 256.626C963.224 257.367 965.696 258.145 968.075 258.969C991.096 266.908 1005.14 278.663 1005.14 287.696C1005.14 297.335 989.971 309.846 965.349 318.024Z"/>
|
|
||||||
</mask>
|
|
||||||
<path d="M971.537 248.894C968.887 247.988 966.217 247.145 963.529 246.365C963.975 244.551 964.386 242.726 964.763 240.892C970.827 211.403 966.862 187.646 953.33 179.825C940.374 172.321 919.125 180.141 897.69 198.84C895.582 200.685 893.518 202.585 891.496 204.541C890.149 203.242 888.767 201.97 887.35 200.727C864.884 180.732 842.365 172.31 828.849 180.157C815.893 187.677 812.042 210.009 817.499 237.958C818.045 240.722 818.658 243.474 819.339 246.214C816.152 247.121 813.073 248.09 810.13 249.117C783.823 258.316 767 272.739 767 287.696C767 303.15 785.056 318.646 812.488 328.042C814.713 328.799 816.959 329.502 819.225 330.152C818.489 333.085 817.838 336.051 817.271 339.05C812.089 366.518 816.131 388.321 829.062 395.795C842.417 403.511 864.822 395.582 886.661 376.458C888.389 374.945 890.116 373.347 891.844 371.664C894.027 373.775 896.273 375.824 898.581 377.811C919.726 396.043 940.607 403.408 953.517 395.914C966.862 388.171 971.2 364.746 965.566 336.246C965.134 334.07 964.637 331.848 964.074 329.582C965.649 329.115 967.192 328.633 968.702 328.136C997.206 318.667 1015.78 303.358 1015.78 287.711C1015.76 272.682 998.403 258.165 971.537 248.894ZM904.681 206.873C923.043 190.854 940.208 184.531 948.033 189.05C956.362 193.865 959.601 213.279 954.367 238.741C954.021 240.399 953.648 242.053 953.247 243.701C942.289 241.208 931.177 239.449 919.985 238.435C913.577 229.204 906.553 220.417 898.959 212.133C900.818 210.33 902.722 208.576 904.671 206.873H904.681ZM840.453 300.476C842.702 304.829 845.053 309.129 847.507 313.376C850.005 317.708 852.608 321.977 855.317 326.182C847.616 325.345 839.963 324.111 832.389 322.487C834.586 315.376 837.292 307.991 840.453 300.476ZM840.453 275.429C837.343 268.074 834.711 260.84 832.55 253.853C839.65 252.262 847.216 250.962 855.104 249.977C852.461 254.102 849.918 258.288 847.475 262.534C845.033 266.781 842.687 271.079 840.437 275.429H840.453ZM846.102 287.955C849.377 281.124 852.905 274.425 856.685 267.857C860.461 261.292 864.481 254.878 868.745 248.614C876.146 248.054 883.733 247.759 891.398 247.759C899.063 247.759 906.697 248.054 914.093 248.619C918.311 254.869 922.307 261.261 926.08 267.795C929.853 274.328 933.427 280.996 936.803 287.799C933.472 294.654 929.906 301.381 926.106 307.981C922.336 314.545 918.363 320.978 914.186 327.281C906.801 327.799 899.156 328.084 891.382 328.084C883.609 328.084 876.109 327.84 868.859 327.369C864.564 321.084 860.511 314.642 856.7 308.043C852.89 301.444 849.352 294.748 846.087 287.955H846.102ZM935.305 313.303C937.81 308.957 940.215 304.553 942.519 300.093C945.678 307.255 948.467 314.576 950.874 322.025C943.21 323.751 935.462 325.074 927.661 325.99C930.293 321.813 932.842 317.584 935.305 313.303ZM942.416 275.434C940.121 271.063 937.736 266.744 935.258 262.477C932.84 258.269 930.319 254.113 927.697 250.008C935.632 251.013 943.245 252.35 950.381 253.983C948.087 261.262 945.428 268.421 942.416 275.434ZM891.502 219.747C896.677 225.401 901.563 231.314 906.143 237.461C896.347 236.994 886.545 236.994 876.736 237.461C881.572 231.066 886.521 225.131 891.502 219.747ZM834.198 189.366C842.521 184.536 860.924 191.439 880.323 208.682C881.561 209.786 882.81 210.941 884.054 212.139C876.422 220.421 869.348 229.201 862.878 238.42C851.711 239.426 840.621 241.157 829.678 243.602C829.046 241.059 828.476 238.499 827.968 235.922C823.278 211.962 826.382 193.901 834.198 189.366ZM822.065 319.89C819.992 319.299 817.945 318.658 815.924 317.967C803.796 313.821 793.779 308.411 786.901 302.518C780.744 297.237 777.624 291.966 777.624 287.696C777.624 278.621 791.13 267.043 813.654 259.191C816.483 258.207 819.337 257.307 822.215 256.491C825.561 267.242 829.612 277.759 834.343 287.976C829.555 298.333 825.454 308.994 822.065 319.89ZM879.659 368.436C870.004 376.894 860.333 382.89 851.808 385.911C844.148 388.622 838.048 388.7 834.363 386.569C826.517 382.035 823.252 364.523 827.704 341.035C828.232 338.271 828.837 335.507 829.518 332.743C840.57 335.124 851.771 336.75 863.044 337.609C869.577 346.876 876.71 355.706 884.396 364.041C882.852 365.559 881.271 367.021 879.659 368.436ZM891.797 356.402C886.76 350.955 881.732 344.927 876.83 338.449C881.594 338.636 886.445 338.729 891.382 338.729C896.451 338.729 901.468 338.62 906.412 338.397C901.849 344.643 896.971 350.652 891.797 356.402ZM956.331 371.219C954.849 379.221 951.869 384.559 948.184 386.694C940.342 391.244 923.577 385.331 905.5 369.736C903.427 367.954 901.354 366.041 899.244 364.036C906.781 355.668 913.747 346.803 920.094 337.501C931.429 336.54 942.685 334.8 953.781 332.292C954.289 334.344 954.738 336.355 955.128 338.325C957.621 350.918 957.979 362.31 956.331 371.219ZM965.349 318.024C963.991 318.475 962.591 318.911 961.171 319.336C957.692 308.535 953.483 297.983 948.572 287.753C953.299 277.659 957.343 267.26 960.679 256.626C963.224 257.367 965.696 258.145 968.075 258.969C991.096 266.908 1005.14 278.663 1005.14 287.696C1005.14 297.335 989.971 309.846 965.349 318.024Z" fill="#FF2D20"/>
|
|
||||||
<path d="M971.537 248.894C968.887 247.988 966.217 247.145 963.529 246.365C963.975 244.551 964.386 242.726 964.763 240.892C970.827 211.403 966.862 187.646 953.33 179.825C940.374 172.321 919.125 180.141 897.69 198.84C895.582 200.685 893.518 202.585 891.496 204.541C890.149 203.242 888.767 201.97 887.35 200.727C864.884 180.732 842.365 172.31 828.849 180.157C815.893 187.677 812.042 210.009 817.499 237.958C818.045 240.722 818.658 243.474 819.339 246.214C816.152 247.121 813.073 248.09 810.13 249.117C783.823 258.316 767 272.739 767 287.696C767 303.15 785.056 318.646 812.488 328.042C814.713 328.799 816.959 329.502 819.225 330.152C818.489 333.085 817.838 336.051 817.271 339.05C812.089 366.518 816.131 388.321 829.062 395.795C842.417 403.511 864.822 395.582 886.661 376.458C888.389 374.945 890.116 373.347 891.844 371.664C894.027 373.775 896.273 375.824 898.581 377.811C919.726 396.043 940.607 403.408 953.517 395.914C966.862 388.171 971.2 364.746 965.566 336.246C965.134 334.07 964.637 331.848 964.074 329.582C965.649 329.115 967.192 328.633 968.702 328.136C997.206 318.667 1015.78 303.358 1015.78 287.711C1015.76 272.682 998.403 258.165 971.537 248.894ZM904.681 206.873C923.043 190.854 940.208 184.531 948.033 189.05C956.362 193.865 959.601 213.279 954.367 238.741C954.021 240.399 953.648 242.053 953.247 243.701C942.289 241.208 931.177 239.449 919.985 238.435C913.577 229.204 906.553 220.417 898.959 212.133C900.818 210.33 902.722 208.576 904.671 206.873H904.681ZM840.453 300.476C842.702 304.829 845.053 309.129 847.507 313.376C850.005 317.708 852.608 321.977 855.317 326.182C847.616 325.345 839.963 324.111 832.389 322.487C834.586 315.376 837.292 307.991 840.453 300.476ZM840.453 275.429C837.343 268.074 834.711 260.84 832.55 253.853C839.65 252.262 847.216 250.962 855.104 249.977C852.461 254.102 849.918 258.288 847.475 262.534C845.033 266.781 842.687 271.079 840.437 275.429H840.453ZM846.102 287.955C849.377 281.124 852.905 274.425 856.685 267.857C860.461 261.292 864.481 254.878 868.745 248.614C876.146 248.054 883.733 247.759 891.398 247.759C899.063 247.759 906.697 248.054 914.093 248.619C918.311 254.869 922.307 261.261 926.08 267.795C929.853 274.328 933.427 280.996 936.803 287.799C933.472 294.654 929.906 301.381 926.106 307.981C922.336 314.545 918.363 320.978 914.186 327.281C906.801 327.799 899.156 328.084 891.382 328.084C883.609 328.084 876.109 327.84 868.859 327.369C864.564 321.084 860.511 314.642 856.7 308.043C852.89 301.444 849.352 294.748 846.087 287.955H846.102ZM935.305 313.303C937.81 308.957 940.215 304.553 942.519 300.093C945.678 307.255 948.467 314.576 950.874 322.025C943.21 323.751 935.462 325.074 927.661 325.99C930.293 321.813 932.842 317.584 935.305 313.303ZM942.416 275.434C940.121 271.063 937.736 266.744 935.258 262.477C932.84 258.269 930.319 254.113 927.697 250.008C935.632 251.013 943.245 252.35 950.381 253.983C948.087 261.262 945.428 268.421 942.416 275.434ZM891.502 219.747C896.677 225.401 901.563 231.314 906.143 237.461C896.347 236.994 886.545 236.994 876.736 237.461C881.572 231.066 886.521 225.131 891.502 219.747ZM834.198 189.366C842.521 184.536 860.924 191.439 880.323 208.682C881.561 209.786 882.81 210.941 884.054 212.139C876.422 220.421 869.348 229.201 862.878 238.42C851.711 239.426 840.621 241.157 829.678 243.602C829.046 241.059 828.476 238.499 827.968 235.922C823.278 211.962 826.382 193.901 834.198 189.366ZM822.065 319.89C819.992 319.299 817.945 318.658 815.924 317.967C803.796 313.821 793.779 308.411 786.901 302.518C780.744 297.237 777.624 291.966 777.624 287.696C777.624 278.621 791.13 267.043 813.654 259.191C816.483 258.207 819.337 257.307 822.215 256.491C825.561 267.242 829.612 277.759 834.343 287.976C829.555 298.333 825.454 308.994 822.065 319.89ZM879.659 368.436C870.004 376.894 860.333 382.89 851.808 385.911C844.148 388.622 838.048 388.7 834.363 386.569C826.517 382.035 823.252 364.523 827.704 341.035C828.232 338.271 828.837 335.507 829.518 332.743C840.57 335.124 851.771 336.75 863.044 337.609C869.577 346.876 876.71 355.706 884.396 364.041C882.852 365.559 881.271 367.021 879.659 368.436ZM891.797 356.402C886.76 350.955 881.732 344.927 876.83 338.449C881.594 338.636 886.445 338.729 891.382 338.729C896.451 338.729 901.468 338.62 906.412 338.397C901.849 344.643 896.971 350.652 891.797 356.402ZM956.331 371.219C954.849 379.221 951.869 384.559 948.184 386.694C940.342 391.244 923.577 385.331 905.5 369.736C903.427 367.954 901.354 366.041 899.244 364.036C906.781 355.668 913.747 346.803 920.094 337.501C931.429 336.54 942.685 334.8 953.781 332.292C954.289 334.344 954.738 336.355 955.128 338.325C957.621 350.918 957.979 362.31 956.331 371.219ZM965.349 318.024C963.991 318.475 962.591 318.911 961.171 319.336C957.692 308.535 953.483 297.983 948.572 287.753C953.299 277.659 957.343 267.26 960.679 256.626C963.224 257.367 965.696 258.145 968.075 258.969C991.096 266.908 1005.14 278.663 1005.14 287.696C1005.14 297.335 989.971 309.846 965.349 318.024Z" stroke="#FF2D20" stroke-width="4" mask="url(#path-1-outside-1_1_7)"/>
|
|
||||||
<path d="M891.382 309.96C895.793 309.974 900.109 308.679 903.783 306.24C907.457 303.8 910.325 300.324 912.023 296.253C913.721 292.182 914.172 287.699 913.321 283.372C912.469 279.044 910.352 275.066 907.239 271.943C904.125 268.819 900.154 266.689 895.829 265.823C891.504 264.957 887.02 265.394 882.944 267.079C878.867 268.764 875.383 271.62 872.931 275.286C870.479 278.953 869.17 283.264 869.17 287.675C869.164 290.597 869.733 293.492 870.847 296.193C871.96 298.895 873.595 301.351 875.658 303.421C877.72 305.49 880.171 307.133 882.869 308.255C885.567 309.377 888.46 309.957 891.382 309.96Z" fill="#FF2D20"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M484.904 113.113C485.084 113.78 485.176 114.467 485.178 115.157V222.481C485.178 223.859 484.815 225.212 484.125 226.404C483.435 227.596 482.443 228.584 481.25 229.27L391.267 281.132V383.926C391.267 386.724 389.782 389.306 387.359 390.715L199.527 498.958C199.097 499.202 198.628 499.359 198.159 499.525C197.984 499.584 197.817 499.691 197.632 499.74C196.319 500.087 194.939 500.087 193.626 499.74C193.411 499.682 193.215 499.564 193.01 499.486C192.58 499.33 192.131 499.193 191.72 498.958L3.92801 390.715C2.73502 390.029 1.74377 389.04 1.05416 387.848C0.364547 386.656 0.000945257 385.304 0 383.926L0 61.9554C0 61.2511 0.0977118 60.5664 0.273593 59.9012C0.33222 59.6762 0.469016 59.4708 0.547186 59.2459C0.693753 58.835 0.83055 58.4144 1.04552 58.0329C1.19208 57.7786 1.40705 57.5732 1.58293 57.3384C1.80767 57.0254 2.01286 56.7026 2.27668 56.4288C2.50142 56.2038 2.79456 56.0375 3.04861 55.8419C3.33197 55.6071 3.58602 55.3528 3.90847 55.1669L97.8192 1.04537C99.0082 0.360469 100.356 0 101.728 0C103.099 0 104.447 0.360469 105.636 1.04537L199.537 55.1669H199.557C199.869 55.3626 200.133 55.6071 200.417 55.8321C200.671 56.0277 200.954 56.2038 201.179 56.419C201.452 56.7026 201.648 57.0254 201.882 57.3384C202.048 57.5732 202.273 57.7786 202.41 58.0329C202.635 58.4242 202.762 58.835 202.918 59.2459C202.996 59.4708 203.133 59.6762 203.192 59.911C203.371 60.5776 203.463 61.2649 203.465 61.9554V263.055L281.713 217.952V115.148C281.713 114.463 281.81 113.768 281.986 113.113C282.055 112.878 282.182 112.673 282.26 112.448C282.416 112.037 282.553 111.617 282.768 111.235C282.915 110.981 283.13 110.775 283.296 110.541C283.53 110.228 283.726 109.905 283.999 109.631C284.224 109.406 284.507 109.24 284.761 109.044C285.054 108.809 285.309 108.555 285.621 108.369L379.542 54.2475C380.73 53.5616 382.078 53.2006 383.45 53.2006C384.822 53.2006 386.17 53.5616 387.359 54.2475L481.26 108.369C481.592 108.565 481.846 108.809 482.139 109.034C482.383 109.23 482.667 109.406 482.891 109.621C483.165 109.905 483.36 110.228 483.595 110.541C483.771 110.775 483.986 110.981 484.123 111.235C484.347 111.617 484.474 112.037 484.631 112.448C484.719 112.673 484.846 112.878 484.904 113.113ZM469.524 217.952V128.705L436.664 147.642L391.267 173.808V263.055L469.534 217.952H469.524ZM375.623 379.397V290.091L330.969 315.621L203.455 388.475V478.622L375.623 379.397ZM15.6534 75.5029V379.397L187.802 478.612V388.485L97.8681 337.532L97.8388 337.513L97.7997 337.493C97.4968 337.317 97.2427 337.063 96.9594 336.847C96.7151 336.652 96.4317 336.495 96.2167 336.28L96.1972 336.251C95.9432 336.006 95.7673 335.703 95.5523 335.429C95.3569 335.165 95.1224 334.94 94.966 334.666L94.9563 334.637C94.7804 334.343 94.6729 333.991 94.5459 333.659C94.4188 333.365 94.2527 333.091 94.1746 332.778C94.0769 332.407 94.0573 332.006 94.0182 331.624C93.9791 331.331 93.901 331.037 93.901 330.744V120.606L48.5139 94.4303L15.6534 75.5029ZM101.737 16.872L23.4997 61.9554L101.718 107.039L179.946 61.9456L101.718 16.872H101.737ZM142.425 298.23L187.812 272.074V75.5029L154.951 94.44L109.554 120.606V317.177L142.425 298.23ZM383.45 70.0741L305.222 115.157L383.45 160.241L461.668 115.148L383.45 70.0741ZM375.623 173.808L330.227 147.642L297.366 128.705V217.952L342.753 244.108L375.623 263.055V173.808ZM195.619 374.927L310.362 309.351L367.719 276.583L289.549 231.529L199.547 283.401L117.518 330.675L195.619 374.927Z" fill="#FF2D20"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M661.371 63V345.115H763.474V393.787H607V63H661.371ZM1047.57 393.787V173.576H1187.5V224.268H1099.09V393.811H1047.57V393.787ZM1374.22 203.347V173.576H1425.75V393.799H1374.22V364.016C1367.29 375.045 1357.45 383.712 1344.68 390.004C1331.92 396.308 1319.08 399.454 1306.15 399.454C1289.45 399.454 1274.17 396.394 1260.3 390.249C1246.79 384.36 1234.64 375.751 1224.61 364.958C1214.66 354.224 1206.81 341.728 1201.45 328.112C1195.88 313.965 1193.07 298.88 1193.17 283.675C1193.17 268.251 1195.93 253.513 1201.45 239.484C1206.77 225.777 1214.62 213.193 1224.6 202.393C1234.64 191.605 1246.79 183 1260.3 177.114C1274.17 170.969 1289.45 167.909 1306.15 167.909C1319.07 167.909 1331.92 171.055 1344.68 177.359C1357.45 183.663 1367.29 192.318 1374.22 203.347ZM1369.5 310.619C1372.67 301.995 1374.27 292.875 1374.22 283.688C1374.22 274.225 1372.63 265.252 1369.5 256.757C1366.54 248.576 1362.06 241.035 1356.27 234.538C1350.5 228.166 1343.5 223.021 1335.7 219.408C1327.67 215.638 1318.75 213.74 1308.99 213.74C1299.22 213.74 1290.39 215.638 1282.52 219.408C1274.81 223.064 1267.9 228.206 1262.19 234.538C1256.42 241.001 1252 248.551 1249.2 256.744C1246.17 265.399 1244.65 274.508 1244.7 283.675C1244.7 293.126 1246.18 302.111 1249.2 310.606C1252.18 319.126 1256.52 326.52 1262.19 332.824C1267.9 339.159 1274.81 344.302 1282.52 347.955C1290.39 351.737 1299.22 353.623 1308.99 353.623C1318.75 353.623 1327.67 351.737 1335.7 347.955C1343.51 344.346 1350.5 339.2 1356.27 332.824C1362.06 326.332 1366.54 318.795 1369.5 310.619ZM1632.33 173.576H1684.52L1599.96 393.799H1535.18L1450.61 173.576H1502.8L1567.57 342.214L1632.33 173.576ZM1796.91 167.909C1867.06 167.909 1914.91 230.07 1905.92 302.821H1734.98C1734.98 321.819 1754.17 358.544 1799.74 358.544C1838.92 358.544 1865.19 324.084 1865.2 324.06L1900.01 350.991C1868.89 384.202 1843.4 399.467 1803.49 399.467C1732.19 399.467 1683.85 354.369 1683.85 283.688C1683.85 219.751 1733.82 167.909 1796.91 167.909ZM1735.11 264.542H1858.6C1858.22 260.307 1851.52 208.819 1796.49 208.819C1741.45 208.819 1735.51 260.307 1735.11 264.542ZM1939.47 393.787V63H1991V393.787H1939.47Z" fill="#FF2D20"/>
|
|
||||||
<path d="M1472.8 35.462C1474.96 33.432 1477.74 31.605 1481.12 29.981C1483.96 28.6277 1487.48 27.4097 1491.68 26.327C1495.87 25.109 1500.81 24.5 1506.49 24.5V42.77C1500.4 42.9053 1494.99 44.191 1490.25 46.627C1488.22 47.7097 1486.19 49.063 1484.16 50.687C1482.13 52.1757 1480.31 54.138 1478.68 56.574C1477.06 59.01 1475.71 61.9197 1474.62 65.303C1473.54 68.551 1472.93 72.3403 1472.8 76.671V126H1454.53V27.342H1472.8V35.462Z" fill="black"/>
|
|
||||||
<path d="M1615.77 94.941C1615.77 99.6777 1614.49 104.076 1611.91 108.136C1609.34 112.196 1605.82 115.782 1601.36 118.895C1597.03 122.008 1591.89 124.444 1585.93 126.203C1580.11 127.962 1573.82 128.842 1567.05 128.842C1559.88 128.842 1553.11 127.489 1546.75 124.782C1540.39 122.075 1534.84 118.354 1530.11 113.617C1525.37 108.88 1521.65 103.332 1518.94 96.971C1516.23 90.6103 1514.88 83.8437 1514.88 76.671C1514.88 69.4983 1516.23 62.7317 1518.94 56.371C1521.65 50.0103 1525.37 44.5293 1530.11 39.928C1534.84 35.1913 1540.39 31.4697 1546.75 28.763C1553.11 26.0563 1559.88 24.703 1567.05 24.703C1574.09 24.703 1580.99 26.0563 1587.76 28.763C1594.52 31.4697 1600.41 35.3943 1605.42 40.537C1610.43 45.6797 1614.15 52.108 1616.58 59.822C1619.15 67.4007 1619.76 76.062 1618.41 85.806H1534.37C1534.37 88.242 1535.25 90.881 1537.01 93.723C1538.77 96.565 1541.07 99.2717 1543.91 101.843C1546.89 104.279 1550.34 106.377 1554.26 108.136C1558.32 109.76 1562.59 110.572 1567.05 110.572C1570.98 110.572 1574.63 110.166 1578.01 109.354C1581.53 108.542 1584.58 107.459 1587.15 106.106C1589.72 104.617 1591.75 102.926 1593.24 101.031C1594.73 99.1363 1595.47 97.1063 1595.47 94.941H1615.77ZM1599.53 67.536C1599.13 65.3707 1598.04 62.9347 1596.28 60.228C1594.52 57.386 1592.22 54.6793 1589.38 52.108C1586.54 49.5367 1583.22 47.3713 1579.43 45.612C1575.65 43.8527 1571.52 42.973 1567.05 42.973C1562.59 42.973 1558.39 43.8527 1554.47 45.612C1550.68 47.3713 1547.36 49.5367 1544.52 52.108C1541.68 54.6793 1539.38 57.386 1537.62 60.228C1535.86 62.9347 1534.77 65.3707 1534.37 67.536H1599.53Z" fill="black"/>
|
|
||||||
<path d="M1709.72 126V116.256C1709.04 117.068 1707.76 118.151 1705.86 119.504C1704.1 120.857 1701.74 122.278 1698.76 123.767C1695.92 125.12 1692.53 126.271 1688.61 127.218C1684.82 128.301 1680.62 128.842 1676.02 128.842C1668.85 128.842 1662.08 127.489 1655.72 124.782C1649.36 122.075 1643.81 118.354 1639.08 113.617C1634.34 108.88 1630.62 103.332 1627.91 96.971C1625.21 90.6103 1623.85 83.8437 1623.85 76.671C1623.85 69.4983 1625.21 62.7317 1627.91 56.371C1630.62 50.0103 1634.34 44.4617 1639.08 39.725C1643.81 34.9883 1649.36 31.2667 1655.72 28.56C1662.08 25.8533 1668.85 24.5 1676.02 24.5C1680.62 24.5 1684.68 24.906 1688.2 25.718C1691.72 26.53 1694.83 27.545 1697.54 28.763C1700.25 29.981 1702.55 31.3343 1704.44 32.823C1706.47 34.3117 1708.23 35.7327 1709.72 37.086V27.342H1727.99V126H1709.72ZM1642.12 76.671C1642.12 80.8663 1643.07 84.994 1644.96 89.054C1646.99 93.114 1649.57 96.768 1652.68 100.016C1655.93 103.129 1659.58 105.7 1663.64 107.73C1667.7 109.625 1671.83 110.572 1676.02 110.572C1680.22 110.572 1684.35 109.625 1688.41 107.73C1692.47 105.7 1696.05 103.129 1699.16 100.016C1702.28 96.768 1704.78 93.114 1706.68 89.054C1708.71 84.994 1709.72 80.8663 1709.72 76.671C1709.72 72.4757 1708.71 68.348 1706.68 64.288C1704.78 60.228 1702.28 56.6417 1699.16 53.529C1696.05 50.281 1692.47 47.7097 1688.41 45.815C1684.35 43.785 1680.22 42.77 1676.02 42.77C1671.83 42.77 1667.7 43.785 1663.64 45.815C1659.58 47.7097 1655.93 50.281 1652.68 53.529C1649.57 56.6417 1646.99 60.228 1644.96 64.288C1643.07 68.348 1642.12 72.4757 1642.12 76.671Z" fill="black"/>
|
|
||||||
<path d="M1836.68 85.806C1836.68 91.7607 1835.32 97.377 1832.62 102.655C1829.91 107.798 1826.25 112.331 1821.65 116.256C1817.05 120.045 1811.57 123.09 1805.21 125.391C1798.98 127.692 1792.35 128.842 1785.32 128.842C1778.14 128.842 1771.38 127.489 1765.02 124.782C1758.79 122.075 1753.31 118.354 1748.57 113.617C1743.84 108.88 1740.11 103.332 1737.41 96.971C1734.7 90.6103 1733.35 83.8437 1733.35 76.671C1733.35 69.4983 1734.7 62.7317 1737.41 56.371C1740.11 50.0103 1743.84 44.5293 1748.57 39.928C1753.31 35.1913 1758.79 31.4697 1765.02 28.763C1771.38 26.0563 1778.14 24.703 1785.32 24.703C1792.35 24.703 1798.98 25.8533 1805.21 28.154C1811.57 30.3193 1817.05 33.3643 1821.65 37.289C1826.25 41.2137 1829.91 45.815 1832.62 51.093C1835.32 56.2357 1836.68 61.7167 1836.68 67.536H1818C1818 64.1527 1817.12 60.9723 1815.36 57.995C1813.74 55.0177 1811.44 52.4463 1808.46 50.281C1805.48 47.9803 1802.03 46.221 1798.11 45.003C1794.18 43.6497 1789.92 42.973 1785.32 42.973C1780.58 42.973 1776.18 43.8527 1772.12 45.612C1768.06 47.3713 1764.47 49.8073 1761.36 52.92C1758.38 55.8973 1756.02 59.4837 1754.26 63.679C1752.5 67.739 1751.62 72.0697 1751.62 76.671C1751.62 81.4077 1752.5 85.806 1754.26 89.866C1756.02 93.926 1758.38 97.5123 1761.36 100.625C1764.47 103.738 1768.06 106.174 1772.12 107.933C1776.18 109.692 1780.58 110.572 1785.32 110.572C1789.92 110.572 1794.18 109.963 1798.11 108.745C1802.03 107.392 1805.48 105.632 1808.46 103.467C1811.44 101.166 1813.74 98.5273 1815.36 95.55C1817.12 92.5727 1818 89.3247 1818 85.806H1836.68Z" fill="black"/>
|
|
||||||
<path d="M1862.21 126V45.612H1843.94V27.342H1862.21V5.418H1880.48V27.342H1898.75V45.612H1880.48V126H1862.21Z" fill="black"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 26 KiB |
|
|
@ -1,13 +1,12 @@
|
||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
|
||||||
@plugin 'tailwindcss-animate';
|
|
||||||
|
|
||||||
@source '../views';
|
@source '../views';
|
||||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: '7Segment';
|
font-family: '7Segment';
|
||||||
src: url('/fonts/7segment.woff') format('woff');
|
/* Relative so Vite resolves and fingerprints it; an absolute /fonts path
|
||||||
|
404s against the dev server, which serves assets itself. */
|
||||||
|
src: url('../fonts/7segment.woff') format('woff');
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
@ -24,159 +23,3 @@ .glow-red {
|
||||||
.glow-red:hover {
|
.glow-red:hover {
|
||||||
box-shadow: 0 0 25px rgba(239, 68, 68, 0.6);
|
box-shadow: 0 0 25px rgba(239, 68, 68, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
|
||||||
|
|
||||||
@theme {
|
|
||||||
--font-sans:
|
|
||||||
'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
|
||||||
|
|
||||||
--font-mono-display:
|
|
||||||
'Major Mono Display', ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace;
|
|
||||||
|
|
||||||
--radius-lg: var(--radius);
|
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
|
||||||
|
|
||||||
--color-background: var(--background);
|
|
||||||
--color-foreground: var(--foreground);
|
|
||||||
|
|
||||||
--color-card: var(--card);
|
|
||||||
--color-card-foreground: var(--card-foreground);
|
|
||||||
|
|
||||||
--color-popover: var(--popover);
|
|
||||||
--color-popover-foreground: var(--popover-foreground);
|
|
||||||
|
|
||||||
--color-primary: var(--primary);
|
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
|
||||||
|
|
||||||
--color-secondary: var(--secondary);
|
|
||||||
--color-secondary-foreground: var(--secondary-foreground);
|
|
||||||
|
|
||||||
--color-muted: var(--muted);
|
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
|
||||||
|
|
||||||
--color-accent: var(--accent);
|
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
|
||||||
|
|
||||||
--color-destructive: var(--destructive);
|
|
||||||
--color-destructive-foreground: var(--destructive-foreground);
|
|
||||||
|
|
||||||
--color-border: var(--border);
|
|
||||||
--color-input: var(--input);
|
|
||||||
--color-ring: var(--ring);
|
|
||||||
|
|
||||||
--color-chart-1: var(--chart-1);
|
|
||||||
--color-chart-2: var(--chart-2);
|
|
||||||
--color-chart-3: var(--chart-3);
|
|
||||||
--color-chart-4: var(--chart-4);
|
|
||||||
--color-chart-5: var(--chart-5);
|
|
||||||
|
|
||||||
--color-sidebar: var(--sidebar);
|
|
||||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
|
||||||
--color-sidebar-primary: var(--sidebar-primary);
|
|
||||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
|
||||||
--color-sidebar-accent: var(--sidebar-accent);
|
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
The default border color has changed to `currentColor` in Tailwind CSS v4,
|
|
||||||
so we've added these compatibility styles to make sure everything still
|
|
||||||
looks the same as it did with Tailwind CSS v3.
|
|
||||||
|
|
||||||
If we ever want to remove these styles, we need to add an explicit border
|
|
||||||
color utility to any element that depends on these defaults.
|
|
||||||
*/
|
|
||||||
@layer base {
|
|
||||||
*,
|
|
||||||
::after,
|
|
||||||
::before,
|
|
||||||
::backdrop,
|
|
||||||
::file-selector-button {
|
|
||||||
border-color: var(--color-gray-200, currentColor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--background: oklch(1 0 0);
|
|
||||||
--foreground: oklch(0.145 0 0);
|
|
||||||
--card: oklch(1 0 0);
|
|
||||||
--card-foreground: oklch(0.145 0 0);
|
|
||||||
--popover: oklch(1 0 0);
|
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
|
||||||
--primary: oklch(0.205 0 0);
|
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
|
||||||
--secondary: oklch(0.97 0 0);
|
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
|
||||||
--muted: oklch(0.97 0 0);
|
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
|
||||||
--accent: oklch(0.97 0 0);
|
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
|
||||||
--destructive-foreground: oklch(0.577 0.245 27.325);
|
|
||||||
--border: oklch(0.922 0 0);
|
|
||||||
--input: oklch(0.922 0 0);
|
|
||||||
--ring: oklch(0.87 0 0);
|
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
|
||||||
--chart-4: oklch(0.828 0.189 84.429);
|
|
||||||
--chart-5: oklch(0.769 0.188 70.08);
|
|
||||||
--radius: 0.625rem;
|
|
||||||
--sidebar: oklch(0.985 0 0);
|
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
|
||||||
--sidebar-ring: oklch(0.87 0 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
|
||||||
--background: oklch(0.145 0 0);
|
|
||||||
--foreground: oklch(0.985 0 0);
|
|
||||||
--card: oklch(0.145 0 0);
|
|
||||||
--card-foreground: oklch(0.985 0 0);
|
|
||||||
--popover: oklch(0.145 0 0);
|
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
|
||||||
--primary: oklch(0.985 0 0);
|
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
|
||||||
--secondary: oklch(0.269 0 0);
|
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
|
||||||
--muted: oklch(0.269 0 0);
|
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
|
||||||
--accent: oklch(0.269 0 0);
|
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
|
||||||
--destructive: oklch(0.396 0.141 25.723);
|
|
||||||
--destructive-foreground: oklch(0.637 0.237 25.331);
|
|
||||||
--border: oklch(0.269 0 0);
|
|
||||||
--input: oklch(0.269 0 0);
|
|
||||||
--ring: oklch(0.439 0 0);
|
|
||||||
--chart-1: oklch(0.488 0.243 264.376);
|
|
||||||
--chart-2: oklch(0.696 0.17 162.48);
|
|
||||||
--chart-3: oklch(0.769 0.188 70.08);
|
|
||||||
--chart-4: oklch(0.627 0.265 303.9);
|
|
||||||
--chart-5: oklch(0.645 0.246 16.439);
|
|
||||||
--sidebar: oklch(0.205 0 0);
|
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-primary: oklch(0.985 0 0);
|
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-border: oklch(0.269 0 0);
|
|
||||||
--sidebar-ring: oklch(0.439 0 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
* {
|
|
||||||
@apply border-border;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
@apply bg-background text-foreground;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
import '../css/app.css';
|
|
||||||
|
|
||||||
import { createInertiaApp } from '@inertiajs/react';
|
|
||||||
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
|
|
||||||
import { createRoot } from 'react-dom/client';
|
|
||||||
import { initializeTheme } from './hooks/use-appearance';
|
|
||||||
|
|
||||||
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
|
||||||
|
|
||||||
createInertiaApp({
|
|
||||||
title: (title) => title ? `${title} - ${appName}` : appName,
|
|
||||||
resolve: (name) => resolvePageComponent(`./pages/${name}.tsx`, import.meta.glob('./pages/**/*.tsx')),
|
|
||||||
setup({ el, App, props }) {
|
|
||||||
const root = createRoot(el);
|
|
||||||
|
|
||||||
root.render(<App {...props} />);
|
|
||||||
},
|
|
||||||
progress: {
|
|
||||||
color: '#4B5563',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// This will set light / dark mode on load...
|
|
||||||
initializeTheme();
|
|
||||||
|
|
@ -1,156 +0,0 @@
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import InputError from '@/components/InputError';
|
|
||||||
import { useForm } from '@inertiajs/react';
|
|
||||||
import { LoaderCircle } from 'lucide-react';
|
|
||||||
import { FormEventHandler, useState, useEffect } from 'react';
|
|
||||||
import ComponentTitle from '@/components/ui/ComponentTitle';
|
|
||||||
|
|
||||||
interface AssetFormData {
|
|
||||||
symbol: string;
|
|
||||||
full_name: string;
|
|
||||||
[key: string]: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AssetSetupFormProps {
|
|
||||||
onSuccess?: () => void;
|
|
||||||
onCancel?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AssetSetupForm({ onSuccess, onCancel }: AssetSetupFormProps) {
|
|
||||||
const { data, setData, patch, processing, errors } = useForm<AssetFormData>({
|
|
||||||
symbol: '',
|
|
||||||
full_name: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load existing asset data on mount
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchCurrentAsset = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/tracker');
|
|
||||||
if (response.ok) {
|
|
||||||
const { tracker } = await response.json();
|
|
||||||
if (tracker?.asset) {
|
|
||||||
setData({
|
|
||||||
symbol: tracker.asset.symbol || '',
|
|
||||||
full_name: tracker.asset.full_name || '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch current asset:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchCurrentAsset();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const [suggestions] = useState([
|
|
||||||
{ symbol: 'VWCE', full_name: 'Vanguard FTSE All-World UCITS ETF' },
|
|
||||||
{ symbol: 'VTI', full_name: 'Vanguard Total Stock Market ETF' },
|
|
||||||
{ symbol: 'SPY', full_name: 'SPDR S&P 500 ETF Trust' },
|
|
||||||
{ symbol: 'QQQ', full_name: 'Invesco QQQ Trust' },
|
|
||||||
{ symbol: 'IWDA', full_name: 'iShares Core MSCI World UCITS ETF' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const submit: FormEventHandler = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
patch(route('tracker.update'), {
|
|
||||||
onSuccess: () => {
|
|
||||||
if (onSuccess) onSuccess();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSuggestionClick = (suggestion: { symbol: string; full_name: string }) => {
|
|
||||||
setData({
|
|
||||||
symbol: suggestion.symbol,
|
|
||||||
full_name: suggestion.full_name,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<ComponentTitle>SET ASSET</ComponentTitle>
|
|
||||||
<p className="text-sm text-red-400/60 font-mono">
|
|
||||||
[SYSTEM] Specify the asset you want to track
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Quick suggestions */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-red-400 font-mono text-xs uppercase tracking-wider">> Quick Select</Label>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{suggestions.map((suggestion) => (
|
|
||||||
<button
|
|
||||||
key={suggestion.symbol}
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleSuggestionClick(suggestion)}
|
|
||||||
className="px-3 py-1 bg-black border border-red-500/50 text-red-400 hover:border-red-400 hover:text-red-300 font-mono text-xs uppercase tracking-wider transition-all rounded-none"
|
|
||||||
>
|
|
||||||
{suggestion.symbol}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={submit} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="symbol" className="text-red-400 font-mono text-xs uppercase tracking-wider">> Asset Symbol</Label>
|
|
||||||
<Input
|
|
||||||
id="symbol"
|
|
||||||
type="text"
|
|
||||||
placeholder="VWCE"
|
|
||||||
value={data.symbol}
|
|
||||||
onChange={(e) => setData('symbol', e.target.value.toUpperCase())}
|
|
||||||
className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none focus:shadow-[0_0_10px_rgba(239,68,68,0.5)] placeholder:text-red-400/40 transition-all"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-red-400/60 mt-1 font-mono">
|
|
||||||
[REQUIRED] ticker symbol (e.g. VWCE, VTI, SPY)
|
|
||||||
</p>
|
|
||||||
<InputError message={errors.symbol} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="full_name" className="text-red-400 font-mono text-xs uppercase tracking-wider">> Full Name (Optional)</Label>
|
|
||||||
<Input
|
|
||||||
id="full_name"
|
|
||||||
type="text"
|
|
||||||
placeholder="Vanguard FTSE All-World UCITS ETF"
|
|
||||||
value={data.full_name}
|
|
||||||
onChange={(e) => setData('full_name', e.target.value)}
|
|
||||||
className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none focus:shadow-[0_0_10px_rgba(239,68,68,0.5)] placeholder:text-red-400/40 transition-all"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-red-400/60 mt-1 font-mono">
|
|
||||||
[OPTIONAL] human-readable asset name
|
|
||||||
</p>
|
|
||||||
<InputError message={errors.full_name} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-3 pt-2">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={processing || !data.symbol}
|
|
||||||
className="flex-1 bg-red-500 hover:bg-red-500 text-black font-mono text-sm font-bold border-red-500 rounded-none border-2 uppercase tracking-wider transition-all glow-red"
|
|
||||||
>
|
|
||||||
{processing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
[EXECUTE]
|
|
||||||
</Button>
|
|
||||||
{onCancel && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onCancel}
|
|
||||||
className="flex-1 bg-black border-red-500 text-red-400 hover:bg-red-950 hover:text-red-300 font-mono text-sm font-bold rounded-none border-2 uppercase tracking-wider transition-all glow-red"
|
|
||||||
>
|
|
||||||
[ABORT]
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
import { SidebarInset } from '@/components/ui/sidebar';
|
|
||||||
import * as React from 'react';
|
|
||||||
|
|
||||||
interface AppContentProps extends React.ComponentProps<'main'> {
|
|
||||||
variant?: 'header' | 'sidebar';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppContent({ variant = 'header', children, ...props }: AppContentProps) {
|
|
||||||
if (variant === 'sidebar') {
|
|
||||||
return <SidebarInset {...props}>{children}</SidebarInset>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="mx-auto flex h-full w-full max-w-7xl flex-1 flex-col gap-4 rounded-xl" {...props}>
|
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,182 +0,0 @@
|
||||||
import { Breadcrumbs } from '@/components/Display/Breadcrumbs';
|
|
||||||
import { Icon } from '@/components/icon';
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/DropdownMenu';
|
|
||||||
import { NavigationMenu, NavigationMenuItem, NavigationMenuList, navigationMenuTriggerStyle } from '@/components/ui/NavigationMenu';
|
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
|
||||||
import { UserMenuContent } from '@/components/Settings/UserMenuContent';
|
|
||||||
import { useInitials } from '@/hooks/use-initials';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { type BreadcrumbItem, type NavItem, type SharedData } from '@/types';
|
|
||||||
import { Link, usePage } from '@inertiajs/react';
|
|
||||||
import { BookOpen, Folder, LayoutGrid, Menu, Search } from 'lucide-react';
|
|
||||||
import AppLogo from './AppLogo';
|
|
||||||
import AppLogoIcon from './AppLogoIcon';
|
|
||||||
|
|
||||||
const mainNavItems: NavItem[] = [
|
|
||||||
{
|
|
||||||
title: 'Dashboard',
|
|
||||||
href: '/dashboard',
|
|
||||||
icon: LayoutGrid,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const rightNavItems: NavItem[] = [
|
|
||||||
{
|
|
||||||
title: 'Repository',
|
|
||||||
href: 'https://github.com/laravel/react-starter-kit',
|
|
||||||
icon: Folder,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Documentation',
|
|
||||||
href: 'https://laravel.com/docs/starter-kits#react',
|
|
||||||
icon: BookOpen,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const activeItemStyles = 'text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100';
|
|
||||||
|
|
||||||
interface AppHeaderProps {
|
|
||||||
breadcrumbs?: BreadcrumbItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppHeader({ breadcrumbs = [] }: AppHeaderProps) {
|
|
||||||
const page = usePage<SharedData>();
|
|
||||||
const { auth } = page.props;
|
|
||||||
const getInitials = useInitials();
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="border-b border-sidebar-border/80">
|
|
||||||
<div className="mx-auto flex h-16 items-center px-4 md:max-w-7xl">
|
|
||||||
{/* Mobile Menu */}
|
|
||||||
<div className="lg:hidden">
|
|
||||||
<Sheet>
|
|
||||||
<SheetTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon" className="mr-2 h-[34px] w-[34px]">
|
|
||||||
<Menu className="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
</SheetTrigger>
|
|
||||||
<SheetContent side="left" className="flex h-full w-64 flex-col items-stretch justify-between bg-sidebar">
|
|
||||||
<SheetTitle className="sr-only">Navigation Menu</SheetTitle>
|
|
||||||
<SheetHeader className="flex justify-start text-left">
|
|
||||||
<AppLogoIcon className="h-6 w-6 fill-current text-black dark:text-white" />
|
|
||||||
</SheetHeader>
|
|
||||||
<div className="flex h-full flex-1 flex-col space-y-4 p-4">
|
|
||||||
<div className="flex h-full flex-col justify-between text-sm">
|
|
||||||
<div className="flex flex-col space-y-4">
|
|
||||||
{mainNavItems.map((item) => (
|
|
||||||
<Link key={item.title} href={item.href} className="flex items-center space-x-2 font-medium">
|
|
||||||
{item.icon && <Icon iconNode={item.icon} className="h-5 w-5" />}
|
|
||||||
<span>{item.title}</span>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col space-y-4">
|
|
||||||
{rightNavItems.map((item) => (
|
|
||||||
<a
|
|
||||||
key={item.title}
|
|
||||||
href={item.href}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center space-x-2 font-medium"
|
|
||||||
>
|
|
||||||
{item.icon && <Icon iconNode={item.icon} className="h-5 w-5" />}
|
|
||||||
<span>{item.title}</span>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Link href="/dashboard" prefetch className="flex items-center space-x-2">
|
|
||||||
<AppLogo />
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* Desktop Navigation */}
|
|
||||||
<div className="ml-6 hidden h-full items-center space-x-6 lg:flex">
|
|
||||||
<NavigationMenu className="flex h-full items-stretch">
|
|
||||||
<NavigationMenuList className="flex h-full items-stretch space-x-2">
|
|
||||||
{mainNavItems.map((item, index) => (
|
|
||||||
<NavigationMenuItem key={index} className="relative flex h-full items-center">
|
|
||||||
<Link
|
|
||||||
href={item.href}
|
|
||||||
className={cn(
|
|
||||||
navigationMenuTriggerStyle(),
|
|
||||||
page.url === item.href && activeItemStyles,
|
|
||||||
'h-9 cursor-pointer px-3',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.icon && <Icon iconNode={item.icon} className="mr-2 h-4 w-4" />}
|
|
||||||
{item.title}
|
|
||||||
</Link>
|
|
||||||
{page.url === item.href && (
|
|
||||||
<div className="absolute bottom-0 left-0 h-0.5 w-full translate-y-px bg-black dark:bg-white"></div>
|
|
||||||
)}
|
|
||||||
</NavigationMenuItem>
|
|
||||||
))}
|
|
||||||
</NavigationMenuList>
|
|
||||||
</NavigationMenu>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="ml-auto flex items-center space-x-2">
|
|
||||||
<div className="relative flex items-center space-x-1">
|
|
||||||
<Button variant="ghost" size="icon" className="group h-9 w-9 cursor-pointer">
|
|
||||||
<Search className="!size-5 opacity-80 group-hover:opacity-100" />
|
|
||||||
</Button>
|
|
||||||
<div className="hidden lg:flex">
|
|
||||||
{rightNavItems.map((item) => (
|
|
||||||
<TooltipProvider key={item.title} delayDuration={0}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<a
|
|
||||||
href={item.href}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="group ml-1 inline-flex h-9 w-9 items-center justify-center rounded-md bg-transparent p-0 text-sm font-medium text-accent-foreground ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<span className="sr-only">{item.title}</span>
|
|
||||||
{item.icon && <Icon iconNode={item.icon} className="size-5 opacity-80 group-hover:opacity-100" />}
|
|
||||||
</a>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{item.title}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" className="size-10 rounded-full p-1">
|
|
||||||
<Avatar className="size-8 overflow-hidden rounded-full">
|
|
||||||
<AvatarImage src={auth.user.avatar} alt={auth.user.name} />
|
|
||||||
<AvatarFallback className="rounded-lg bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white">
|
|
||||||
{getInitials(auth.user.name)}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent className="w-56" align="end">
|
|
||||||
<UserMenuContent user={auth.user} />
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{breadcrumbs.length > 1 && (
|
|
||||||
<div className="flex w-full border-b border-sidebar-border/70">
|
|
||||||
<div className="mx-auto flex h-12 w-full items-center justify-start px-4 text-neutral-500 md:max-w-7xl">
|
|
||||||
<Breadcrumbs breadcrumbs={breadcrumbs} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
import AppLogoIcon from './AppLogoIcon';
|
|
||||||
|
|
||||||
export default function AppLogo() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex aspect-square size-8 items-center justify-center rounded-md bg-sidebar-primary text-sidebar-primary-foreground">
|
|
||||||
<AppLogoIcon className="size-5 fill-current text-white dark:text-black" />
|
|
||||||
</div>
|
|
||||||
<div className="ml-1 grid flex-1 text-left text-sm">
|
|
||||||
<span className="mb-0.5 truncate leading-tight font-semibold">Laravel Starter Kit</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
import { SVGAttributes } from 'react';
|
|
||||||
|
|
||||||
export default function AppLogoIcon(props: SVGAttributes<SVGElement>) {
|
|
||||||
return (
|
|
||||||
<svg {...props} viewBox="0 0 40 42" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
clipRule="evenodd"
|
|
||||||
d="M17.2 5.63325L8.6 0.855469L0 5.63325V32.1434L16.2 41.1434L32.4 32.1434V23.699L40 19.4767V9.85547L31.4 5.07769L22.8 9.85547V18.2999L17.2 21.411V5.63325ZM38 18.2999L32.4 21.411V15.2545L38 12.1434V18.2999ZM36.9409 10.4439L31.4 13.5221L25.8591 10.4439L31.4 7.36561L36.9409 10.4439ZM24.8 18.2999V12.1434L30.4 15.2545V21.411L24.8 18.2999ZM23.8 20.0323L29.3409 23.1105L16.2 30.411L10.6591 27.3328L23.8 20.0323ZM7.6 27.9212L15.2 32.1434V38.2999L2 30.9666V7.92116L7.6 11.0323V27.9212ZM8.6 9.29991L3.05913 6.22165L8.6 3.14339L14.1409 6.22165L8.6 9.29991ZM30.4 24.8101L17.2 32.1434V38.2999L30.4 30.9666V24.8101ZM9.6 11.0323L15.2 7.92117V22.5221L9.6 25.6333V11.0323Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
import { SidebarProvider } from '@/components/ui/sidebar';
|
|
||||||
import { SharedData } from '@/types';
|
|
||||||
import { usePage } from '@inertiajs/react';
|
|
||||||
|
|
||||||
interface AppShellProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
variant?: 'header' | 'sidebar';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppShell({ children, variant = 'header' }: AppShellProps) {
|
|
||||||
const isOpen = usePage<SharedData>().props.sidebarOpen;
|
|
||||||
|
|
||||||
if (variant === 'header') {
|
|
||||||
return <div className="flex min-h-screen w-full flex-col">{children}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <SidebarProvider defaultOpen={isOpen}>{children}</SidebarProvider>;
|
|
||||||
}
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
import { NavFooter } from '@/components/Display/NavFooter';
|
|
||||||
import { NavMain } from '@/components/Display/NavMain';
|
|
||||||
import { NavUser } from '@/components/Display/NavUser';
|
|
||||||
import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar';
|
|
||||||
import { type NavItem } from '@/types';
|
|
||||||
import { Link } from '@inertiajs/react';
|
|
||||||
import { BookOpen, Folder, LayoutGrid } from 'lucide-react';
|
|
||||||
import AppLogo from './AppLogo';
|
|
||||||
|
|
||||||
const mainNavItems: NavItem[] = [
|
|
||||||
{
|
|
||||||
title: 'Dashboard',
|
|
||||||
href: '/dashboard',
|
|
||||||
icon: LayoutGrid,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const footerNavItems: NavItem[] = [
|
|
||||||
{
|
|
||||||
title: 'Repository',
|
|
||||||
href: 'https://github.com/laravel/react-starter-kit',
|
|
||||||
icon: Folder,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Documentation',
|
|
||||||
href: 'https://laravel.com/docs/starter-kits#react',
|
|
||||||
icon: BookOpen,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function AppSidebar() {
|
|
||||||
return (
|
|
||||||
<Sidebar collapsible="icon" variant="inset">
|
|
||||||
<SidebarHeader>
|
|
||||||
<SidebarMenu>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<SidebarMenuButton size="lg" asChild>
|
|
||||||
<Link href="/dashboard" prefetch>
|
|
||||||
<AppLogo />
|
|
||||||
</Link>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarHeader>
|
|
||||||
|
|
||||||
<SidebarContent>
|
|
||||||
<NavMain items={mainNavItems} />
|
|
||||||
</SidebarContent>
|
|
||||||
|
|
||||||
<SidebarFooter>
|
|
||||||
<NavFooter items={footerNavItems} className="mt-auto" />
|
|
||||||
<NavUser />
|
|
||||||
</SidebarFooter>
|
|
||||||
</Sidebar>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
import { Breadcrumbs } from '@/components/Display/Breadcrumbs';
|
|
||||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
|
||||||
import { type BreadcrumbItem as BreadcrumbItemType } from '@/types';
|
|
||||||
|
|
||||||
export function AppSidebarHeader({ breadcrumbs = [] }: { breadcrumbs?: BreadcrumbItemType[] }) {
|
|
||||||
return (
|
|
||||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b border-sidebar-border/50 px-6 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 md:px-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<SidebarTrigger className="-ml-1" />
|
|
||||||
<Breadcrumbs breadcrumbs={breadcrumbs} />
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from '@/components/ui/breadcrumb';
|
|
||||||
import { type BreadcrumbItem as BreadcrumbItemType } from '@/types';
|
|
||||||
import { Link } from '@inertiajs/react';
|
|
||||||
import { Fragment } from 'react';
|
|
||||||
|
|
||||||
export function Breadcrumbs({ breadcrumbs }: { breadcrumbs: BreadcrumbItemType[] }) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{breadcrumbs.length > 0 && (
|
|
||||||
<Breadcrumb>
|
|
||||||
<BreadcrumbList>
|
|
||||||
{breadcrumbs.map((item, index) => {
|
|
||||||
const isLast = index === breadcrumbs.length - 1;
|
|
||||||
return (
|
|
||||||
<Fragment key={index}>
|
|
||||||
<BreadcrumbItem>
|
|
||||||
{isLast ? (
|
|
||||||
<BreadcrumbPage>{item.title}</BreadcrumbPage>
|
|
||||||
) : (
|
|
||||||
<BreadcrumbLink asChild>
|
|
||||||
<Link href={item.href}>{item.title}</Link>
|
|
||||||
</BreadcrumbLink>
|
|
||||||
)}
|
|
||||||
</BreadcrumbItem>
|
|
||||||
{!isLast && <BreadcrumbSeparator />}
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</BreadcrumbList>
|
|
||||||
</Breadcrumb>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
import AddEntryForm from '@/components/Transactions/AddEntryForm';
|
|
||||||
import AddMilestoneForm from '@/components/Milestones/AddMilestoneForm';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
type FormType = 'purchase' | 'milestone';
|
|
||||||
|
|
||||||
interface InlineFormProps {
|
|
||||||
type: FormType | null;
|
|
||||||
unit?: string;
|
|
||||||
onClose: () => void;
|
|
||||||
onSuccess?: (type: FormType) => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function InlineForm({
|
|
||||||
type,
|
|
||||||
unit = 'units',
|
|
||||||
onClose,
|
|
||||||
onSuccess,
|
|
||||||
className,
|
|
||||||
}: InlineFormProps) {
|
|
||||||
if (!type) return null;
|
|
||||||
|
|
||||||
const handleSuccess = () => {
|
|
||||||
if (onSuccess) onSuccess(type);
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'bg-black p-8',
|
|
||||||
'transition-all duration-300',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="w-full border-4 border-red-500 p-2 bg-black space-y-4 glow-red">
|
|
||||||
<div className="flex justify-center">
|
|
||||||
{type === 'purchase' ? (
|
|
||||||
<AddEntryForm
|
|
||||||
unit={unit}
|
|
||||||
onSuccess={handleSuccess}
|
|
||||||
onCancel={onClose}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<AddMilestoneForm
|
|
||||||
onSuccess={handleSuccess}
|
|
||||||
onCancel={onClose}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
interface LedDisplayProps {
|
|
||||||
value: number;
|
|
||||||
unit?: string;
|
|
||||||
className?: string;
|
|
||||||
animate?: boolean;
|
|
||||||
onClick?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LedDisplay({
|
|
||||||
value,
|
|
||||||
unit,
|
|
||||||
className,
|
|
||||||
onClick
|
|
||||||
}: LedDisplayProps) {
|
|
||||||
const [displayValue, setDisplayValue] = useState(0);
|
|
||||||
|
|
||||||
// Animate number changes
|
|
||||||
useEffect(() => {
|
|
||||||
setDisplayValue(value);
|
|
||||||
return;
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
// Format number with zero-padding for consistent width
|
|
||||||
const formatValue = (value: number) => {
|
|
||||||
// Always pad to 5 digits for consistent display width
|
|
||||||
const integerPart = Math.floor(value);
|
|
||||||
return integerPart.toString().padStart(5, '0');
|
|
||||||
};
|
|
||||||
|
|
||||||
const formattedValue = formatValue(displayValue);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"w-full text-center select-none cursor-pointer",
|
|
||||||
"bg-black text-red-500",
|
|
||||||
"px-8 py-12 transition-all duration-300",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
<div className="relative w-full flex items-center justify-center">
|
|
||||||
<div className={cn(
|
|
||||||
"relative z-10",
|
|
||||||
"text-[8rem] md:text-[12rem] lg:text-[16rem]",
|
|
||||||
"font-digital font-normal",
|
|
||||||
"text-red-500",
|
|
||||||
"drop-shadow-[0_0_10px_rgba(239,68,68,0.8)]",
|
|
||||||
"filter brightness-110",
|
|
||||||
"leading-none",
|
|
||||||
"transition-all duration-300"
|
|
||||||
)}
|
|
||||||
style={{ letterSpacing: '0.15em' }}>
|
|
||||||
{formattedValue}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{unit && (
|
|
||||||
<div className="text-red-500/50 font-mono text-sm uppercase tracking-widest mt-2">
|
|
||||||
{unit}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
import { Icon } from '@/components/icon';
|
|
||||||
import { SidebarGroup, SidebarGroupContent, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar';
|
|
||||||
import { type NavItem } from '@/types';
|
|
||||||
import { type ComponentPropsWithoutRef } from 'react';
|
|
||||||
|
|
||||||
export function NavFooter({
|
|
||||||
items,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: ComponentPropsWithoutRef<typeof SidebarGroup> & {
|
|
||||||
items: NavItem[];
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<SidebarGroup {...props} className={`group-data-[collapsible=icon]:p-0 ${className || ''}`}>
|
|
||||||
<SidebarGroupContent>
|
|
||||||
<SidebarMenu>
|
|
||||||
{items.map((item) => (
|
|
||||||
<SidebarMenuItem key={item.title}>
|
|
||||||
<SidebarMenuButton
|
|
||||||
asChild
|
|
||||||
className="text-neutral-600 hover:text-neutral-800 dark:text-neutral-300 dark:hover:text-neutral-100"
|
|
||||||
>
|
|
||||||
<a href={item.href} target="_blank" rel="noopener noreferrer">
|
|
||||||
{item.icon && <Icon iconNode={item.icon} className="h-5 w-5" />}
|
|
||||||
<span>{item.title}</span>
|
|
||||||
</a>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
))}
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroupContent>
|
|
||||||
</SidebarGroup>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
import { SidebarGroup, SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar';
|
|
||||||
import { type NavItem } from '@/types';
|
|
||||||
import { Link, usePage } from '@inertiajs/react';
|
|
||||||
|
|
||||||
export function NavMain({ items = [] }: { items: NavItem[] }) {
|
|
||||||
const page = usePage();
|
|
||||||
return (
|
|
||||||
<SidebarGroup className="px-2 py-0">
|
|
||||||
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
|
||||||
<SidebarMenu>
|
|
||||||
{items.map((item) => (
|
|
||||||
<SidebarMenuItem key={item.title}>
|
|
||||||
<SidebarMenuButton asChild isActive={page.url.startsWith(item.href)} tooltip={{ children: item.title }}>
|
|
||||||
<Link href={item.href} prefetch>
|
|
||||||
{item.icon && <item.icon />}
|
|
||||||
<span>{item.title}</span>
|
|
||||||
</Link>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
))}
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroup>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/DropdownMenu';
|
|
||||||
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from '@/components/ui/sidebar';
|
|
||||||
import { UserInfo } from '@/components/Settings/UserInfo';
|
|
||||||
import { UserMenuContent } from '@/components/Settings/UserMenuContent';
|
|
||||||
import { useIsMobile } from '@/hooks/use-mobile';
|
|
||||||
import { type SharedData } from '@/types';
|
|
||||||
import { usePage } from '@inertiajs/react';
|
|
||||||
import { ChevronsUpDown } from 'lucide-react';
|
|
||||||
|
|
||||||
export function NavUser() {
|
|
||||||
const { auth } = usePage<SharedData>().props;
|
|
||||||
const { state } = useSidebar();
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarMenu>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<SidebarMenuButton size="lg" className="group text-sidebar-accent-foreground data-[state=open]:bg-sidebar-accent">
|
|
||||||
<UserInfo user={auth.user} />
|
|
||||||
<ChevronsUpDown className="ml-auto size-4" />
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
|
||||||
align="end"
|
|
||||||
side={isMobile ? 'bottom' : state === 'collapsed' ? 'left' : 'bottom'}
|
|
||||||
>
|
|
||||||
<UserMenuContent user={auth.user} />
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
</SidebarMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import type { Milestone } from '@/types/domain';
|
|
||||||
|
|
||||||
interface ProgressBarProps {
|
|
||||||
currentQuantity: number;
|
|
||||||
milestones: Milestone[];
|
|
||||||
selectedMilestoneIndex?: number;
|
|
||||||
className?: string;
|
|
||||||
onClick?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProgressBar({
|
|
||||||
currentQuantity,
|
|
||||||
milestones,
|
|
||||||
selectedMilestoneIndex = 0,
|
|
||||||
className,
|
|
||||||
onClick
|
|
||||||
}: ProgressBarProps) {
|
|
||||||
const selectedMilestone = milestones.length > 0 && selectedMilestoneIndex < milestones.length
|
|
||||||
? milestones[selectedMilestoneIndex]
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const progressPercentage = selectedMilestone
|
|
||||||
? Math.min((currentQuantity / selectedMilestone.target) * 100, 100)
|
|
||||||
: 0;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"bg-black cursor-pointer",
|
|
||||||
"transition-all duration-300",
|
|
||||||
"p-8",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
{/* Progress Bar Container */}
|
|
||||||
<div className="w-full">
|
|
||||||
{/* Old-school progress bar with overlaid text */}
|
|
||||||
<div className="w-full border-4 border-red-500 p-2 bg-black relative overflow-hidden glow-red">
|
|
||||||
{/* Inner container */}
|
|
||||||
<div className="relative h-8">
|
|
||||||
{/* Progress fill */}
|
|
||||||
<div
|
|
||||||
className="absolute top-0 left-0 h-full bg-red-500 transition-all duration-500 ease-out"
|
|
||||||
style={{ width: `${progressPercentage}%` }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Text overlay */}
|
|
||||||
{selectedMilestone && (
|
|
||||||
<div className="relative h-full flex items-center justify-center">
|
|
||||||
{/* Base text (red on black background) */}
|
|
||||||
<div className="text-red-500 font-mono text-sm font-bold mix-blend-difference relative z-10">
|
|
||||||
{progressPercentage.toFixed(1)}%
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { Plus, ChevronRight } from 'lucide-react';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import ComponentTitle from '@/components/ui/ComponentTitle';
|
|
||||||
import type { Milestone } from '@/types/domain';
|
|
||||||
|
|
||||||
interface StatsBoxProps {
|
|
||||||
stats: {
|
|
||||||
totalShares: number;
|
|
||||||
};
|
|
||||||
unit?: string;
|
|
||||||
milestones?: Milestone[];
|
|
||||||
selectedMilestoneIndex?: number;
|
|
||||||
onMilestoneSelect?: (index: number) => void;
|
|
||||||
className?: string;
|
|
||||||
onAddPurchase?: () => void;
|
|
||||||
onAddMilestone?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function StatsBox({
|
|
||||||
stats,
|
|
||||||
unit = 'units',
|
|
||||||
milestones = [],
|
|
||||||
selectedMilestoneIndex = 0,
|
|
||||||
onMilestoneSelect,
|
|
||||||
className,
|
|
||||||
onAddPurchase,
|
|
||||||
onAddMilestone,
|
|
||||||
}: StatsBoxProps) {
|
|
||||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
|
||||||
|
|
||||||
const handleCycleMilestone = () => {
|
|
||||||
if (milestones.length === 0 || !onMilestoneSelect) return;
|
|
||||||
const nextIndex = (selectedMilestoneIndex + 1) % milestones.length;
|
|
||||||
onMilestoneSelect(nextIndex);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"bg-black p-8",
|
|
||||||
"transition-all duration-300",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="w-full border-4 border-red-500 p-2 bg-black space-y-4 glow-red">
|
|
||||||
<div className="flex justify-between items-center mb-6 relative">
|
|
||||||
<ComponentTitle>Stats</ComponentTitle>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2 relative">
|
|
||||||
{/* Action Dropdown */}
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
|
||||||
className="flex items-center justify-center px-2 py-1 rounded border border-red-500/50 text-red-500 hover:bg-red-800/40 hover:text-red-300 transition-colors text-sm"
|
|
||||||
aria-label="Add actions"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isDropdownOpen && (
|
|
||||||
<div className="absolute top-full right-0 mt-2 bg-black border-2 border-red-500/50 rounded shadow-lg min-w-40 z-10">
|
|
||||||
{onAddPurchase && (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
onAddPurchase();
|
|
||||||
setIsDropdownOpen(false);
|
|
||||||
}}
|
|
||||||
className="w-full text-left px-4 py-2 text-red-400 hover:bg-red-600/20 hover:text-red-300 transition-colors text-sm font-mono border-b border-red-500/20 last:border-b-0"
|
|
||||||
>
|
|
||||||
ADD ENTRY
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onAddMilestone && (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
onAddMilestone();
|
|
||||||
setIsDropdownOpen(false);
|
|
||||||
}}
|
|
||||||
className="w-full text-left px-4 py-2 text-red-400 hover:bg-red-600/20 hover:text-red-300 transition-colors text-sm font-mono border-b border-red-500/20 last:border-b-0"
|
|
||||||
>
|
|
||||||
ADD MILESTONE
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Milestone Cycle Button */}
|
|
||||||
{milestones.length > 1 && (
|
|
||||||
<button
|
|
||||||
onClick={handleCycleMilestone}
|
|
||||||
className="flex items-center justify-center px-2 py-1 rounded border border-red-500/50 text-red-500 hover:bg-red-800/40 hover:text-red-300 transition-colors text-sm"
|
|
||||||
aria-label="Cycle milestone"
|
|
||||||
>
|
|
||||||
<ChevronRight className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Milestone Table */}
|
|
||||||
<div className="pt-4">
|
|
||||||
<div className="text-red-500 underline font-bold mb-3 font-mono">MILESTONES</div>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm font-mono">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th className="text-left text-red-500 text-xs py-2">DESCRIPTION</th>
|
|
||||||
<th className="text-right text-red-500 text-xs py-2">{unit.toUpperCase()}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr className="text-red-500 font-bold">
|
|
||||||
<td className="py-1 pr-4">CURRENT</td>
|
|
||||||
<td className="text-right py-1">
|
|
||||||
{Math.floor(stats.totalShares).toLocaleString()}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
{milestones.map((milestone, index) => (
|
|
||||||
<tr
|
|
||||||
key={index}
|
|
||||||
className={cn(
|
|
||||||
index === selectedMilestoneIndex
|
|
||||||
? "bg-red-500 text-black"
|
|
||||||
: "text-red-500 font-bold"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<td className="py-1 pr-4">{milestone.description}</td>
|
|
||||||
<td className="text-right py-1">
|
|
||||||
{Math.floor(milestone.target).toLocaleString()}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
export default function HeadingSmall({ title, description }: { title: string; description?: string }) {
|
|
||||||
return (
|
|
||||||
<header>
|
|
||||||
<h3 className="mb-0.5 text-base font-medium">{title}</h3>
|
|
||||||
{description && <p className="text-sm text-muted-foreground">{description}</p>}
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { type HTMLAttributes } from 'react';
|
|
||||||
|
|
||||||
export default function InputError({ message, className = '', ...props }: HTMLAttributes<HTMLParagraphElement> & { message?: string }) {
|
|
||||||
return message ? (
|
|
||||||
<p {...props} className={cn('text-sm text-red-600 dark:text-red-400', className)}>
|
|
||||||
{message}
|
|
||||||
</p>
|
|
||||||
) : null;
|
|
||||||
}
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import InputError from '@/components/InputError';
|
|
||||||
import { useForm } from '@inertiajs/react';
|
|
||||||
import { LoaderCircle } from 'lucide-react';
|
|
||||||
import { FormEventHandler } from 'react';
|
|
||||||
import ComponentTitle from '@/components/ui/ComponentTitle';
|
|
||||||
|
|
||||||
interface MilestoneFormData {
|
|
||||||
target: string;
|
|
||||||
description: string;
|
|
||||||
[key: string]: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AddMilestoneFormProps {
|
|
||||||
onSuccess?: () => void;
|
|
||||||
onCancel?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AddMilestoneForm({ onSuccess, onCancel }: AddMilestoneFormProps) {
|
|
||||||
const { data, setData, post, processing, errors, reset } = useForm<MilestoneFormData>({
|
|
||||||
target: '',
|
|
||||||
description: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const submit: FormEventHandler = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
post(route('milestones.store'), {
|
|
||||||
onSuccess: () => {
|
|
||||||
reset();
|
|
||||||
if (onSuccess) {
|
|
||||||
onSuccess();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<ComponentTitle>ADD MILESTONE</ComponentTitle>
|
|
||||||
<form onSubmit={submit} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="target" className="text-red-400 font-mono text-xs uppercase tracking-wider">> Target Number</Label>
|
|
||||||
<Input
|
|
||||||
id="target"
|
|
||||||
type="number"
|
|
||||||
step="1"
|
|
||||||
min="1"
|
|
||||||
placeholder="1500"
|
|
||||||
value={data.target}
|
|
||||||
onChange={(e) => setData('target', e.target.value)}
|
|
||||||
className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none placeholder:text-red-400/40 transition-all glow-red"
|
|
||||||
/>
|
|
||||||
<InputError message={errors.target} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="description" className="text-red-400 font-mono text-xs uppercase tracking-wider">> Description</Label>
|
|
||||||
<Input
|
|
||||||
id="description"
|
|
||||||
type="text"
|
|
||||||
placeholder="First milestone"
|
|
||||||
value={data.description}
|
|
||||||
onChange={(e) => setData('description', e.target.value)}
|
|
||||||
className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none placeholder:text-red-400/40 transition-all glow-red"
|
|
||||||
/>
|
|
||||||
<InputError message={errors.description} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-3 pt-2">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={processing}
|
|
||||||
className="flex-1 bg-red-500 hover:bg-red-500 text-black font-mono text-sm font-bold border-red-500 rounded-none border-2 uppercase tracking-wider transition-all glow-red"
|
|
||||||
>
|
|
||||||
{processing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
[EXECUTE]
|
|
||||||
</Button>
|
|
||||||
{onCancel && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onCancel}
|
|
||||||
className="flex-1 bg-black border-red-500 text-red-400 hover:bg-red-950 hover:text-red-300 font-mono text-sm font-bold rounded-none border-2 uppercase tracking-wider transition-all glow-red"
|
|
||||||
>
|
|
||||||
[ABORT]
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import InputError from '@/components/InputError';
|
|
||||||
import { LoaderCircle } from 'lucide-react';
|
|
||||||
import { FormEventHandler, useState } from 'react';
|
|
||||||
import ComponentTitle from '@/components/ui/ComponentTitle';
|
|
||||||
|
|
||||||
interface CreateTrackerStepProps {
|
|
||||||
onSuccess: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CreateTrackerStep({ onSuccess }: CreateTrackerStepProps) {
|
|
||||||
const [label, setLabel] = useState('');
|
|
||||||
const [unit, setUnit] = useState('');
|
|
||||||
const [processing, setProcessing] = useState(false);
|
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
||||||
|
|
||||||
const submit: FormEventHandler = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setProcessing(true);
|
|
||||||
setErrors({});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(route('tracker.store'), {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content ?? '',
|
|
||||||
'Accept': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
label,
|
|
||||||
unit,
|
|
||||||
price_tracking_enabled: 0,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok || response.status === 201 || response.status === 409) {
|
|
||||||
onSuccess();
|
|
||||||
} else {
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.errors) {
|
|
||||||
setErrors(data.errors);
|
|
||||||
} else if (data.message) {
|
|
||||||
setErrors({ label: data.message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setErrors({ label: 'Something went wrong. Please try again.' });
|
|
||||||
} finally {
|
|
||||||
setProcessing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<ComponentTitle>SET UP YOUR TRACKER</ComponentTitle>
|
|
||||||
<p className="text-sm text-red-400/60 font-mono">
|
|
||||||
[SYSTEM] What are you tracking?
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form onSubmit={submit} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="label" className="text-red-400 font-mono text-xs uppercase tracking-wider">
|
|
||||||
> Tracker Name
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="label"
|
|
||||||
type="text"
|
|
||||||
placeholder="My Portfolio"
|
|
||||||
value={label}
|
|
||||||
onChange={(e) => setLabel(e.target.value)}
|
|
||||||
className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none focus:shadow-[0_0_10px_rgba(239,68,68,0.5)] placeholder:text-red-400/40 transition-all"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-red-400/60 mt-1 font-mono">
|
|
||||||
[REQUIRED] e.g. "My Portfolio", "Books Read", "KM Run"
|
|
||||||
</p>
|
|
||||||
<InputError message={errors.label} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="unit" className="text-red-400 font-mono text-xs uppercase tracking-wider">
|
|
||||||
> Unit
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="unit"
|
|
||||||
type="text"
|
|
||||||
placeholder="shares"
|
|
||||||
value={unit}
|
|
||||||
onChange={(e) => setUnit(e.target.value)}
|
|
||||||
className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none focus:shadow-[0_0_10px_rgba(239,68,68,0.5)] placeholder:text-red-400/40 transition-all"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-red-400/60 mt-1 font-mono">
|
|
||||||
[REQUIRED] e.g. "shares", "books", "km"
|
|
||||||
</p>
|
|
||||||
<InputError message={errors.unit} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={processing || !label || !unit}
|
|
||||||
className="w-full bg-red-500 hover:bg-red-500 text-black font-mono text-sm font-bold border-red-500 rounded-none border-2 uppercase tracking-wider transition-all glow-red"
|
|
||||||
>
|
|
||||||
{processing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
[INITIALIZE]
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,177 +0,0 @@
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import AddEntryForm from '@/components/Transactions/AddEntryForm';
|
|
||||||
import AddMilestoneForm from '@/components/Milestones/AddMilestoneForm';
|
|
||||||
import CreateTrackerStep from '@/components/Onboarding/CreateTrackerStep';
|
|
||||||
|
|
||||||
interface OnboardingStep {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
completed: boolean;
|
|
||||||
required: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const STEPS: OnboardingStep[] = [
|
|
||||||
{ id: 'entries', title: 'STARTING AMOUNT', description: 'Enter your starting amount', completed: false, required: true },
|
|
||||||
{ id: 'milestones', title: 'SET MILESTONES', description: 'Define your goals', completed: false, required: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface OnboardingFlowProps {
|
|
||||||
onComplete?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
|
|
||||||
const [trackerCreated, setTrackerCreated] = useState(false);
|
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
|
||||||
const [steps, setSteps] = useState<OnboardingStep[]>([]);
|
|
||||||
|
|
||||||
// On mount: check if a tracker already exists and skip step 1 if so
|
|
||||||
useEffect(() => {
|
|
||||||
fetch('/tracker')
|
|
||||||
.then(r => r.ok ? r.json() : null)
|
|
||||||
.then(data => {
|
|
||||||
if (data?.tracker) {
|
|
||||||
setTrackerCreated(true);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const checkOnboardingStatus = useCallback(async (currentSteps: OnboardingStep[]) => {
|
|
||||||
try {
|
|
||||||
const [entriesData, milestonesData] = await Promise.all([
|
|
||||||
fetch('/entries/summary').then(r => r.json()),
|
|
||||||
fetch('/milestones').then(r => r.json()),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const hasEntries = entriesData.total_quantity > 0;
|
|
||||||
const hasMilestones = milestonesData.length > 0;
|
|
||||||
|
|
||||||
const freshSteps = currentSteps.map(step => ({
|
|
||||||
...step,
|
|
||||||
completed:
|
|
||||||
(step.id === 'entries' && hasEntries) ||
|
|
||||||
(step.id === 'milestones' && hasMilestones),
|
|
||||||
}));
|
|
||||||
|
|
||||||
setSteps(freshSteps);
|
|
||||||
|
|
||||||
const firstIncompleteRequired = freshSteps.findIndex(s => s.required && !s.completed);
|
|
||||||
|
|
||||||
if (firstIncompleteRequired !== -1) {
|
|
||||||
setCurrentStep(firstIncompleteRequired);
|
|
||||||
} else if (onComplete) {
|
|
||||||
onComplete();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to check onboarding status:', error);
|
|
||||||
}
|
|
||||||
}, [onComplete]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!trackerCreated) return;
|
|
||||||
|
|
||||||
setSteps(STEPS);
|
|
||||||
setCurrentStep(0);
|
|
||||||
checkOnboardingStatus(STEPS);
|
|
||||||
}, [trackerCreated, checkOnboardingStatus]);
|
|
||||||
|
|
||||||
const handleTrackerCreated = () => {
|
|
||||||
setTrackerCreated(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStepComplete = async () => {
|
|
||||||
const updatedSteps = steps.map((step, index) =>
|
|
||||||
index === currentStep ? { ...step, completed: true } : step
|
|
||||||
);
|
|
||||||
setSteps(updatedSteps);
|
|
||||||
await checkOnboardingStatus(updatedSteps);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStepSelect = (stepIndex: number) => {
|
|
||||||
setCurrentStep(stepIndex);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderStepContent = () => {
|
|
||||||
const step = steps[currentStep];
|
|
||||||
if (!step) return null;
|
|
||||||
|
|
||||||
switch (step.id) {
|
|
||||||
case 'entries':
|
|
||||||
return <AddEntryForm onSuccess={handleStepComplete} />;
|
|
||||||
case 'milestones':
|
|
||||||
return <AddMilestoneForm onSuccess={handleStepComplete} />;
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-black flex items-center justify-center p-4">
|
|
||||||
<div className="w-full max-w-4xl">
|
|
||||||
<div className="border-2 border-red-500 bg-black shadow-[0_0_20px_rgba(239,68,68,0.3)] p-8">
|
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-red-400 font-mono text-2xl font-bold uppercase tracking-wider mb-2">
|
|
||||||
[SYSTEM] ONBOARDING SEQUENCE
|
|
||||||
</h1>
|
|
||||||
<p className="text-red-400/60 font-mono text-sm">
|
|
||||||
{!trackerCreated ? 'Set up your tracker' : 'Configure your tracker'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!trackerCreated ? (
|
|
||||||
<div className="border border-red-500/30 bg-black/50 p-6">
|
|
||||||
<CreateTrackerStep onSuccess={handleTrackerCreated} />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="mb-8">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
{steps.map((step, index) => (
|
|
||||||
<button
|
|
||||||
key={step.id}
|
|
||||||
onClick={() => handleStepSelect(index)}
|
|
||||||
className={`flex-1 px-4 py-2 font-mono text-xs uppercase tracking-wider border border-red-500/50 transition-all ${
|
|
||||||
index === currentStep
|
|
||||||
? 'bg-red-500 text-black border-red-500'
|
|
||||||
: step.completed
|
|
||||||
? 'bg-red-950/50 text-red-300 border-red-400'
|
|
||||||
: 'bg-black text-red-400/60 hover:text-red-400 hover:border-red-400'
|
|
||||||
} ${index > 0 ? 'ml-2' : ''}`}
|
|
||||||
>
|
|
||||||
{step.completed ? '[✓]' : '[REQ]'} {step.title}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-red-400 font-mono text-sm">
|
|
||||||
{steps[currentStep]?.description}
|
|
||||||
</p>
|
|
||||||
<p className="text-red-400/60 font-mono text-xs mt-1">
|
|
||||||
STEP {currentStep + 1}/{steps.length}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border border-red-500/30 bg-black/50 p-6">
|
|
||||||
{renderStepContent()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 pt-4 border-t border-red-500/30">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<p className="text-red-400/60 font-mono text-xs">
|
|
||||||
[STATUS] {steps.filter(s => s.completed).length}/{steps.length} STEPS COMPLETE
|
|
||||||
</p>
|
|
||||||
<p className="text-red-400/60 font-mono text-xs">
|
|
||||||
{steps.filter(s => !s.completed).length} REQUIRED REMAINING
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import InputError from '@/components/InputError';
|
|
||||||
import { useForm } from '@inertiajs/react';
|
|
||||||
import { todayISO } from '@/lib/utils';
|
|
||||||
import { LoaderCircle } from 'lucide-react';
|
|
||||||
import { FormEventHandler } from 'react';
|
|
||||||
import ComponentTitle from '@/components/ui/ComponentTitle';
|
|
||||||
|
|
||||||
interface PriceUpdateFormData {
|
|
||||||
date: string;
|
|
||||||
price: string;
|
|
||||||
[key: string]: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UpdatePriceFormProps {
|
|
||||||
currentPrice?: number;
|
|
||||||
className?: string;
|
|
||||||
onSuccess?: () => void;
|
|
||||||
onCancel?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function UpdatePriceForm({ currentPrice, className, onSuccess, onCancel }: UpdatePriceFormProps) {
|
|
||||||
const { data, setData, post, processing, errors } = useForm<PriceUpdateFormData>({
|
|
||||||
date: todayISO(), // Today's date in YYYY-MM-DD format
|
|
||||||
price: currentPrice?.toString() || '100.00',
|
|
||||||
});
|
|
||||||
|
|
||||||
const submit: FormEventHandler = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
post(route('pricing.update'), {
|
|
||||||
onSuccess: () => {
|
|
||||||
// Keep the date, reset only price if needed
|
|
||||||
// User might want to update same day multiple times
|
|
||||||
if (onSuccess) {
|
|
||||||
onSuccess();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (errors) => {
|
|
||||||
console.error('Price update failed:', errors);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<ComponentTitle>UPDATE PRICE</ComponentTitle>
|
|
||||||
{currentPrice && (
|
|
||||||
<p className="text-sm text-red-400/60 font-mono">
|
|
||||||
[CURRENT] €{currentPrice.toFixed(4)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<form onSubmit={submit} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="date" className="text-red-400 font-mono text-xs uppercase tracking-wider">> Price Date</Label>
|
|
||||||
<Input
|
|
||||||
id="date"
|
|
||||||
type="date"
|
|
||||||
value={data.date}
|
|
||||||
onChange={(e) => setData('date', e.target.value)}
|
|
||||||
max={todayISO()}
|
|
||||||
className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none transition-all glow-red"
|
|
||||||
/>
|
|
||||||
<InputError message={errors.date} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="price" className="text-red-400 font-mono text-xs uppercase tracking-wider">> Asset Price (€)</Label>
|
|
||||||
<Input
|
|
||||||
id="price"
|
|
||||||
type="number"
|
|
||||||
step="0.0001"
|
|
||||||
min="0"
|
|
||||||
placeholder="123.4567"
|
|
||||||
value={data.price}
|
|
||||||
onChange={(e) => setData('price', e.target.value)}
|
|
||||||
className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none focus:shadow-[0_0_10px_rgba(239,68,68,0.5)] placeholder:text-red-400/40 transition-all"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-red-400/60 mt-1 font-mono">
|
|
||||||
[UNIT] price per share
|
|
||||||
</p>
|
|
||||||
<InputError message={errors.price} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-3 pt-2">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={processing}
|
|
||||||
className="flex-1 bg-red-500 hover:bg-red-500 text-black font-mono text-sm font-bold border-red-500 rounded-none border-2 uppercase tracking-wider transition-all glow-red"
|
|
||||||
>
|
|
||||||
{processing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
[EXECUTE]
|
|
||||||
</Button>
|
|
||||||
{onCancel && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onCancel}
|
|
||||||
className="flex-1 bg-black border-red-500 text-red-400 hover:bg-red-950 hover:text-red-300 font-mono text-sm font-bold rounded-none border-2 uppercase tracking-wider transition-all glow-red"
|
|
||||||
>
|
|
||||||
[ABORT]
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
import { useForm } from '@inertiajs/react';
|
|
||||||
import { FormEventHandler, useRef } from 'react';
|
|
||||||
|
|
||||||
import InputError from '@/components/InputError';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
|
|
||||||
import HeadingSmall from '@/components/HeadingSmall';
|
|
||||||
|
|
||||||
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
|
||||||
|
|
||||||
export default function DeleteUser() {
|
|
||||||
const passwordInput = useRef<HTMLInputElement>(null);
|
|
||||||
const { data, setData, delete: destroy, processing, reset, errors, clearErrors } = useForm<Required<{ password: string }>>({ password: '' });
|
|
||||||
|
|
||||||
const deleteUser: FormEventHandler = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
destroy(route('profile.destroy'), {
|
|
||||||
preserveScroll: true,
|
|
||||||
onSuccess: () => closeModal(),
|
|
||||||
onError: () => passwordInput.current?.focus(),
|
|
||||||
onFinish: () => reset(),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeModal = () => {
|
|
||||||
clearErrors();
|
|
||||||
reset();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<HeadingSmall title="Delete account" description="Delete your account and all of its resources" />
|
|
||||||
<div className="space-y-4 rounded-lg border border-red-100 bg-red-50 p-4 dark:border-red-200/10 dark:bg-red-700/10">
|
|
||||||
<div className="relative space-y-0.5 text-red-600 dark:text-red-100">
|
|
||||||
<p className="font-medium">Warning</p>
|
|
||||||
<p className="text-sm">Please proceed with caution, this cannot be undone.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button variant="destructive">Delete account</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogTitle>Are you sure you want to delete your account?</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Once your account is deleted, all of its resources and data will also be permanently deleted. Please enter your password
|
|
||||||
to confirm you would like to permanently delete your account.
|
|
||||||
</DialogDescription>
|
|
||||||
<form className="space-y-6" onSubmit={deleteUser}>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="password" className="sr-only">
|
|
||||||
Password
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
name="password"
|
|
||||||
ref={passwordInput}
|
|
||||||
value={data.password}
|
|
||||||
onChange={(e) => setData('password', e.target.value)}
|
|
||||||
placeholder="Password"
|
|
||||||
autoComplete="current-password"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<InputError message={errors.password} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="gap-2">
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button variant="secondary" onClick={closeModal}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
|
|
||||||
<Button variant="destructive" disabled={processing} asChild>
|
|
||||||
<button type="submit">Delete account</button>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
||||||
import { useInitials } from '@/hooks/use-initials';
|
|
||||||
import { type User } from '@/types';
|
|
||||||
|
|
||||||
export function UserInfo({ user, showEmail = false }: { user: User; showEmail?: boolean }) {
|
|
||||||
const getInitials = useInitials();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Avatar className="h-8 w-8 overflow-hidden rounded-full">
|
|
||||||
<AvatarImage src={user.avatar} alt={user.name} />
|
|
||||||
<AvatarFallback className="rounded-lg bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white">
|
|
||||||
{getInitials(user.name)}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
|
||||||
<span className="truncate font-medium">{user.name}</span>
|
|
||||||
{showEmail && <span className="truncate text-xs text-muted-foreground">{user.email}</span>}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator } from '@/components/ui/DropdownMenu';
|
|
||||||
import { UserInfo } from '@/components/Settings/UserInfo';
|
|
||||||
import { useMobileNavigation } from '@/hooks/use-mobile-navigation';
|
|
||||||
import { type User } from '@/types';
|
|
||||||
import { Link, router } from '@inertiajs/react';
|
|
||||||
import { LogOut, Settings } from 'lucide-react';
|
|
||||||
|
|
||||||
interface UserMenuContentProps {
|
|
||||||
user: User;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UserMenuContent({ user }: UserMenuContentProps) {
|
|
||||||
const cleanup = useMobileNavigation();
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
|
||||||
cleanup();
|
|
||||||
router.flushAll();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DropdownMenuLabel className="p-0 font-normal">
|
|
||||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
|
||||||
<UserInfo user={user} showEmail={true} />
|
|
||||||
</div>
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link className="block w-full" href={route('profile.edit')} as="button" prefetch onClick={cleanup}>
|
|
||||||
<Settings className="mr-2" />
|
|
||||||
Settings
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link className="block w-full" method="post" href={route('logout')} as="button" onClick={handleLogout}>
|
|
||||||
<LogOut className="mr-2" />
|
|
||||||
Log out
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { Link } from '@inertiajs/react';
|
|
||||||
import { ComponentProps } from 'react';
|
|
||||||
|
|
||||||
type LinkProps = ComponentProps<typeof Link>;
|
|
||||||
|
|
||||||
export default function TextLink({ className = '', children, ...props }: LinkProps) {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
className={cn(
|
|
||||||
'text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import InputError from '@/components/InputError';
|
|
||||||
import { useForm } from '@inertiajs/react';
|
|
||||||
import { todayISO } from '@/lib/utils';
|
|
||||||
import { LoaderCircle } from 'lucide-react';
|
|
||||||
import { FormEventHandler, useEffect, useState } from 'react';
|
|
||||||
import ComponentTitle from '@/components/ui/ComponentTitle';
|
|
||||||
|
|
||||||
interface EntryFormData {
|
|
||||||
date: string;
|
|
||||||
quantity: string;
|
|
||||||
[key: string]: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AddEntryFormProps {
|
|
||||||
unit?: string;
|
|
||||||
onSuccess?: () => void;
|
|
||||||
onCancel?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EntrySummary {
|
|
||||||
total_quantity: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AddEntryForm({ unit = 'units', onSuccess, onCancel }: AddEntryFormProps) {
|
|
||||||
const { data, setData, post, processing, errors, reset } = useForm<EntryFormData>({
|
|
||||||
date: todayISO(),
|
|
||||||
quantity: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const [currentHoldings, setCurrentHoldings] = useState<EntrySummary | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchSummary = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/entries/summary');
|
|
||||||
if (response.ok) {
|
|
||||||
const summary = await response.json();
|
|
||||||
setCurrentHoldings(summary);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch entry summary:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchSummary();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const submit: FormEventHandler = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
post(route('entries.store'), {
|
|
||||||
onSuccess: () => {
|
|
||||||
reset();
|
|
||||||
setData('date', todayISO());
|
|
||||||
if (onSuccess) onSuccess();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<ComponentTitle>ADD ENTRY</ComponentTitle>
|
|
||||||
{currentHoldings && currentHoldings.total_quantity > 0 && (
|
|
||||||
<p className="text-sm text-red-400/60 font-mono">
|
|
||||||
[CURRENT] {currentHoldings.total_quantity.toFixed(6)} {unit}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<form onSubmit={submit} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="date" className="text-red-400 font-mono text-xs uppercase tracking-wider">> Date</Label>
|
|
||||||
<Input
|
|
||||||
id="date"
|
|
||||||
type="date"
|
|
||||||
value={data.date}
|
|
||||||
onChange={(e) => setData('date', e.target.value)}
|
|
||||||
max={todayISO()}
|
|
||||||
className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none transition-all glow-red"
|
|
||||||
/>
|
|
||||||
<InputError message={errors.date} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="quantity" className="text-red-400 font-mono text-xs uppercase tracking-wider">
|
|
||||||
> Quantity ({unit})
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="quantity"
|
|
||||||
type="number"
|
|
||||||
step="0.000001"
|
|
||||||
min="0"
|
|
||||||
placeholder="1.234567"
|
|
||||||
value={data.quantity}
|
|
||||||
onChange={(e) => setData('quantity', e.target.value)}
|
|
||||||
className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none placeholder:text-red-400/40 transition-all glow-red"
|
|
||||||
/>
|
|
||||||
<InputError message={errors.quantity} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-3 pt-2">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={processing}
|
|
||||||
className="flex-1 bg-red-500 hover:bg-red-500 text-black font-mono text-sm font-bold border-red-500 rounded-none border-2 uppercase tracking-wider transition-all glow-red"
|
|
||||||
>
|
|
||||||
{processing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
[EXECUTE]
|
|
||||||
</Button>
|
|
||||||
{onCancel && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onCancel}
|
|
||||||
className="flex-1 bg-black border-red-500 text-red-400 hover:bg-red-950 hover:text-red-300 font-mono text-sm font-bold rounded-none border-2 uppercase tracking-wider transition-all glow-red"
|
|
||||||
>
|
|
||||||
[ABORT]
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
export default function Heading({ title, description }: { title: string; description?: string }) {
|
|
||||||
return (
|
|
||||||
<div className="mb-8 space-y-0.5">
|
|
||||||
<h2 className="text-xl font-semibold tracking-tight">{title}</h2>
|
|
||||||
{description && <p className="text-sm text-muted-foreground">{description}</p>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { type LucideProps } from 'lucide-react';
|
|
||||||
import { type ComponentType } from 'react';
|
|
||||||
|
|
||||||
interface IconProps extends Omit<LucideProps, 'ref'> {
|
|
||||||
iconNode: ComponentType<LucideProps>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Icon({ iconNode: IconComponent, className, ...props }: IconProps) {
|
|
||||||
return <IconComponent className={cn('h-4 w-4', className)} {...props} />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
import { FC, ReactNode } from 'react';
|
|
||||||
|
|
||||||
interface ComponentTitleProps {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ComponentTitle: FC<ComponentTitleProps> = ({ children }) => {
|
|
||||||
return (
|
|
||||||
<h2 className="text-red-500 text-lg font-mono font-bold tracking-wider uppercase">
|
|
||||||
{ children }
|
|
||||||
</h2>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ComponentTitle
|
|
||||||
|
|
@ -1,255 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
|
||||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function DropdownMenu({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
|
||||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuPortal({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuTrigger({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Trigger
|
|
||||||
data-slot="dropdown-menu-trigger"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuContent({
|
|
||||||
className,
|
|
||||||
sideOffset = 4,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Portal>
|
|
||||||
<DropdownMenuPrimitive.Content
|
|
||||||
data-slot="dropdown-menu-content"
|
|
||||||
sideOffset={sideOffset}
|
|
||||||
className={cn(
|
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</DropdownMenuPrimitive.Portal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuGroup({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuItem({
|
|
||||||
className,
|
|
||||||
inset,
|
|
||||||
variant = "default",
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
|
||||||
inset?: boolean
|
|
||||||
variant?: "default" | "destructive"
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Item
|
|
||||||
data-slot="dropdown-menu-item"
|
|
||||||
data-inset={inset}
|
|
||||||
data-variant={variant}
|
|
||||||
className={cn(
|
|
||||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive-foreground data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/40 data-[variant=destructive]:focus:text-destructive-foreground data-[variant=destructive]:*:[svg]:!text-destructive-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuCheckboxItem({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
checked,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.CheckboxItem
|
|
||||||
data-slot="dropdown-menu-checkbox-item"
|
|
||||||
className={cn(
|
|
||||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
checked={checked}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
|
||||||
<CheckIcon className="size-4" />
|
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
{children}
|
|
||||||
</DropdownMenuPrimitive.CheckboxItem>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuRadioGroup({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.RadioGroup
|
|
||||||
data-slot="dropdown-menu-radio-group"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuRadioItem({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.RadioItem
|
|
||||||
data-slot="dropdown-menu-radio-item"
|
|
||||||
className={cn(
|
|
||||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
|
||||||
<CircleIcon className="size-2 fill-current" />
|
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
{children}
|
|
||||||
</DropdownMenuPrimitive.RadioItem>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuLabel({
|
|
||||||
className,
|
|
||||||
inset,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
|
||||||
inset?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Label
|
|
||||||
data-slot="dropdown-menu-label"
|
|
||||||
data-inset={inset}
|
|
||||||
className={cn(
|
|
||||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuSeparator({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Separator
|
|
||||||
data-slot="dropdown-menu-separator"
|
|
||||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuShortcut({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"span">) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
data-slot="dropdown-menu-shortcut"
|
|
||||||
className={cn(
|
|
||||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuSub({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
|
||||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuSubTrigger({
|
|
||||||
className,
|
|
||||||
inset,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
|
||||||
inset?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.SubTrigger
|
|
||||||
data-slot="dropdown-menu-sub-trigger"
|
|
||||||
data-inset={inset}
|
|
||||||
className={cn(
|
|
||||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<ChevronRightIcon className="ml-auto size-4" />
|
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuSubContent({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.SubContent
|
|
||||||
data-slot="dropdown-menu-sub-content"
|
|
||||||
className={cn(
|
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuPortal,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuCheckboxItem,
|
|
||||||
DropdownMenuRadioGroup,
|
|
||||||
DropdownMenuRadioItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuShortcut,
|
|
||||||
DropdownMenuSub,
|
|
||||||
DropdownMenuSubTrigger,
|
|
||||||
DropdownMenuSubContent,
|
|
||||||
}
|
|
||||||
|
|
@ -1,168 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
|
|
||||||
import { cva } from "class-variance-authority"
|
|
||||||
import { ChevronDownIcon } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function NavigationMenu({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
viewport = true,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
|
|
||||||
viewport?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<NavigationMenuPrimitive.Root
|
|
||||||
data-slot="navigation-menu"
|
|
||||||
data-viewport={viewport}
|
|
||||||
className={cn(
|
|
||||||
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
{viewport && <NavigationMenuViewport />}
|
|
||||||
</NavigationMenuPrimitive.Root>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function NavigationMenuList({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
|
|
||||||
return (
|
|
||||||
<NavigationMenuPrimitive.List
|
|
||||||
data-slot="navigation-menu-list"
|
|
||||||
className={cn(
|
|
||||||
"group flex flex-1 list-none items-center justify-center gap-1",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function NavigationMenuItem({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
|
|
||||||
return (
|
|
||||||
<NavigationMenuPrimitive.Item
|
|
||||||
data-slot="navigation-menu-item"
|
|
||||||
className={cn("relative", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const navigationMenuTriggerStyle = cva(
|
|
||||||
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[active=true]:bg-accent/50 data-[state=open]:bg-accent/50 data-[active=true]:text-accent-foreground ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1"
|
|
||||||
)
|
|
||||||
|
|
||||||
function NavigationMenuTrigger({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
|
|
||||||
return (
|
|
||||||
<NavigationMenuPrimitive.Trigger
|
|
||||||
data-slot="navigation-menu-trigger"
|
|
||||||
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}{" "}
|
|
||||||
<ChevronDownIcon
|
|
||||||
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</NavigationMenuPrimitive.Trigger>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function NavigationMenuContent({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
|
|
||||||
return (
|
|
||||||
<NavigationMenuPrimitive.Content
|
|
||||||
data-slot="navigation-menu-content"
|
|
||||||
className={cn(
|
|
||||||
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
|
|
||||||
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function NavigationMenuViewport({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"absolute top-full left-0 isolate z-50 flex justify-center"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<NavigationMenuPrimitive.Viewport
|
|
||||||
data-slot="navigation-menu-viewport"
|
|
||||||
className={cn(
|
|
||||||
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function NavigationMenuLink({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
|
|
||||||
return (
|
|
||||||
<NavigationMenuPrimitive.Link
|
|
||||||
data-slot="navigation-menu-link"
|
|
||||||
className={cn(
|
|
||||||
"hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function NavigationMenuIndicator({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
|
|
||||||
return (
|
|
||||||
<NavigationMenuPrimitive.Indicator
|
|
||||||
data-slot="navigation-menu-indicator"
|
|
||||||
className={cn(
|
|
||||||
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
|
|
||||||
</NavigationMenuPrimitive.Indicator>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
NavigationMenu,
|
|
||||||
NavigationMenuList,
|
|
||||||
NavigationMenuItem,
|
|
||||||
NavigationMenuContent,
|
|
||||||
NavigationMenuTrigger,
|
|
||||||
NavigationMenuLink,
|
|
||||||
NavigationMenuIndicator,
|
|
||||||
NavigationMenuViewport,
|
|
||||||
navigationMenuTriggerStyle,
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
import { useId } from 'react';
|
|
||||||
|
|
||||||
interface PlaceholderPatternProps {
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PlaceholderPattern({ className }: PlaceholderPatternProps) {
|
|
||||||
const patternId = useId();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<svg className={className} fill="none">
|
|
||||||
<defs>
|
|
||||||
<pattern id={patternId} x="0" y="0" width="10" height="10" patternUnits="userSpaceOnUse">
|
|
||||||
<path d="M-3 13 15-5M-5 5l18-18M-1 21 17 3"></path>
|
|
||||||
</pattern>
|
|
||||||
</defs>
|
|
||||||
<rect stroke="none" fill={`url(#${patternId})`} width="100%" height="100%"></rect>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
interface TerminalSpinnerProps {
|
|
||||||
text?: string;
|
|
||||||
size?: 'sm' | 'md' | 'lg';
|
|
||||||
fullScreen?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TerminalSpinner({
|
|
||||||
text = 'LOADING',
|
|
||||||
size = 'md',
|
|
||||||
fullScreen = false
|
|
||||||
}: TerminalSpinnerProps) {
|
|
||||||
const [dots, setDots] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
setDots(prev => (prev + 1) % 4);
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getDots = () => {
|
|
||||||
switch (dots) {
|
|
||||||
case 0: return ' ';
|
|
||||||
case 1: return '. ';
|
|
||||||
case 2: return '.. ';
|
|
||||||
case 3: return '...';
|
|
||||||
default: return ' ';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const sizeClasses = {
|
|
||||||
sm: 'text-sm',
|
|
||||||
md: 'text-lg',
|
|
||||||
lg: 'text-xl'
|
|
||||||
};
|
|
||||||
|
|
||||||
const spinner = (
|
|
||||||
<div className="border border-red-500/30 bg-black p-6">
|
|
||||||
<span className={`text-red-500 font-mono ${sizeClasses[size]} uppercase tracking-wider`}>
|
|
||||||
[SYSTEM] {text}<span className="inline-block w-8">{getDots()}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (fullScreen) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-black flex items-center justify-center">
|
|
||||||
{spinner}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return spinner;
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue