diff --git a/app/Filament/Resources/SettingResource.php b/app/Filament/Resources/SettingResource.php
index 10c38cb..90981fd 100644
--- a/app/Filament/Resources/SettingResource.php
+++ b/app/Filament/Resources/SettingResource.php
@@ -3,10 +3,12 @@
namespace App\Filament\Resources;
use App\Filament\Resources\SettingResource\Pages\ManageSettings;
+use App\Filament\Resources\SettingResource\ValueColumn;
use App\Models\Setting;
+use Filament\Actions\Action;
+use Filament\Forms\Components\FileUpload;
use Filament\Resources\Resource;
use Filament\Tables\Columns\TextColumn;
-use Filament\Tables\Columns\TextInputColumn;
use Filament\Tables\Table;
class SettingResource extends Resource
@@ -16,16 +18,37 @@ class SettingResource extends Resource
public static function table(Table $table): Table
{
+ // Attached to the value column below, so clicking an image setting's cell opens the upload modal.
+ $upload = Action::make('upload')
+ ->schema([
+ FileUpload::make('value')
+ ->label(fn(Setting $record): string => $record->label)
+ ->disk('local')
+ ->directory('settings')
+ ->image()
+ ->orientImagesFromExif(false) // Avoid re-encoding
+ ->acceptedFileTypes(['image/jpeg', 'image/png'])
+ ->imagePreviewHeight('120'),
+ ])
+ ->fillForm(fn(Setting $record): array => ['value' => $record->value])
+ ->action(fn(Setting $record, array $data) => $record->update(['value' => $data['value']]));
+
return $table
->defaultSort('weight', 'asc')
->columns([
TextColumn::make('field')
->label(__('field'))
->state(fn(Setting $record): string => "{$record->label} ({$record->field})")
- ->html(),
- TextInputColumn::make('value')
+ ->html()
+ ->tooltip(fn(?Setting $record): ?string => match ($record?->field) {
+ 'logo' => __('expectedFormat', ['format' => 'JPEG/PNG']) . '. ' . __('squareRatioRecommended'),
+ 'signature' => __('expectedFormat', ['format' => 'JPEG/PNG']),
+ default => null,
+ }),
+ ValueColumn::make('value')
->label(__('value'))
- ->grow(),
+ ->grow()
+ ->imageAction($upload),
])
->paginated(false);
}
diff --git a/app/Filament/Resources/SettingResource/ValueColumn.php b/app/Filament/Resources/SettingResource/ValueColumn.php
new file mode 100644
index 0000000..8954f65
--- /dev/null
+++ b/app/Filament/Resources/SettingResource/ValueColumn.php
@@ -0,0 +1,71 @@
+disabledClick(fn(): bool => ! $this->isImageSetting());
+ }
+
+ public function imageAction(Action $action): static
+ {
+ $this->imageAction = $action;
+
+ return $this;
+ }
+
+ public function getAction(): Action|Closure|null
+ {
+ return $this->imageAction;
+ }
+
+ public function toEmbeddedHtml(): string
+ {
+ if (! $this->isImageSetting()) {
+ return parent::toEmbeddedHtml();
+ }
+
+ $path = $this->getRecord()?->value;
+ // temporaryUrl() is the mechanism Laravel provides for serving a private local-disk file.
+ $url = $path ? Storage::disk('local')->temporaryUrl($path, now()->addMinutes(5)) : null;
+
+ $content = $url
+ ? '
'
+ : '' . e(__('clickToUpload')) . '';
+
+ return <<
+ $content
+
+ HTML;
+ }
+
+ protected function isImageSetting(): bool
+ {
+ $record = $this->getRecord();
+
+ return $record instanceof Setting && $record->type === 'image';
+ }
+}
diff --git a/app/Models/Setting.php b/app/Models/Setting.php
index 762a7a7..87baa67 100644
--- a/app/Models/Setting.php
+++ b/app/Models/Setting.php
@@ -4,6 +4,7 @@
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Facades\Storage;
class Setting extends Model
{
@@ -23,6 +24,18 @@ public static function get(string $field)
return self::find($field)?->value;
}
+ /**
+ * Delete the old file from disk when an image setting's value is replaced or cleared
+ */
+ protected static function booted(): void
+ {
+ static::updating(function (self $setting) {
+ if ($setting->type === 'image' && $setting->isDirty('value') && $setting->getOriginal('value')) {
+ Storage::disk('local')->delete($setting->getOriginal('value'));
+ }
+ });
+ }
+
/**
* Address field
*/
diff --git a/app/Services/PdfTemplate.php b/app/Services/PdfTemplate.php
index b24fe0b..0e6aa6f 100644
--- a/app/Services/PdfTemplate.php
+++ b/app/Services/PdfTemplate.php
@@ -10,6 +10,7 @@
use fpdf\Enums\PdfTextAlignment;
use fpdf\PdfDocument;
use fpdf\Traits\PdfAttachmentTrait;
+use Illuminate\Support\Facades\Storage;
class PdfTemplate extends PdfDocument
{
@@ -42,8 +43,10 @@ public function header(): void
// Title bar
$this->setFillColor(Color::MAIN->pdfColor())
->rect(0, 9, 210, 30, PdfRectangleStyle::FILL);
- // Logo
- $this->image(Setting::get('logo'), 12, 13, 22, 22, 'JPEG');
+ // Logo (JPEG or PNG; type is inferred from the file extension)
+ if (($logo = Setting::get('logo')) && Storage::disk('local')->exists($logo)) {
+ $this->image(Storage::disk('local')->path($logo), 12, 13, height: 22);
+ }
// Title text
$title = strtoupper(
match ($this->type) {
@@ -76,8 +79,10 @@ public function footer(): void
$this->setDrawColor(Color::LINE->pdfColor())
->setLineWidth(0.4)
->line(10, 277, 202, 277);
- // Signature
- $this->image(Setting::get('signature'), 13, 262, 24, 18, 'PNG');
+ // Signature (JPEG or PNG; type is inferred from the file extension)
+ if (($signature = Setting::get('signature')) && Storage::disk('local')->exists($signature)) {
+ $this->image(Storage::disk('local')->path($signature), 13, 262, height: 18);
+ }
// Page number
$this->setY(-26)
->setFontSizeInPoint(9)
diff --git a/database/migrations/2026_09_16_000001_convert_logo_signature_to_image_type.php b/database/migrations/2026_09_16_000001_convert_logo_signature_to_image_type.php
new file mode 100644
index 0000000..32eafcc
--- /dev/null
+++ b/database/migrations/2026_09_16_000001_convert_logo_signature_to_image_type.php
@@ -0,0 +1,22 @@
+update(['type' => 'image']);
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Setting::whereIn('field', ['logo', 'signature'])->update(['type' => 'textarea']);
+ }
+};
diff --git a/lang/de.json b/lang/de.json
index 13345e6..0974a9a 100644
--- a/lang/de.json
+++ b/lang/de.json
@@ -17,6 +17,7 @@
"category": "Kategorie",
"city": "Stadt",
"clickForLatestInvoice": "Klicken um zur aktuellen Rechnung zu springen",
+ "clickToUpload": "Zum Hochladen klicken",
"client": "Kunde|Kunden",
"client.name.hint": "Person oder Firmentitel",
"clientsHours": "Stunden pro Kunde",
@@ -62,6 +63,7 @@
"entry": "Eintrag|Einträge",
"estimate": "Schätzung|Schätzungen",
"estimatedHours": "Geschätzte Stundenanzahl",
+ "expectedFormat": "Erwartetes Format: :format",
"expendedAt": "Ausgegeben",
"expense": "Ausgabe|Ausgaben",
"field": "Feld",
@@ -198,6 +200,7 @@
"sick": "Krank",
"signature": "Unterschrift",
"size": "Größe",
+ "squareRatioRecommended": "Quadratisches Seitenverhältnis empfohlen",
"startAt": "Beginn",
"startedAt": "Begonnen am",
"statementOfWork": "Tätigkeit und Leistungsbeschreibung",
diff --git a/lang/en.json b/lang/en.json
index 0afc342..dd29642 100644
--- a/lang/en.json
+++ b/lang/en.json
@@ -17,6 +17,7 @@
"category": "Category",
"city": "City",
"clickForLatestInvoice": "Click to jump to open invoice",
+ "clickToUpload": "Click to upload",
"client": "Client|Clients",
"client.name.hint": "Person or company title",
"clientsHours": "Hours per client",
@@ -62,6 +63,7 @@
"entry": "Entry|Entries",
"estimate": "Estimate|Estimates",
"estimatedHours": "Estimated hours",
+ "expectedFormat": "Expected format: :format",
"expendedAt": "Expended at",
"expense": "Expense|Expenses",
"field": "Field",
@@ -198,6 +200,7 @@
"sick": "Sick",
"signature": "Signature",
"size": "Size",
+ "squareRatioRecommended": "Square ratio is recommended",
"startAt": "Start at",
"startedAt": "Started at",
"statementOfWork": "Statement of Work",