Add Livewire Flux components and new tests for project proposal and editing forms

This commit is contained in:
HolgerHatGarKeineNode
2026-01-18 15:19:00 +01:00
parent 30e78711c9
commit 0694a2d837
19 changed files with 816 additions and 444 deletions

View File

@@ -14,6 +14,8 @@ This application is a Laravel application and its main Laravel ecosystems packag
- laravel/prompts (PROMPTS) - v0 - laravel/prompts (PROMPTS) - v0
- laravel/reverb (REVERB) - v1 - laravel/reverb (REVERB) - v1
- laravel/sail (SAIL) - v1 - laravel/sail (SAIL) - v1
- livewire/flux (FLUXUI_FREE) - v2
- livewire/flux-pro (FLUXUI_PRO) - v2
- livewire/livewire (LIVEWIRE) - v4 - livewire/livewire (LIVEWIRE) - v4
- laravel/mcp (MCP) - v0 - laravel/mcp (MCP) - v0
- laravel/pint (PINT) - v1 - laravel/pint (PINT) - v1
@@ -124,6 +126,13 @@ protected function isAccessible(User $user, ?string $path = null): bool
- Execute PHP scripts: `vendor/bin/sail php [script]` - Execute PHP scripts: `vendor/bin/sail php [script]`
- View all available Sail commands by running `vendor/bin/sail` without arguments. - View all available Sail commands by running `vendor/bin/sail` without arguments.
=== tests rules ===
## Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test --compact` with a specific filename or filter.
=== laravel/core rules === === laravel/core rules ===
## Do Things the Laravel Way ## Do Things the Laravel Way
@@ -191,6 +200,28 @@ protected function isAccessible(User $user, ?string $path = null): bool
### Models ### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. - Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== fluxui-pro/core rules ===
## Flux UI Pro
- This project is using the Pro version of Flux UI. It has full access to the free components and variants, as well as full access to the Pro components and variants.
- Flux UI is a component library for Livewire. Flux is a robust, hand-crafted UI component library for your Livewire applications. It's built using Tailwind CSS and provides a set of components that are easy to use and customize.
- You should use Flux UI components when available.
- Fallback to standard Blade components if Flux is unavailable.
- If available, use the `search-docs` tool to get the exact documentation and code snippets available for this project.
- Flux UI components look like this:
<code-snippet name="Flux UI Component Example" lang="blade">
<flux:button variant="primary"/>
</code-snippet>
### Available Components
This is correct as of Boost installation, but there may be additional components within the codebase.
<available-flux-components>
accordion, autocomplete, avatar, badge, brand, breadcrumbs, button, calendar, callout, card, chart, checkbox, command, composer, context, date-picker, dropdown, editor, field, file-upload, heading, icon, input, kanban, modal, navbar, otp-input, pagination, pillbox, popover, profile, radio, select, separator, skeleton, slider, switch, table, tabs, text, textarea, time-picker, toast, tooltip
</available-flux-components>
=== livewire/core rules === === livewire/core rules ===
## Livewire ## Livewire

View File

@@ -14,6 +14,8 @@ This application is a Laravel application and its main Laravel ecosystems packag
- laravel/prompts (PROMPTS) - v0 - laravel/prompts (PROMPTS) - v0
- laravel/reverb (REVERB) - v1 - laravel/reverb (REVERB) - v1
- laravel/sail (SAIL) - v1 - laravel/sail (SAIL) - v1
- livewire/flux (FLUXUI_FREE) - v2
- livewire/flux-pro (FLUXUI_PRO) - v2
- livewire/livewire (LIVEWIRE) - v4 - livewire/livewire (LIVEWIRE) - v4
- laravel/mcp (MCP) - v0 - laravel/mcp (MCP) - v0
- laravel/pint (PINT) - v1 - laravel/pint (PINT) - v1
@@ -124,6 +126,13 @@ protected function isAccessible(User $user, ?string $path = null): bool
- Execute PHP scripts: `vendor/bin/sail php [script]` - Execute PHP scripts: `vendor/bin/sail php [script]`
- View all available Sail commands by running `vendor/bin/sail` without arguments. - View all available Sail commands by running `vendor/bin/sail` without arguments.
=== tests rules ===
## Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test --compact` with a specific filename or filter.
=== laravel/core rules === === laravel/core rules ===
## Do Things the Laravel Way ## Do Things the Laravel Way
@@ -191,6 +200,28 @@ protected function isAccessible(User $user, ?string $path = null): bool
### Models ### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. - Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== fluxui-pro/core rules ===
## Flux UI Pro
- This project is using the Pro version of Flux UI. It has full access to the free components and variants, as well as full access to the Pro components and variants.
- Flux UI is a component library for Livewire. Flux is a robust, hand-crafted UI component library for your Livewire applications. It's built using Tailwind CSS and provides a set of components that are easy to use and customize.
- You should use Flux UI components when available.
- Fallback to standard Blade components if Flux is unavailable.
- If available, use the `search-docs` tool to get the exact documentation and code snippets available for this project.
- Flux UI components look like this:
<code-snippet name="Flux UI Component Example" lang="blade">
<flux:button variant="primary"/>
</code-snippet>
### Available Components
This is correct as of Boost installation, but there may be additional components within the codebase.
<available-flux-components>
accordion, autocomplete, avatar, badge, brand, breadcrumbs, button, calendar, callout, card, chart, checkbox, command, composer, context, date-picker, dropdown, editor, field, file-upload, heading, icon, input, kanban, modal, navbar, otp-input, pagination, pillbox, popover, profile, radio, select, separator, skeleton, slider, switch, table, tabs, text, textarea, time-picker, toast, tooltip
</available-flux-components>
=== livewire/core rules === === livewire/core rules ===
## Livewire ## Livewire

View File

@@ -14,6 +14,8 @@ This application is a Laravel application and its main Laravel ecosystems packag
- laravel/prompts (PROMPTS) - v0 - laravel/prompts (PROMPTS) - v0
- laravel/reverb (REVERB) - v1 - laravel/reverb (REVERB) - v1
- laravel/sail (SAIL) - v1 - laravel/sail (SAIL) - v1
- livewire/flux (FLUXUI_FREE) - v2
- livewire/flux-pro (FLUXUI_PRO) - v2
- livewire/livewire (LIVEWIRE) - v4 - livewire/livewire (LIVEWIRE) - v4
- laravel/mcp (MCP) - v0 - laravel/mcp (MCP) - v0
- laravel/pint (PINT) - v1 - laravel/pint (PINT) - v1
@@ -124,6 +126,13 @@ protected function isAccessible(User $user, ?string $path = null): bool
- Execute PHP scripts: `vendor/bin/sail php [script]` - Execute PHP scripts: `vendor/bin/sail php [script]`
- View all available Sail commands by running `vendor/bin/sail` without arguments. - View all available Sail commands by running `vendor/bin/sail` without arguments.
=== tests rules ===
## Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test --compact` with a specific filename or filter.
=== laravel/core rules === === laravel/core rules ===
## Do Things the Laravel Way ## Do Things the Laravel Way
@@ -191,6 +200,28 @@ protected function isAccessible(User $user, ?string $path = null): bool
### Models ### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. - Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== fluxui-pro/core rules ===
## Flux UI Pro
- This project is using the Pro version of Flux UI. It has full access to the free components and variants, as well as full access to the Pro components and variants.
- Flux UI is a component library for Livewire. Flux is a robust, hand-crafted UI component library for your Livewire applications. It's built using Tailwind CSS and provides a set of components that are easy to use and customize.
- You should use Flux UI components when available.
- Fallback to standard Blade components if Flux is unavailable.
- If available, use the `search-docs` tool to get the exact documentation and code snippets available for this project.
- Flux UI components look like this:
<code-snippet name="Flux UI Component Example" lang="blade">
<flux:button variant="primary"/>
</code-snippet>
### Available Components
This is correct as of Boost installation, but there may be additional components within the codebase.
<available-flux-components>
accordion, autocomplete, avatar, badge, brand, breadcrumbs, button, calendar, callout, card, chart, checkbox, command, composer, context, date-picker, dropdown, editor, field, file-upload, heading, icon, input, kanban, modal, navbar, otp-input, pagination, pillbox, popover, profile, radio, select, separator, skeleton, slider, switch, table, tabs, text, textarea, time-picker, toast, tooltip
</available-flux-components>
=== livewire/core rules === === livewire/core rules ===
## Livewire ## Livewire

View File

@@ -9,19 +9,15 @@ use PowerComponents\LivewirePowerGrid\Button;
use PowerComponents\LivewirePowerGrid\Column; use PowerComponents\LivewirePowerGrid\Column;
use PowerComponents\LivewirePowerGrid\Detail; use PowerComponents\LivewirePowerGrid\Detail;
use PowerComponents\LivewirePowerGrid\Exportable; use PowerComponents\LivewirePowerGrid\Exportable;
use PowerComponents\LivewirePowerGrid\Facades\Rule;
use PowerComponents\LivewirePowerGrid\Footer; use PowerComponents\LivewirePowerGrid\Footer;
use PowerComponents\LivewirePowerGrid\Header; use PowerComponents\LivewirePowerGrid\Header;
use PowerComponents\LivewirePowerGrid\PowerGrid;
use PowerComponents\LivewirePowerGrid\PowerGridFields;
use PowerComponents\LivewirePowerGrid\PowerGridComponent; use PowerComponents\LivewirePowerGrid\PowerGridComponent;
use PowerComponents\LivewirePowerGrid\Traits\WithExport;
use WireUi\Traits\WireUiActions;
final class EinundzwanzigPlebTable extends PowerGridComponent class EinundzwanzigPlebTable extends PowerGridComponent
{ {
use WireUiActions; public string $sortField = 'association_status';
use WithExport;
public string $sortDirection = 'desc';
public string $sortField = 'association_status'; public string $sortField = 'association_status';
@@ -49,8 +45,7 @@ final class EinundzwanzigPlebTable extends PowerGridComponent
return EinundzwanzigPleb::query() return EinundzwanzigPleb::query()
->with([ ->with([
'profile', 'profile',
'paymentEvents' => fn($query) 'paymentEvents' => fn ($query) => $query
=> $query
->where('year', date('Y')) ->where('year', date('Y'))
->where('paid', true), ->where('paid', true),
]) ])
@@ -64,41 +59,37 @@ final class EinundzwanzigPlebTable extends PowerGridComponent
->add('pubkey') ->add('pubkey')
->add( ->add(
'avatar', 'avatar',
fn($model, fn ($model,
) ) => '<img class="w-8 h-8 shrink-0 grow-0 rounded-full" onerror="this.onerror=null; this.src=\'https://robohash.org/test\'";" src="'.asset(
=> '<img class="w-8 h-8 shrink-0 grow-0 rounded-full" onerror="this.onerror=null; this.src=\'https://robohash.org/test\'";" src="'.asset(
$model->profile?->picture, $model->profile?->picture,
).'">', ).'">',
) )
->add( ->add(
'for', 'for',
fn($model, fn ($model,
) ) => $model->application_for ? '<div class="m-1.5"><div class="text-xs inline-flex font-medium bg-red-500/20 text-red-700 rounded-full text-center px-2.5 py-1">'.AssociationStatus::from(
=> $model->application_for ? '<div class="m-1.5"><div class="text-xs inline-flex font-medium bg-red-500/20 text-red-700 rounded-full text-center px-2.5 py-1">'.AssociationStatus::from(
$model->application_for, $model->application_for,
)->label().'</div></div>' : '', )->label().'</div></div>' : '',
) )
->add( ->add(
'payment', 'payment',
fn(EinundzwanzigPleb $model) fn (EinundzwanzigPleb $model) => $model->paymentEvents->count() > 0 && $model->paymentEvents->first()->paid ? '<span class="text-green-500">'.number_format(
=> $model->paymentEvents->count() > 0 && $model->paymentEvents->first()->paid ? '<span class="text-green-500">'.number_format(
$model->paymentEvents->first()->amount, $model->paymentEvents->first()->amount,
0, 0,
',', ',',
'.', '.',
).'</span>' : 'keine Zahlung vorhanden', ).'</span>' : 'keine Zahlung vorhanden',
) )
->add('npub_export', fn(EinundzwanzigPleb $model) => $model->npub) ->add('npub_export', fn (EinundzwanzigPleb $model) => $model->npub)
->add( ->add(
'npub', 'npub',
fn(EinundzwanzigPleb $model) fn (EinundzwanzigPleb $model) => '<a target="_blank" class="btn-xs bg-gray-900 text-gray-100 hover:bg-gray-800 dark:bg-gray-100 dark:text-gray-800 dark:hover:bg-white" href="https://nostrudel.ninja/u/'.e(
=> '<a target="_blank" class="btn-xs bg-gray-900 text-gray-100 hover:bg-gray-800 dark:bg-gray-100 dark:text-gray-800 dark:hover:bg-white" href="https://nostrudel.ninja/u/'.e(
$model->npub, $model->npub,
).'">Nostr Profile</a>', ).'">Nostr Profile</a>',
) )
->add('association_status') ->add('association_status')
->add('association_status_name', fn(EinundzwanzigPleb $model) => $model->association_status->name) ->add('association_status_name', fn (EinundzwanzigPleb $model) => $model->association_status->name)
->add('paid_export', fn(EinundzwanzigPleb $model) => $model->paymentEvents->first()?->amount) ->add('paid_export', fn (EinundzwanzigPleb $model) => $model->paymentEvents->first()?->amount)
->add( ->add(
'association_status_formatted', 'association_status_formatted',
function (EinundzwanzigPleb $model) { function (EinundzwanzigPleb $model) {
@@ -109,13 +100,13 @@ final class EinundzwanzigPlebTable extends PowerGridComponent
AssociationStatus::HONORARY => 'text-xs inline-flex font-medium rounded-full text-center px-2.5 py-1 bg-blue-500/20 text-blue-700', AssociationStatus::HONORARY => 'text-xs inline-flex font-medium rounded-full text-center px-2.5 py-1 bg-blue-500/20 text-blue-700',
default => 'text-xs inline-flex font-medium rounded-full text-center px-2.5 py-1 text-red-700', default => 'text-xs inline-flex font-medium rounded-full text-center px-2.5 py-1 text-red-700',
}; };
return '<span class="'.$class.'">'.$model->association_status->label().'</span>'; return '<span class="'.$class.'">'.$model->association_status->label().'</span>';
}, },
) )
->add( ->add(
'name_lower', 'name_lower',
fn(EinundzwanzigPleb $model) fn (EinundzwanzigPleb $model) => strtolower(
=> strtolower(
e($model->profile?->name ?: $model->profile?->display_name ?? ''), e($model->profile?->name ?: $model->profile?->display_name ?? ''),
), ),
); );
@@ -251,9 +242,8 @@ final class EinundzwanzigPlebTable extends PowerGridComponent
return [ return [
// Hide button edit for ID 1 // Hide button edit for ID 1
Rule::button('accept') Rule::button('accept')
->when(fn($row) => $row->application_for === null) ->when(fn ($row) => $row->application_for === null)
->hide(), ->hide(),
]; ];
} }
} }

View File

@@ -17,6 +17,8 @@
"laravel/reverb": "^1.0", "laravel/reverb": "^1.0",
"laravel/sail": "^1.31", "laravel/sail": "^1.31",
"laravel/tinker": "^2.9", "laravel/tinker": "^2.9",
"livewire/flux": "^2.10",
"livewire/flux-pro": "^2.10",
"livewire/livewire": "^4.0", "livewire/livewire": "^4.0",
"openspout/openspout": "^4.24", "openspout/openspout": "^4.24",
"power-components/livewire-powergrid": "^6.7", "power-components/livewire-powergrid": "^6.7",
@@ -33,8 +35,7 @@
"spatie/laravel-sluggable": "^3.6", "spatie/laravel-sluggable": "^3.6",
"spatie/laravel-tags": "^4.9.2", "spatie/laravel-tags": "^4.9.2",
"staudenmeir/eloquent-has-many-deep": "^1.7", "staudenmeir/eloquent-has-many-deep": "^1.7",
"swentel/nostr-php": "^1.4", "swentel/nostr-php": "^1.4"
"wireui/wireui": "^2.5.1"
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",
@@ -95,5 +96,10 @@
} }
}, },
"minimum-stability": "stable", "minimum-stability": "stable",
"prefer-stable": true "prefer-stable": true,
"repositories": [{
"name": "flux-pro",
"type": "composer",
"url": "https://composer.fluxui.dev"
}]
} }

270
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "7ce22785474454dbae00168baafb6ccf", "content-hash": "39a9ce519dbfeb237966b7441b59a562",
"packages": [ "packages": [
{ {
"name": "akuechler/laravel-geoly", "name": "akuechler/laravel-geoly",
@@ -3025,6 +3025,145 @@
], ],
"time": "2026-01-15T06:54:53+00:00" "time": "2026-01-15T06:54:53+00:00"
}, },
{
"name": "livewire/flux",
"version": "v2.10.2",
"source": {
"type": "git",
"url": "https://github.com/livewire/flux.git",
"reference": "e7a93989788429bb6c0a908a056d22ea3a6c7975"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/livewire/flux/zipball/e7a93989788429bb6c0a908a056d22ea3a6c7975",
"reference": "e7a93989788429bb6c0a908a056d22ea3a6c7975",
"shasum": ""
},
"require": {
"illuminate/console": "^10.0|^11.0|^12.0",
"illuminate/support": "^10.0|^11.0|^12.0",
"illuminate/view": "^10.0|^11.0|^12.0",
"laravel/prompts": "^0.1|^0.2|^0.3",
"livewire/livewire": "^3.7.3|^4.0",
"php": "^8.1",
"symfony/console": "^6.0|^7.0"
},
"conflict": {
"livewire/blaze": "<1.0.0"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Flux": "Flux\\Flux"
},
"providers": [
"Flux\\FluxServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Flux\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"proprietary"
],
"authors": [
{
"name": "Caleb Porzio",
"email": "calebporzio@gmail.com"
}
],
"description": "The official UI component library for Livewire.",
"keywords": [
"components",
"flux",
"laravel",
"livewire",
"ui"
],
"support": {
"issues": "https://github.com/livewire/flux/issues",
"source": "https://github.com/livewire/flux/tree/v2.10.2"
},
"time": "2025-12-19T02:11:45+00:00"
},
{
"name": "livewire/flux-pro",
"version": "2.10.2",
"dist": {
"type": "zip",
"url": "https://composer.fluxui.dev/download/a0a0798f-1cf8-4999-8f57-8688c21f2d59/flux-pro-2.10.2.zip",
"reference": "9440435e467c4bb775efbc2c510ec7dea61e17d7",
"shasum": "177bf08ae75c628c96a1a3a4fd1e788b1b798e1d"
},
"require": {
"illuminate/console": "^10.0|^11.0|^12.0",
"illuminate/support": "^10.0|^11.0|^12.0",
"illuminate/view": "^10.0|^11.0|^12.0",
"laravel/prompts": "^0.1.24|^0.2|^0.3",
"livewire/flux": "2.10.2|dev-main",
"livewire/livewire": "^3.7.3|^4.0",
"php": "^8.1",
"symfony/console": "^6.0|^7.0"
},
"require-dev": {
"livewire/volt": "*",
"orchestra/testbench": "^10.8"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Flux": "FluxPro\\FluxPro"
},
"providers": [
"FluxPro\\FluxProServiceProvider"
]
}
},
"autoload": {
"files": [
"src/helpers.php"
],
"psr-4": {
"FluxPro\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\": "workbench/app/"
}
},
"scripts": {
"serve": [
"Composer\\Config::disableProcessTimeout",
"@php vendor/bin/testbench workbench:build --ansi",
"@php vendor/bin/testbench serve --port 3000 --ansi"
]
},
"license": [
"proprietary"
],
"authors": [
{
"name": "Caleb Porzio",
"email": "calebporzio@gmail.com"
}
],
"description": "The pro version of Flux, the official UI component library for Livewire.",
"keywords": [
"components",
"flux",
"laravel",
"livewire",
"ui"
],
"time": "2025-12-19T02:22:27+00:00"
},
{ {
"name": "livewire/livewire", "name": "livewire/livewire",
"version": "v4.0.1", "version": "v4.0.1",
@@ -10924,135 +11063,6 @@
} }
], ],
"time": "2024-11-21T01:49:47+00:00" "time": "2024-11-21T01:49:47+00:00"
},
{
"name": "wireui/heroicons",
"version": "v2.9.0",
"source": {
"type": "git",
"url": "https://github.com/wireui/heroicons.git",
"reference": "ccd2ab94293d6f231271c0847c1db34305313c6f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/wireui/heroicons/zipball/ccd2ab94293d6f231271c0847c1db34305313c6f",
"reference": "ccd2ab94293d6f231271c0847c1db34305313c6f",
"shasum": ""
},
"require": {
"laravel/framework": "^9.16|^10.0|^11.0|^12.0",
"php": "^8.1|^8.2|^8.3|^8.4"
},
"require-dev": {
"larastan/larastan": "^3.0",
"laravel/pint": "^1.6",
"orchestra/testbench": "^10.0",
"pestphp/pest": "^3.0"
},
"type": "library",
"extra": {
"aliases": [],
"laravel": {
"providers": [
"WireUi\\Heroicons\\HeroiconsServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"WireUi\\Heroicons\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Pedro Oliveira",
"email": "pedrolivertwd@gmail.com"
}
],
"description": "The Tailwind Heroicons for laravel blade by WireUI",
"keywords": [
"blade components",
"blade heroicons",
"laravel components",
"livewire icons",
"livewire icons components",
"wireui"
],
"support": {
"issues": "https://github.com/wireui/heroicons/issues",
"source": "https://github.com/wireui/heroicons/tree/v2.9.0"
},
"time": "2025-03-02T22:06:22+00:00"
},
{
"name": "wireui/wireui",
"version": "v2.5.1",
"source": {
"type": "git",
"url": "https://github.com/wireui/wireui.git",
"reference": "e4d966689eb840986281f5237149b2480eacf87c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/wireui/wireui/zipball/e4d966689eb840986281f5237149b2480eacf87c",
"reference": "e4d966689eb840986281f5237149b2480eacf87c",
"shasum": ""
},
"require": {
"laravel/framework": "^10.0|^11.0|^12.0",
"php": "^8.2|^8.3|^8.4|^8.5",
"wireui/heroicons": "^2.8"
},
"require-dev": {
"laravel/pint": "^1.19",
"livewire/livewire": "^3.6",
"orchestra/testbench": "^10.0",
"orchestra/testbench-dusk": "^10.0",
"pestphp/pest": "^4.0",
"pestphp/pest-plugin-laravel": "^4.0",
"pestphp/pest-plugin-livewire": "^4.0"
},
"type": "library",
"extra": {
"laravel": {
"aliases": [],
"providers": [
"WireUi\\ServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"WireUi\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Pedro Oliveira",
"email": "pedrolivertwd@gmail.com"
}
],
"description": "TallStack components",
"keywords": [
"blade components",
"laravel components",
"livewire components",
"livewire-ui",
"wireui"
],
"support": {
"issues": "https://github.com/wireui/wireui/issues",
"source": "https://github.com/wireui/wireui/tree/v2.5.1"
},
"time": "2025-11-10T04:46:35+00:00"
} }
], ],
"packages-dev": [ "packages-dev": [

View File

@@ -73,11 +73,15 @@
<form method="post" action="{{ route('logout') }}" <form method="post" action="{{ route('logout') }}"
@submit="$dispatch('nostrLoggedOut')"> @submit="$dispatch('nostrLoggedOut')">
@csrf @csrf
<x-button secondary label="Logout" type="submit"/> <flux:button secondary type="submit">
Logout
</flux:button>
</form> </form>
@else @else
<x-button wire:key="loginBtn" label="Mit Nostr verbinden" @click="openNostrLogin" <flux:button wire:key="loginBtn" @click="openNostrLogin"
x-show="!$store.nostr.user"/> x-show="!$store.nostr.user">
Mit Nostr verbinden
</flux:button>
@endif @endif
<!-- Info button --> <!-- Info button -->

View File

@@ -89,25 +89,29 @@
($currentPleb && $currentPleb->id === $project->einundzwanzig_pleb_id) ($currentPleb && $currentPleb->id === $project->einundzwanzig_pleb_id)
|| ($currentPleb && in_array($currentPleb->npub, config('einundzwanzig.config.current_board'), true)) || ($currentPleb && in_array($currentPleb->npub, config('einundzwanzig.config.current_board'), true))
) )
<x-button <flux:button
icon="trash" icon="trash"
xs xs
negative negative
wire:click="confirmDelete({{ $project->id }})" wire:click="confirmDelete({{ $project->id }})"
label="Löschen"/> wire:loading.attr="disabled">
<x-button Löschen
</flux:button>
<flux:button
icon="pencil" icon="pencil"
xs xs
secondary secondary
:href="route('association.projectSupport.edit', ['projectProposal' => $project])" :href="route('association.projectSupport.edit', ['projectProposal' => $project])">
label="Editieren"/> Editieren
</flux:button>
@endif @endif
@if(($currentPleb && $currentPleb->association_status->value > 2) || $project->accepted) @if(($currentPleb && $currentPleb->association_status->value > 2) || $project->accepted)
<x-button <flux:button
icon="folder-open" icon="folder-open"
xs xs
:href="route('association.projectSupport.item', ['projectProposal' => $project])" :href="route('association.projectSupport.item', ['projectProposal' => $project])">
label="Öffnen"/> Öffnen
</flux:button>
@endif @endif
</div> </div>
<div class="py-2"> <div class="py-2">

