Compare commits
No commits in common. "main" and "feature/onboarding" have entirely different histories.
main
...
feature/on
191 changed files with 16446 additions and 10521 deletions
|
|
@ -1,14 +0,0 @@
|
|||
node_modules
|
||||
vendor
|
||||
.git
|
||||
.forgejo
|
||||
docker/dev
|
||||
!docker/dev/container-start.sh
|
||||
.env
|
||||
.env.*
|
||||
.env.testing
|
||||
storage/logs/*
|
||||
storage/framework/cache/*
|
||||
storage/framework/sessions/*
|
||||
storage/framework/views/*
|
||||
tests
|
||||
24
.env.example
24
.env.example
|
|
@ -1,33 +1,35 @@
|
|||
APP_NAME=incr
|
||||
APP_ENV=production
|
||||
APP_NAME=Laravel
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
# APP_MAINTENANCE_STORE=database
|
||||
|
||||
PHP_CLI_SERVER_WORKERS=4
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=error
|
||||
LOG_LEVEL=debug
|
||||
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=db
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=incr
|
||||
DB_USERNAME=incr_user
|
||||
DB_PASSWORD=change_me_in_production
|
||||
DB_PASSWORD=incr_password
|
||||
|
||||
SESSION_DRIVER=cookie
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=true
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
|
|
@ -38,6 +40,13 @@ QUEUE_CONNECTION=database
|
|||
CACHE_STORE=database
|
||||
# CACHE_PREFIX=
|
||||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
MAIL_MAILER=log
|
||||
MAIL_SCHEME=null
|
||||
MAIL_HOST=127.0.0.1
|
||||
|
|
@ -53,3 +62,4 @@ AWS_DEFAULT_REGION=us-east-1
|
|||
AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
APP_ENV=testing
|
||||
APP_KEY=base64:+7T2RuonhTIij1yLp3rTOv2uQlYJh0TQulu20MlCA+s=
|
||||
|
||||
DB_CONNECTION=sqlite
|
||||
DB_DATABASE=:memory:
|
||||
|
||||
SESSION_DRIVER=array
|
||||
CACHE_STORE=array
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
# Tags only. A release merges to main and is tagged at the same commit, so a
|
||||
# branch trigger here would build and push the image twice.
|
||||
push:
|
||||
tags: ['v*']
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
|
||||
steps:
|
||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: https://data.forgejo.org/docker/setup-qemu-action@v3
|
||||
|
||||
- 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: Determine tags
|
||||
id: meta
|
||||
run: |
|
||||
TAG="${{ github.ref_name }}"
|
||||
echo "tags=forge.lvl0.xyz/lvl0/incr:${TAG},forge.lvl0.xyz/lvl0/incr:latest" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push
|
||||
uses: https://data.forgejo.org/docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: docker/production/Dockerfile
|
||||
# arm64 is deliberate: nothing of ours deploys to it, but the image is
|
||||
# published for self-hosters and dropping it would exclude ARM boxes.
|
||||
# It is emulated via QEMU, so keep the Dockerfile free of compilation.
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
cache-from: type=registry,ref=forge.lvl0.xyz/lvl0/incr:buildcache
|
||||
cache-to: type=registry,ref=forge.lvl0.xyz/lvl0/incr:buildcache,mode=max
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['release/*']
|
||||
pull_request:
|
||||
branches: [main, 'release/*']
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: forge.lvl0.xyz/lvl0/incr-ci:php8.3-3
|
||||
|
||||
# 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:
|
||||
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||
|
||||
- name: Cache Composer dependencies
|
||||
uses: https://data.forgejo.org/actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/composer
|
||||
key: composer-${{ hashFiles('composer.lock') }}
|
||||
restore-keys: composer-
|
||||
|
||||
- name: Install PHP dependencies
|
||||
run: composer install --no-interaction --prefer-dist --no-progress
|
||||
|
||||
- name: Prepare environment
|
||||
run: cp .env.testing .env
|
||||
|
||||
- name: Lint
|
||||
run: vendor/bin/pint --test
|
||||
|
||||
- name: Static analysis
|
||||
run: vendor/bin/phpstan analyse --memory-limit=1G --no-progress --error-format=github
|
||||
|
||||
- name: Tests
|
||||
run: php artisan test --coverage-clover coverage.xml --coverage-text
|
||||
|
||||
- name: Parse coverage
|
||||
if: github.event_name == 'pull_request'
|
||||
id: coverage
|
||||
run: |
|
||||
COVERAGE=$(php -r '
|
||||
$xml = simplexml_load_file("coverage.xml");
|
||||
if ($xml === false || !isset($xml->project->metrics)) {
|
||||
echo "0";
|
||||
exit;
|
||||
}
|
||||
$metrics = $xml->project->metrics;
|
||||
$statements = (int) $metrics["statements"];
|
||||
$covered = (int) $metrics["coveredstatements"];
|
||||
echo $statements > 0 ? round(($covered / $statements) * 100, 2) : 0;
|
||||
')
|
||||
echo "percentage=$COVERAGE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Comment coverage on PR
|
||||
if: github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
COVERAGE: ${{ steps.coverage.outputs.percentage }}
|
||||
REPO: ${{ github.repository }}
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
COMMIT_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
API_URL="${SERVER_URL}/api/v1/repos/${REPO}/issues/${PR_NUMBER}/comments"
|
||||
MARKER="<!-- incr-ci-coverage-report -->"
|
||||
|
||||
BODY="${MARKER}
|
||||
## Code Coverage Report
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| **Line Coverage** | ${COVERAGE}% |
|
||||
|
||||
_Updated by CI — commit ${COMMIT_SHA}_"
|
||||
|
||||
EXISTING=$(curl -sf -H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
"${API_URL}?limit=50" | \
|
||||
php -r '
|
||||
$comments = json_decode(file_get_contents("php://stdin"), true);
|
||||
if (!is_array($comments)) exit;
|
||||
foreach ($comments as $c) {
|
||||
if (str_contains($c["body"], "<!-- incr-ci-coverage-report -->")) {
|
||||
echo $c["id"];
|
||||
exit;
|
||||
}
|
||||
}
|
||||
' || true)
|
||||
|
||||
if [ -n "$EXISTING" ]; then
|
||||
curl -sf -X PATCH \
|
||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(php -r 'echo json_encode(["body" => $argv[1]]);' "$BODY")" \
|
||||
"${SERVER_URL}/api/v1/repos/${REPO}/issues/comments/${EXISTING}" > /dev/null
|
||||
else
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(php -r 'echo json_encode(["body" => $argv[1]]);' "$BODY")" \
|
||||
"${API_URL}" > /dev/null
|
||||
fi
|
||||
|
||||
build:
|
||||
runs-on: docker
|
||||
container:
|
||||
# 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:
|
||||
# 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
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build assets
|
||||
run: npm run build
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
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
Normal file
45
.github/workflows/lint.yml
vendored
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
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
Normal file
50
.github/workflows/tests.yml
vendored
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
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
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -12,6 +12,7 @@
|
|||
.env.production
|
||||
.phpactor.json
|
||||
.phpunit.result.cache
|
||||
/composer.lock
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
npm-debug.log
|
||||
|
|
|
|||
3
.prettierignore
Normal file
3
.prettierignore
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
resources/js/components/ui/*
|
||||
resources/js/ziggy.js
|
||||
resources/views/mail/*
|
||||
19
.prettierrc
Normal file
19
.prettierrc
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
103
CHANGELOG.md
103
CHANGELOG.md
|
|
@ -1,103 +0,0 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.4.1] - 2026-08-16
|
||||
|
||||
Cleanup after v0.4.0, found once the release was actually cut.
|
||||
|
||||
### Changed
|
||||
|
||||
- The production image is built on version tags only (#62). A release merges to
|
||||
`main` and is tagged at the same commit, so both triggers matched and the
|
||||
multi-arch image was built twice — 36 minutes of the duplicate, and `:latest`
|
||||
published twice from identical source.
|
||||
- The production image should build considerably faster (#65)
|
||||
- PHP extensions come from prebuilt binaries rather than being compiled. Six
|
||||
C compiles under QEMU emulation dominated the arm64 half of the build.
|
||||
- Composer dependencies install before the application source is copied, so a
|
||||
code change no longer reinstalls them.
|
||||
- The build uses a registry cache, and `exif` is no longer installed.
|
||||
- `linux/arm64` is kept deliberately: nothing of ours deploys to it, but the
|
||||
image is published for self-hosters and dropping it would exclude ARM
|
||||
machines.
|
||||
- The onboarding and set-value forms are one Blade component (#64). They were
|
||||
near-identical, down to a 200-character class string that existed twice.
|
||||
|
||||
### Removed
|
||||
|
||||
- `fakerphp/faker` and `laravel/sail`, neither of which the project used, along
|
||||
with `docker/dev/podman-sail-alias.sh` and the `faker_locale` config (#63)
|
||||
|
||||
## [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.
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
# 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
Normal file
63
Jenkinsfile
vendored
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
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 AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
|
|
@ -7,15 +7,17 @@
|
|||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
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
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
|
|
@ -24,34 +26,44 @@ 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
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
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.
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
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.
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
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
|
||||
modification follow.
|
||||
|
|
@ -60,7 +72,7 @@ modification follow.
|
|||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
|
@ -537,45 +549,35 @@ 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
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
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.
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
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,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
|
|
@ -633,29 +635,40 @@ the "copyright" line and a pointer to where the full notice is found.
|
|||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
You should have received a copy of the GNU General Public License
|
||||
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.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
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
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
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,
|
||||
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 AGPL, see
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<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>.
|
||||
|
|
|
|||
215
README.md
215
README.md
|
|
@ -1,120 +1,157 @@
|
|||
<div align="center">
|
||||
|
||||
# incr
|
||||
# 📈 incr
|
||||
|
||||
**A minimalist counter**
|
||||
**A minimalist investment tracker for VWCE shares with milestone-driven progress**
|
||||
|
||||
*Track anything you accumulate — click to increment*
|
||||
*Track your portfolio growth with visual progress indicators and milestone reinforcement*
|
||||
|
||||
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||
[](https://laravel.com/)
|
||||
[](https://livewire.laravel.com/)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||
[](https://www.docker.com/)
|
||||
[](https://laravel.com/)
|
||||
[](https://reactjs.org/)
|
||||
|
||||
---
|
||||
|
||||
**[Introduction](#introduction) • [Features](#features) • [Tech Stack](#tech-stack) • [Getting Started](#getting-started) • [Development](#development) • [Contributing](#contributing) • [License](#license)**
|
||||
|
||||
---
|
||||
|
||||
</div>
|
||||
|
||||
## About
|
||||
## Introduction
|
||||
|
||||
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.
|
||||
Incr is a minimalist, one-page investment tracking application designed specifically for VWCE (Vanguard FTSE All-World UCITS ETF) shareholders. It combines the satisfaction of visual progress tracking with practical portfolio management, featuring a distinctive LED-style digital display and milestone-based goal setting.
|
||||
|
||||
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.
|
||||
The application emphasizes simplicity and focus, providing just what you need to track your investment journey without overwhelming complexity.
|
||||
|
||||
## Features
|
||||
|
||||
- **LED-style display** — large red digital counter
|
||||
- **Click to increment** — click the number to add to it
|
||||
- **Set a value directly** — for when you need to add more than one
|
||||
- **Self-hosted** — your data stays on your server
|
||||
- **LED-style display**: Large red digital counter showing current share count
|
||||
- **Progress tracking**: Visual progress bar toward configurable milestones
|
||||
- **Purchase management**: Add and track share purchases with historical data
|
||||
- **Financial insights**: Portfolio value and withdrawal estimates
|
||||
- **Milestone cycling**: Track progress toward multiple investment goals (1500→3000→4500→6000)
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Backend**: Laravel 13 (PHP 8.3+) with MySQL
|
||||
- **Frontend**: Livewire 4 with Blade
|
||||
- **Styling**: Tailwind CSS 4
|
||||
- **Deployment**: Docker / Podman with multi-stage builds
|
||||
- **Backend**: Laravel 12 (PHP 8.2+) with MySQL database
|
||||
- **Frontend**: React 19 + TypeScript with Inertia.js
|
||||
- **Styling**: Tailwind CSS 4 with shadcn/ui components
|
||||
- **Deployment**: Docker with multi-stage builds
|
||||
|
||||
## Self-hosting
|
||||
## Getting Started
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
app:
|
||||
image: forge.lvl0.xyz/lvl0/incr:latest
|
||||
container_name: incr-app
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- APP_ENV=production
|
||||
- APP_DEBUG=false
|
||||
- APP_KEY=base64:YOUR_APP_KEY_HERE
|
||||
- DB_CONNECTION=mysql
|
||||
- DB_HOST=db
|
||||
- DB_PORT=3306
|
||||
- DB_DATABASE=incr
|
||||
- DB_USERNAME=incr_user
|
||||
- DB_PASSWORD=change_me
|
||||
ports:
|
||||
- "5001:80"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- incr-network
|
||||
### Quick Start (Production)
|
||||
|
||||
db:
|
||||
image: mysql:8.0
|
||||
container_name: incr-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- MYSQL_DATABASE=incr
|
||||
- MYSQL_USER=incr_user
|
||||
- MYSQL_PASSWORD=change_me
|
||||
- MYSQL_ROOT_PASSWORD=change_me_root
|
||||
volumes:
|
||||
- db_data:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "incr_user", "-pchange_me"]
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
interval: 10s
|
||||
start_period: 10s
|
||||
networks:
|
||||
- incr-network
|
||||
#### Docker
|
||||
|
||||
networks:
|
||||
incr-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
```
|
||||
|
||||
Generate an app key with: `php artisan key:generate --show`
|
||||
|
||||
The app will be available at `http://localhost:5001`.
|
||||
|
||||
## Development
|
||||
|
||||
Requires [Nix](https://nixos.org/download/). Enter the dev shell:
|
||||
Clone the repository and run with Docker Compose:
|
||||
|
||||
```bash
|
||||
nix-shell
|
||||
dev-up
|
||||
git clone https://github.com/your-username/incr.git
|
||||
cd incr
|
||||
```
|
||||
|
||||
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`.
|
||||
Run the application using the provided docker-compose configuration:
|
||||
|
||||
The dev stack binds host ports 8000, 5173 and 3307. Only one project using these can run at a time.
|
||||
```bash
|
||||
# Using Docker Compose
|
||||
docker-compose -f docker/production/docker-compose.yml up --build
|
||||
|
||||
# Or using Podman Compose
|
||||
podman-compose -f docker/production/docker-compose.yml up --build
|
||||
```
|
||||
|
||||
The application will be available at `http://localhost:5001`.
|
||||
|
||||
### Development
|
||||
|
||||
#### Local Development Setup
|
||||
|
||||
**Option 1: Laravel Sail (Docker)**
|
||||
|
||||
For local development with Laravel Sail:
|
||||
|
||||
```bash
|
||||
# Install Laravel Sail
|
||||
composer install
|
||||
sail artisan sail:install
|
||||
|
||||
# Start development environment
|
||||
sail up -d
|
||||
|
||||
# Install frontend dependencies and build assets
|
||||
npm install
|
||||
npm run dev
|
||||
|
||||
# Run migrations
|
||||
sail artisan migrate
|
||||
```
|
||||
|
||||
**Option 2: Podman Development**
|
||||
|
||||
For Fedora Atomic or other Podman-based systems:
|
||||
|
||||
```bash
|
||||
# Quick start with helper script
|
||||
bash docker/dev/podman/start-dev.sh
|
||||
|
||||
# Or manually:
|
||||
# Install podman-compose if not available
|
||||
pip3 install --user podman-compose
|
||||
|
||||
# Start development environment
|
||||
podman-compose -f docker/dev/podman/docker-compose.yml up -d
|
||||
|
||||
# Run migrations
|
||||
podman exec incr-dev-app php artisan migrate
|
||||
```
|
||||
|
||||
**Option 3: Sail with Podman (Compatibility Layer)**
|
||||
|
||||
To use Laravel Sail commands with Podman:
|
||||
|
||||
```bash
|
||||
# Source the alias script
|
||||
source docker/dev/podman/podman-sail-alias.sh
|
||||
|
||||
# Now you can use sail commands as normal
|
||||
sail up -d
|
||||
sail artisan migrate
|
||||
sail npm run dev
|
||||
```
|
||||
|
||||
The development server will be available at `http://localhost` with hot reload enabled.
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `app/` - Laravel backend (controllers, models, services)
|
||||
- `resources/js/` - React frontend components and pages
|
||||
- `docker/production/` - Production Docker configuration
|
||||
- `docker/dev/podman/` - Development Podman configuration
|
||||
- `database/migrations/` - Database schema definitions
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
We welcome contributions to incr! Whether you're reporting bugs, suggesting features, or submitting pull requests, your input helps make this project better.
|
||||
|
||||
### How to Contribute
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
|
||||
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
### Bug Reports
|
||||
|
||||
If you find a bug, please create an issue with:
|
||||
- A clear description of the problem
|
||||
- Steps to reproduce the issue
|
||||
- Expected vs actual behavior
|
||||
- Your environment details
|
||||
|
||||
## License
|
||||
|
||||
incr is free software, licensed under the [GNU AGPL-3.0](LICENSE).
|
||||
This project is licensed under the GNU General Public License v3.0 - see the [LICENSE](LICENSE) file for details.
|
||||
|
|
|
|||
106
app/Http/Controllers/AssetController.php
Normal file
106
app/Http/Controllers/AssetController.php
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Asset;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class AssetController extends Controller
|
||||
{
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$assets = Asset::orderBy('symbol')->get();
|
||||
|
||||
return response()->json($assets);
|
||||
}
|
||||
|
||||
public function current(): JsonResponse
|
||||
{
|
||||
// Get the first/default user (since no auth)
|
||||
$user = \App\Models\User::first();
|
||||
$asset = $user ? $user->asset : null;
|
||||
|
||||
return response()->json([
|
||||
'asset' => $asset,
|
||||
]);
|
||||
}
|
||||
|
||||
public function setCurrent(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'symbol' => 'required|string|max:10',
|
||||
'full_name' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
$asset = Asset::findOrCreateBySymbol(
|
||||
$validated['symbol'],
|
||||
$validated['full_name'] ?? null
|
||||
);
|
||||
|
||||
// Get or create the first/default user (since no auth)
|
||||
$user = \App\Models\User::first();
|
||||
|
||||
if (!$user) {
|
||||
// Create a default user if none exists
|
||||
$user = \App\Models\User::create([
|
||||
'name' => 'Default User',
|
||||
'email' => 'user@example.com',
|
||||
'password' => 'password', // This will be hashed automatically
|
||||
'asset_id' => $asset->id,
|
||||
]);
|
||||
} else {
|
||||
$user->update(['asset_id' => $asset->id]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Asset set successfully!');
|
||||
}
|
||||
|
||||
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');
|
||||
$currentPrice = $asset->currentPrice();
|
||||
|
||||
return response()->json([
|
||||
'asset' => $asset,
|
||||
'current_price' => $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);
|
||||
}
|
||||
}
|
||||
51
app/Http/Controllers/Auth/AuthenticatedSessionController.php
Normal file
51
app/Http/Controllers/Auth/AuthenticatedSessionController.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\LoginRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class AuthenticatedSessionController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the login page.
|
||||
*/
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
return Inertia::render('auth/login', [
|
||||
'canResetPassword' => Route::has('password.request'),
|
||||
'status' => $request->session()->get('status'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming authentication request.
|
||||
*/
|
||||
public function store(LoginRequest $request): RedirectResponse
|
||||
{
|
||||
$request->authenticate();
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy an authenticated session.
|
||||
*/
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::guard('web')->logout();
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
}
|
||||
41
app/Http/Controllers/Auth/ConfirmablePasswordController.php
Normal file
41
app/Http/Controllers/Auth/ConfirmablePasswordController.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class ConfirmablePasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the confirm password page.
|
||||
*/
|
||||
public function show(): Response
|
||||
{
|
||||
return Inertia::render('auth/confirm-password');
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm the user's password.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
if (! Auth::guard('web')->validate([
|
||||
'email' => $request->user()->email,
|
||||
'password' => $request->password,
|
||||
])) {
|
||||
throw ValidationException::withMessages([
|
||||
'password' => __('auth.password'),
|
||||
]);
|
||||
}
|
||||
|
||||
$request->session()->put('auth.password_confirmed_at', time());
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class EmailVerificationNotificationController extends Controller
|
||||
{
|
||||
/**
|
||||
* Send a new email verification notification.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
if ($request->user()->hasVerifiedEmail()) {
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
|
||||
$request->user()->sendEmailVerificationNotification();
|
||||
|
||||
return back()->with('status', 'verification-link-sent');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class EmailVerificationPromptController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the email verification prompt page.
|
||||
*/
|
||||
public function __invoke(Request $request): Response|RedirectResponse
|
||||
{
|
||||
return $request->user()->hasVerifiedEmail()
|
||||
? redirect()->intended(route('dashboard', absolute: false))
|
||||
: Inertia::render('auth/verify-email', ['status' => $request->session()->get('status')]);
|
||||
}
|
||||
}
|
||||
70
app/Http/Controllers/Auth/NewPasswordController.php
Normal file
70
app/Http/Controllers/Auth/NewPasswordController.php
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\PasswordReset;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class NewPasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the password reset page.
|
||||
*/
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
return Inertia::render('auth/reset-password', [
|
||||
'email' => $request->email,
|
||||
'token' => $request->route('token'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming new password request.
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'token' => 'required',
|
||||
'email' => 'required|email',
|
||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||
]);
|
||||
|
||||
// Here we will attempt to reset the user's password. If it is successful we
|
||||
// will update the password on an actual user model and persist it to the
|
||||
// database. Otherwise we will parse the error and return the response.
|
||||
$status = Password::reset(
|
||||
$request->only('email', 'password', 'password_confirmation', 'token'),
|
||||
function (User $user) use ($request) {
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($request->password),
|
||||
'remember_token' => Str::random(60),
|
||||
])->save();
|
||||
|
||||
event(new PasswordReset($user));
|
||||
}
|
||||
);
|
||||
|
||||
// If the password was successfully reset, we will redirect the user back to
|
||||
// the application's home authenticated view. If there is an error we can
|
||||
// redirect them back to where they came from with their error message.
|
||||
if ($status == Password::PasswordReset) {
|
||||
return to_route('login')->with('status', __($status));
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => [__($status)],
|
||||
]);
|
||||
}
|
||||
}
|
||||
41
app/Http/Controllers/Auth/PasswordResetLinkController.php
Normal file
41
app/Http/Controllers/Auth/PasswordResetLinkController.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class PasswordResetLinkController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the password reset link request page.
|
||||
*/
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
return Inertia::render('auth/forgot-password', [
|
||||
'status' => $request->session()->get('status'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming password reset link request.
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'email' => 'required|email',
|
||||
]);
|
||||
|
||||
Password::sendResetLink(
|
||||
$request->only('email')
|
||||
);
|
||||
|
||||
return back()->with('status', __('A reset link will be sent if the account exists.'));
|
||||
}
|
||||
}
|
||||
51
app/Http/Controllers/Auth/RegisteredUserController.php
Normal file
51
app/Http/Controllers/Auth/RegisteredUserController.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?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 Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class RegisteredUserController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the registration page.
|
||||
*/
|
||||
public function create(): Response
|
||||
{
|
||||
return Inertia::render('auth/register');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming registration request.
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$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::create([
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'password' => Hash::make($request->password),
|
||||
]);
|
||||
|
||||
event(new Registered($user));
|
||||
|
||||
Auth::login($user);
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
}
|
||||
30
app/Http/Controllers/Auth/VerifyEmailController.php
Normal file
30
app/Http/Controllers/Auth/VerifyEmailController.php
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class VerifyEmailController extends Controller
|
||||
{
|
||||
/**
|
||||
* Mark the authenticated user's email address as verified.
|
||||
*/
|
||||
public function __invoke(EmailVerificationRequest $request): RedirectResponse
|
||||
{
|
||||
if ($request->user()->hasVerifiedEmail()) {
|
||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||
}
|
||||
|
||||
if ($request->user()->markEmailAsVerified()) {
|
||||
/** @var \Illuminate\Contracts\Auth\MustVerifyEmail $user */
|
||||
$user = $request->user();
|
||||
|
||||
event(new Verified($user));
|
||||
}
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||
}
|
||||
}
|
||||
34
app/Http/Controllers/Milestones/MilestoneController.php
Normal file
34
app/Http/Controllers/Milestones/MilestoneController.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Milestones;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Milestone;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class MilestoneController extends Controller
|
||||
{
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'target' => 'required|integer|min:1',
|
||||
'description' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
Milestone::create([
|
||||
'target' => $request->target,
|
||||
'description' => $request->description,
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Milestone created successfully');
|
||||
}
|
||||
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$milestones = Milestone::orderBy('target')->get();
|
||||
|
||||
return response()->json($milestones);
|
||||
}
|
||||
}
|
||||
69
app/Http/Controllers/Pricing/PricingController.php
Normal file
69
app/Http/Controllers/Pricing/PricingController.php
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Pricing;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Pricing\AssetPrice;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PricingController extends Controller
|
||||
{
|
||||
public function current(): JsonResponse
|
||||
{
|
||||
// Get the first/default user (since no auth)
|
||||
$user = \App\Models\User::first();
|
||||
$assetId = $user ? $user->asset_id : null;
|
||||
|
||||
$price = AssetPrice::current($assetId);
|
||||
|
||||
return response()->json([
|
||||
'current_price' => $price,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'date' => 'required|date|before_or_equal:today',
|
||||
'price' => 'required|numeric|min:0.0001',
|
||||
]);
|
||||
|
||||
// Get the first/default user (since no auth)
|
||||
$user = \App\Models\User::first();
|
||||
|
||||
if (!$user || !$user->asset_id) {
|
||||
return back()->withErrors(['asset' => 'Please set an asset first.']);
|
||||
}
|
||||
|
||||
$assetPrice = AssetPrice::updatePrice($user->asset_id, $validated['date'], $validated['price']);
|
||||
|
||||
return back()->with('success', 'Asset price updated successfully!');
|
||||
}
|
||||
|
||||
public function history(Request $request): JsonResponse
|
||||
{
|
||||
// Get the first/default user (since no auth)
|
||||
$user = \App\Models\User::first();
|
||||
$assetId = $user ? $user->asset_id : null;
|
||||
|
||||
$limit = $request->get('limit', 30);
|
||||
$history = AssetPrice::history($assetId, $limit);
|
||||
|
||||
return response()->json($history);
|
||||
}
|
||||
|
||||
public function forDate(Request $request, string $date): JsonResponse
|
||||
{
|
||||
// Get the first/default user (since no auth)
|
||||
$user = \App\Models\User::first();
|
||||
$assetId = $user ? $user->asset_id : null;
|
||||
|
||||
$price = AssetPrice::forDate($date, $assetId);
|
||||
|
||||
return response()->json([
|
||||
'date' => $date,
|
||||
'price' => $price,
|
||||
]);
|
||||
}
|
||||
}
|
||||
39
app/Http/Controllers/Settings/PasswordController.php
Normal file
39
app/Http/Controllers/Settings/PasswordController.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class PasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the user's password settings page.
|
||||
*/
|
||||
public function edit(): Response
|
||||
{
|
||||
return Inertia::render('settings/password');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user's password.
|
||||
*/
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'current_password' => ['required', 'current_password'],
|
||||
'password' => ['required', Password::defaults(), 'confirmed'],
|
||||
]);
|
||||
|
||||
$request->user()->update([
|
||||
'password' => Hash::make($validated['password']),
|
||||
]);
|
||||
|
||||
return back();
|
||||
}
|
||||
}
|
||||
63
app/Http/Controllers/Settings/ProfileController.php
Normal file
63
app/Http/Controllers/Settings/ProfileController.php
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Settings\ProfileUpdateRequest;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class ProfileController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the user's profile settings page.
|
||||
*/
|
||||
public function edit(Request $request): Response
|
||||
{
|
||||
return Inertia::render('settings/profile', [
|
||||
'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail,
|
||||
'status' => $request->session()->get('status'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user's profile settings.
|
||||
*/
|
||||
public function update(ProfileUpdateRequest $request): RedirectResponse
|
||||
{
|
||||
$request->user()->fill($request->validated());
|
||||
|
||||
if ($request->user()->isDirty('email')) {
|
||||
$request->user()->email_verified_at = null;
|
||||
}
|
||||
|
||||
$request->user()->save();
|
||||
|
||||
return to_route('profile.edit');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the user's account.
|
||||
*/
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'password' => ['required', 'current_password'],
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
Auth::logout();
|
||||
|
||||
$user->delete();
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
}
|
||||
72
app/Http/Controllers/Transactions/PurchaseController.php
Normal file
72
app/Http/Controllers/Transactions/PurchaseController.php
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Transactions;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Transactions\Purchase;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Redirect;
|
||||
|
||||
class PurchaseController extends Controller
|
||||
{
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$purchases = Purchase::orderBy('date', 'desc')->get();
|
||||
|
||||
return response()->json($purchases);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'date' => 'required|date|before_or_equal:today',
|
||||
'shares' => 'required|numeric|min:0.000001',
|
||||
'price_per_share' => 'required|numeric|min:0.01',
|
||||
'total_cost' => 'required|numeric|min:0.01',
|
||||
]);
|
||||
|
||||
// Verify calculation is correct
|
||||
$calculatedTotal = $validated['shares'] * $validated['price_per_share'];
|
||||
if (abs($calculatedTotal - $validated['total_cost']) > 0.01) {
|
||||
return back()->withErrors([
|
||||
'total_cost' => 'Total cost does not match shares × price per share.'
|
||||
]);
|
||||
}
|
||||
|
||||
Purchase::create([
|
||||
'date' => $validated['date'],
|
||||
'shares' => $validated['shares'],
|
||||
'price_per_share' => $validated['price_per_share'],
|
||||
'total_cost' => $validated['total_cost'],
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Purchase added successfully!');
|
||||
}
|
||||
|
||||
public function summary()
|
||||
{
|
||||
$totalShares = Purchase::totalShares();
|
||||
$totalInvestment = Purchase::totalInvestment();
|
||||
$averageCost = Purchase::averageCostPerShare();
|
||||
|
||||
return response()->json([
|
||||
'total_shares' => $totalShares,
|
||||
'total_investment' => $totalInvestment,
|
||||
'average_cost_per_share' => $averageCost,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified purchase.
|
||||
*/
|
||||
public function destroy(Purchase $purchase)
|
||||
{
|
||||
$purchase->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Purchase deleted successfully!',
|
||||
]);
|
||||
}
|
||||
}
|
||||
23
app/Http/Middleware/HandleAppearance.php
Normal file
23
app/Http/Middleware/HandleAppearance.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?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(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
View::share('appearance', $request->cookie('appearance') ?? 'system');
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
56
app/Http/Middleware/HandleInertiaRequests.php
Normal file
56
app/Http/Middleware/HandleInertiaRequests.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?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(),
|
||||
],
|
||||
'ziggy' => fn (): array => [
|
||||
...(new Ziggy)->toArray(),
|
||||
'location' => $request->url(),
|
||||
],
|
||||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||
];
|
||||
}
|
||||
}
|
||||
85
app/Http/Requests/Auth/LoginRequest.php
Normal file
85
app/Http/Requests/Auth/LoginRequest.php
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Auth;
|
||||
|
||||
use Illuminate\Auth\Events\Lockout;
|
||||
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, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => ['required', 'string', 'email'],
|
||||
'password' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to authenticate the request's credentials.
|
||||
*
|
||||
* @throws \Illuminate\Validation\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 \Illuminate\Validation\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());
|
||||
}
|
||||
}
|
||||
32
app/Http/Requests/Settings/ProfileUpdateRequest.php
Normal file
32
app/Http/Requests/Settings/ProfileUpdateRequest.php
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<?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),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
<?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;
|
||||
|
||||
if ($tracker !== null) {
|
||||
$this->syncFrom($tracker);
|
||||
}
|
||||
}
|
||||
|
||||
public function initialise(): void
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
$tracker = Tracker::current() ?? Tracker::create([
|
||||
'label' => 'Counter',
|
||||
'unit' => 'units',
|
||||
'count' => $this->value,
|
||||
]);
|
||||
|
||||
$this->syncFrom($tracker);
|
||||
$this->needsOnboarding = false;
|
||||
}
|
||||
|
||||
public function increment(): void
|
||||
{
|
||||
$tracker = Tracker::current();
|
||||
|
||||
if (! $tracker || $tracker->count >= self::MAX_COUNT) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tracker->increment('count');
|
||||
|
||||
$this->syncFrom($tracker->refresh());
|
||||
}
|
||||
|
||||
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->syncFrom($tracker);
|
||||
$this->editing = false;
|
||||
}
|
||||
|
||||
public function cancel(): void
|
||||
{
|
||||
$this->value = $this->count;
|
||||
$this->resetValidation();
|
||||
$this->editing = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tracker is the source of truth; the form input always mirrors it.
|
||||
*/
|
||||
private function syncFrom(Tracker $tracker): void
|
||||
{
|
||||
$this->count = $tracker->count;
|
||||
$this->value = $this->count;
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.counter')->layout('layouts.app');
|
||||
}
|
||||
}
|
||||
67
app/Models/Asset.php
Normal file
67
app/Models/Asset.php
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<?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 users(): HasMany
|
||||
{
|
||||
return $this->hasMany(User::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;
|
||||
}
|
||||
}
|
||||
21
app/Models/Milestone.php
Normal file
21
app/Models/Milestone.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @method static create(array $array)
|
||||
* @method static orderBy(string $string)
|
||||
*/
|
||||
class Milestone extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'target',
|
||||
'description',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'target' => 'integer',
|
||||
];
|
||||
}
|
||||
84
app/Models/Pricing/AssetPrice.php
Normal file
84
app/Models/Pricing/AssetPrice.php
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models\Pricing;
|
||||
|
||||
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(\App\Models\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();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\TrackerFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Tracker extends Model
|
||||
{
|
||||
/** @use HasFactory<TrackerFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'label',
|
||||
'unit',
|
||||
'count',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'count' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public static function current(): ?self
|
||||
{
|
||||
return self::orderBy('id')->first();
|
||||
}
|
||||
}
|
||||
52
app/Models/Transactions/Purchase.php
Normal file
52
app/Models/Transactions/Purchase.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models\Transactions;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Purchase extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'date',
|
||||
'shares',
|
||||
'price_per_share',
|
||||
'total_cost',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'date' => 'date',
|
||||
'shares' => 'decimal:6',
|
||||
'price_per_share' => 'decimal:4',
|
||||
'total_cost' => 'decimal:2',
|
||||
];
|
||||
|
||||
/**
|
||||
* Calculate total shares
|
||||
*/
|
||||
public static function totalShares(): float
|
||||
{
|
||||
return static::sum('shares');
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total investment
|
||||
*/
|
||||
public static function totalInvestment(): float
|
||||
{
|
||||
return static::sum('total_cost');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get average cost per share
|
||||
*/
|
||||
public static function averageCostPerShare(): float
|
||||
{
|
||||
$totalShares = static::totalShares();
|
||||
$totalCost = static::totalInvestment();
|
||||
|
||||
return $totalShares > 0 ? $totalCost / $totalShares : 0;
|
||||
}
|
||||
}
|
||||
71
app/Models/User.php
Normal file
71
app/Models/User.php
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
/**
|
||||
* @property int $asset_id
|
||||
*/
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'asset_id',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
|
||||
public function asset(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Asset::class);
|
||||
}
|
||||
|
||||
public function hasCompletedOnboarding(): bool
|
||||
{
|
||||
// Check if user has asset, purchases, and milestones
|
||||
return $this->asset_id !== null
|
||||
&& $this->hasPurchases()
|
||||
&& $this->hasMilestones();
|
||||
}
|
||||
|
||||
public function hasPurchases(): bool
|
||||
{
|
||||
return \App\Models\Transactions\Purchase::totalShares() > 0;
|
||||
}
|
||||
|
||||
public function hasMilestones(): bool
|
||||
{
|
||||
return \App\Models\Milestone::count() > 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Middleware\HandleAppearance;
|
||||
use App\Http\Middleware\HandleInertiaRequests;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
|
|
@ -12,7 +14,11 @@
|
|||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware) {
|
||||
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
|
||||
|
||||
$middleware->web(append: [
|
||||
HandleAppearance::class,
|
||||
HandleInertiaRequests::class,
|
||||
AddLinkHeadersForPreloadedAssets::class,
|
||||
]);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
<?php
|
||||
|
||||
use App\Providers\AppServiceProvider;
|
||||
|
||||
return [
|
||||
AppServiceProvider::class,
|
||||
App\Providers\AppServiceProvider::class,
|
||||
];
|
||||
|
|
|
|||
21
components.json
Normal file
21
components.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"$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",
|
||||
"name": "lvl0/incr",
|
||||
"name": "laravel/react-starter-kit",
|
||||
"type": "project",
|
||||
"description": "A minimalist counter.",
|
||||
"description": "The skeleton application for the Laravel framework.",
|
||||
"keywords": [
|
||||
"laravel",
|
||||
"framework"
|
||||
],
|
||||
"license": "AGPL-3.0-only",
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"laravel/framework": "^13.0",
|
||||
"laravel/tinker": "^3.0",
|
||||
"livewire/livewire": "^4.0"
|
||||
"inertiajs/inertia-laravel": "^2.0",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"tightenco/ziggy": "^2.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"larastan/larastan": "^3.5",
|
||||
"fakerphp/faker": "^1.23",
|
||||
"laravel/pail": "^1.2.2",
|
||||
"laravel/pint": "^1.18",
|
||||
"laravel/sail": "^1.43",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"phpstan/phpstan": "^2.1.32 <2.2",
|
||||
"phpunit/phpunit": "^12.0"
|
||||
"phpunit/phpunit": "^11.5.3"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
|
@ -55,6 +56,11 @@
|
|||
"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"
|
||||
],
|
||||
"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": [
|
||||
"@php artisan config:clear --ansi",
|
||||
"@php artisan test"
|
||||
|
|
|
|||
8434
composer.lock
generated
8434
composer.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -82,6 +82,8 @@
|
|||
|
||||
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
|
||||
|
||||
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Encryption Key
|
||||
|
|
|
|||
115
config/auth.php
Normal file
115
config/auth.php
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
<?php
|
||||
|
||||
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', App\Models\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),
|
||||
|
||||
];
|
||||
55
config/inertia.php
Normal file
55
config/inertia.php
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<?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', 'cookie'),
|
||||
'driver' => env('SESSION_DRIVER', 'database'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
<?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,
|
||||
];
|
||||
}
|
||||
}
|
||||
44
database/factories/UserFactory.php
Normal file
44
database/factories/UserFactory.php
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<?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');
|
||||
}
|
||||
};
|
||||
52
database/migrations/0001_01_01_000001_create_users_table.php
Normal file
52
database/migrations/0001_01_01_000001_create_users_table.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?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->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');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<?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('purchases', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->date('date');
|
||||
$table->decimal('shares', 12, 6); // Supports fractional shares
|
||||
$table->decimal('price_per_share', 8, 4); // Price in euros
|
||||
$table->decimal('total_cost', 12, 2); // Total cost in euros
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('date');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('purchases');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?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');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<?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,37 +0,0 @@
|
|||
<?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,7 +2,8 @@
|
|||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Tracker;
|
||||
use App\Models\User;
|
||||
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class DatabaseSeeder extends Seeder
|
||||
|
|
@ -12,10 +13,11 @@ class DatabaseSeeder extends Seeder
|
|||
*/
|
||||
public function run(): void
|
||||
{
|
||||
Tracker::firstOrCreate([], [
|
||||
'label' => 'Counter',
|
||||
'unit' => 'units',
|
||||
'count' => 0,
|
||||
// User::factory(10)->create();
|
||||
|
||||
User::factory()->create([
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,38 +0,0 @@
|
|||
# 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,2 +0,0 @@
|
|||
CREATE DATABASE IF NOT EXISTS `testing` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
GRANT ALL PRIVILEGES ON `testing`.* TO 'incr_user'@'%';
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
FROM docker.io/library/php:8.3-fpm
|
||||
FROM docker.io/library/php:8.2-fpm
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
|
|
@ -9,21 +9,39 @@ RUN apt-get update && apt-get install -y \
|
|||
libxml2-dev \
|
||||
zip \
|
||||
unzip \
|
||||
nodejs \
|
||||
npm \
|
||||
default-mysql-client \
|
||||
&& docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd
|
||||
|
||||
# Install Node.js 20.x via nodesource
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y nodejs
|
||||
|
||||
# Install Composer
|
||||
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /var/www/html
|
||||
|
||||
# Install Node.js 20.x (for better compatibility)
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y nodejs
|
||||
|
||||
# Copy composer files and install PHP dependencies
|
||||
COPY composer.json composer.lock ./
|
||||
RUN composer install --no-dev --optimize-autoloader --no-scripts
|
||||
|
||||
# Copy package.json and install Node dependencies
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Set permissions
|
||||
RUN chown -R www-data:www-data /var/www/html \
|
||||
&& chmod -R 755 /var/www/html/storage \
|
||||
&& chmod -R 755 /var/www/html/bootstrap/cache
|
||||
|
||||
# Copy and set up container start script
|
||||
COPY docker/dev/container-start.sh /usr/local/bin/container-start.sh
|
||||
COPY docker/dev/podman/container-start.sh /usr/local/bin/container-start.sh
|
||||
RUN chmod +x /usr/local/bin/container-start.sh
|
||||
|
||||
EXPOSE 8000 5173
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Create .env file if it doesn't exist
|
||||
if [ ! -f /var/www/html/.env ]; then
|
||||
|
|
@ -7,19 +6,15 @@ if [ ! -f /var/www/html/.env ]; then
|
|||
fi
|
||||
|
||||
# Fix database name to match compose file
|
||||
sed -i 's|^DB_DATABASE=.*|DB_DATABASE=incr_dev|' /var/www/html/.env
|
||||
sed -i 's/DB_DATABASE=incr$/DB_DATABASE=incr_dev/' /var/www/html/.env
|
||||
|
||||
# Generate app key if not set or empty
|
||||
if ! grep -q "APP_KEY=base64:" /var/www/html/.env; then
|
||||
# Generate a new key and set it directly
|
||||
NEW_KEY=$(php -r "echo 'base64:' . base64_encode(random_bytes(32));")
|
||||
sed -i "s|^APP_KEY=.*|APP_KEY=$NEW_KEY|" /var/www/html/.env
|
||||
sed -i "s/APP_KEY=/APP_KEY=$NEW_KEY/" /var/www/html/.env
|
||||
fi
|
||||
|
||||
# Install dependencies if needed
|
||||
[ ! -f vendor/autoload.php ] && composer install --no-interaction
|
||||
[ ! -d node_modules/.bin ] && npm install
|
||||
|
||||
# Run migrations
|
||||
php artisan migrate --force
|
||||
|
||||
|
|
@ -1,18 +1,28 @@
|
|||
name: incr
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: docker/dev/Dockerfile
|
||||
context: ../../..
|
||||
dockerfile: docker/dev/podman/Dockerfile
|
||||
container_name: incr-dev-app
|
||||
restart: unless-stopped
|
||||
working_dir: /var/www/html
|
||||
environment:
|
||||
- APP_ENV=local
|
||||
- APP_DEBUG=true
|
||||
- APP_KEY=base64:YOUR_APP_KEY_HERE
|
||||
- DB_CONNECTION=mysql
|
||||
- DB_HOST=db
|
||||
- DB_PORT=3306
|
||||
- DB_DATABASE=incr_dev
|
||||
- DB_USERNAME=incr_user
|
||||
- DB_PASSWORD=incr_password
|
||||
- VITE_PORT=5173
|
||||
volumes:
|
||||
- ../../:/var/www/html:Z
|
||||
- incr_app_node_modules:/var/www/html/node_modules
|
||||
- ../../../:/var/www/html:Z
|
||||
- /var/www/html/node_modules
|
||||
- /var/www/html/vendor
|
||||
ports:
|
||||
- "8000:8000"
|
||||
- "5173:5173"
|
||||
|
|
@ -32,8 +42,7 @@ services:
|
|||
- MYSQL_PASSWORD=incr_password
|
||||
- MYSQL_ROOT_PASSWORD=root_password
|
||||
volumes:
|
||||
- incr_db_data:/var/lib/mysql
|
||||
- ./mysql-init:/docker-entrypoint-initdb.d:ro
|
||||
- db_data:/var/lib/mysql
|
||||
ports:
|
||||
- "3307:3306"
|
||||
healthcheck:
|
||||
|
|
@ -45,12 +54,19 @@ services:
|
|||
networks:
|
||||
- incr-dev-network
|
||||
|
||||
redis:
|
||||
image: docker.io/library/redis:7-alpine
|
||||
container_name: incr-dev-redis
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6379:6379"
|
||||
networks:
|
||||
- incr-dev-network
|
||||
|
||||
networks:
|
||||
incr-dev-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
incr_db_data:
|
||||
driver: local
|
||||
incr_app_node_modules:
|
||||
db_data:
|
||||
driver: local
|
||||
26
docker/dev/podman/podman-sail-alias.sh
Normal file
26
docker/dev/podman/podman-sail-alias.sh
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Podman aliases for Laravel Sail compatibility
|
||||
# Source this file to use Sail commands with Podman
|
||||
# Usage: source docker/dev/podman/podman-sail-alias.sh
|
||||
|
||||
# Create docker alias pointing to podman
|
||||
alias docker='podman'
|
||||
|
||||
# Create docker-compose alias pointing to podman-compose
|
||||
alias docker-compose='podman-compose'
|
||||
|
||||
# Sail wrapper function that uses podman-compose
|
||||
sail() {
|
||||
if [[ -f docker/dev/podman/docker-compose.yml ]]; then
|
||||
podman-compose -f docker/dev/podman/docker-compose.yml "$@"
|
||||
else
|
||||
echo "❌ Podman compose file not found at docker/dev/podman/docker-compose.yml"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
echo "✅ Podman aliases set up for Laravel Sail compatibility"
|
||||
echo "🐳 'docker' → 'podman'"
|
||||
echo "🔧 'docker-compose' → 'podman-compose'"
|
||||
echo "⛵ 'sail' → uses podman-compose with dev configuration"
|
||||
|
|
@ -22,7 +22,7 @@ fi
|
|||
|
||||
# Start services
|
||||
echo "🔧 Starting services..."
|
||||
podman-compose -f docker/dev/docker-compose.yml up -d
|
||||
podman-compose -f docker/dev/podman/docker-compose.yml up -d
|
||||
|
||||
# Wait for database to be ready
|
||||
echo "⏳ Waiting for database to be ready..."
|
||||
|
|
@ -48,5 +48,5 @@ echo "🌐 Application: http://localhost:8000"
|
|||
echo "🔥 Vite dev server: http://localhost:5173"
|
||||
echo "💾 Database: localhost:3307"
|
||||
echo ""
|
||||
echo "To stop: podman-compose -f docker/dev/docker-compose.yml down"
|
||||
echo "To view logs: podman-compose -f docker/dev/docker-compose.yml logs -f"
|
||||
echo "To stop: podman-compose -f docker/dev/podman/docker-compose.yml down"
|
||||
echo "To view logs: podman-compose -f docker/dev/podman/docker-compose.yml logs -f"
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
# Multi-stage build for Laravel + Livewire application
|
||||
# Multi-stage build for Laravel + React application
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
|
@ -7,62 +7,63 @@ WORKDIR /app
|
|||
COPY package*.json ./
|
||||
|
||||
# Install Node dependencies
|
||||
RUN npm ci
|
||||
RUN npm ci --only=production
|
||||
|
||||
# Copy frontend source
|
||||
COPY resources/ resources/
|
||||
COPY public/ public/
|
||||
COPY vite.config.ts ./
|
||||
COPY tsconfig.json ./
|
||||
COPY components.json ./
|
||||
COPY eslint.config.js ./
|
||||
|
||||
# Build frontend assets
|
||||
RUN npm run build
|
||||
|
||||
# PHP runtime stage
|
||||
FROM php:8.3-fpm-alpine
|
||||
FROM php:8.2-fpm-alpine
|
||||
|
||||
# mysql-client backs the database readiness loop in start-app.sh.
|
||||
# Install system dependencies
|
||||
RUN apk add --no-cache \
|
||||
git \
|
||||
curl \
|
||||
libpng-dev \
|
||||
libxml2-dev \
|
||||
zip \
|
||||
unzip \
|
||||
oniguruma-dev \
|
||||
mysql-client \
|
||||
nginx \
|
||||
supervisor
|
||||
|
||||
# Prebuilt binaries rather than docker-php-ext-install: compiling these under
|
||||
# QEMU for the arm64 image dominated the build time.
|
||||
COPY --from=mlocati/php-extension-installer:2 /usr/bin/install-php-extensions /usr/local/bin/
|
||||
|
||||
RUN install-php-extensions \
|
||||
# Install PHP extensions
|
||||
RUN docker-php-ext-install \
|
||||
pdo_mysql \
|
||||
mbstring \
|
||||
exif \
|
||||
pcntl \
|
||||
bcmath \
|
||||
gd
|
||||
|
||||
# Install Composer
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /var/www/html
|
||||
|
||||
# Dependencies before the source, so a code change does not reinstall vendor.
|
||||
# --no-scripts because package discovery needs the full application.
|
||||
COPY composer.json composer.lock ./
|
||||
RUN composer install --no-dev --optimize-autoloader --no-interaction --no-scripts
|
||||
|
||||
# Copy application code first
|
||||
COPY . .
|
||||
|
||||
# dump-autoload triggers post-autoload-dump, which runs package:discover.
|
||||
RUN composer dump-autoload --optimize --no-interaction
|
||||
# Install PHP dependencies after copying all files
|
||||
RUN composer install --no-dev --optimize-autoloader --no-interaction
|
||||
|
||||
# Copy built frontend assets from builder stage
|
||||
COPY --from=frontend-builder /app/public/build/ ./public/build/
|
||||
|
||||
# Copy nginx and supervisor configurations
|
||||
COPY docker/production/nginx.conf /etc/nginx/http.d/default.conf
|
||||
COPY docker/production/supervisord.conf /etc/supervisord.conf
|
||||
COPY docker/production/start-app.sh /usr/local/bin/start-app
|
||||
COPY docker/nginx.conf /etc/nginx/http.d/default.conf
|
||||
COPY docker/supervisord.conf /etc/supervisord.conf
|
||||
COPY docker/start-app.sh /usr/local/bin/start-app
|
||||
|
||||
# Set proper permissions
|
||||
RUN chown -R www-data:www-data storage bootstrap/cache public/build \
|
||||
|
|
|
|||
|
|
@ -4,12 +4,6 @@ server {
|
|||
root /var/www/html/public;
|
||||
index index.php index.html;
|
||||
|
||||
server_tokens off;
|
||||
|
||||
add_header X-Content-Type-Options "nosniff";
|
||||
add_header X-Frame-Options "SAMEORIGIN";
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin";
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,16 +10,14 @@ fi
|
|||
|
||||
# Wait for database to be ready
|
||||
echo "Waiting for database..."
|
||||
until mysql -h"${DB_HOST:-db}" -u"${DB_USERNAME:-incr_user}" -p"${DB_PASSWORD}" -e "SELECT 1" >/dev/null 2>&1; do
|
||||
until php artisan tinker --execute="DB::connection()->getPdo();" 2>/dev/null; do
|
||||
echo "Database not ready, waiting..."
|
||||
sleep 2
|
||||
done
|
||||
echo "Database is ready!"
|
||||
|
||||
# Generate app key only if not already set
|
||||
if ! grep -q "APP_KEY=base64:" /var/www/html/.env 2>/dev/null; then
|
||||
php artisan key:generate --force
|
||||
fi
|
||||
# Generate app key if not set
|
||||
php artisan key:generate --force
|
||||
|
||||
# Laravel optimizations
|
||||
php artisan config:cache
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[supervisord]
|
||||
nodaemon=true
|
||||
user=www-data
|
||||
user=root
|
||||
|
||||
[program:nginx]
|
||||
command=nginx -g "daemon off;"
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 136 KiB |
44
eslint.config.js
Normal file
44
eslint.config.js
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
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
|
||||
];
|
||||
7158
package-lock.json
generated
7158
package-lock.json
generated
File diff suppressed because it is too large
Load diff
51
package.json
51
package.json
|
|
@ -3,13 +3,58 @@
|
|||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite"
|
||||
"build:ssr": "vite build && vite build --ssr",
|
||||
"dev": "vite",
|
||||
"format": "prettier --write resources/",
|
||||
"format:check": "prettier --check resources/",
|
||||
"lint": "eslint . --fix",
|
||||
"types": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@types/node": "^22.13.5",
|
||||
"eslint": "^9.17.0",
|
||||
"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": "^2.0.0",
|
||||
"@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",
|
||||
"laravel-vite-plugin": "^3.1.0",
|
||||
"@types/react": "^19.0.3",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"concurrently": "^9.0.1",
|
||||
"globals": "^15.14.0",
|
||||
"laravel-vite-plugin": "^1.0",
|
||||
"lucide-react": "^0.475.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwind-merge": "^3.0.1",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"vite": "^8.0.10"
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-linux-x64-gnu": "4.9.5",
|
||||
|
|
|
|||
13
phpstan.neon
13
phpstan.neon
|
|
@ -1,13 +0,0 @@
|
|||
includes:
|
||||
- vendor/larastan/larastan/extension.neon
|
||||
|
||||
parameters:
|
||||
level: 7
|
||||
paths:
|
||||
- app/
|
||||
- database/
|
||||
- tests/
|
||||
|
||||
excludePaths:
|
||||
- bootstrap/*.php
|
||||
- storage/*
|
||||
19
phpunit.xml
19
phpunit.xml
|
|
@ -18,14 +18,15 @@
|
|||
</include>
|
||||
</source>
|
||||
<php>
|
||||
<env name="APP_ENV" value="testing" force="true"/>
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file" force="true"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4" force="true"/>
|
||||
<env name="CACHE_STORE" value="array" force="true"/>
|
||||
<env name="MAIL_MAILER" value="array" force="true"/>
|
||||
<env name="PULSE_ENABLED" value="false" force="true"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync" force="true"/>
|
||||
<env name="SESSION_DRIVER" value="array" force="true"/>
|
||||
<env name="TELESCOPE_ENABLED" value="false" force="true"/>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="CACHE_STORE" value="array"/>
|
||||
<env name="DB_DATABASE" value="testing"/>
|
||||
<env name="MAIL_MAILER" value="array"/>
|
||||
<env name="PULSE_ENABLED" value="false"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
<env name="SESSION_DRIVER" value="array"/>
|
||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
|
|
|
|||
BIN
public/apple-touch-icon.png
Normal file
BIN
public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
|
|
@ -1,12 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<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 width="166" height="166" viewBox="0 0 166 166" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<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"/>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 603 B After Width: | Height: | Size: 3.5 KiB |
16
public/logo.svg
Normal file
16
public/logo.svg
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 26 KiB |
|
|
@ -1,12 +1,13 @@
|
|||
@import 'tailwindcss';
|
||||
|
||||
@plugin 'tailwindcss-animate';
|
||||
|
||||
@source '../views';
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
|
||||
@font-face {
|
||||
font-family: '7Segment';
|
||||
/* 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');
|
||||
src: url('/fonts/7segment.woff') format('woff');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
|
@ -23,3 +24,159 @@ .glow-red {
|
|||
.glow-red:hover {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
24
resources/js/app.tsx
Normal file
24
resources/js/app.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
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();
|
||||
156
resources/js/components/Assets/AssetSetupForm.tsx
Normal file
156
resources/js/components/Assets/AssetSetupForm.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
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, post, processing, errors } = useForm<AssetFormData>({
|
||||
symbol: '',
|
||||
full_name: '',
|
||||
});
|
||||
|
||||
// Load existing asset data on mount
|
||||
useEffect(() => {
|
||||
const fetchCurrentAsset = async () => {
|
||||
try {
|
||||
const response = await fetch('/assets/current');
|
||||
if (response.ok) {
|
||||
const assetData = await response.json();
|
||||
if (assetData.asset) {
|
||||
setData({
|
||||
symbol: assetData.asset.symbol || '',
|
||||
full_name: assetData.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();
|
||||
|
||||
post(route('assets.set-current'), {
|
||||
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>
|
||||
);
|
||||
}
|
||||
18
resources/js/components/Display/AppContent.tsx
Normal file
18
resources/js/components/Display/AppContent.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
182
resources/js/components/Display/AppHeader.tsx
Normal file
182
resources/js/components/Display/AppHeader.tsx
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
14
resources/js/components/Display/AppLogo.tsx
Normal file
14
resources/js/components/Display/AppLogo.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
13
resources/js/components/Display/AppLogoIcon.tsx
Normal file
13
resources/js/components/Display/AppLogoIcon.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
18
resources/js/components/Display/AppShell.tsx
Normal file
18
resources/js/components/Display/AppShell.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
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>;
|
||||
}
|
||||
56
resources/js/components/Display/AppSidebar.tsx
Normal file
56
resources/js/components/Display/AppSidebar.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
14
resources/js/components/Display/AppSidebarHeader.tsx
Normal file
14
resources/js/components/Display/AppSidebarHeader.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
34
resources/js/components/Display/Breadcrumbs.tsx
Normal file
34
resources/js/components/Display/Breadcrumbs.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
70
resources/js/components/Display/InlineForm.tsx
Normal file
70
resources/js/components/Display/InlineForm.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import AddMilestoneForm from '@/components/Milestones/AddMilestoneForm';
|
||||
import AddPurchaseForm from '@/components/Transactions/AddPurchaseForm';
|
||||
import UpdatePriceForm from '@/components/Pricing/UpdatePriceForm';
|
||||
import { cn } from '@/lib/utils';
|
||||
import ComponentTitle from '@/components/ui/ComponentTitle';
|
||||
|
||||
interface InlineFormProps {
|
||||
type: 'purchase' | 'milestone' | 'price' | null;
|
||||
onClose: () => void;
|
||||
onPurchaseSuccess?: () => void;
|
||||
onMilestoneSuccess?: () => void;
|
||||
onPriceSuccess?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function InlineForm({
|
||||
type,
|
||||
onClose,
|
||||
onPurchaseSuccess,
|
||||
onMilestoneSuccess,
|
||||
onPriceSuccess,
|
||||
className
|
||||
}: InlineFormProps) {
|
||||
if (!type) return null;
|
||||
|
||||
const title = type === 'purchase' ? 'ADD PURCHASE' : type === 'milestone' ? 'ADD MILESTONE' : 'UPDATE PRICE';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-black p-8",
|
||||
"transition-all duration-300",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="w-full border-4 border-red-500 p-2 bg-black space-y-4 glow-red">
|
||||
|
||||
{/* Form Content */}
|
||||
<div className="flex justify-center">
|
||||
{type === 'purchase' ? (
|
||||
<AddPurchaseForm
|
||||
onSuccess={() => {
|
||||
if (onPurchaseSuccess) onPurchaseSuccess();
|
||||
onClose();
|
||||
}}
|
||||
onCancel={onClose}
|
||||
/>
|
||||
) : type === 'milestone' ? (
|
||||
<AddMilestoneForm
|
||||
onSuccess={() => {
|
||||
if (onMilestoneSuccess) onMilestoneSuccess();
|
||||
onClose();
|
||||
}}
|
||||
onCancel={onClose}
|
||||
/>
|
||||
) : (
|
||||
<UpdatePriceForm
|
||||
onSuccess={() => {
|
||||
if (onPriceSuccess) onPriceSuccess();
|
||||
onClose();
|
||||
}}
|
||||
onCancel={onClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
resources/js/components/Display/LedDisplay.tsx
Normal file
60
resources/js/components/Display/LedDisplay.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { cn } from '@/lib/utils';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface LedDisplayProps {
|
||||
value: number;
|
||||
className?: string;
|
||||
animate?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export default function LedDisplay({
|
||||
value,
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
resources/js/components/Display/NavFooter.tsx
Normal file
34
resources/js/components/Display/NavFooter.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
24
resources/js/components/Display/NavMain.tsx
Normal file
24
resources/js/components/Display/NavMain.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
36
resources/js/components/Display/NavUser.tsx
Normal file
36
resources/js/components/Display/NavUser.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
69
resources/js/components/Display/ProgressBar.tsx
Normal file
69
resources/js/components/Display/ProgressBar.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Milestone {
|
||||
target: number;
|
||||
description: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ProgressBarProps {
|
||||
currentShares: number;
|
||||
milestones: Milestone[];
|
||||
selectedMilestoneIndex?: number;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export default function ProgressBar({
|
||||
currentShares,
|
||||
milestones,
|
||||
selectedMilestoneIndex = 0,
|
||||
className,
|
||||
onClick
|
||||
}: ProgressBarProps) {
|
||||
// Get the selected milestone for progress calculation
|
||||
const selectedMilestone = milestones.length > 0 && selectedMilestoneIndex < milestones.length
|
||||
? milestones[selectedMilestoneIndex]
|
||||
: null;
|
||||
|
||||
// Calculate progress percentage
|
||||
const progressPercentage = selectedMilestone
|
||||
? Math.min((currentShares / 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>
|
||||
);
|
||||
}
|
||||
214
resources/js/components/Display/StatsBox.tsx
Normal file
214
resources/js/components/Display/StatsBox.tsx
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
import { cn } from '@/lib/utils';
|
||||
import { Plus, ChevronRight } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import ComponentTitle from '@/components/ui/ComponentTitle';
|
||||
|
||||
interface Milestone {
|
||||
target: number;
|
||||
description: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface StatsBoxProps {
|
||||
stats: {
|
||||
totalShares: number;
|
||||
totalInvestment: number;
|
||||
averageCostPerShare: number;
|
||||
currentPrice?: number;
|
||||
currentValue?: number;
|
||||
profitLoss?: number;
|
||||
profitLossPercentage?: number;
|
||||
};
|
||||
milestones?: Milestone[];
|
||||
selectedMilestoneIndex?: number;
|
||||
onMilestoneSelect?: (index: number) => void;
|
||||
className?: string;
|
||||
onAddPurchase?: () => void;
|
||||
onAddMilestone?: () => void;
|
||||
onUpdatePrice?: () => void;
|
||||
}
|
||||
|
||||
export default function StatsBox({
|
||||
stats,
|
||||
milestones = [],
|
||||
selectedMilestoneIndex = 0,
|
||||
onMilestoneSelect,
|
||||
className,
|
||||
onAddPurchase,
|
||||
onAddMilestone,
|
||||
onUpdatePrice
|
||||
}: StatsBoxProps) {
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
|
||||
const handleCycleMilestone = () => {
|
||||
if (milestones.length === 0 || !onMilestoneSelect) return;
|
||||
const nextIndex = (selectedMilestoneIndex + 1) % milestones.length;
|
||||
onMilestoneSelect(nextIndex);
|
||||
};
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const formatCurrencyDetailed = (amount: number) => {
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: 4,
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
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">
|
||||
{/* STATS Title and Current Price */}
|
||||
<div className="flex justify-between items-center mb-6 relative">
|
||||
<ComponentTitle>Stats</ComponentTitle>
|
||||
|
||||
<div className="flex items-center space-x-2 relative">
|
||||
{stats.currentPrice && (
|
||||
<div className="text-red-500 text-sm font-mono tracking-wider">
|
||||
VWCE: {formatCurrencyDetailed(stats.currentPrice)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{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 PURCHASE
|
||||
</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 transition-colors text-sm font-mono border-b border-red-500/20 last:border-b-0"
|
||||
>
|
||||
ADD MILESTONE
|
||||
</button>
|
||||
)}
|
||||
{onUpdatePrice && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onUpdatePrice();
|
||||
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 transition-colors text-sm font-mono border-b border-red-500/20 last:border-b-0"
|
||||
>
|
||||
UPDATE PRICE
|
||||
</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">SHARES</th>
|
||||
<th className="text-right text-red-500 text-xs py-2 pr-4">SWR 3%</th>
|
||||
<th className="text-right text-red-500 text-xs py-2">SWR 4%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{/* Current position row */}
|
||||
<tr className="text-red-500 font-bold">
|
||||
<td className="py-1 pr-4">CURRENT</td>
|
||||
<td className="text-right py-1 pr-4">
|
||||
{Math.floor(stats.totalShares).toLocaleString()}
|
||||
</td>
|
||||
<td className="text-right py-1 pr-4">
|
||||
{stats.currentPrice ? formatCurrency(stats.totalShares * stats.currentPrice * 0.03) : 'N/A'}
|
||||
</td>
|
||||
<td className="text-right py-1">
|
||||
{stats.currentPrice ? formatCurrency(stats.totalShares * stats.currentPrice * 0.04) : 'N/A'}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Render milestones after current */}
|
||||
{milestones.map((milestone, index) => {
|
||||
const swr3 = stats.currentPrice ? milestone.target * stats.currentPrice * 0.03 : 0;
|
||||
const swr4 = stats.currentPrice ? milestone.target * stats.currentPrice * 0.04 : 0;
|
||||
|
||||
const isSelectedMilestone = index === selectedMilestoneIndex;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={index}
|
||||
className={cn(
|
||||
isSelectedMilestone
|
||||
? "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 pr-4">
|
||||
{Math.floor(milestone.target).toLocaleString()}
|
||||
</td>
|
||||
<td className="text-right py-1 pr-4">
|
||||
{stats.currentPrice ? formatCurrency(swr3) : 'N/A'}
|
||||
</td>
|
||||
<td className="text-right py-1">
|
||||
{stats.currentPrice ? formatCurrency(swr4) : 'N/A'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue