Compare commits

..

No commits in common. "main" and "feature/onboarding" have entirely different histories.

191 changed files with 16446 additions and 10521 deletions

View file

@ -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

View file

@ -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}"

View file

@ -1,8 +0,0 @@
APP_ENV=testing
APP_KEY=base64:+7T2RuonhTIij1yLp3rTOv2uQlYJh0TQulu20MlCA+s=
DB_CONNECTION=sqlite
DB_DATABASE=:memory:
SESSION_DRIVER=array
CACHE_STORE=array

View file

@ -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

View file

@ -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

View file

@ -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
View 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
View 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
View file

@ -12,6 +12,7 @@
.env.production
.phpactor.json
.phpunit.result.cache
/composer.lock
Homestead.json
Homestead.yaml
npm-debug.log

3
.prettierignore Normal file
View file

@ -0,0 +1,3 @@
resources/js/components/ui/*
resources/js/ziggy.js
resources/views/mail/*

19
.prettierrc Normal file
View 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
}
}
]
}

View file

@ -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.

View file

@ -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
View 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
View file

@ -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
View file

@ -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*
[![License: AGPL v3](https://img.shields.io/badge/License-AGPLv3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
[![Laravel](https://img.shields.io/badge/Laravel-13-FF2D20?logo=laravel&logoColor=white)](https://laravel.com/)
[![Livewire](https://img.shields.io/badge/Livewire-4-FB70A9?logo=livewire&logoColor=white)](https://livewire.laravel.com/)
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
[![Docker](https://img.shields.io/badge/Docker-Ready-2496ED?logo=docker&logoColor=white)](https://www.docker.com/)
[![Laravel](https://img.shields.io/badge/Laravel-12-FF2D20?logo=laravel&logoColor=white)](https://laravel.com/)
[![React](https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black)](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 counter](docs/screenshots/counter.png)
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.

View 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);
}
}

View 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('/');
}
}

View 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));
}
}

View file

@ -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');
}
}

View file

@ -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')]);
}
}

View 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)],
]);
}
}

View 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.'));
}
}

View 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));
}
}

View 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');
}
}

View 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);
}
}

View 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,
]);
}
}

View 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();
}
}

View 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('/');
}
}

View 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!',
]);
}
}

View 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);
}
}

View 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',
];
}
}

View 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());
}
}

View 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),
],
];
}
}

View file

@ -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
View 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
View 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',
];
}

View 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();
}
}

View file

@ -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();
}
}

View 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
View 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;
}
}

View file

@ -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,
]);
})

View file

@ -1,7 +1,5 @@
<?php
use App\Providers\AppServiceProvider;
return [
AppServiceProvider::class,
App\Providers\AppServiceProvider::class,
];

21
components.json Normal file
View 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"
}

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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
View 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
View 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',
],
],
];

View file

@ -18,7 +18,7 @@
|
*/
'driver' => env('SESSION_DRIVER', 'cookie'),
'driver' => env('SESSION_DRIVER', 'database'),
/*
|--------------------------------------------------------------------------

View file

@ -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,
];
}
}

View 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,
]);
}
}

View file

@ -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');
}
};

View 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');
}
};

View file

@ -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');
}
};

View file

@ -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');
}
};

View file

@ -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');
}
};

View file

@ -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');
}
};

View file

@ -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',
]);
}
}

View file

@ -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

View file

@ -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'@'%';

View file

@ -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

View file

@ -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

View file

@ -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

View 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"

View file

@ -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"

View file

@ -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 \

View file

@ -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;
}

View file

@ -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

View file

@ -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
View 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
];

7156
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -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",

View file

@ -1,13 +0,0 @@
includes:
- vendor/larastan/larastan/extension.neon
parameters:
level: 7
paths:
- app/
- database/
- tests/
excludePaths:
- bootstrap/*.php
- storage/*

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -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
View 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

View file

@ -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
View 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();

View 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">&gt; 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">&gt; 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">&gt; 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>
);
}

View 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>
);
}

View 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>
)}
</>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>;
}

View 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>
);
}

View 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>
);
}

View 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>
)}
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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