View File

@@ -3,11 +3,10 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
{!! seo($seo ?? null) !!} {!! seo($seo ?? null) !}
<title>{{ $title ?? 'Page Title' }}</title> <title>{{ $title ?? 'Page Title' }}</title>
@livewireStyles @livewireStyles
@wireUiScripts
@stack('scripts') @stack('scripts')
@vite(['resources/js/app.js','resources/css/app.css']) @vite(['resources/js/app.js','resources/css/app.css'])
@googlefonts @googlefonts
@@ -15,183 +14,92 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.css">
<script src="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js"></script>
@include('components.layouts.partials.styles') @include('components.layouts.partials.styles')
@fluxAppearance
</head> </head>
<body <body class="min-h-screen bg-white dark:bg-zinc-800 antialiased"
class="font-sans antialiased bg-gray-100 dark:bg-[#222222] text-gray-600 dark:text-gray-400" x-data="nostrLogin"
:class="{ 'sidebar-expanded': sidebarExpanded }"
x-data="{ sidebarOpen: false, sidebarExpanded: localStorage.getItem('sidebar-expanded') == 'true', inboxSidebarOpen: false }"
x-init="$watch('sidebarExpanded', value => localStorage.setItem('sidebar-expanded', value))"
> >
<x-dialog /> <flux:header container class="bg-zinc-50 dark:bg-zinc-900 border-b border-zinc-200 dark:border-zinc-700">
<x-notifications /> <flux:sidebar.toggle class="lg:hidden" icon="bars-2" inset="left" />
<script>
if (localStorage.getItem('sidebar-expanded') == 'true') {
document.querySelector('body').classList.add('sidebar-expanded');
} else {
document.querySelector('body').classList.remove('sidebar-expanded');
}
</script>
<div x-data="nostrLogin"
class="flex h-[100dvh] overflow-hidden">
<livewire:layout.sidebar/>
<div
class="relative flex flex-col flex-1 overflow-y-auto overflow-x-hidden">
<!-- Site header -->
<header
class="sticky top-0 before:absolute before:inset-0 before:backdrop-blur-md before:bg-white/90 dark:before:bg-[#222222]/90 lg:before:bg-[#222222]/90 dark:lg:before:bg-[#222222]/90 before:-z-10 max-lg:shadow-sm z-30">
<div class="px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16 lg:border-b border-gray-200 dark:border-gray-700/60">
<!-- Header: Left side --> <flux:brand href="/" name="Einundzwanzig" class="max-lg:hidden dark:hidden">
<div class="flex"> <img src="{{ asset('einundzwanzig-alpha.jpg') }}" alt="Logo" class="h-6 w-6">
<!-- Hamburger button --> </flux:brand>
<button <flux:brand href="/" name="Einundzwanzig" class="max-lg:hidden! hidden dark:flex">
class="text-gray-500 hover:text-gray-600 dark:hover:text-gray-400 lg:hidden" <img src="{{ asset('einundzwanzig-alpha.jpg') }}" alt="Logo" class="h-6 w-6">
@click.stop="sidebarOpen = !sidebarOpen" </flux:brand>
aria-controls="sidebar"
:aria-expanded="sidebarOpen"
>
<span class="sr-only">Open sidebar</span>
<svg class="w-6 h-6 fill-current" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="5" width="16" height="2"/>
<rect x="4" y="11" width="16" height="2"/>
<rect x="4" y="17" width="16" height="2"/>
</svg>
</button>
</div> <flux:navbar class="-mb-px max-lg:hidden">
@if(\App\Support\NostrAuth::check())
<flux:navbar.item icon="rss" :href="route('association.news')" {{ request()->routeIs('association.news') ? 'current' : '' }}>News</flux:navbar.item>
<flux:navbar.item icon="id-card-clip" :href="route('association.profile')" {{ request()->routeIs('association.profile') ? 'current' : '' }}>Profil</flux:navbar.item>
<flux:navbar.item icon="hand-heart" :href="route('association.projectSupport')" {{ request()->routeIs('association.projectSupport') ? 'current' : '' }}>Projekt-Unterstützungen</flux:navbar.item>
@endif
</flux:navbar>
<!-- Header: Right side --> <flux:spacer />
<div class="flex items-center space-x-3">
{{--@include('components.layouts.partials.search-button')--}} <flux:navbar class="me-4">
<flux:dropdown position="bottom" align="end" class="max-lg:hidden">
{{--@include('components.layouts.partials.notification-buttons')--}} <flux:navbar.item icon:trailing="information-circle">Info</flux:navbar.item>
<flux:menu>
<flux:menu.item href="https://gitworkshop.dev/r/naddr1qvzqqqrhnypzqzklvar4enzu53t06vpzu3h465nwkzhk9p9ls4y5crwhs3lnu5pnqy88wumn8ghj7mn0wvhxcmmv9uqpxetfde6kuer6wasku7nfvukkummnw3eqdgsn8w/issues" target="_blank">Issues/Feedback</flux:menu.item>
<flux:menu.item :href="route('changelog')">Changelog</flux:menu.item>
<flux:menu.item href="https://github.com/HolgerHatGarkeineNode/einundzwanzig-nostr" target="_blank">Github</flux:menu.item>
<flux:menu.item href="https://einundzwanzig.space/kontakt/" target="_blank">Impressum</flux:menu.item>
</flux:menu>
</flux:dropdown>
@if(\App\Support\NostrAuth::check()) @if(\App\Support\NostrAuth::check())
<form method="post" action="{{ route('logout') }}" <form method="post" action="{{ route('logout') }}" @submit="$dispatch('nostrLoggedOut')">
@submit="$dispatch('nostrLoggedOut')">
@csrf @csrf
<x-button secondary label="Logout" type="submit"/> <flux:navbar.item type="submit" icon="arrow-right-start-on-rectangle">Logout</flux:navbar.item>
</form> </form>
@else @else
<x-button wire:key="loginBtn" label="Mit Nostr verbinden" @click="openNostrLogin" <flux:navbar.item icon="user" wire:key="loginBtn" @click="openNostrLogin">Mit Nostr verbinden</flux:navbar.item>
x-show="!$store.nostr.user"/> @endif
</flux:navbar>
</flux:header>
<flux:sidebar sticky collapsible="mobile" class="bg-zinc-50 dark:bg-zinc-900 border-r border-zinc-200 dark:border-zinc-700">
<flux:sidebar.header>
<flux:sidebar.brand
href="/"
name="Einundzwanzig"
>
<img src="{{ asset('einundzwanzig-alpha.jpg') }}" alt="Logo" class="h-6 w-6">
</flux:sidebar.brand>
<flux:sidebar.collapse class="in-data-flux-sidebar-on-desktop:not(in-data-flux-sidebar-collapsed-desktop):-mr-2" />
</flux:sidebar.header>
<flux:sidebar.nav>
@if(\App\Support\NostrAuth::check())
<flux:sidebar.item icon="rss" :href="route('association.news')" {{ request()->routeIs('association.news') ? 'current' : '' }}>News</flux:sidebar.item>
<flux:sidebar.item icon="id-card-clip" :href="route('association.profile')" {{ request()->routeIs('association.profile') ? 'current' : '' }}>Meine Mitgliedschaft</flux:sidebar.item>
<flux:sidebar.item icon="hand-heart" :href="route('association.projectSupport')" {{ request()->routeIs('association.projectSupport') ? 'current' : '' }}>Projekt-Unterstützungen</flux:sidebar.item>
@endif @endif
<!-- Info button --> @include('components.layouts.navigation.admin')
<div class="relative inline-flex" x-data="{ open: false }"> </flux:sidebar.nav>
<button </flux:sidebar>
class="w-8 h-8 flex items-center justify-center hover:bg-[#1B1B1B] lg:hover:bg-[#1B1B1B] dark:hover:bg-[#1B1B1B]/50 dark:lg:hover:bg-[#1B1B1B] rounded-full"
:class="{ 'bg-gray-200 dark:bg-[#1B1B1B]': open }"
aria-haspopup="true"
@click.prevent="open = !open"
:aria-expanded="open"
>
<span class="sr-only">Info</span>
<svg class="fill-current text-gray-500/80 dark:text-gray-400/80" width="16" height="16"
viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path
d="M9 7.5a1 1 0 1 0-2 0v4a1 1 0 1 0 2 0v-4ZM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"/>
<path fill-rule="evenodd"
d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16Zm6-8A6 6 0 1 1 2 8a6 6 0 0 1 12 0Z"/>
</svg>
</button>
<div
class="origin-top-right z-10 absolute top-full right-0 min-w-44 bg-white dark:bg-[#1B1B1B] border border-gray-200 dark:border-[#1B1B1B]/60 py-1.5 rounded-lg shadow-lg overflow-hidden mt-1"
@click.outside="open = false"
@keydown.escape.window="open = false"
x-show="open"
x-transition:enter="transition ease-out duration-200 transform"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-out duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
x-cloak
>
<div
class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase pt-1.5 pb-2 px-3">
Information
</div>
<ul>
<li>
<a class="font-medium text-sm text-amber-500 hover:text-amber-600 dark:hover:text-amber-400 flex items-center py-1 px-3"
target="_blank"
href="https://gitworkshop.dev/r/naddr1qvzqqqrhnypzqzklvar4enzu53t06vpzu3h465nwkzhk9p9ls4y5crwhs3lnu5pnqy88wumn8ghj7mn0wvhxcmmv9uqpxetfde6kuer6wasku7nfvukkummnw3eqdgsn8w/issues"
@click="open = false" @focus="open = true"
@focusout="open = false">
<i class="fa-sharp-duotone fa-solid fa-code w-3 h-3 fill-current text-amber-500 shrink-0 mr-2"></i>
<span>Issues/Feedback</span>
</a>
</li>
<li>
<a class="font-medium text-sm text-amber-500 hover:text-amber-600 dark:hover:text-amber-400 flex items-center py-1 px-3"
href="{{ route('changelog') }}" @click="open = false" @focus="open = true"
@focusout="open = false">
<i class="fa-sharp-duotone fa-solid fa-code w-3 h-3 fill-current text-amber-500 shrink-0 mr-2"></i>
<span>Changelog</span>
</a>
</li>
<li>
<a class="font-medium text-sm text-amber-500 hover:text-amber-600 dark:hover:text-amber-400 flex items-center py-1 px-3"
href="https://github.com/HolgerHatGarKeineNode/einundzwanzig-nostr"
target="_blank" @click="open = false" @focus="open = true"
@focusout="open = false">
<i class="fa-brands fa-github w-3 h-3 fill-current text-amber-500 shrink-0 mr-2"></i>
<span>Github</span>
</a>
</li>
<li>
<a class="font-medium text-sm text-amber-500 hover:text-amber-600 dark:hover:text-amber-400 flex items-center py-1 px-3"
href="https://einundzwanzig.space/kontakt/" target="_blank"
@click="open = false" @focus="open = true"
@focusout="open = false">
<i class="fa-sharp-duotone fa-solid fa-info w-3 h-3 fill-current text-amber-500 shrink-0 mr-2"></i>
<span>Impressum</span>
</a>
</li>
</ul>
</div>
</div>
{{--@include('components.layouts.partials.dark-mode-toggle')--}} <flux:main container>
<!-- Divider -->
{{--<hr class="w-px h-6 bg-gray-200 dark:bg-gray-700/60 border-none"/>--}}
{{--@include('components.layouts.partials.user-button')--}}
</div>
</div>
</div>
</header>
<main class="grow">
{{ $slot }} {{ $slot }}
</main> </flux:main>
</div>
</div> @fluxScripts
@livewireScriptConfig @livewireScriptConfig
<script> <script>
window.wnjParams = { window.wnjParams = {
position: 'bottom', position: 'bottom',
// The only accepted value is 'bottom', default is top
accent: 'orange', accent: 'orange',
// Supported values: cyan (default), green, purple, red, orange, neutral, stone
startHidden: false, startHidden: false,
// If the host page has a button that call `getPublicKey` to start a
// login procedure, the minimized widget can be hidden until connected
compactMode: false, compactMode: false,
// Show the minimized widget in a compact form
disableOverflowFix: false, disableOverflowFix: false,
// If the host page on mobile has an horizontal scrolling, the floating
// element/modal are pushed to the extreme right/bottom and exit the
// viewport. A style is injected in the html/body elements fix this.
// This option permit to disable this default behavior
} }
</script> </script>
<script src="{{ asset('dist/window.nostr.min.js.js') }}"></script> <script src="{{ asset('dist/window.nostr.min.js.js') }}"></script>
</body> </body>
</html> </html>

View File

@@ -78,11 +78,16 @@ new class extends Component {
{{ $election['year'] }} {{ $election['year'] }}
</div> </div>
<div class="shadow-lg rounded-lg overflow-hidden"> <div class="shadow-lg rounded-lg overflow-hidden">
<x-textarea wire:model="elections.{{ $loop->index }}.candidates" rows="25" <flux:field>
label="candidates" placeholder=""/> <flux:label>Kandidaten</flux:label>
<flux:textarea wire:model="elections.{{ $loop->index }}.candidates" rows="25" placeholder="Kandidaten..."/>
<flux:error name="elections.{{ $loop->index }}.candidates" />
</flux:field>
</div> </div>
<div class="py-2"> <div class="py-2">
<x-button label="Speichern" wire:click="saveElection({{ $loop->index }})" wire:loading.attr="disabled"/> <flux:button wire:click="saveElection({{ $loop->index }})" wire:loading.attr="disabled">
Speichern
</flux:button>
</div> </div>
</div> </div>
@endforeach @endforeach

View File

@@ -478,16 +478,16 @@ new class extends Component {
class="flex flex-col space-y-2 sm:space-y-0 sm:flex-row justify-between items-center w-full"> class="flex flex-col space-y-2 sm:space-y-0 sm:flex-row justify-between items-center w-full">
<div> <div>
@if($isNotClosed) @if($isNotClosed)
<x-badge success <flux:badge color="success" label="Die Wahl ist geöffnet bis zum {{ $election->end_time?->timezone('Europe/Berlin')->format('d.m.Y H:i') }}"/>
label="Die Wahl ist geöffnet bis zum {{ $election->end_time?->timezone('Europe/Berlin')->format('d.m.Y H:i') }}"/>
@else @else
<x-badge negative label="Die Wahl ist geschlossen"/> <flux:badge color="danger" label="Die Wahl ist geschlossen"/>
@endif @endif
</div> </div>
<div> <div>
<x-button secondary <flux:button secondary
:href="route('association.election.admin', ['election' => $election])" :href="route('association.election.admin', ['election' => $election])"
label="Wahl-Admin"/> label="Wahl-Admin">
</flux:button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -202,19 +202,22 @@ class extends Component {
</div> </div>
</div> </div>
<div class="mt-2 flex justify-end w-full space-x-2"> <div class="mt-2 flex justify-end w-full space-x-2">
<x-button <flux:button
xs xs
target="_blank" target="_blank"
:href="url()->temporarySignedRoute('dl', now()->addMinutes(30), ['media' => $post->getFirstMedia('pdf')])" :href="url()->temporarySignedRoute('dl', now()->addMinutes(30), ['media' => $post->getFirstMedia('pdf')])"
label="Öffnen" icon="cloud-arrow-down">
primary icon="cloud-arrow-down"/> Öffnen
</flux:button>
@if($canEdit) @if($canEdit)
<x-button <flux:button
xs xs
negative
wire:click="delete({{ $post->id }})" wire:click="delete({{ $post->id }})"
wire:loading.attr="disabled" wire:loading.attr="disabled"
label="Löschen" icon="trash">
negative icon="trash"/> Löschen
</flux:button>
@endif @endif
</div> </div>
</article> </article>
@@ -253,27 +256,40 @@ class extends Component {
@enderror @enderror
</div> </div>
<div wire:dirty> <div wire:dirty>
<x-native-select <flux:field>
<flux:label>Kategorie</flux:label>
<flux:select
wire:model="form.category" wire:model="form.category"
label="Kategorie"
placeholder="Wähle Kategorie" placeholder="Wähle Kategorie"
:options="\App\Enums\NewsCategory::selectOptions()" >
option-label="label" option-value="value" @foreach(\App\Enums\NewsCategory::selectOptions() as $category)
<flux:select.option
:label="$category['label']"
:value="$category['value']"
/> />
@endforeach
</flux:select>
<flux:error name="form.category" />
</flux:field>
</div> </div>
<div wire:dirty> <div wire:dirty>
<x-input label="Titel" wire:model="form.name"/> <flux:field>
<flux:label>Titel</flux:label>
<flux:input wire:model="form.name" placeholder="News-Titel" />
<flux:error name="form.name" />
</flux:field>
</div> </div>
<div wire:dirty> <div wire:dirty>
<x-textarea <flux:field>
description="optional" <flux:label>Beschreibung</flux:label>
label="Beschreibung" wire:model="form.description"/> <flux:description>optional</flux:description>
<flux:textarea wire:model="form.description" rows="4" placeholder="Beschreibung..." />
<flux:error name="form.description" />
</flux:field>
</div> </div>
<button <flux:button wire:click="save" class="w-full">
wire:click="save"
class="btn-sm w-full bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700/60 hover:border-gray-300 dark:hover:border-gray-600 text-gray-800 dark:text-gray-300">
Hinzufügen Hinzufügen
</button> </flux:button>
</div> </div>
</div> </div>
@endif @endif

View File

@@ -506,16 +506,19 @@ new class extends Component {
<div class="sm:w-1/2 flex flex-col space-y-2"> <div class="sm:w-1/2 flex flex-col space-y-2">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<div wire:dirty> <div wire:dirty>
<x-checkbox wire:model="form.check" <flux:field variant="inline">
label="Ich stimme den Vereins-Statuten zu"/> <flux:checkbox wire:model="form.check" label="Ich stimme den Vereins-Statuten zu"/>
<flux:error name="form.check" />
</flux:field>
</div> </div>
<div> <div>
<a href="https://einundzwanzig.space/verein/" target="_blank" <a href="https://einundzwanzig.space/verein/" target="_blank"
class="text-amber-500">Statuten</a> class="text-amber-500">Statuten</a>
</div> </div>
</div> </div>
<x-button label="Mit deinem aktuellen Nostr-Profil Mitglied werden" <flux:button wire:click="save({{ AssociationStatus::PASSIVE() }})">
wire:click="save({{ AssociationStatus::PASSIVE() }})"/> Mit deinem aktuellen Nostr-Profil Mitglied werden
</flux:button>
</div> </div>
</div> </div>
@endif @endif
@@ -544,24 +547,34 @@ new class extends Component {
</div> </div>
<div <div
class="flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-2 text-amber-500"> class="flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-2 text-amber-500">
<x-toggle xl warning <flux:field variant="inline" xl>
<flux:label>NEIN</flux:label>
<flux:description>Ich informiere mich selbst in der News Sektion und gebe keine E-Mail Adresse raus.</flux:description>
<flux:switch
wire:model.live="no" wire:model.live="no"
wire:dirty wire:dirty
label="NEIN"> />
<x-slot name="description"> <flux:error name="no" />
<span class="py-2 text-amber-500">Ich informiere mich selbst in der News Sektion und gebe keine E-Mail Adresse raus.</span> </flux:field>
</x-slot>
</x-toggle>
</div> </div>
@if($showEmail) @if($showEmail)
<div wire:key="showEmail" <div wire:key="showEmail"
class="flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-2"> class="flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-2">
<x-input wire:model.live.debounce="fax" wire:dirty label="Fax-Nummer"/> <flux:field>
<x-input wire:model.live.debounce="email" wire:dirty <flux:label>Fax-Nummer</flux:label>
label="E-Mail Adresse"/> <flux:input wire:model.live.debounce="fax" wire:dirty placeholder="Fax-Nummer"/>
<flux:error name="fax" />
</flux:field>
<flux:field>
<flux:label>E-Mail Adresse</flux:label>
<flux:input type="email" wire:model.live.debounce="email" wire:dirty placeholder="E-Mail Adresse"/>
<flux:error name="email" />
</flux:field>
</div> </div>
<div wire:key="showSave" class="flex space-x-2 mt-2"> <div wire:key="showSave" class="flex space-x-2 mt-2">
<x-button wire:click="saveEmail" label="Speichern"/> <flux:button wire:click="saveEmail" wire:loading.attr="disabled">
Speichern
</flux:button>
</div> </div>
@endif @endif
</div> </div>

View File

@@ -70,23 +70,22 @@ class extends Component {
</h2> </h2>
<div class="space-y-4"> <div class="space-y-4">
<div wire:dirty> <div wire:dirty>
<x-input label="Name" wire:model="form.name"/> <flux:field>
@error('form.name') <flux:label>Name</flux:label>
<span class="text-red-500">{{ $message }}</span> <flux:input wire:model="form.name" placeholder="Projektname" />
@enderror <flux:error name="form.name" />
</flux:field>
</div> </div>
<div wire:dirty> <div wire:dirty>
<x-textarea label="Beschreibung" wire:model="form.description"/> <flux:field>
@error('form.description') <flux:label>Beschreibung</flux:label>
<span class="text-red-500">{{ $message }}</span> <flux:textarea wire:model="form.description" rows="6" placeholder="Projektbeschreibung..." />
@enderror <flux:error name="form.description" />
</flux:field>
</div> </div>
<button <flux:button wire:click="save" wire:loading.attr="disabled" variant="primary" class="w-full">
wire:click="save"
wire:loading.attr="disabled"
class="w-full btn-sm bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700/60 hover:border-gray-300 dark:hover:border-gray-600 text-gray-800 dark:text-gray-300">
Speichern Speichern
</button> </flux:button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -27,7 +27,13 @@ class extends Component {
$currentPubkey = NostrAuth::pubkey(); $currentPubkey = NostrAuth::pubkey();
$currentPleb = \App\Models\EinundzwanzigPleb::query()->where('pubkey', $currentPubkey)->first(); $currentPleb = \App\Models\EinundzwanzigPleb::query()->where('pubkey', $currentPubkey)->first();
if ($currentPleb && $currentPleb->id === $project->einundzwanzig_pleb_id) { if (
(
$currentPleb
&& $currentPleb->id === $project->einundzwanzig_pleb_id
)
|| in_array($currentPleb->npub, config('einundzwanzig.config.current_board'))
) {
$this->isAllowed = true; $this->isAllowed = true;
$this->form = [ $this->form = [
'name' => $project->name, 'name' => $project->name,
@@ -76,23 +82,22 @@ class extends Component {
</h2> </h2>
<div class="space-y-4"> <div class="space-y-4">
<div wire:dirty> <div wire:dirty>
<x-input label="Name" wire:model="form.name"/> <flux:field>
@error('form.name') <flux:label>Name</flux:label>
<span class="text-red-500">{{ $message }}</span> <flux:input wire:model="form.name" placeholder="Projektname" />
@enderror <flux:error name="form.name" />
</flux:field>
</div> </div>
<div wire:dirty> <div wire:dirty>
<x-textarea label="Beschreibung" wire:model="form.description"/> <flux:field>
@error('form.description') <flux:label>Beschreibung</flux:label>
<span class="text-red-500">{{ $message }}</span> <flux:textarea wire:model="form.description" rows="6" placeholder="Projektbeschreibung..." />
@enderror <flux:error name="form.description" />
</flux:field>
</div> </div>
<button <flux:button wire:click="update" wire:loading.attr="disabled" variant="primary" class="w-full">
wire:click="update"
wire:loading.attr="disabled"
class="w-full btn-sm bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700/60 hover:border-gray-300 dark:hover:border-gray-600 text-gray-800 dark:text-gray-300">
Speichern Speichern
</button> </flux:button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -118,14 +118,15 @@ new class extends Component {
<!-- Search form --> <!-- Search form -->
<form class="relative"> <form class="relative">
<x-input type="search" wire:model.live.debounce="search" <flux:input type="search" wire:model.live.debounce="search"
placeholder="Suche"/> placeholder="Suche" icon="magnifying-glass"/>
</form> </form>
<!-- Add meetup button --> <!-- Add meetup button -->
@if($currentPleb && $currentPleb->association_status->value > 1 && $currentPleb->paymentEvents()->where('year', date('Y'))->where('paid', true)->exists()) @if($currentPleb && $currentPleb->association_status->value > 1 && $currentPleb->paymentEvents()->where('year', date('Y'))->where('paid', true)->exists())
<x-button :href="route('association.projectSupport.create')" icon="plus" <flux:button :href="route('association.projectSupport.create')" icon="plus" variant="primary">
label="Projekt einreichen"/> Projekt einreichen
</flux:button>
@endif @endif
</div> </div>

View File

@@ -0,0 +1,111 @@
<?php
use App\Livewire\Forms\ProjectProposalForm;
it('has correct validation rules for all fields', function () {
$form = new ProjectProposalForm;
// Test name field - required|min:5
$form->name = '';
expect(fn () => $form->validate())->toThrow();
$form->name = 'short'; // Less than 5 characters
expect(fn () => $form->validate())->toThrow();
// Test support_in_sats field - required|numeric|min:21
$form->name = 'Valid Project';
$form->support_in_sats = '';
expect(fn () => $form->validate())->toThrow();
$form->support_in_sats = 'not-numeric';
expect(fn () => $form->validate())->toThrow();
$form->support_in_sats = '20'; // Less than 21
expect(fn () => $form->validate())->toThrow();
// Test description field - required|string|min:5
$form->name = 'Valid Project';
$form->support_in_sats = '21000';
$form->description = '';
expect(fn () => $form->validate())->toThrow();
$form->description = 'short';
expect(fn () => $form->validate())->toThrow();
// Test website field - required|url
$form->name = 'Valid Project';
$form->support_in_sats = '21000';
$form->description = 'Valid description';
$form->website = 'not-a-url';
expect(fn () => $form->validate())->toThrow();
});
it('accepts valid project proposal data', function () {
$form = new ProjectProposalForm;
$form->name = 'Test Project';
$form->support_in_sats = '21000';
$form->description = 'This is a test project description that meets the minimum length requirement.';
$form->website = 'https://example.com';
$form->accepted = true;
$form->sats_paid = 5000;
$result = $form->validate();
expect($result)->toBeArray();
expect($result)->toBeEmpty();
});
it('validates accepted field as boolean', function () {
$form = new ProjectProposalForm;
$form->name = 'Valid Project';
$form->support_in_sats = '21000';
$form->description = 'Valid description';
$form->website = 'https://example.com';
$form->accepted = 'not-boolean';
expect(fn () => $form->validate())->toThrow();
// Test with boolean values
$form->accepted = false;
expect($form->accepted)->toBeBool();
$form->accepted = true;
expect($form->accepted)->toBeBool();
});
it('validates sats_paid as nullable numeric', function () {
$form = new ProjectProposalForm;
$form->name = 'Valid Project';
$form->support_in_sats = '21000';
$form->description = 'Valid description';
$form->website = 'https://example.com';
// Test with null (should be acceptable)
$form->sats_paid = null;
$form->accepted = false;
$result = $form->validate();
expect($result)->toBeArray();
expect($result)->toBeEmpty();
// Test with numeric
$form->sats_paid = 'not-numeric';
expect(fn () => $form->validate())->toThrow();
$form->sats_paid = 10000;
$form->accepted = false;
$result = $form->validate();
expect($result)->toBeArray();
expect($result)->toBeEmpty();
});
it('has correct default values', function () {
$form = new ProjectProposalForm;
expect($form->name)->toBe('');
expect($form->support_in_sats)->toBe('');
expect($form->description)->toBe('');
expect($form->website)->toBe('');
expect($form->accepted)->toBeFalse();
expect($form->sats_paid)->toBe(0);
});

View File

@@ -0,0 +1,98 @@
<?php
use App\Enums\AssociationStatus;
use App\Models\EinundzwanzigPleb;
use App\Models\ProjectProposal;
use Illuminate\Support\Str;
use Livewire\Livewire;
beforeEach(function () {
$this->pleb = EinundzwanzigPleb::query()->create([
'pubkey' => 'test_pubkey_'.Str::random(20),
'npub' => 'test_npub_'.Str::random(20),
'association_status' => AssociationStatus::ACTIVE->value,
]);
// Create payment event for the current year
$this->pleb->paymentEvents()->create([
'year' => date('Y'),
'amount' => 21000,
'paid' => true,
'event_id' => 'test_event_'.Str::random(40),
]);
});
it('renders create form for authorized users', function () {
Livewire::actingAs($this->pleb)
->test('association.project-support.form.create')
->assertStatus(200)
->assertSee('Projektförderung anlegen')
->assertSeeLivewire('association.project-support.form.create');
});
it('does not render create form for unauthorized users', function () {
$unauthorizedPleb = EinundzwanzigPleb::query()->create([
'pubkey' => 'test_pubkey_'.Str::random(20),
'npub' => 'test_npub_'.Str::random(20),
'association_status' => AssociationStatus::DEFAULT->value,
]);
Livewire::actingAs($unauthorizedPleb)
->test('association.project-support.form.create')
->assertSet('isAllowed', false)
->assertDontSee('Projektförderung anlegen');
});
it('validates required name field', function () {
Livewire::actingAs($this->pleb)
->test('association.project-support.form.create')
->set('form.name', '')
->set('form.description', 'Test description')
->call('save')
->assertHasErrors(['form.name']);
});
it('validates name max length', function () {
Livewire::actingAs($this->pleb)
->test('association.project-support.form.create')
->set('form.name', Str::random(300))
->set('form.description', 'Test description')
->call('save')
->assertHasErrors(['form.name']);
});
it('validates required description field', function () {
Livewire::actingAs($this->pleb)
->test('association.project-support.form.create')
->set('form.name', 'Test Project')
->set('form.description', '')
->call('save')
->assertHasErrors(['form.description']);
});
it('creates project proposal successfully', function () {
Livewire::actingAs($this->pleb)
->test('association.project-support.form.create')
->set('form.name', 'Test Project')
->set('form.description', 'This is a test project for unit testing purposes.')
->call('save')
->assertHasNoErrors()
->assertRedirect(route('association.projectSupport'));
expect(ProjectProposal::count())->toBe(1);
$project = ProjectProposal::first();
expect($project->name)->toBe('Test Project');
expect($project->description)->toBe('This is a test project for unit testing purposes.');
});
it('associates project proposal with current pleb', function () {
Livewire::actingAs($this->pleb)
->test('association.project-support.form.create')
->set('form.name', 'Test Project')
->set('form.description', 'Test description')
->call('save')
->assertHasNoErrors();
$project = ProjectProposal::first();
expect($project->einundzwanzig_pleb_id)->toBe($this->pleb->id);
});

View File

@@ -0,0 +1,109 @@
<?php
use App\Enums\AssociationStatus;
use App\Models\EinundzwanzigPleb;
use App\Models\ProjectProposal;
use Illuminate\Support\Str;
use Livewire\Livewire;
beforeEach(function () {
$this->pleb = EinundzwanzigPleb::query()->create([
'pubkey' => 'test_pubkey_'.Str::random(20),
'npub' => 'test_npub_'.Str::random(20),
'association_status' => AssociationStatus::ACTIVE->value,
]);
// Create payment event for the current year
$this->pleb->paymentEvents()->create([
'year' => date('Y'),
'amount' => 21000,
'paid' => true,
'event_id' => 'test_event_'.Str::random(40),
]);
$this->project = ProjectProposal::query()->create([
'einundzwanzig_pleb_id' => $this->pleb->id,
'name' => 'Original Project',
'description' => 'Original Description',
]);
// Get board member pubkeys from config
$boardPubkeys = config('einundzwanzig.config.current_board', []);
$this->boardMember = EinundzwanzigPleb::query()->create([
'pubkey' => 'board_pubkey_'.Str::random(20),
'npub' => 'board_npub_'.Str::random(20),
'association_status' => AssociationStatus::HONORARY->value,
]);
// Simulate board member by temporarily updating config for testing
config(['einundzwanzig.config.current_board' => [$this->boardMember->npub]]);
});
it('renders edit form for authorized project owners', function () {
Livewire::actingAs($this->pleb)
->test('association.project-support.form.edit', ['project' => $this->project])
->assertStatus(200)
->assertSee('Projektförderung bearbeiten')
->assertSet('form.name', $this->project->name)
->assertSet('form.description', $this->project->description);
});
it('renders edit form for board members', function () {
Livewire::actingAs($this->boardMember)
->test('association.project-support.form.edit', ['project' => $this->project])
->assertStatus(200)
->assertSee('Projektförderung bearbeiten');
});
it('does not render edit form for unauthorized users', function () {
$unauthorizedPleb = EinundzwanzigPleb::query()->create([
'pubkey' => 'test_pubkey_'.Str::random(20),
'npub' => 'test_npub_'.Str::random(20),
'association_status' => AssociationStatus::ACTIVE->value,
]);
Livewire::actingAs($unauthorizedPleb)
->test('association.project-support.form.edit', ['project' => $this->project])
->assertSet('isAllowed', false);
});
it('validates required name field', function () {
Livewire::actingAs($this->pleb)
->test('association.project-support.form.edit', ['project' => $this->project])
->set('form.name', '')
->set('form.description', 'Test description')
->call('update')
->assertHasErrors(['form.name']);
});
it('validates required description field', function () {
Livewire::actingAs($this->pleb)
->test('association.project-support.form.edit', ['project' => $this->project])
->set('form.name', 'Test Project')
->set('form.description', '')
->call('update')
->assertHasErrors(['form.description']);
});
it('updates project proposal successfully', function () {
Livewire::actingAs($this->pleb)
->test('association.project-support.form.edit', ['project' => $this->project])
->set('form.name', 'Updated Name')
->set('form.description', 'Updated Description')
->call('update')
->assertHasNoErrors()
->assertRedirect(route('association.projectSupport.item', $this->project));
$this->project->refresh();
expect($this->project->name)->toBe('Updated Name');
expect($this->project->description)->toBe('Updated Description');
});
it('disables update button during save', function () {
Livewire::actingAs($this->pleb)
->test('association.project-support.form.edit', ['project' => $this->project])
->set('form.name', 'Test')
->set('form.description', 'Test')
->call('update')
->assertSeeHtml('wire:loading');
});