diff --git a/app/Enum/LandingAnimationPreset.php b/app/Enum/LandingAnimationPreset.php new file mode 100644 index 00000000000..488082c978c --- /dev/null +++ b/app/Enum/LandingAnimationPreset.php @@ -0,0 +1,23 @@ +orderBy('sort_order')->orderBy('created_at')->get(); + + return new LandingFeaturedItemCollection($landing_featured_items); + } + + public function store(StoreLandingFeaturedItemRequest $request): LandingFeaturedItemResource + { + $validated = $request->validated(); + + /** @var LandingFeaturedItem $landing_featured_item */ + $landing_featured_item = LandingFeaturedItem::create($validated); + + return new LandingFeaturedItemResource($landing_featured_item); + } + + public function show(ShowLandingFeaturedItemRequest $request): LandingFeaturedItemResource + { + return new LandingFeaturedItemResource($request->landing_featured_item); + } + + public function update(UpdateLandingFeaturedItemRequest $request): LandingFeaturedItemResource + { + $validated = $request->validated(); + unset($validated['landing_featured_item_id']); + $request->landing_featured_item->fill($validated)->save(); + + return new LandingFeaturedItemResource($request->landing_featured_item); + } + + public function patch(PatchLandingFeaturedItemRequest $request): LandingFeaturedItemResource + { + $validated = $request->validated(); + unset($validated['landing_featured_item_id']); + $request->landing_featured_item->fill($validated)->save(); + + return new LandingFeaturedItemResource($request->landing_featured_item); + } + + public function destroy(DestroyLandingFeaturedItemRequest $request): void + { + $request->landing_featured_item->delete(); + } + + public function reorder(ReorderLandingFeaturedItemRequest $request): LandingFeaturedItemCollection + { + $existing_ids = LandingFeaturedItem::query()->pluck('id')->sort()->values()->all(); + $submitted_ids = collect($request->ids)->sort()->values()->all(); + + if ($existing_ids !== $submitted_ids) { + throw ValidationException::withMessages(['ids' => 'The submitted ids must be the complete set of existing LandingFeaturedItem ids.']); + } + + DB::transaction(function () use ($request): void { + foreach ($request->ids as $index => $id) { + LandingFeaturedItem::query()->whereKey($id)->update(['sort_order' => $index]); + } + }); + + $landing_featured_items = LandingFeaturedItem::query()->orderBy('sort_order')->orderBy('created_at')->get(); + + return new LandingFeaturedItemCollection($landing_featured_items); + } +} diff --git a/app/Http/Controllers/Admin/LandingLinkController.php b/app/Http/Controllers/Admin/LandingLinkController.php new file mode 100644 index 00000000000..0f66e75c556 --- /dev/null +++ b/app/Http/Controllers/Admin/LandingLinkController.php @@ -0,0 +1,91 @@ +orderBy('sort_order')->orderBy('created_at')->get(); + + return new LandingLinkCollection($landing_links); + } + + public function store(StoreLandingLinkRequest $request): LandingLinkResource + { + $validated = $request->validated(); + + /** @var LandingLink $landing_link */ + $landing_link = LandingLink::create($validated); + + return new LandingLinkResource($landing_link); + } + + public function show(ShowLandingLinkRequest $request): LandingLinkResource + { + return new LandingLinkResource($request->landing_link); + } + + public function update(UpdateLandingLinkRequest $request): LandingLinkResource + { + $validated = $request->validated(); + unset($validated['landing_link_id']); + $request->landing_link->fill($validated)->save(); + + return new LandingLinkResource($request->landing_link); + } + + public function patch(PatchLandingLinkRequest $request): LandingLinkResource + { + $validated = $request->validated(); + unset($validated['landing_link_id']); + $request->landing_link->fill($validated)->save(); + + return new LandingLinkResource($request->landing_link); + } + + public function destroy(DestroyLandingLinkRequest $request): void + { + $request->landing_link->delete(); + } + + public function reorder(ReorderLandingLinkRequest $request): LandingLinkCollection + { + $existing_ids = LandingLink::query()->pluck('id')->sort()->values()->all(); + $submitted_ids = collect($request->ids)->sort()->values()->all(); + + if ($existing_ids !== $submitted_ids) { + throw ValidationException::withMessages(['ids' => 'The submitted ids must be the complete set of existing LandingLink ids.']); + } + + DB::transaction(function () use ($request): void { + foreach ($request->ids as $index => $id) { + LandingLink::query()->whereKey($id)->update(['sort_order' => $index]); + } + }); + + $landing_links = LandingLink::query()->orderBy('sort_order')->orderBy('created_at')->get(); + + return new LandingLinkCollection($landing_links); + } +} diff --git a/app/Http/Requests/LandingFeaturedItem/DestroyLandingFeaturedItemRequest.php b/app/Http/Requests/LandingFeaturedItem/DestroyLandingFeaturedItemRequest.php new file mode 100644 index 00000000000..dcd57402fd2 --- /dev/null +++ b/app/Http/Requests/LandingFeaturedItem/DestroyLandingFeaturedItemRequest.php @@ -0,0 +1,43 @@ +may_administrate === true; + } + + protected function prepareForValidation(): void + { + /** @disregard */ + $this->merge(['landing_featured_item_id' => $this->route('landingFeaturedItem')]); + } + + public function rules(): array + { + return ['landing_featured_item_id' => ['required', 'string']]; + } + + protected function processValidatedValues(array $values, array $files): void + { + $this->landing_featured_item = LandingFeaturedItem::findOrFail($values['landing_featured_item_id']); + } +} diff --git a/app/Http/Requests/LandingFeaturedItem/IndexLandingFeaturedItemRequest.php b/app/Http/Requests/LandingFeaturedItem/IndexLandingFeaturedItemRequest.php new file mode 100644 index 00000000000..f3e88de6109 --- /dev/null +++ b/app/Http/Requests/LandingFeaturedItem/IndexLandingFeaturedItemRequest.php @@ -0,0 +1,24 @@ +may_administrate === true; + } +} diff --git a/app/Http/Requests/LandingFeaturedItem/PatchLandingFeaturedItemRequest.php b/app/Http/Requests/LandingFeaturedItem/PatchLandingFeaturedItemRequest.php new file mode 100644 index 00000000000..af4e2be84f2 --- /dev/null +++ b/app/Http/Requests/LandingFeaturedItem/PatchLandingFeaturedItemRequest.php @@ -0,0 +1,54 @@ +may_administrate === true; + } + + protected function prepareForValidation(): void + { + /** @disregard */ + $this->merge(['landing_featured_item_id' => $this->route('landingFeaturedItem')]); + } + + public function rules(): array + { + return [ + 'landing_featured_item_id' => ['required', 'string'], + 'item_type' => ['sometimes', 'required', 'string', new Enum(LandingFeaturedItemType::class)], + 'item_id' => ['sometimes', 'required', 'string', new LandingFeaturedItemExistsRule( + LandingFeaturedItem::find($this->route('landingFeaturedItem'))?->item_type->value, + )], + 'sort_order' => ['sometimes', 'integer'], + 'enabled' => ['sometimes', 'boolean'], + ]; + } + + protected function processValidatedValues(array $values, array $files): void + { + $this->landing_featured_item = LandingFeaturedItem::findOrFail($values['landing_featured_item_id']); + } +} diff --git a/app/Http/Requests/LandingFeaturedItem/ReorderLandingFeaturedItemRequest.php b/app/Http/Requests/LandingFeaturedItem/ReorderLandingFeaturedItemRequest.php new file mode 100644 index 00000000000..50d8d1054f4 --- /dev/null +++ b/app/Http/Requests/LandingFeaturedItem/ReorderLandingFeaturedItemRequest.php @@ -0,0 +1,40 @@ +may_administrate === true; + } + + public function rules(): array + { + return [ + 'ids' => ['required', 'array'], + 'ids.*' => ['required', 'string', 'distinct'], + ]; + } + + protected function processValidatedValues(array $values, array $files): void + { + $this->ids = $values['ids']; + } +} diff --git a/app/Http/Requests/LandingFeaturedItem/ShowLandingFeaturedItemRequest.php b/app/Http/Requests/LandingFeaturedItem/ShowLandingFeaturedItemRequest.php new file mode 100644 index 00000000000..aec722c844b --- /dev/null +++ b/app/Http/Requests/LandingFeaturedItem/ShowLandingFeaturedItemRequest.php @@ -0,0 +1,43 @@ +may_administrate === true; + } + + protected function prepareForValidation(): void + { + /** @disregard */ + $this->merge(['landing_featured_item_id' => $this->route('landingFeaturedItem')]); + } + + public function rules(): array + { + return ['landing_featured_item_id' => ['required', 'string']]; + } + + protected function processValidatedValues(array $values, array $files): void + { + $this->landing_featured_item = LandingFeaturedItem::findOrFail($values['landing_featured_item_id']); + } +} diff --git a/app/Http/Requests/LandingFeaturedItem/StoreLandingFeaturedItemRequest.php b/app/Http/Requests/LandingFeaturedItem/StoreLandingFeaturedItemRequest.php new file mode 100644 index 00000000000..fcc9adf795b --- /dev/null +++ b/app/Http/Requests/LandingFeaturedItem/StoreLandingFeaturedItemRequest.php @@ -0,0 +1,42 @@ +may_administrate === true; + } + + public function rules(): array + { + return [ + 'item_type' => ['required', 'string', new Enum(LandingFeaturedItemType::class)], + 'item_id' => ['required', 'string', new LandingFeaturedItemExistsRule()], + 'sort_order' => ['sometimes', 'integer'], + 'enabled' => ['sometimes', 'boolean'], + ]; + } + + protected function processValidatedValues(array $values, array $files): void + { + // No pre-processing needed; validated values are used directly. + } +} diff --git a/app/Http/Requests/LandingFeaturedItem/UpdateLandingFeaturedItemRequest.php b/app/Http/Requests/LandingFeaturedItem/UpdateLandingFeaturedItemRequest.php new file mode 100644 index 00000000000..ee55cdcada5 --- /dev/null +++ b/app/Http/Requests/LandingFeaturedItem/UpdateLandingFeaturedItemRequest.php @@ -0,0 +1,52 @@ +may_administrate === true; + } + + protected function prepareForValidation(): void + { + /** @disregard */ + $this->merge(['landing_featured_item_id' => $this->route('landingFeaturedItem')]); + } + + public function rules(): array + { + return [ + 'landing_featured_item_id' => ['required', 'string'], + 'item_type' => ['required', 'string', new Enum(LandingFeaturedItemType::class)], + 'item_id' => ['required', 'string', new LandingFeaturedItemExistsRule()], + 'sort_order' => ['sometimes', 'integer'], + 'enabled' => ['sometimes', 'boolean'], + ]; + } + + protected function processValidatedValues(array $values, array $files): void + { + $this->landing_featured_item = LandingFeaturedItem::findOrFail($values['landing_featured_item_id']); + } +} diff --git a/app/Http/Requests/LandingLink/DestroyLandingLinkRequest.php b/app/Http/Requests/LandingLink/DestroyLandingLinkRequest.php new file mode 100644 index 00000000000..0650c1008b9 --- /dev/null +++ b/app/Http/Requests/LandingLink/DestroyLandingLinkRequest.php @@ -0,0 +1,48 @@ +may_administrate === true; + } + + protected function prepareForValidation(): void + { + /** @disregard */ + $this->merge(['landing_link_id' => $this->route('landingLink')]); + } + + public function rules(): array + { + return ['landing_link_id' => ['required', 'string']]; + } + + protected function processValidatedValues(array $values, array $files): void + { + $this->landing_link = LandingLink::findOrFail($values['landing_link_id']); + + if ($this->landing_link->is_built_in) { + throw ValidationException::withMessages(['landing_link_id' => 'This built-in link cannot be deleted.']); + } + } +} diff --git a/app/Http/Requests/LandingLink/IndexLandingLinkRequest.php b/app/Http/Requests/LandingLink/IndexLandingLinkRequest.php new file mode 100644 index 00000000000..f5a8c0541b5 --- /dev/null +++ b/app/Http/Requests/LandingLink/IndexLandingLinkRequest.php @@ -0,0 +1,24 @@ +may_administrate === true; + } +} diff --git a/app/Http/Requests/LandingLink/PatchLandingLinkRequest.php b/app/Http/Requests/LandingLink/PatchLandingLinkRequest.php new file mode 100644 index 00000000000..03a9b3a084b --- /dev/null +++ b/app/Http/Requests/LandingLink/PatchLandingLinkRequest.php @@ -0,0 +1,58 @@ +may_administrate === true; + } + + protected function prepareForValidation(): void + { + /** @disregard */ + $this->merge(['landing_link_id' => $this->route('landingLink')]); + } + + public function rules(): array + { + return [ + 'landing_link_id' => ['required', 'string'], + 'label' => ['sometimes', 'required', 'string', 'max:255'], + 'url' => ['sometimes', 'required', 'string', 'url', 'max:2048'], + 'placement' => ['sometimes', 'required', 'string', new Enum(LandingLinkPlacement::class)], + 'open_in_new_tab' => ['sometimes', 'boolean'], + 'sort_order' => ['sometimes', 'integer'], + 'enabled' => ['sometimes', 'boolean'], + ]; + } + + protected function processValidatedValues(array $values, array $files): void + { + $this->landing_link = LandingLink::findOrFail($values['landing_link_id']); + + if ($this->landing_link->is_built_in && array_key_exists('url', $values) && $values['url'] !== $this->landing_link->url) { + throw ValidationException::withMessages(['url' => 'The URL of a built-in link cannot be changed.']); + } + } +} diff --git a/app/Http/Requests/LandingLink/ReorderLandingLinkRequest.php b/app/Http/Requests/LandingLink/ReorderLandingLinkRequest.php new file mode 100644 index 00000000000..5e720b098c4 --- /dev/null +++ b/app/Http/Requests/LandingLink/ReorderLandingLinkRequest.php @@ -0,0 +1,40 @@ +may_administrate === true; + } + + public function rules(): array + { + return [ + 'ids' => ['required', 'array'], + 'ids.*' => ['required', 'string', 'distinct'], + ]; + } + + protected function processValidatedValues(array $values, array $files): void + { + $this->ids = $values['ids']; + } +} diff --git a/app/Http/Requests/LandingLink/ShowLandingLinkRequest.php b/app/Http/Requests/LandingLink/ShowLandingLinkRequest.php new file mode 100644 index 00000000000..f163b1b9136 --- /dev/null +++ b/app/Http/Requests/LandingLink/ShowLandingLinkRequest.php @@ -0,0 +1,43 @@ +may_administrate === true; + } + + protected function prepareForValidation(): void + { + /** @disregard */ + $this->merge(['landing_link_id' => $this->route('landingLink')]); + } + + public function rules(): array + { + return ['landing_link_id' => ['required', 'string']]; + } + + protected function processValidatedValues(array $values, array $files): void + { + $this->landing_link = LandingLink::findOrFail($values['landing_link_id']); + } +} diff --git a/app/Http/Requests/LandingLink/StoreLandingLinkRequest.php b/app/Http/Requests/LandingLink/StoreLandingLinkRequest.php new file mode 100644 index 00000000000..8a69198e1c1 --- /dev/null +++ b/app/Http/Requests/LandingLink/StoreLandingLinkRequest.php @@ -0,0 +1,43 @@ +may_administrate === true; + } + + public function rules(): array + { + return [ + 'label' => ['required', 'string', 'max:255'], + 'url' => ['required', 'string', 'url', 'max:2048'], + 'placement' => ['required', 'string', new Enum(LandingLinkPlacement::class)], + 'open_in_new_tab' => ['sometimes', 'boolean'], + 'sort_order' => ['sometimes', 'integer'], + 'enabled' => ['sometimes', 'boolean'], + ]; + } + + protected function processValidatedValues(array $values, array $files): void + { + // No pre-processing needed; validated values are used directly. + } +} diff --git a/app/Http/Requests/LandingLink/UpdateLandingLinkRequest.php b/app/Http/Requests/LandingLink/UpdateLandingLinkRequest.php new file mode 100644 index 00000000000..b137a5ce238 --- /dev/null +++ b/app/Http/Requests/LandingLink/UpdateLandingLinkRequest.php @@ -0,0 +1,58 @@ +may_administrate === true; + } + + protected function prepareForValidation(): void + { + /** @disregard */ + $this->merge(['landing_link_id' => $this->route('landingLink')]); + } + + public function rules(): array + { + return [ + 'landing_link_id' => ['required', 'string'], + 'label' => ['required', 'string', 'max:255'], + 'url' => ['required', 'string', 'url', 'max:2048'], + 'placement' => ['required', 'string', new Enum(LandingLinkPlacement::class)], + 'open_in_new_tab' => ['sometimes', 'boolean'], + 'sort_order' => ['sometimes', 'integer'], + 'enabled' => ['sometimes', 'boolean'], + ]; + } + + protected function processValidatedValues(array $values, array $files): void + { + $this->landing_link = LandingLink::findOrFail($values['landing_link_id']); + + if ($this->landing_link->is_built_in && $values['url'] !== $this->landing_link->url) { + throw ValidationException::withMessages(['url' => 'The URL of a built-in link cannot be changed.']); + } + } +} diff --git a/app/Http/Resources/Collections/LandingFeaturedItemCollection.php b/app/Http/Resources/Collections/LandingFeaturedItemCollection.php new file mode 100644 index 00000000000..1c4db01d48f --- /dev/null +++ b/app/Http/Resources/Collections/LandingFeaturedItemCollection.php @@ -0,0 +1,32 @@ + */ + #[LiteralTypeScriptType('App.Http.Resources.Models.LandingFeaturedItemResource[]')] + public Collection $landing_featured_items; + + /** + * @param Collection $landing_featured_items + */ + public function __construct(Collection $landing_featured_items) + { + $this->landing_featured_items = $landing_featured_items->map(fn (LandingFeaturedItem $item) => new LandingFeaturedItemResource($item)); + } +} diff --git a/app/Http/Resources/Collections/LandingLinkCollection.php b/app/Http/Resources/Collections/LandingLinkCollection.php new file mode 100644 index 00000000000..c9ae285f5e8 --- /dev/null +++ b/app/Http/Resources/Collections/LandingLinkCollection.php @@ -0,0 +1,32 @@ + */ + #[LiteralTypeScriptType('App.Http.Resources.Models.LandingLinkResource[]')] + public Collection $landing_links; + + /** + * @param Collection $landing_links + */ + public function __construct(Collection $landing_links) + { + $this->landing_links = $landing_links->map(fn (LandingLink $landing_link) => new LandingLinkResource($landing_link)); + } +} diff --git a/app/Http/Resources/GalleryConfigs/LandingFeaturedContentResource.php b/app/Http/Resources/GalleryConfigs/LandingFeaturedContentResource.php new file mode 100644 index 00000000000..853a4fc707f --- /dev/null +++ b/app/Http/Resources/GalleryConfigs/LandingFeaturedContentResource.php @@ -0,0 +1,59 @@ +item_type = LandingFeaturedItemType::PHOTO; + $this->id = $item->id; + $this->title = $item->title; + $this->thumb_url = $item->size_variants->getThumb()?->url ?? $item->size_variants->getMedium()?->url ?? self::FALLBACK_IMAGE; + $album_id = $item->albums()->first()?->id; + $this->url = $album_id !== null + ? route('gallery', ['albumId' => $album_id, 'photoId' => $item->id]) + : route('gallery'); + $this->num_photos = null; + + return; + } + + $this->item_type = LandingFeaturedItemType::ALBUM; + $this->id = $item->id; + $this->title = $item->title; + $cover_id = $item->cover_id ?? $item->auto_cover_id_least_privilege; + $cover_photo = $cover_id !== null ? Photo::query()->with('size_variants')->find($cover_id) : null; + $this->thumb_url = $cover_photo?->size_variants->getThumb()?->url ?? $cover_photo?->size_variants->getMedium()?->url ?? self::FALLBACK_IMAGE; + $this->url = route('gallery', ['albumId' => $item->id]); + $this->num_photos = $item->num_photos; + } +} diff --git a/app/Http/Resources/GalleryConfigs/LandingLinkEmbedResource.php b/app/Http/Resources/GalleryConfigs/LandingLinkEmbedResource.php new file mode 100644 index 00000000000..5b9306b22a4 --- /dev/null +++ b/app/Http/Resources/GalleryConfigs/LandingLinkEmbedResource.php @@ -0,0 +1,43 @@ +id = $landing_link->id; + $this->label = $landing_link->label; + $this->url = $landing_link->url; + $this->placement = $landing_link->placement; + $this->open_in_new_tab = $landing_link->open_in_new_tab; + $this->is_built_in = $landing_link->is_built_in; + } +} diff --git a/app/Http/Resources/GalleryConfigs/LandingPageResource.php b/app/Http/Resources/GalleryConfigs/LandingPageResource.php index f434ceb487a..81c393e9896 100644 --- a/app/Http/Resources/GalleryConfigs/LandingPageResource.php +++ b/app/Http/Resources/GalleryConfigs/LandingPageResource.php @@ -8,11 +8,23 @@ namespace App\Http\Resources\GalleryConfigs; +use App\Enum\LandingAnimationPreset; use App\Enum\LandingBackgroundModeType; +use App\Enum\LandingCtaPosition; +use App\Enum\LandingFeaturedItemsMode; +use App\Enum\LandingFeaturedItemType; +use App\Enum\LandingLayoutType; +use App\Enum\LandingTextPosition; +use App\Enum\ShiftType; +use App\Enum\ShiftX; +use App\Enum\ShiftY; use App\Models\Album; +use App\Models\LandingFeaturedItem; +use App\Models\LandingLink; use App\Models\Photo; use App\Policies\AlbumQueryPolicy; use App\Policies\PhotoQueryPolicy; +use LycheeVerify\Verify; use Spatie\LaravelData\Data; use Spatie\TypeScriptTransformer\Attributes\TypeScript; @@ -30,8 +42,36 @@ class LandingPageResource extends Data public string $landing_header_logo; public FooterConfig $footer; + public LandingLayoutType $layout; + public bool $intro_screen_enabled; + public LandingTextPosition $hero_text_position; + public string $hero_text_color; + public int $hero_text_opacity; + public LandingAnimationPreset $animation_preset; + public bool $about_enabled; + public string $about_text; + public bool $featured_items_enabled; + public LandingFeaturedItemsMode $featured_items_mode; + /** @var LandingFeaturedContentResource[] */ + public array $featured_items; + /** @var LandingLinkEmbedResource[] */ + public array $links; + public string $cta_text; + public LandingCtaPosition $cta_position; + public ShiftType $cta_shift_type; + public int $cta_shift_x; + public ShiftX $cta_shift_x_direction; + public int $cta_shift_y; + public ShiftY $cta_shift_y_direction; + private const FALLBACK_IMAGE = 'dist/cat.webp'; + /** SE-only landing layouts. Non-SE requesters silently fall back to `classic`. */ + private const SE_LAYOUTS = [LandingLayoutType::PORTFOLIO, LandingLayoutType::MERIDIAN, LandingLayoutType::STUDIO]; + + /** SE-only animation presets. Non-SE requesters silently fall back to `classic_fade`. */ + private const SE_ANIMATION_PRESETS = [LandingAnimationPreset::ZOOM_IN, LandingAnimationPreset::PARALLAX_SCROLL, LandingAnimationPreset::SLIDE_REVEAL]; + public function __construct() { $this->footer = new FooterConfig(); @@ -52,6 +92,126 @@ public function __construct() $this->site_title = request()->configs()->getValueAsString('site_title'); $this->landing_logo = request()->configs()->getValueAsString('landing_logo'); $this->landing_header_logo = request()->configs()->getValueAsString('landing_header_logo'); + + $is_se_enabled = $this->isSeEnabled(); + + $stored_layout = request()->configs()->getValueAsEnum('landing_layout', LandingLayoutType::class) ?? LandingLayoutType::CLASSIC; + $this->layout = ($is_se_enabled || !in_array($stored_layout, self::SE_LAYOUTS, true)) ? $stored_layout : LandingLayoutType::CLASSIC; + + $stored_animation_preset = request()->configs()->getValueAsEnum('landing_animation_preset', LandingAnimationPreset::class) ?? LandingAnimationPreset::CLASSIC_FADE; + $this->animation_preset = ($is_se_enabled || !in_array($stored_animation_preset, self::SE_ANIMATION_PRESETS, true)) + ? $stored_animation_preset + : LandingAnimationPreset::CLASSIC_FADE; + + $this->intro_screen_enabled = request()->configs()->getValueAsBool('landing_intro_screen_enabled'); + $this->hero_text_position = request()->configs()->getValueAsEnum('landing_hero_text_position', LandingTextPosition::class) ?? LandingTextPosition::CENTER; + $this->hero_text_color = request()->configs()->getValueAsString('landing_hero_text_color'); + $this->hero_text_opacity = request()->configs()->getValueAsInt('landing_hero_text_opacity'); + + $this->about_enabled = request()->configs()->getValueAsBool('landing_about_enabled'); + $this->about_text = request()->configs()->getValueAsString('landing_about_text'); + + $this->cta_text = request()->configs()->getValueAsString('landing_cta_text'); + $this->cta_position = request()->configs()->getValueAsEnum('landing_cta_position', LandingCtaPosition::class) ?? LandingCtaPosition::BOTTOM; + $this->cta_shift_type = request()->configs()->getValueAsEnum('landing_cta_shift_type', ShiftType::class) ?? ShiftType::RELATIVE; + $this->cta_shift_x = request()->configs()->getValueAsInt('landing_cta_shift_x'); + $this->cta_shift_x_direction = request()->configs()->getValueAsEnum('landing_cta_shift_x_direction', ShiftX::class) ?? ShiftX::RIGHT; + $this->cta_shift_y = request()->configs()->getValueAsInt('landing_cta_shift_y'); + $this->cta_shift_y_direction = request()->configs()->getValueAsEnum('landing_cta_shift_y_direction', ShiftY::class) ?? ShiftY::UP; + + $this->links = LandingLink::query()->enabled()->orderBy('sort_order')->get() + ->map(fn (LandingLink $landing_link) => new LandingLinkEmbedResource($landing_link)) + ->all(); + + // Featured content is SE-gated in its entirety (Goal 6 / FR-054-09). + $this->featured_items_enabled = $is_se_enabled && request()->configs()->getValueAsBool('landing_featured_items_enabled'); + + $stored_featured_items_mode = request()->configs()->getValueAsEnum('landing_featured_items_mode', LandingFeaturedItemsMode::class) ?? LandingFeaturedItemsMode::AUTOMATIC; + $this->featured_items_mode = ($is_se_enabled || $stored_featured_items_mode !== LandingFeaturedItemsMode::MANUAL) + ? $stored_featured_items_mode + : LandingFeaturedItemsMode::AUTOMATIC; + + $this->featured_items = $this->featured_items_enabled ? $this->resolveFeaturedItems($this->featured_items_mode) : []; + } + + /** + * Mirrors `InitConfig::set_supporter_properties()`'s `is_se_enabled` derivation. + */ + private function isSeEnabled(): bool + { + $verify = request()->verify(); + + return $verify instanceof Verify && $verify->validate() && $verify->is_supporter(); + } + + /** + * @return LandingFeaturedContentResource[] + */ + private function resolveFeaturedItems(LandingFeaturedItemsMode $mode): array + { + try { + return match ($mode) { + LandingFeaturedItemsMode::AUTOMATIC => $this->resolveAutomaticFeaturedItems(), + LandingFeaturedItemsMode::MANUAL => $this->resolveManualFeaturedItems(), + }; + } catch (\Throwable $e) { + \Log::notice('Landing featured-items resolution failed', [ + 'mode' => $mode->value, + 'error' => $e->getMessage(), + ]); + + return []; + } + } + + /** + * Most-recently-published public albums, same query shape as + * `resolveLatestAlbumCover()` (Feature 025). + * + * @return LandingFeaturedContentResource[] + */ + private function resolveAutomaticFeaturedItems(): array + { + $count = request()->configs()->getValueAsInt('landing_featured_items_count'); + + $album_query_policy = resolve(AlbumQueryPolicy::class); + $query = Album::query()->with(['cover.size_variants', 'min_privilege_cover.size_variants']); + $query = $album_query_policy->applyVisibilityFilter($query, null); + + $albums = $query + ->orderBy('published_at', 'DESC') + ->orderBy('created_at', 'DESC') + ->orderBy('id', 'DESC') + ->limit($count) + ->get(); + + return $albums->map(fn (Album $album) => new LandingFeaturedContentResource($album))->all(); + } + + /** + * Admin-curated photos/albums, resolved by direct lookup on `item_id` + * without a visibility-policy check (admin-trusted, mirrors + * `resolvePhotoById()`'s precedent). Missing/deleted items are skipped. + * + * @return LandingFeaturedContentResource[] + */ + private function resolveManualFeaturedItems(): array + { + $items = LandingFeaturedItem::query()->enabled()->orderBy('sort_order')->get(); + + $resolved = []; + foreach ($items as $item) { + $model = match ($item->item_type) { + LandingFeaturedItemType::PHOTO => Photo::query()->with('size_variants')->find($item->item_id), + LandingFeaturedItemType::ALBUM => Album::query()->with(['cover.size_variants', 'min_privilege_cover.size_variants'])->find($item->item_id), + }; + + if ($model !== null) { + $resolved[] = new LandingFeaturedContentResource($model); + } + } + + return $resolved; } /** diff --git a/app/Http/Resources/Models/LandingFeaturedItemResource.php b/app/Http/Resources/Models/LandingFeaturedItemResource.php new file mode 100644 index 00000000000..1a427e72843 --- /dev/null +++ b/app/Http/Resources/Models/LandingFeaturedItemResource.php @@ -0,0 +1,41 @@ +id = $landing_featured_item->id; + $this->item_type = $landing_featured_item->item_type; + $this->item_id = $landing_featured_item->item_id; + $this->sort_order = $landing_featured_item->sort_order; + $this->enabled = $landing_featured_item->enabled; + $this->created_at = $landing_featured_item->created_at; + $this->updated_at = $landing_featured_item->updated_at; + } +} diff --git a/app/Http/Resources/Models/LandingLinkResource.php b/app/Http/Resources/Models/LandingLinkResource.php new file mode 100644 index 00000000000..23b5a1cdc69 --- /dev/null +++ b/app/Http/Resources/Models/LandingLinkResource.php @@ -0,0 +1,44 @@ +id = $landing_link->id; + $this->label = $landing_link->label; + $this->url = $landing_link->url; + $this->placement = $landing_link->placement; + $this->open_in_new_tab = $landing_link->open_in_new_tab; + $this->sort_order = $landing_link->sort_order; + $this->enabled = $landing_link->enabled; + $this->is_built_in = $landing_link->is_built_in; + $this->created_at = $landing_link->created_at; + $this->updated_at = $landing_link->updated_at; + } +} diff --git a/app/Http/Resources/Rights/ModulesRightsResource.php b/app/Http/Resources/Rights/ModulesRightsResource.php index 2c1c439802c..16998601434 100644 --- a/app/Http/Resources/Rights/ModulesRightsResource.php +++ b/app/Http/Resources/Rights/ModulesRightsResource.php @@ -27,6 +27,7 @@ class ModulesRightsResource extends Data public bool $is_mod_frame_enabled = false; public bool $is_mod_flow_enabled = false; public bool $is_watermarker_enabled = false; + public bool $is_watermarker_available = false; public bool $is_photo_timeline_enabled = false; public bool $is_mod_renamer_enabled = false; public bool $is_mod_webshop_enabled = false; @@ -47,6 +48,7 @@ public function __construct() $this->is_mod_frame_enabled = $this->isModFrameEnabled(); $this->is_mod_flow_enabled = $this->isModFlowEnabled($is_logged_in); $this->is_watermarker_enabled = $this->isWatermarkerEnabled($is_logged_in); + $this->is_watermarker_available = $this->isWatermarkerAvailable($is_logged_in); $this->is_photo_timeline_enabled = $this->isTimelinePhotosEnabled($is_logged_in); $this->is_mod_renamer_enabled = $this->isRenamerEnabled(); $this->is_mod_webshop_enabled = $this->isWebshopEnabled(); @@ -157,6 +159,29 @@ private function isWatermarkerEnabled(bool $is_logged_in): bool return resolve(Watermarker::class)->can_watermark(); } + /** + * Check if the watermarker module is configured (config toggle + Imagick) and accessible to + * the current user, regardless of whether a watermark photo has been picked yet. + * + * Unlike {@see isWatermarkerEnabled()}, this does not require a watermark photo to already be + * selected. It drives visibility of the watermarker configuration tile/page, where the admin + * is meant to pick that photo in the first place. + * + * @return bool true if the watermarker module is configured and accessible, false otherwise + */ + private function isWatermarkerAvailable(bool $is_logged_in): bool + { + if (!$is_logged_in) { + return false; + } + + if (!request()->verify()->check()) { + return false; + } + + return resolve(Watermarker::class)->is_module_enabled(); + } + /** * Check if the renamer module is enabled and accessible to the current user. * diff --git a/app/Image/Watermarker.php b/app/Image/Watermarker.php index ee38f124ee5..31b66af1a8e 100644 --- a/app/Image/Watermarker.php +++ b/app/Image/Watermarker.php @@ -47,23 +47,35 @@ public function __construct() } /** - * Check if configuration is set to allow watermark. + * Check if the watermarker module is enabled and technically usable (config toggle + * and Imagick both on), regardless of whether a watermark photo has been picked yet. * * @return bool */ - private function is_watermark_enabled(): bool + public function is_module_enabled(): bool { $config_manager = resolve(ConfigManager::class); $is_enabled = $config_manager->getValueAsBool('watermark_enabled'); $is_imagick_enabled = $config_manager->getValueAsBool('imagick'); $is_imagick_loaded = extension_loaded('imagick'); - if (!$is_enabled || !$is_imagick_enabled || !$is_imagick_loaded) { + return $is_enabled && $is_imagick_enabled && $is_imagick_loaded; + } + + /** + * Check if configuration is set to allow watermark. + * + * @return bool + */ + private function is_watermark_enabled(): bool + { + if (!$this->is_module_enabled()) { // If watermarking is not enabled or Imagick is not available, we cannot watermark // Exit now. return false; } + $config_manager = resolve(ConfigManager::class); $this->watermark_photo_id = $config_manager->getValueAsString('watermark_photo_id'); if ($this->watermark_photo_id === '') { // Watermark photo ID is not set, we cannot watermark diff --git a/app/Models/Configs.php b/app/Models/Configs.php index 46fd65807f5..2832c244bb9 100644 --- a/app/Models/Configs.php +++ b/app/Models/Configs.php @@ -103,6 +103,18 @@ public function sanity(?string $candidate_value, ?string $message_template = nul ]; $message_template ??= 'Error: Wrong property for ' . $this->key . ', expected %s, got ' . ($candidate_value ?? 'NULL') . '.'; + + // Bounded integer range, e.g. `int:0:100`. Not part of ConfigType as it is parameterised per key. + if (preg_match('/^int:(\d+):(\d+)$/', $this->type_range, $bounds) === 1) { + $min = intval($bounds[1]); + $max = intval($bounds[2]); + if (!ctype_digit(strval($candidate_value)) || intval($candidate_value) < $min || intval($candidate_value) > $max) { + $message = sprintf($message_template, "an integer between {$min} and {$max}"); + } + + return $message; + } + switch ($this->type_range) { case ConfigType::STRING->value: case ConfigType::DISABLED->value: diff --git a/app/Models/LandingFeaturedItem.php b/app/Models/LandingFeaturedItem.php new file mode 100644 index 00000000000..60b964b506e --- /dev/null +++ b/app/Models/LandingFeaturedItem.php @@ -0,0 +1,73 @@ + */ + use HasFactory; + use UTCBasedTimes; + use ThrowsConsistentExceptions; + + public $incrementing = false; + protected $keyType = 'string'; + + protected $fillable = [ + 'item_type', 'item_id', 'sort_order', 'enabled', + ]; + + /** @var array */ + protected $attributes = [ + 'sort_order' => 0, + 'enabled' => true, + ]; + + protected $casts = [ + 'item_type' => LandingFeaturedItemType::class, + 'sort_order' => 'integer', + 'enabled' => 'boolean', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + protected static function boot(): void + { + parent::boot(); + + static::creating(function (LandingFeaturedItem $landing_featured_item): void { + if ($landing_featured_item->id === null || $landing_featured_item->id === '') { + $landing_featured_item->id = (string) \Illuminate\Support\Str::ulid(); + } + }); + } + + public function scopeEnabled(Builder $query): Builder + { + return $query->where('enabled', '=', true); + } +} diff --git a/app/Models/LandingLink.php b/app/Models/LandingLink.php new file mode 100644 index 00000000000..dda6f8ac08e --- /dev/null +++ b/app/Models/LandingLink.php @@ -0,0 +1,85 @@ + */ + use HasFactory; + use UTCBasedTimes; + use ThrowsConsistentExceptions; + + public $incrementing = false; + protected $keyType = 'string'; + + /** + * `is_built_in` is intentionally excluded: it marks the built-in + * "Gallery"/"Contact" rows and must never be settable through mass + * assignment from the Store/Update API requests. + */ + protected $fillable = [ + 'label', 'url', 'placement', 'open_in_new_tab', 'sort_order', 'enabled', + ]; + + /** @var array */ + protected $attributes = [ + 'open_in_new_tab' => true, + 'sort_order' => 0, + 'enabled' => true, + 'is_built_in' => false, + ]; + + protected $casts = [ + 'placement' => LandingLinkPlacement::class, + 'open_in_new_tab' => 'boolean', + 'sort_order' => 'integer', + 'enabled' => 'boolean', + 'is_built_in' => 'boolean', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + protected static function boot(): void + { + parent::boot(); + + static::creating(function (LandingLink $landing_link): void { + if ($landing_link->id === null || $landing_link->id === '') { + $landing_link->id = (string) \Illuminate\Support\Str::ulid(); + } + }); + } + + public function scopeEnabled(Builder $query): Builder + { + return $query->where('enabled', '=', true); + } +} diff --git a/app/Rules/LandingFeaturedItemExistsRule.php b/app/Rules/LandingFeaturedItemExistsRule.php new file mode 100644 index 00000000000..787960ea9fc --- /dev/null +++ b/app/Rules/LandingFeaturedItemExistsRule.php @@ -0,0 +1,58 @@ + */ + private array $data = []; + + public function __construct(private readonly ?string $fallback_item_type = null) + { + } + + /** + * {@inheritDoc} + */ + public function setData(array $data): static + { + $this->data = $data; + + return $this; + } + + /** + * {@inheritDoc} + */ + public function validate(string $attribute, mixed $value, \Closure $fail): void + { + $type = $this->data['item_type'] ?? $this->fallback_item_type; + + $exists = match ($type) { + LandingFeaturedItemType::PHOTO->value => Photo::query()->whereKey($value)->exists(), + LandingFeaturedItemType::ALBUM->value => Album::query()->whereKey($value)->exists(), + default => false, + }; + + if (!$exists) { + $fail('The selected :attribute does not reference an existing item of the given item_type.'); + } + } +} diff --git a/database/factories/LandingFeaturedItemFactory.php b/database/factories/LandingFeaturedItemFactory.php new file mode 100644 index 00000000000..1edf6c58101 --- /dev/null +++ b/database/factories/LandingFeaturedItemFactory.php @@ -0,0 +1,47 @@ + + */ +class LandingFeaturedItemFactory extends Factory +{ + protected $model = LandingFeaturedItem::class; + + public function definition(): array + { + return [ + 'item_type' => LandingFeaturedItemType::ALBUM, + 'item_id' => (string) Str::ulid(), + 'sort_order' => 0, + 'enabled' => true, + ]; + } + + public function disabled(): static + { + return $this->state(fn (array $attributes) => ['enabled' => false]); + } + + public function photo(string $photo_id): static + { + return $this->state(fn (array $attributes) => ['item_type' => LandingFeaturedItemType::PHOTO, 'item_id' => $photo_id]); + } + + public function album(string $album_id): static + { + return $this->state(fn (array $attributes) => ['item_type' => LandingFeaturedItemType::ALBUM, 'item_id' => $album_id]); + } +} diff --git a/database/factories/LandingLinkFactory.php b/database/factories/LandingLinkFactory.php new file mode 100644 index 00000000000..299ef5791f0 --- /dev/null +++ b/database/factories/LandingLinkFactory.php @@ -0,0 +1,58 @@ + + */ +class LandingLinkFactory extends Factory +{ + protected $model = LandingLink::class; + + public function definition(): array + { + return [ + 'label' => $this->faker->words(2, true), + 'url' => $this->faker->url(), + 'placement' => $this->faker->randomElement(LandingLinkPlacement::cases()), + 'open_in_new_tab' => true, + 'sort_order' => 0, + 'enabled' => true, + ]; + } + + public function disabled(): static + { + return $this->state(fn (array $attributes) => ['enabled' => false]); + } + + public function nav(): static + { + return $this->state(fn (array $attributes) => ['placement' => LandingLinkPlacement::NAV]); + } + + public function footer(): static + { + return $this->state(fn (array $attributes) => ['placement' => LandingLinkPlacement::FOOTER]); + } + + public function both(): static + { + return $this->state(fn (array $attributes) => ['placement' => LandingLinkPlacement::BOTH]); + } + + public function builtIn(): static + { + return $this->state(fn (array $attributes) => ['is_built_in' => true]); + } +} diff --git a/database/migrations/2026_08_11_000000_add_landing_page_configs.php b/database/migrations/2026_08_11_000000_add_landing_page_configs.php new file mode 100644 index 00000000000..f7bf85233c5 --- /dev/null +++ b/database/migrations/2026_08_11_000000_add_landing_page_configs.php @@ -0,0 +1,245 @@ + 'landing_layout', + 'value' => 'classic', + 'cat' => self::MOD_WELCOME, + 'type_range' => self::LAYOUT, + 'description' => 'Landing page layout', + 'details' => 'Options: classic (default, free), portfolio/meridian/studio (require Lychee SE).', + 'is_secret' => false, + 'is_expert' => false, + 'level' => 0, + 'order' => 13, + ], + [ + 'key' => 'landing_intro_screen_enabled', + 'value' => '1', + 'cat' => self::MOD_WELCOME, + 'type_range' => self::BOOL, + 'description' => 'Enable the animated intro splash screen', + 'details' => 'Applies to every layout. Disable to skip straight to the hero.', + 'is_secret' => false, + 'is_expert' => false, + 'level' => 0, + 'order' => 14, + ], + [ + 'key' => 'landing_hero_text_position', + 'value' => 'center', + 'cat' => self::MOD_WELCOME, + 'type_range' => self::TEXT_POSITION, + 'description' => 'Hero headline/subtitle/CTA position', + 'details' => 'Applies to the portfolio layout.', + 'is_secret' => false, + 'is_expert' => false, + 'level' => 0, + 'order' => 15, + ], + [ + 'key' => 'landing_hero_text_color', + 'value' => '', + 'cat' => self::MOD_WELCOME, + 'type_range' => 'color', + 'description' => 'Hero headline/subtitle text color', + 'details' => 'Leave empty to use the default white. Applies to the headline and subtitle text on the portfolio and studio layouts.', + 'is_secret' => false, + 'is_expert' => false, + 'level' => 0, + 'order' => 16, + ], + [ + 'key' => 'landing_hero_text_opacity', + 'value' => '100', + 'cat' => self::MOD_WELCOME, + 'type_range' => self::OPACITY_RANGE, + 'description' => 'Hero headline/subtitle text opacity (%)', + 'details' => 'Range 0-100. Applies to the headline and subtitle text on the portfolio and studio layouts.', + 'is_secret' => false, + 'is_expert' => false, + 'level' => 0, + 'order' => 17, + ], + [ + 'key' => 'landing_animation_preset', + 'value' => 'classic_fade', + 'cat' => self::MOD_WELCOME, + 'type_range' => self::ANIMATION_PRESET, + 'description' => 'Landing page animation preset', + 'details' => 'Options: none/classic_fade (free), zoom_in/parallax_scroll/slide_reveal (require Lychee SE).', + 'is_secret' => false, + 'is_expert' => false, + 'level' => 0, + 'order' => 18, + ], + [ + 'key' => 'landing_about_enabled', + 'value' => '0', + 'cat' => self::MOD_WELCOME, + 'type_range' => self::BOOL, + 'description' => 'Enable the about section', + 'details' => 'Applies to the portfolio and studio layouts.', + 'is_secret' => false, + 'is_expert' => false, + 'level' => 0, + 'order' => 19, + ], + [ + 'key' => 'landing_about_text', + 'value' => '', + 'cat' => self::MOD_WELCOME, + 'type_range' => self::STRING, + 'description' => 'About section text', + 'details' => 'Admin-authored HTML, rendered verbatim (same trust model as the footer additional text).', + 'is_secret' => false, + 'is_expert' => false, + 'level' => 0, + 'order' => 20, + ], + [ + 'key' => 'landing_featured_items_enabled', + 'value' => '0', + 'cat' => self::MOD_WELCOME, + 'type_range' => self::BOOL, + 'description' => 'Enable the featured content section (SE)', + 'details' => 'Applies to the portfolio layout only. Requires Lychee SE to take effect.', + 'is_secret' => false, + 'is_expert' => false, + 'level' => 0, + 'order' => 21, + ], + [ + 'key' => 'landing_featured_items_mode', + 'value' => 'automatic', + 'cat' => self::MOD_WELCOME, + 'type_range' => self::FEATURED_ITEMS_MODE, + 'description' => 'Featured content mode', + 'details' => 'Automatic: most recently published public albums. Manual: admin-curated photos/albums.', + 'is_secret' => false, + 'is_expert' => false, + 'level' => 0, + 'order' => 22, + ], + [ + 'key' => 'landing_featured_items_count', + 'value' => '6', + 'cat' => self::MOD_WELCOME, + 'type_range' => self::FEATURED_COUNT_RANGE, + 'description' => 'Number of automatic featured items', + 'details' => 'Range 3-12. Only used in automatic featured content mode.', + 'is_secret' => false, + 'is_expert' => false, + 'level' => 0, + 'order' => 23, + ], + [ + 'key' => 'landing_cta_text', + 'value' => '', + 'cat' => self::MOD_WELCOME, + 'type_range' => self::STRING, + 'description' => 'Primary call-to-action text override', + 'details' => 'Leave empty to use each layout\'s default label.', + 'is_secret' => false, + 'is_expert' => false, + 'level' => 0, + 'order' => 24, + ], + [ + 'key' => 'landing_cta_position', + 'value' => 'bottom', + 'cat' => self::MOD_WELCOME, + 'type_range' => self::CTA_POSITION, + 'description' => 'Call-to-action button anchor position', + 'details' => 'Applies to the classic, meridian, studio and portfolio layouts, independently of the hero text position.', + 'is_secret' => false, + 'is_expert' => false, + 'level' => 0, + 'order' => 25, + ], + [ + 'key' => 'landing_cta_shift_type', + 'value' => 'relative', + 'cat' => self::MOD_WELCOME, + 'type_range' => self::SHIFT_TYPE, + 'description' => 'Shift the CTA relatively to the viewport', + 'details' => 'When using relative, the CTA will be shifted proportionally to the size of the viewport.
When using absolute the CTA will be shifted by a quantity of pixels.', + 'is_secret' => false, + 'is_expert' => true, + 'level' => 0, + 'order' => 26, + ], + [ + 'key' => 'landing_cta_shift_x', + 'value' => '0', + 'cat' => self::MOD_WELCOME, + 'type_range' => self::INT, + 'description' => 'CTA horizontal shift', + 'details' => 'Number of pixel/proportional translation applied horizontally to the CTA anchor.', + 'is_secret' => false, + 'is_expert' => true, + 'level' => 0, + 'order' => 27, + ], + [ + 'key' => 'landing_cta_shift_x_direction', + 'value' => 'right', + 'cat' => self::MOD_WELCOME, + 'type_range' => self::SHIFT_X_DIRECTION, + 'description' => 'Direction of the CTA horizontal shift', + 'details' => 'Direction of the translation applied to the CTA: to the left or to the right?', + 'is_secret' => false, + 'is_expert' => true, + 'level' => 0, + 'order' => 28, + ], + [ + 'key' => 'landing_cta_shift_y', + 'value' => '30', + 'cat' => self::MOD_WELCOME, + 'type_range' => self::INT, + 'description' => 'CTA vertical shift', + 'details' => 'Number of pixel/proportional translation applied vertically to the CTA anchor.', + 'is_secret' => false, + 'is_expert' => true, + 'level' => 0, + 'order' => 29, + ], + [ + 'key' => 'landing_cta_shift_y_direction', + 'value' => 'up', + 'cat' => self::MOD_WELCOME, + 'type_range' => self::SHIFT_Y_DIRECTION, + 'description' => 'Direction of the CTA vertical shift', + 'details' => 'Direction of the translation applied to the CTA: up or down?', + 'is_secret' => false, + 'is_expert' => true, + 'level' => 0, + 'order' => 30, + ], + ]; + } +}; diff --git a/database/migrations/2026_08_11_000001_create_landing_links_table.php b/database/migrations/2026_08_11_000001_create_landing_links_table.php new file mode 100644 index 00000000000..820506b84d7 --- /dev/null +++ b/database/migrations/2026_08_11_000001_create_landing_links_table.php @@ -0,0 +1,36 @@ +ulid('id')->primary(); + $table->string('label', 255); + $table->string('url', 2048); + $table->string('placement', 20); // LandingLinkPlacement: nav | footer | both + $table->boolean('open_in_new_tab')->default(true); + $table->integer('sort_order')->default(0); + $table->boolean('enabled')->default(true); + $table->dateTime('created_at', 6)->nullable(false); + $table->dateTime('updated_at', 6)->nullable(false); + + $table->index('enabled'); + $table->index('placement'); + }); + } + + public function down(): void + { + Schema::dropIfExists('landing_links'); + } +}; diff --git a/database/migrations/2026_08_11_000002_create_landing_featured_items_table.php b/database/migrations/2026_08_11_000002_create_landing_featured_items_table.php new file mode 100644 index 00000000000..b7142927818 --- /dev/null +++ b/database/migrations/2026_08_11_000002_create_landing_featured_items_table.php @@ -0,0 +1,34 @@ +ulid('id')->primary(); + $table->string('item_type', 10); // LandingFeaturedItemType: photo | album + $table->string('item_id'); + $table->integer('sort_order')->default(0); + $table->boolean('enabled')->default(true); + $table->dateTime('created_at', 6)->nullable(false); + $table->dateTime('updated_at', 6)->nullable(false); + + $table->index('enabled'); + $table->index('item_type'); + }); + } + + public function down(): void + { + Schema::dropIfExists('landing_featured_items'); + } +}; diff --git a/database/migrations/2026_08_14_000000_add_is_built_in_to_landing_links_table.php b/database/migrations/2026_08_14_000000_add_is_built_in_to_landing_links_table.php new file mode 100644 index 00000000000..cf2cc068239 --- /dev/null +++ b/database/migrations/2026_08_14_000000_add_is_built_in_to_landing_links_table.php @@ -0,0 +1,83 @@ +boolean('is_built_in')->default(false)->after('enabled'); + }); + + // The URL column holds a Vue Router route name (not a real URL) for + // built-in rows; the frontend resolves it client-side. This avoids + // baking APP_URL into stored data, which is unreliable (e.g. it may + // change between environments or not match how the app is actually + // accessed). + $this->seedBuiltIn('Gallery', 'home'); + $this->seedBuiltIn('Contact', 'contact'); + + // A pre-release, superseded version of this migration added a + // `is_gallery_link` column on some local databases; clean it up if present. + if (Schema::hasColumn('landing_links', 'is_gallery_link')) { + Schema::table('landing_links', function (Blueprint $table): void { + $table->dropColumn('is_gallery_link'); + }); + } + } + + public function down(): void + { + DB::table('landing_links')->whereIn('url', ['home', 'contact'])->where('is_built_in', '=', true)->delete(); + + Schema::table('landing_links', function (Blueprint $table): void { + $table->dropColumn('is_built_in'); + }); + } + + /** + * Seeds the built-in, non-deletable row so it can be ordered and + * shown/hidden alongside admin-created links, same as any other row. + * + * Idempotent by `url`: if a matching row already exists (e.g. seeded by + * the superseded pre-release migration this one replaces), it is simply + * flagged as built-in instead of duplicated. + */ + private function seedBuiltIn(string $label, string $url): void + { + $existing = DB::table('landing_links')->where('url', '=', $url)->first(); + + if ($existing !== null) { + DB::table('landing_links')->where('id', '=', $existing->id)->update(['is_built_in' => true]); + + return; + } + + $max_sort_order = DB::table('landing_links')->max('sort_order'); + $next_sort_order = $max_sort_order === null ? 0 : ((int) $max_sort_order) + 1; + $now = now(); + + DB::table('landing_links')->insert([ + 'id' => (string) Str::ulid(), + 'label' => $label, + 'url' => $url, + 'placement' => 'nav', + 'open_in_new_tab' => false, + 'sort_order' => $next_sort_order, + 'enabled' => true, + 'is_built_in' => true, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } +}; diff --git a/docs/specs/4-architecture/features/054-configurable-landing-page/plan.md b/docs/specs/4-architecture/features/054-configurable-landing-page/plan.md new file mode 100644 index 00000000000..601010fbe78 --- /dev/null +++ b/docs/specs/4-architecture/features/054-configurable-landing-page/plan.md @@ -0,0 +1,235 @@ +# Feature Plan 054 – Configurable Landing Page + +_Linked specification:_ `docs/specs/4-architecture/features/054-configurable-landing-page/spec.md` +_Status:_ Draft +_Last updated:_ 2026-08-11 + +> Guardrail: Keep this plan traceable back to the governing spec. Reference FR/NFR/Scenario IDs from `spec.md` where relevant, log any new high- or medium-impact questions in [docs/specs/4-architecture/open-questions.md](../../open-questions.md), and assume clarifications are resolved only when the spec's normative sections have been updated. + +## Vision & Success Criteria + +An admin can pick a landing-page layout (`classic`/`portfolio`/`minimal`/`studio`), reposition the hero text, pick an animation preset, toggle content blocks (intro splash, about text, featured content — automatic or manually curated), and manage an arbitrary list of extra links — all through the existing flat Settings list and a dedicated admin page with a live preview, with zero code changes required per install. Success signals: +- `classic` with all new configs at their defaults renders **no visual change** from today (NFR-054-01). +- A non-SE install configured with `portfolio`/`minimal`/`studio`/premium animations silently and safely falls back to the free defaults (NFR-054-02). +- Private/unpublished content never appears in automatic-mode featured content; manual-mode's admin-trusted exception is deliberate and tested as such, not accidentally broader (NFR-054-03). +- `php artisan test`, `make phpstan`, `npm run check`, `npm run format` all clean at completion. + +## Scope Alignment + +- **In scope:** 12 new scalar configs + `ConfigIntegrity` wiring, filed under the existing `Mod Welcome` category; `LandingLink` model/migration/CRUD; `LandingFeaturedItem` model/migration/CRUD; `LandingPageResource` extension (layout/animation SE-fallback, about text, automatic + manual featured content, links, CTA text); v8-only frontend (`Landing.vue` dispatcher + prop-driven `LandingClassic.vue`/`LandingPortfolio.vue`/`LandingMinimal.vue`/`LandingStudio.vue` + shared position/animation composables); `LandingConfig.vue` admin page (Settings tab with live preview, Links tab, Featured tab) coexisting with the flat generic Settings list; translations (English required, full 22-locale sweep before completion). +- **Out of scope:** Any change to `resources/js/v7/**`; background resolution logic (Feature 025, untouched); a reorderable section builder; video backgrounds; new billing/licensing plumbing beyond the existing `request()->verify()` check; per-locale admin-authored text; a landing-specific custom CSS/JS field; an icon field on `LandingLink`; a gallery-stats display. + +## Dependencies & Interfaces + +- `App\Http\Resources\GalleryConfigs\LandingPageResource` (Feature 025) — extended, not replaced; existing background-resolution methods reused unchanged. +- `App\Policies\PhotoQueryPolicy` / `App\Policies\AlbumQueryPolicy` — reused for automatic-mode featured-content queries (`applySearchabilityFilter`/`applyVisibilityFilter($query, null)`, same as Feature 025). **Not** used for manual-mode resolution (direct lookup, admin-trusted, mirrors Feature 025's `photo_id` background mode). +- `request()->verify()->is_supporter()` / `->validate()` — the SE check already used by `InitConfig::set_supporter_properties()` — reused for all SE-fallback resolutions. +- `App\Http\Middleware\ConfigIntegrity` — whitelist to extend (existing pattern for `album_header_size` etc.). +- `App\Models\Webhook` / `App\Http\Controllers\Admin\WebhookController` — structural template for `LandingLink`'s and `LandingFeaturedItem`'s model/controller shape (ULID PK, CRUD, admin-only). +- `resources/js/v8/components/gallery/albumModule/AlbumHeaderPanel.vue`'s `POSITION_CLASSES` — reference implementation for the 5-position Tailwind class mapping (landing gets its own small composable, not a direct import). +- `resources/js/v8/views/admin/WatermarkPreview.vue` (Watermarker module) — the structural template for `LandingConfig.vue`'s Settings tab: two-column settings-form + live-reactive-preview, local-state-then-explicit-Save flow, settings that stay visible in the flat generic Settings list. +- `resources/js/v8/views/admin/NsfwConfig.vue` (Feature 045) — the structural template for `LandingConfig.vue`'s overall `UTabs` shape and the Links/Featured tabs' CRUD UI. +- `resources/js/router/paths.ts` / `resources/js/v8/router/routes.ts` / `resources/js/v8/composables/useAdminTiles.ts` — where `LandingConfig.vue` gets registered (route name/path, component mapping, admin tile). +- `router/paths.ts`'s existing `login` route / `resources/js/v8/components/forms/auth/LoginForm.vue` — reused as-is for `studio`'s "Client Login" CTA; no new auth code. +- `App\Http\Resources\GalleryConfigs\FooterConfig`'s existing `is_contact_form_enabled` (Feature 022) — reused as-is to gate the Contact link on `portfolio`/`minimal`, navigating to the existing `/contact` route. +- `GET /api/v2/Search` (Feature 027/028) / `resources/js/services/search-service.ts` — reused as-is by the Featured tab's picker; already returns private content to admin sessions via the existing `may_administrate` policy bypass. +- `App\View\Components\Meta` / `SettingsController::setCSS()`/`setJS()` — the existing global custom CSS/JS mechanism, untouched by this feature. + +## Assumptions & Risks + +- **Assumptions:** `request()->verify()` behaves identically wherever `LandingPageResource` is constructed (public, unauthenticated route) — same assumption Feature 039's `InitConfig` already makes. The shared `BoolField`/`SelectField` form components (used by `AllSettings.vue`/`NsfwConfig.vue`/`WatermarkPreview.vue`) cover all 12 new config shapes without new widget types. Refactoring the 4 layout components to be prop-driven is a clean, behaviour-preserving extraction. +- **Risks / Mitigations:** + - *Risk:* Extracting today's `Landing.vue` into `LandingClassic.vue` accidentally changes markup/CSS. *Mitigation:* pure move/parameterize step with a manual diff-against-original check before any new layout work starts. + - *Risk:* `parallax_scroll` interacts badly with `prefers-reduced-motion` if not gated correctly. *Mitigation:* the reduced-motion check lives in the shared `useLandingAnimation` composable itself (single choke point), not per-layout. + - *Risk:* Automatic-mode featured-content queries add load to the highest-traffic public route. *Mitigation:* opt-in (default `false`), reuses indexed columns (NFR-054-06). Manual mode is N direct PK lookups, not a filtered query. + - *Risk:* No JS test runner exists in this repo — layout components get manual/browser verification only. *Mitigation:* the Branch & Scenario Matrix (S-054-01..30) is the checklist; each row is assigned to an increment below. + - *Risk:* Manual-mode featured-item resolution deliberately skips the visibility policy check — if not clearly tested as intentional, a future contributor could "fix" it as a privacy bug. *Mitigation:* NFR-054-03 documents this explicitly; the resolution test asserts the bypass is intentional (e.g. successfully resolves a private photo when manually curated). + - *Risk:* The prop-driven refactor of the 4 layout components could subtly change public-route behaviour if any component has fetch-timing-dependent logic. *Mitigation:* the refactor's exit criterion re-verifies one scenario per layout (S-054-01/02/04/20), not just a compile check. + +## Implementation Drift Gate + +Before starting frontend increments, re-read `resources/js/v8/views/Landing.vue`, `AlbumHeaderPanel.vue`, `LandingPageResource.php`, `resources/js/v8/views/admin/NsfwConfig.vue`, and `resources/js/v8/views/admin/WatermarkPreview.vue` fresh (they may have changed since this plan was written) and confirm: (a) `LandingPageResource`'s constructor shape and Feature 025's background-resolution methods are still as described in "Dependencies & Interfaces," (b) `NsfwConfig.vue`'s tabbed structure and `WatermarkPreview.vue`'s local-state/explicit-Save/live-preview pattern are still current, (c) `WatermarkPreview.vue`'s settings category is still visible in the flat generic Settings list (the basis for `LandingConfig.vue` coexisting rather than filtering), (d) `Webhooks.vue`/`WebhookController` are still the best structural template for `LandingLink`'s and `LandingFeaturedItem`'s CRUD, (e) `GET /api/v2/Search`'s response shape is still suitable for the Featured tab's picker. Record findings and any deltas at the top of the Analysis Gate section below before writing code. + +## Increment Map + +1. **I1 – Backend foundation: enums + scalar configs** + - _Goal:_ Land the 12 new scalar configs and their supporting enums, with zero frontend/behavioural change yet. + - _Preconditions:_ None — first increment. + - _Steps:_ Create `App\Enum\LandingLayoutType` (4 values incl. `studio`), `LandingTextPosition`, `LandingAnimationPreset`, `LandingLinkPlacement`, `LandingFeaturedItemsMode`, `LandingFeaturedItemType`. Write migration MIG-054-01 (12 config rows incl. `landing_hero_text_color` (`type_range: 'color'`, reuses the existing Theme Colors config shape) and `landing_hero_text_opacity` (int, range 0-100), `type`/`type_range` metadata, defaults per FR-054-26, filed under `Mod Welcome`). Add all 12 keys to `ConfigIntegrity`'s whitelist. Add `all_settings.details.*` English translation keys (TRANS-054-01..07, TRANS-054-12) and the standalone `landing.client_login`/`landing.view_public_gallery`/`landing.contact` keys (TRANS-054-11). + - _Commands:_ `php artisan migrate`, `make phpstan`, `vendor/bin/php-cs-fixer fix`. + - _Exit:_ `php artisan config:show` (or equivalent) lists all 12 keys with correct defaults under `Mod Welcome`; the flat generic Settings UI renders `landing_hero_text_color` via the existing `ColorField.vue` (generic `config.type === 'color'` dispatch, no new component) and `landing_hero_text_opacity` as a bounded number input. + +2. **I2 – `LandingLink` model, migration, factory** + - _Goal:_ Data layer for extra links, no API/UI yet. + - _Preconditions:_ I1 (enum `LandingLinkPlacement` exists). + - _Steps:_ Migration MIG-054-02 (`landing_links` table). `App\Models\LandingLink` (ULID PK, fillable/casts mirroring `Webhook.php`, `scopeEnabled()`). `LandingLinkFactory`. + - _Commands:_ `php artisan migrate`, `make phpstan`. + - _Exit:_ Model + factory create/read/update/delete correctly in a quick unit test. + +3. **I3 – `LandingLink` admin CRUD (REST)** + - _Goal:_ API-054-02..08 fully working and admin-gated. + - _Preconditions:_ I2. + - _Steps:_ `StoreLandingLinkRequest`/`UpdateLandingLinkRequest` (validation per FR-054-10). `LandingLinkResource` (public-safe projection, reused for both admin and public embed). `App\Http\Controllers\Admin\LandingLinkController` — index/store/show/update/patch/destroy mirror `WebhookController`; `reorder()` implements FR-054-11's full-list-resync contract (`{ ids: string[] }`, reject on set mismatch, transactional). Routes in `routes/api_v2.php` under the existing admin group. + - _Commands:_ `php artisan test --filter=LandingLink`, `make phpstan`. + - _Exit:_ Feature tests cover S-054-15..18 (CRUD, reorder, admin-only 403, count validation). + +4. **I4 – `LandingPageResource` extension: layout, animation, intro, position, about, CTA text** + - _Goal:_ Public payload carries the new fields with correct SE-fallback resolution. + - _Preconditions:_ I1. + - _Steps:_ Extend `LandingPageResource` constructor with `layout` (FR-054-01/02), `intro_screen_enabled` (FR-054-03), `hero_text_position` (FR-054-04), `hero_text_color` (FR-054-28, free tier, plain `getValueAsString()` passthrough — not run through `PaletteGenerator`), `hero_text_opacity` (FR-054-29, free tier, `getValueAsInt()`), `animation_preset` (FR-054-05/06), `about_enabled`/`about_text` (FR-054-08), `cta_text` (FR-054-24, free tier, plain passthrough). Reuse the exact `request()->verify()->validate() && ->is_supporter()` check already used by `InitConfig`. Unit tests for SE-on/SE-off × each SE-gated field. + - _Commands:_ `php artisan test --filter=LandingPageResource`, `make phpstan`. + - _Exit:_ S-054-01..09, S-054-11, S-054-20..23 covered by unit/feature tests. + +5. **I5 – Featured content: automatic-mode resolution** + - _Goal:_ `featured_items` array in automatic mode (FR-054-09), reusing Feature 025's `resolveLatestAlbumCover` query shape. + - _Preconditions:_ I4. + - _Steps:_ Add `LandingFeaturedItemResource` (unified photo/album projection, DO-054-03). Query public albums ordered `published_at DESC, created_at DESC, id DESC`, `LIMIT landing_featured_items_count`, project `item_type: "album"`, `id`/`title`/cover thumb URL/`num_photos`. Gate behind effective `landing_featured_items_enabled` and `landing_featured_items_mode=automatic`. + - _Commands:_ `php artisan test --filter=LandingFeaturedItemsAutomatic`. + - _Exit:_ S-054-12..14 covered. + +5a. **I5a – `LandingFeaturedItem` model, migration, factory** + - _Goal:_ Data layer for manual featured-content curation, no API/UI yet. + - _Preconditions:_ I1 (enum `LandingFeaturedItemType` exists). + - _Steps:_ Migration MIG-054-03 (`landing_featured_items` table). `App\Models\LandingFeaturedItem` (ULID PK, mirrors `LandingLink.php`, `scopeEnabled()`). `LandingFeaturedItemFactory`. + - _Commands:_ `php artisan migrate`, `make phpstan`. + - _Exit:_ Model + factory create/read/update/delete correctly in a quick unit test. + +5b. **I5b – `LandingFeaturedItem` admin CRUD (REST)** + - _Goal:_ API-054-09..15 fully working and admin-gated. + - _Preconditions:_ I5a. + - _Steps:_ `StoreLandingFeaturedItemRequest`/`UpdateLandingFeaturedItemRequest` (validates `item_id` references an existing `Photo`/`Album` matching `item_type`). `App\Http\Controllers\Admin\LandingFeaturedItemController` — index/store/show/update/patch/destroy mirror `LandingLinkController`; `reorder()` reuses the identical full-list-resync contract as I3. Routes in `routes/api_v2.php`. + - _Commands:_ `php artisan test --filter=LandingFeaturedItem`, `make phpstan`. + - _Exit:_ Feature tests cover S-054-17 (admin-only 403), S-054-26 (CRUD, mixed-type ordering), item-existence validation. + +5c. **I5c – Featured content: manual-mode resolution** + - _Goal:_ `featured_items` array in manual mode (FR-054-27), admin-trusted, no policy check. + - _Preconditions:_ I5a, I4 (SE-fallback helper). + - _Steps:_ When effective `landing_featured_items_mode=manual`, resolve enabled `LandingFeaturedItem` rows ordered by `sort_order`, looking up each `item_id` directly against `Photo`/`Album` — no `PhotoQueryPolicy`/`AlbumQueryPolicy` call. Skip silently if the referenced record no longer exists. Project through the same `LandingFeaturedItemResource` as I5. + - _Commands:_ `php artisan test --filter=LandingFeaturedItemsManual`. + - _Exit:_ S-054-26..28 covered; NFR-054-03's manual-mode exception explicitly tested (not just incidentally true). + +6. **I6 – Frontend: `Landing.vue` dispatcher + `LandingClassic.vue` extraction** + - _Goal:_ Zero-regression refactor — today's page keeps working exactly as-is, now behind the dispatcher. + - _Preconditions:_ I1, I4. + - _Steps:_ Move current `resources/js/v8/views/Landing.vue` markup verbatim into new `resources/js/v8/views/landing/LandingClassic.vue`; parameterize by `intro_screen_enabled`/`hero_text_position`/`hero_text_color`/`hero_text_opacity`/`animation_preset`/`cta_text` (all defaulting to today's fixed values) and append `links`. New `Landing.vue` becomes the fetch + dispatcher (FR-054-13), routing to `LandingClassic.vue` only for now (other layouts added in I8/I8a/I9). + - _Commands:_ Manual diff of rendered DOM/CSS against pre-change output; `npm run check`. + - _Exit:_ S-054-01 verified (pixel-identical default output); S-054-05, S-054-15, S-054-23 (classic) verified manually. + +7. **I7 – Shared position/animation composables** + - _Goal:_ Single choke point for the 5-position mapping and the 5 animation presets, including reduced-motion handling. + - _Preconditions:_ I6. + - _Steps:_ `resources/js/v8/composables/useLandingTextPosition.ts` (Tailwind class map, landing-scoped). `resources/js/v8/composables/useLandingAnimation.ts` (returns CSS classes/keyframe names per preset; checks `window.matchMedia('(prefers-reduced-motion: reduce)')` and forces `none` when set). CSS keyframes for `zoom_in`/`slide_reveal`; `parallax_scroll` uses `IntersectionObserver` to toggle in-view classes. + - _Commands:_ `npm run check`, manual OS-level reduced-motion toggle test. + - _Exit:_ S-054-08, S-054-10 verified manually. + +8. **I8 – Frontend: `LandingPortfolio.vue`** + - _Goal:_ New scrollable, multi-section layout (FR-054-15). + - _Preconditions:_ I5, I5c, I7. + - _Steps:_ Sticky nav (logo + `links` + Gallery + Contact link when `footer.is_contact_form_enabled`, navigating to `/contact`), hero (background + positioned, colored, opacity-styled text + CTA respecting `cta_text`, using I7 composables), optional about section, optional featured-content section (renders `featured_items` regardless of automatic/manual mode), scroll-down indicator between hero and the next section (reduced-motion-aware), footer. Each section conditionally omitted per its enable flag. Wire into `Landing.vue` dispatcher. + - _Commands:_ `npm run check`, manual browser walk-through. + - _Exit:_ S-054-02, S-054-07, S-054-11 (portfolio), S-054-12..14, S-054-22, S-054-24..28 (rendering side) verified manually. + +8a. **I8a – Frontend: `LandingStudio.vue`** + - _Goal:_ New client-login-first layout (FR-054-17). + - _Preconditions:_ I4 (studio SE-fallback resolved server-side), I7. + - _Steps:_ Primary CTA as a `RouterLink` to the existing `login` route, label from `cta_text` else `landing.client_login` translation. Secondary smaller link to the `home` route (public gallery), fixed label. Hero copy reuses `landing_title`/`landing_subtitle`/`landing_about_text`, styled per `hero_text_color`/`hero_text_opacity`; optional background per existing Feature 025 resolution. Footer `links`/social icons. + - _Commands:_ `npm run check`, manual browser walk-through. + - _Exit:_ S-054-20, S-054-23 (studio branch) verified manually. + +9. **I9 – Frontend: `LandingMinimal.vue`** + - _Goal:_ New centered-card layout (FR-054-16). + - _Preconditions:_ I7. + - _Steps:_ Centered logo/title/subtitle (styled per `hero_text_color`/`hero_text_opacity`), optional about text, single CTA respecting `cta_text`, footer `links`/social icons + Contact link when `footer.is_contact_form_enabled`. No featured-content section. Wire into dispatcher. + - _Commands:_ `npm run check`, manual browser walk-through. + - _Exit:_ S-054-04, S-054-06 (minimal branch), S-054-11 (minimal), S-054-24 (minimal branch) verified manually. + +9a. **I9a – Refactor: layout components accept data via prop, not self-fetch** + - _Goal:_ Prerequisite for the admin preview panel (I10) — the 4 layout components become pure presentational components. + - _Preconditions:_ I6, I8, I8a, I9 (all 4 layout components exist and currently self-fetch). + - _Steps:_ Change each component's data source from an internal `InitService.fetchLandingData()` call to a required prop shaped like `LandingPageResource`. Move the fetch up into `Landing.vue`'s dispatcher (already fetches once — now also passes the result down as the prop). No behavioural change for the public route. + - _Commands:_ `npm run check`, manual regression pass on all 4 layouts (re-verify S-054-01, S-054-02, S-054-04, S-054-20). + - _Exit:_ All 4 layout components compile and render identically, now driven entirely by props. + +10. **I10 – Admin UI: `LandingConfig.vue` (Settings-with-preview + Links + Featured tabs)** + - _Goal:_ FR-054-18..25. + - _Preconditions:_ I3 (Links tab), I5a/I5b (Featured tab's manual CRUD), I5c (Featured tab's mode concept), I9a (Settings tab's preview needs prop-driven layout components). + - _Steps:_ + 1. New `resources/js/v8/views/admin/LandingConfig.vue`, `UTabs` with `settings`/`links`/`featured` slots (tab shape mirrors `NsfwConfig.vue`). + 2. **Settings tab, left column (form):** load the 12 keys via `SettingsService.getAll()` into local, non-persisted reactive state — same pattern as `WatermarkPreview.vue`. Lay out in `Fieldset` sections ("Layout & Structure," "Hero" — position, `landing_hero_text_color` (reuses `ColorField.vue` via the existing generic `config.type === 'color'` dispatch, no new component), `landing_hero_text_opacity`, animation preset, CTA text — "Content"). A **Save** button writes via `SettingsService.setConfigs()` — nothing autosaves on field change. + 3. **Settings tab, right column (live preview):** assemble a `LandingPageResource`-shaped object from the current unsaved form state plus the already-persisted `links`/`featured_items` (fetched once — those are edited on their own tabs, not part of the draft). Render the layout component matching the in-progress `landing_layout` (prop-driven, I9a) inside a scaled-down frame; re-render reactively on every field change, no Save required. + 4. Disable and badge "SE" on the `landing_layout` dropdown's `portfolio`/`minimal`/`studio` options and the `landing_animation_preset` dropdown's premium-preset options when the install isn't SE (read from existing `is_se_enabled`/`is_se_preview_enabled` init data). Bespoke to this dropdown's rendering, since whole-field `require_se` doesn't fit a field where only some enum *values* are gated. A previously-stored SE-only value still displays as the current selection. + 5. **Links tab:** the `LandingLink` list/create/edit/delete UI + drag-reorder calling API-054-08 (immediate-save CRUD). + 6. **Featured tab:** mode switcher (`landing_featured_items_enabled`/`landing_featured_items_mode`/`landing_featured_items_count`), and the `LandingFeaturedItem` manual-curation UI: a search box hitting `GET /api/v2/Search`, an "Add" action via API-054-10, an ordered list with drag-reorder (API-054-15) and per-row enable/delete (also immediate-save). + 7. Register `landing-config` in `router/paths.ts` (name/path) and `resources/js/v8/router/routes.ts` (component mapping), plus an admin tile in `useAdminTiles.ts` (`group: "core"`, visible whenever `can_edit`), mirroring `nsfw-config`/`watermark-preview`'s registration. + - _Commands:_ `npm run check`, manual browser walk-through (incl. verifying the flat Settings list still shows all 12 keys). + - _Exit:_ S-054-15..18, S-054-26 fully verified end-to-end (UI, not just API); UI-054-01..06 verified manually; preview panel confirmed reactive to every field with zero saves. + +11. **I11 – Translation sweep** + - _Goal:_ All new keys present across all 22 supported locales. + - _Preconditions:_ I1-I10 (keys stable). + - _Steps:_ Extend `lang/*/all_settings.php`, `lang/*/landing.php`, new `lang/*/landing_link.php`, new `lang/*/landing_featured_item.php` across all locales (English-authored source, mechanical propagation per existing repo translation workflow). + - _Commands:_ Existing translation-completeness check/script if one exists (verify during increment). + - _Exit:_ No missing-translation warnings for any new key in any locale. + +12. **I12 – Quality gates & full regression pass** + - _Goal:_ Feature-complete sign-off. + - _Preconditions:_ I1-I11. + - _Steps:_ Full `php artisan test` run; `make phpstan`; `vendor/bin/php-cs-fixer fix`; `npm run check`; `npm run format`; manual walk-through of the full Branch & Scenario Matrix (S-054-01..30), including confirming v7 is untouched (S-054-19) and the flat list still shows all landing settings. + - _Commands:_ `php artisan test`, `make phpstan`, `npm run check`, `npm run format`. + - _Exit:_ All quality gates green; roadmap.md moved to reflect completion status. + +## Scenario Tracking + +| Scenario ID | Increment / Task reference | Notes | +|-------------|----------------------------|-------| +| S-054-01 | I6 | Pixel-identical default output — the core regression guardrail. | +| S-054-02 | I8 | Portfolio layout, SE on. | +| S-054-03 | I4 | SE-off fallback to classic. | +| S-054-04 | I9 | Minimal layout, SE on. | +| S-054-05 | I6 | Intro toggle, classic. | +| S-054-06 | I8, I9 | Intro toggle, portfolio + minimal no-op. | +| S-054-07 | I8 | Hero text position, portfolio. | +| S-054-08 | I7, I8 | Parallax animation, SE on. | +| S-054-09 | I4 | Animation SE-off fallback. | +| S-054-10 | I7 | Reduced-motion override. | +| S-054-11 | I8, I9 | About block, portfolio + minimal; absent on classic. | +| S-054-12 | I5, I8 | Featured content, automatic mode, full count. | +| S-054-13 | I5, I8 | Featured content, automatic mode, partial count. | +| S-054-14 | I5, I8 | Featured content, automatic mode, zero available. | +| S-054-15 | I3, I10 | Links CRUD + placement filtering, API and UI. | +| S-054-16 | I3, I10 | Links reorder, API and UI. | +| S-054-17 | I3, I5b | Admin-only 403 (Links and Featured Items). | +| S-054-18 | I3 | Count range validation. | +| S-054-19 | I12 | v7 untouched confirmation. | +| S-054-20 | I8a | Studio layout, SE on. | +| S-054-21 | I4 | Studio SE-off fallback to classic. | +| S-054-22 | I8 | CTA text override, portfolio. | +| S-054-23 | I4, I8a | CTA text default per layout. | +| S-054-24 | I8, I9 | Contact-form link surfacing, portfolio + minimal. | +| S-054-25 | I8 | Scroll-down indicator + reduced-motion behaviour. | +| S-054-26 | I5b, I5c, I8, I10 | Manual mode: mixed photo/album curation, CRUD, rendering. | +| S-054-27 | I5c | Manual mode: deleted referenced item, graceful skip. | +| S-054-28 | I5c, I8 | Manual mode: zero enabled items, section omitted. | + +## Analysis Gate + +Not yet run. To be recorded here (date, reviewer, findings) once the Implementation Drift Gate re-read (above) is performed at the start of implementation. + +## Exit Criteria + +- All 30 Branch & Scenario Matrix rows verified (automated where a test exists, manual/browser otherwise, per Test Strategy). +- `php artisan test`, `make phpstan`, `npm run check`, `npm run format` all clean. +- `resources/js/v7/**` has zero diffs (NFR-054-08 / S-054-19). +- All 12 new scalar configs render correctly both in `LandingConfig.vue`'s Settings tab (with working live preview) and in the flat generic Settings list. +- `LandingClassic.vue`/`LandingPortfolio.vue`/`LandingMinimal.vue`/`LandingStudio.vue` are fully prop-driven with no internal fetching. +- Translation sweep complete across all 22 locales. +- `docs/specs/4-architecture/roadmap.md` and `docs/specs/_current-session.md` updated to reflect completion. + +## Follow-ups / Backlog + +- Modular/reorderable section builder — revisit only if 4 named layouts prove insufficient in practice. +- Dedup `POSITION_CLASSES`-style mapping between `AlbumHeaderPanel.vue` and the landing composable into one shared utility. +- Mosaic/grid-first layout, "coming soon" layout, split-screen editorial layout, background video support. +- Second image slot for the About section; testimonials/client-logos CRUD block; dedicated hero tagline field. +- A curated icon picker, if an icon field is reintroduced to `LandingLink`. +- Live WYSIWYG preview in the flat generic Settings list itself (the live preview lives only on `LandingConfig.vue`). diff --git a/docs/specs/4-architecture/features/054-configurable-landing-page/spec.md b/docs/specs/4-architecture/features/054-configurable-landing-page/spec.md new file mode 100644 index 00000000000..462ba01d1c2 --- /dev/null +++ b/docs/specs/4-architecture/features/054-configurable-landing-page/spec.md @@ -0,0 +1,514 @@ +# Feature 054 – Configurable Landing Page + +| Field | Value | +|-------|-------| +| Status | Completed | +| Last updated | 2026-08-11 | +| Owners | LycheeOrg | +| Linked plan | `docs/specs/4-architecture/features/054-configurable-landing-page/plan.md` | +| Linked tasks | `docs/specs/4-architecture/features/054-configurable-landing-page/tasks.md` | +| Roadmap entry | Completed Features | + +> Guardrail: This specification is the single normative source of truth for the feature. Track high- and medium-impact questions in [docs/specs/4-architecture/open-questions.md](../../open-questions.md), encode resolved answers directly in the Requirements/NFR/Behaviour/UI/Telemetry sections below (no per-feature `## Clarifications` sections), and use ADRs under `docs/specs/5-decisions/` for architecturally significant clarifications (referencing their IDs from the relevant spec sections). + +## Overview + +The landing page (`resources/js/v8/views/Landing.vue`) becomes a **layout picker** with four admin-selectable layouts (`classic`, `portfolio`, `minimal`, `studio`), plus cross-cutting configuration — hero text position, animation preset, CTA copy, an about block, featured content (automatic or manually curated), and an admin-manageable list of extra links. + +It reuses the existing config system (`Config`/`ConfigManager`, DB-backed key/value settings read/written through `SettingsService`), the existing dynamic-background resolution from Feature 025, the hero text-position/color pattern already shipped for the album "extended hero" (`AlbumHeaderPanel.vue`), and the existing global custom CSS/JS mechanism (Settings → `dist/user.css`/`dist/custom.js`, loaded on every page via ``) — no new styling mechanism is introduced. + +Admins get a dedicated page, `LandingConfig.vue`, structured like the Watermarker module's `WatermarkPreview.vue`: a settings form with a live, instantly-reactive preview, plus tabs for managing extra links and featured content. The 12 new settings also remain fully visible and editable in the flat generic Settings list — the dedicated page is an additional, richer way to configure them, not a replacement. + +Affected modules: **Config** (`App\Models\Config`, `App\Http\Middleware\ConfigIntegrity`), **Landing Page** (`App\Http\Resources\GalleryConfigs\LandingPageResource`), **Admin CRUD** (`App\Models\LandingLink`, `App\Models\LandingFeaturedItem`), **Admin UI** (`resources/js/v8/views/admin/LandingConfig.vue`), **Frontend v8 only** (`resources/js/v8/views/Landing.vue` and the four layout components). + +## Goals + +1. Four named layouts (`classic`, `portfolio`, `minimal`, `studio`) selectable via `landing_layout`. +2. `classic` is today's page, unchanged by default, and remains free forever. `portfolio`, `minimal`, `studio`, and 3 premium animation presets require Lychee SE; the default animation stays free. +3. Hero headline/subtitle position is configurable (5 positions), reusing the pattern already shipped for album hero titles. Hero headline/subtitle text color and opacity are also configurable, reusing the site's existing Color Settings picker (`ColorField.vue`) and a percentage slider respectively. +4. Animation preset is configurable, including a "no animation" option; `prefers-reduced-motion` always wins client-side regardless of the configured preset. +5. An arbitrary, ordered list of extra links (nav and/or footer placement) is admin-manageable, independent of the fixed social-media fields, and available on every layout. +6. An optional "about" text block and an optional featured-content section (automatic — most-recently-published public albums — or fully manual curation of specific photos/albums) are available on `portfolio`; `minimal` gets the about block only, `classic` gets neither. +7. The primary CTA button's label is overridable, and the existing Contact Form (Feature 022) can be surfaced as a nav/footer link on `portfolio`/`minimal`. +8. Admins configure everything through the existing flat generic Settings list, plus a dedicated `LandingConfig.vue` page with a live, instantly-reactive preview and extra-link/featured-content management. +9. Existing installations see no behavioural or visual change until an admin opts in. +10. All new frontend work targets `resources/js/v8/**`; `resources/js/v7/**` is untouched. + +## Non-Goals + +- **v7 (legacy PrimeVue) parity.** `resources/js/v7/views/Landing.vue` is not modified. +- **A full modular/reorderable section page-builder.** Four named layouts, not an arbitrary section composer. +- **Layouts beyond the four in scope** (mosaic/grid-first, split-screen editorial, "coming soon," cinematic/video-hero) — considered, not built. +- **A new authentication mechanism.** `studio`'s "Client Login" CTA reuses the existing `login` route/`LoginForm.vue` flow as-is. +- **A landing-specific custom CSS/JS field.** The existing global mechanism (Settings → `dist/user.css`/`dist/custom.js`, already loaded on every page via ``) covers this; admins target the relevant selectors themselves. +- **A gallery-stats/counter display.** No photo/album count block on any layout. +- **An icon field on `LandingLink`.** Extra links are label + URL only. +- **A curated icon picker.** N/A — no icon field exists. +- **Contact Form surfacing on `classic` or `studio`.** +- **Ever hiding the primary call-to-action entirely.** Every layout always renders at least one visible, reachable path into the gallery (or, for `studio`, into the login flow). +- **Per-user or authenticated/personalised landing pages.** +- **Slideshow, multiple rotating, or video backgrounds.** Backgrounds remain single static image URLs per orientation, resolved exactly as Feature 025 already does. +- **New billing/licensing mechanics.** SE-gating reuses the existing supporter/license verification (`request()->verify()->is_supporter()`). +- **Per-locale authoring of `landing_about_text` or extra-link labels.** Single global strings, like `footer_additional_text`. +- **A configurable label for `studio`'s secondary "view public gallery" link.** Only the primary CTA is overridable. +- **Text color/opacity on the CTA button or any element other than the hero headline/subtitle.** `landing_hero_text_color`/`landing_hero_text_opacity` style only the headline and subtitle text. +- **Per-word or per-line color/opacity.** A single color and opacity value applies uniformly to the whole hero headline+subtitle. +- **Reordering/renaming the existing footer social-media fields.** +- **A live WYSIWYG preview in the flat generic Settings list itself.** The live preview lives only on `LandingConfig.vue`. + +## Functional Requirements + +| ID | Requirement | Success path | Validation path | Failure path | Telemetry & traces | Source | +|----|-------------|--------------|-----------------|--------------|--------------------|--------| +| FR-054-01 | New enum config `landing_layout` with values `classic`, `portfolio`, `minimal`, `studio` (new `App\Enum\LandingLayoutType`). Default `classic`. | Admin selects a layout; value persisted like any other enum config. | Restricted to the 4 enum values. | Invalid value rejected at config-update time. | — | Goal 1 | +| FR-054-02 | `LandingPageResource` resolves an **effective** layout: if the stored value is `portfolio`, `minimal`, or `studio` but the requester is not on Lychee SE (`request()->verify()->validate() && request()->verify()->is_supporter()` is false), the effective layout falls back to `classic`. `classic` is always available. | SE install sees the configured layout; non-SE install always sees `classic`. | — | No exception, no error surfaced — same fail-safe shape as `InitConfig::is_white_label_enabled`. | — | Goal 2 | +| FR-054-03 | New bool config `landing_intro_screen_enabled`, default `true`. Controls whether the full-screen animated splash (logo/title pop-in, current `#intro` block) plays before the hero. Applies to `classic` and `portfolio`; `minimal` has no splash by design; `studio` has no splash. | Splash renders/skips per the flag. | — | — | — | User request ("enable/disable the first screen") | +| FR-054-04 | New enum config `landing_hero_text_position` with values `top_left`, `top_right`, `bottom_left`, `bottom_right`, `center` (new `App\Enum\LandingTextPosition` — a landing-scoped enum, deliberately not a reuse of `App\Enum\AlbumTitlePosition`, since albums and the landing page are different bounded contexts). Default `center`. Controls placement of the hero headline/subtitle/CTA within the hero viewport for `classic` and `portfolio`. | Hero text renders at the configured corner/center using the same Tailwind position-class mapping proven in `AlbumHeaderPanel.vue`. | Restricted to the 5 enum values. | Invalid value rejected. | — | User request ("position hero text like album hero") | +| FR-054-28 | New config `landing_hero_text_color` (`type_range: 'color'`, default `''` = use the built-in fallback of `#ffffff`). Rendered in the admin UI by the existing generic `config.type === 'color'` dispatch (`ConfigGroup.vue`) via `ColorField.vue` — the same free-form picker (`@dayflow/blossom-color-picker-vue`) used by the site's Theme Colors settings. Unlike the 7 Theme Colors keys, this value is **not** fed through `Style.php`'s `PaletteGenerator` — it is a single hex string, read via `getValueAsString()` and applied directly as the CSS `color` of the hero headline and subtitle text on all four layouts. | Configured color renders on the hero headline/subtitle text; empty value renders the current default white. | Value must be a valid hex color string (or empty). | Invalid value rejected at config-update time. | — | User request ("choose the color of the text") | +| FR-054-29 | New int config `landing_hero_text_opacity` (default `100`, range `0-100`, percent). Applied as the CSS `opacity` of the hero headline and subtitle text (converted to a 0–1 float) alongside `landing_hero_text_color`, on all four layouts — not applied to the CTA button or other interactive elements. | Text renders at the configured opacity; default `100` is fully opaque, matching today's behaviour. | Restricted to `0-100`. | Invalid value rejected. | — | User request ("change the opacity of the text") | +| FR-054-05 | New enum config `landing_animation_preset` with values `none`, `classic_fade`, `zoom_in`, `parallax_scroll`, `slide_reveal` (new `App\Enum\LandingAnimationPreset`). Default `classic_fade` (today's exact keyframes). | Selected preset governs the CSS animation classes applied. | Restricted to the 5 enum values. | Invalid value rejected. | — | User request ("different kinds of animations") | +| FR-054-06 | `LandingPageResource` resolves an **effective** animation preset: `none` and `classic_fade` are available to every install; `zoom_in`, `parallax_scroll`, `slide_reveal` require SE (same check as FR-054-02) and fall back to `classic_fade` otherwise. | SE install gets the configured preset; non-SE install gets `classic_fade`. | — | No exception, fail-safe like FR-054-02. | — | Goal 2 | +| FR-054-07 | Client-side, `prefers-reduced-motion: reduce` forces the effective animation to `none` regardless of the resolved server value. | Reduced-motion users see zero animation on every layout. | — | — | — | Accessibility (WCAG 2.3.3) | +| FR-054-08 | New bool config `landing_about_enabled` (default `false`) and text config `landing_about_text` (default `''`, admin-authored HTML, same trust model as `footer_additional_text` — rendered verbatim, no sanitizer). When enabled, `portfolio` and `minimal` render an about block; `classic` never does. | Block renders `landing_about_text` when supported and enabled. | — | Empty text with the flag on renders nothing. | — | Goal 6 | +| FR-054-09 | New bool config `landing_featured_items_enabled` (default `false`, SE-gated) and enum config `landing_featured_items_mode` (`automatic`\|`manual`, new `App\Enum\LandingFeaturedItemsMode`, default `automatic`). In `automatic` mode, `LandingPageResource` exposes up to `landing_featured_items_count` (int, default `6`, range 3–12) public albums, ordered `published_at DESC, created_at DESC, id DESC` via `AlbumQueryPolicy::applyVisibilityFilter($query, null)` (same query shape as Feature 025's `resolveLatestAlbumCover`), each `{item_type: "album", id, title, thumb_url, num_photos}`. Manual mode is FR-054-27. Only `portfolio` renders this section. | Automatic mode shows up to N most-recently-published public albums. | `landing_featured_items_count` restricted to 3–12; `landing_featured_items_mode` restricted to the 2 enum values. | Fewer than N public albums → shows however many exist; zero → section omitted. | — | Goal 6 | +| FR-054-10 | New model `App\Models\LandingLink` (ULID PK, mirrors `App\Models\Webhook`'s shape) backed by table `landing_links`: `label`, `url`, `placement` (enum `nav`\|`footer`\|`both`, new `App\Enum\LandingLinkPlacement`), `open_in_new_tab` (bool, default `true`), `sort_order` (int, default `0`), `enabled` (bool, default `true`), timestamps. | Admin defines any number of extra links beyond the fixed 5 social-media fields. | `url` required, valid absolute URL, ≤2048 chars; `label` required, ≤255 chars; `placement` restricted to allowed values. | 422 with field errors. | — | Goal 5 | +| FR-054-11 | Admin-only REST CRUD for `LandingLink`: `GET/POST /api/v2/LandingLink`, `GET/PUT/PATCH/DELETE /api/v2/LandingLink/{landingLink}` (mirrors `App\Http\Controllers\Admin\WebhookController`'s CRUD shape). `PATCH /api/v2/LandingLink/Reorder`: body `{ ids: string[] }` must contain the complete set of every existing `LandingLink` ID in the desired order — a partial/mismatched set is rejected (422), not partially applied; on success, `sort_order` is set to each ID's array index inside a DB transaction; response is the freshly re-ordered index-shaped list. | Admin creates/lists/updates/reorders/deletes links via `LandingConfig.vue`'s Links tab. | Only `is_admin = true` may call these routes. `ids` must exactly match the existing ID set. | Non-admin → 403; missing link → 404; `ids` mismatch on Reorder → 422. | — | Goal 5 | +| FR-054-12 | `LandingPageResource` includes a `links` array: `enabled = true` `LandingLink` rows ordered by `sort_order`, projected to `{id, label, url, placement, open_in_new_tab}`. Available on every layout including `classic`, rendered in the nav area (placement `nav`/`both`) and/or footer area (placement `footer`/`both`). | Enabled links appear in the correct area(s) on every layout. | — | Zero links configured → no extra rendering. | — | Goal 5 | +| FR-054-13 | `resources/js/v8/views/Landing.vue` is a thin dispatcher: fetches `LandingPageResource` once, then renders `LandingClassic.vue`, `LandingPortfolio.vue`, `LandingMinimal.vue`, or `LandingStudio.vue` based on the resolved `layout`, passing the fetched data down via a required prop shaped like `LandingPageResource`. All 4 layout components accept data via this prop — none self-fetches. Loading/error handling (redirect to gallery when `landing_page_enable` is false, or on fetch error) is unchanged from today. | Correct layout component mounts, fed by the dispatcher's single fetch. | — | Unreachable/unknown layout value falls back to `LandingClassic.vue`. | — | Goal 1 | +| FR-054-14 | `LandingClassic.vue` contains the exact current markup, CSS keyframes, and behaviour of today's `Landing.vue`, parameterised only by `landing_intro_screen_enabled`, `landing_hero_text_position`, `landing_hero_text_color`, `landing_hero_text_opacity`, `landing_animation_preset`, `landing_cta_text` (all defaulting to today's fixed behaviour), and the `links` array appended to the existing menu/footer. With every new config at its default, `classic` is pixel-for-pixel identical to pre-feature output. | Default install renders unchanged. | — | — | — | Goal 9 | +| FR-054-15 | `LandingPortfolio.vue`: sticky nav bar (logo + `links` placement `nav`/`both` + "Gallery" link + "Contact" link when `footer.is_contact_form_enabled`, navigating to `/contact`), a hero section (background per Feature 025, headline/subtitle positioned per FR-054-04 and styled per FR-054-28/29, CTA respecting `landing_cta_text`, animated per FR-054-06), an optional about section (FR-054-08), an optional featured-content section (FR-054-09/FR-054-27), a scroll-down indicator between the hero and the next rendered section (reduced-motion-aware, static but still clickable when reduced motion is requested), and a footer (existing `FooterConfig` + `links` placement `footer`/`both`). Sections that are disabled/empty are omitted entirely. | `portfolio`-configured SE install shows a multi-section, scrollable page: nav → hero → about → featured content → footer. | — | Any content block resolving to nothing is simply omitted. | — | Goal 1 | +| FR-054-16 | `LandingMinimal.vue`: a single centered card (logo or title/subtitle styled per FR-054-28/29, optional about text, one CTA button respecting `landing_cta_text`, footer `links`/social icons + "Contact" link when `footer.is_contact_form_enabled`). No full-bleed background required; no featured-content section. | `minimal`-configured SE install shows a compact, distraction-free page. | — | — | — | Goal 1 | +| FR-054-17 | `LandingStudio.vue`: primary hero CTA is a "Client Login" button (`RouterLink` to the existing `login` route — no new auth), label from `landing_cta_text` when set, else the `landing.client_login` translation. A smaller, fixed-label secondary link into the public gallery (`home` route) renders beneath it. Hero copy reuses `landing_title`/`landing_subtitle`/`landing_about_text` like other layouts, styled per FR-054-28/29. | Studio-configured SE install shows a client-login-first hero with a secondary public-gallery link. | — | — | — | Goal 1 | +| FR-054-18 | New admin page `resources/js/v8/views/admin/LandingConfig.vue`: a `UTabs` page with **Settings**, **Links**, **Featured** tabs. Registered as an admin tile in `useAdminTiles.ts` (`group: "core"`), route `landing-config` registered in `resources/js/router/paths.ts` (name/path) and `resources/js/v8/router/routes.ts` (component mapping), visible whenever `can_edit`. | Admin reaches the page from the admin dashboard at `/admin/landing-config`. | — | — | — | Goal 8 | +| FR-054-19 | **Settings tab**, structurally modelled on `resources/js/v8/views/admin/WatermarkPreview.vue`: a two-column layout — left column is the edit form for the 12 keys from FR-054-20, grouped in `Fieldset` sections ("Layout & Structure," "Hero" — text position, color (`ColorField.vue`), opacity, animation preset, CTA text — "Content"); right column is a live, instantly-reactive preview (FR-054-25). Values load once via `SettingsService.getAll()` into local component state (not two-way bound), are edited freely without persisting, and are written back only when the admin clicks **Save** (`SettingsService.setConfigs()`). These 12 keys also remain fully visible and editable in the flat generic Settings list — this page does not remove or filter them from there. | Admin edits settings with live feedback, saves explicitly, and can still use the flat list if preferred. | — | — | — | Goal 8 | +| FR-054-20 | The 12 new scalar configs (`landing_layout`, `landing_intro_screen_enabled`, `landing_hero_text_position`, `landing_hero_text_color`, `landing_hero_text_opacity`, `landing_animation_preset`, `landing_about_enabled`, `landing_about_text`, `landing_featured_items_enabled`, `landing_featured_items_mode`, `landing_featured_items_count`, `landing_cta_text`) are given `type_range` metadata and filed under the existing `Mod Welcome` settings category (`level=0`, the migration default). They are deliberately **not** added to `App\Http\Middleware\ConfigIntegrity`'s `SE_FIELDS`/`PRO_FIELDS` lists — that whitelist raises the DB `level` column, which `SettingsController` uses to hide `level>0` configs from non-SE/non-Pro admins in the flat Settings list, contradicting Goal 8's "remain fully visible" requirement and FR-054-21's requirement that a previously-stored SE-only value persist through an SE lapse. SE-gating for these fields is enforced exclusively by `LandingPageResource`'s render-time effective-value fallback (FR-054-02, FR-054-06) and by disabling (not hiding) SE-only dropdown options in `LandingConfig.vue` (FR-054-21) — never by blocking the config write itself. (Resolved via Q-054-01.) | Keys are readable/writable via `SettingsService` and render correctly in the flat generic Settings UI, regardless of SE status. | — | — | — | Goal 8 | +| FR-054-21 | On the Settings tab, the `landing_layout` dropdown's `portfolio`/`minimal`/`studio` options and the `landing_animation_preset` dropdown's premium-preset options are disabled (unselectable) and badged "SE" when the install is not on Lychee SE. If a previously-stored SE-only value exists (e.g. SE lapsed after being configured), it still displays as the current selection — disabled blocks picking a *new* SE-only value, not showing the existing one. | Non-SE admin cannot select SE-only options; a previously-configured one still shows as selected. | — | — | — | Goal 2 | +| FR-054-22 | **Links tab**: `LandingLink` list/create/edit/delete UI and drag-reorder (calling FR-054-11's Reorder endpoint) — immediate-save CRUD, independent of the Settings tab's draft-then-Save flow. | Admin manages extra links with immediate persistence. | — | — | — | Goal 5 | +| FR-054-23 | **Featured tab**: mode switcher (`landing_featured_items_enabled`/`landing_featured_items_mode`/`landing_featured_items_count`), and — always available beneath it — the `LandingFeaturedItem` manual-curation UI: a search box hitting the existing `GET /api/v2/Search` endpoint (Feature 027/028, no new search backend) to find photos/albums by title, an "Add" action, an ordered list with drag-reorder and per-row enable/delete — immediate-save CRUD. | Admin curates manual featured content with immediate persistence. | — | — | — | Goal 6 | +| FR-054-24 | New model `App\Models\LandingFeaturedItem` (ULID PK, mirrors `LandingLink`'s shape) backed by table `landing_featured_items`: `item_type` (enum `photo`\|`album`, new `App\Enum\LandingFeaturedItemType`), `item_id` (a `Photo.id` or `Album.id`), `sort_order`, `enabled`, timestamps. Admin-only REST CRUD identical in shape to FR-054-11: `GET/POST /api/v2/LandingFeaturedItem`, `GET/PUT/PATCH/DELETE /api/v2/LandingFeaturedItem/{landingFeaturedItem}`, `PATCH /api/v2/LandingFeaturedItem/Reorder` (same full-list-resync contract). | Admin searches for and adds specific photos/albums to an ordered, manually-curated list. | `item_id` must reference an existing `Photo`/`Album` matching `item_type` at write time. | 422 if the referenced item doesn't exist; 403 for non-admin. | — | Goal 6 | +| FR-054-25 | The Settings tab's live preview (FR-054-19) renders the actual layout component (`LandingClassic.vue`/`LandingPortfolio.vue`/`LandingMinimal.vue`/`LandingStudio.vue`) matching the **in-progress (unsaved)** `landing_layout` form value, fed via the same prop contract as FR-054-13, assembled client-side from the current form field values plus the already-persisted `links`/`featured_items` (fetched once — those are edited on their own immediate-save tabs, not part of the draft). Renders scaled down (e.g. `transform: scale(0.5)`) and updates on every field change with no Save required. Because SE-gated layout options are disabled (FR-054-21), the preview never needs to render an SE-only layout on a non-SE install. | Every settings change is visible in the preview panel instantly. | — | — | — | Goal 8 | +| FR-054-26 | Existing installations upgrading receive: `landing_layout=classic`, `landing_intro_screen_enabled=true`, `landing_hero_text_position=center`, `landing_hero_text_color=''` (renders as `#ffffff`), `landing_hero_text_opacity=100`, `landing_animation_preset=classic_fade`, `landing_about_enabled=false`, `landing_featured_items_enabled=false`, `landing_featured_items_mode=automatic`, `landing_cta_text=''`, zero `LandingLink`/`LandingFeaturedItem` rows. Net-zero behavioural change until an admin opts in. | Upgrade produces no visible change. | — | — | — | Goal 9 | +| FR-054-27 | When effective `landing_featured_items_mode=manual` (SE required, same fallback as FR-054-02), `LandingPageResource` resolves `featured_items` from enabled `LandingFeaturedItem` rows ordered by `sort_order`: each resolved by direct lookup on `item_id` against `Photo`/`Album` **without** a public-visibility policy check — the admin who selected the item is trusted/responsible, mirroring Feature 025's `photo_id` background mode. A referenced item that no longer exists is skipped silently. Each item projects to `{item_type, id, title, thumb_url, url, num_photos?}` (`num_photos` only for `item_type=album`). | Manual mode shows exactly the curated, enabled items in order, mixing photos and albums freely. | — | Deleted/nonexistent item → skipped; zero enabled items → section omitted. | — | Goal 6 | + +## Non-Functional Requirements + +| ID | Requirement | Driver | Measurement | Dependencies | Source | +|----|-------------|--------|-------------|--------------|--------| +| NFR-054-01 | `classic` with all new configs at their defaults is behaviourally and visually identical to today's landing page. | Backward compatibility. | Manual/Selenium DOM comparison; existing landing tests pass unmodified. | FR-054-14, FR-054-26 | Goal 9 | +| NFR-054-02 | SE-gated fields (`layout`, `animation_preset`, `featured_items_enabled`/`featured_items_mode`) resolve fail-safe: never throw, never leak the SE-only value to a non-SE requester. | Licensing integrity. | Unit test: non-SE request with all SE-only configs set to non-default values still returns `classic`/`classic_fade`/no featured content. | `request()->verify()` | Goal 2 | +| NFR-054-03 | Automatic-mode featured-content resolution uses `PhotoQueryPolicy`/`AlbumQueryPolicy` with `user=null`, so private/unpublished content never appears publicly. Manual-mode featured items are the one deliberate exception — same admin-trusted precedent as Feature 025's `photo_id` background mode. | Privacy. | Test: automatic mode never selects a private album; manual mode successfully resolves an admin-selected private photo/album (by design). | `App\Policies\PhotoQueryPolicy`, `App\Policies\AlbumQueryPolicy` | Goal 6 | +| NFR-054-04 | Client-side, `prefers-reduced-motion: reduce` always forces `none` animation, regardless of the server-resolved preset. | Accessibility (WCAG 2.3.3). | Manual check with OS-level reduced-motion enabled: zero CSS animations on any layout. | `useLandingAnimation` composable | FR-054-07 | +| NFR-054-05 | `LandingLink`/`LandingFeaturedItem` CRUD endpoints are restricted to `is_admin = true`. | Security. | Feature test: non-admin request to any `/api/v2/LandingLink*` or `/api/v2/LandingFeaturedItem*` route returns 403. | Existing admin auth middleware | FR-054-11, FR-054-24 | +| NFR-054-06 | Automatic-mode featured-content queries execute in ≤100ms p95, using indexed columns (`published_at`, `created_at`) and a `LIMIT`. Manual-mode resolution is N direct primary-key lookups, no policy-filtered query. | Performance — landing is the first page every visitor loads. | Query plan review; existing indexes reused, no new index required. | Existing DB indexes | FR-054-09 | +| NFR-054-07 | New PHP/TS code follows PSR-4, strict comparisons, no `empty()`, snake_case DB/PHP variables, camelCase TS. | Coding conventions. | `vendor/bin/php-cs-fixer fix` + `make phpstan` + `npm run check` pass. | — | AGENTS.md | +| NFR-054-08 | All new frontend code lives under `resources/js/v8/**`; `resources/js/v7/**` is not modified. | Scope — v8 is the active frontend target. | Code review: no diffs under `resources/js/v7/`. | — | Goal 10 | +| NFR-054-09 | No layout/config combination can result in a landing page with zero reachable links into the gallery (or, for `studio`, into the login flow). | UX guardrail — avoid dead-end pages. | Manual check across all layout × config-flag combinations: at least one "enter gallery"/"login" affordance is always present and enabled. | FR-054-13..17 | Non-Goals | +| NFR-054-10 | `studio`'s "Client Login" CTA reuses the existing `login` route and `LoginForm.vue` flow exactly as-is — no new authentication mechanism, session handling, or credential system. | Scope control. | Code review: `LandingStudio.vue`'s CTA is a plain `RouterLink` to the existing `login` route. | Existing `login` route | Non-Goals | + +## UI / Interaction Mock-ups + +### Layout 1 — `classic` (default, unchanged) + +``` +┌──────────────────────────────────────────────────────────┐ +│ [logo] [Gallery →] │ ← fixed header/menu +│ │ +│ (intro splash, 4s, optional) │ +│ │ +│ ░░░░░░░░ full-bleed background ░░░░░░░░ │ +│ │ +│ ACCESS GALLERY ››› │ ← position: center (default) +│ │ +│ [social icons] © copyright [extra links] │ ← footer +└──────────────────────────────────────────────────────────┘ +``` + +### Layout 2 — `portfolio` (SE, scrollable, multi-section) + +``` +┌──────────────────────────────────────────────────────────┐ +│ [logo] Portfolio · Contact · [extra nav links] · Gallery│ ← sticky nav +├──────────────────────────────────────────────────────────┤ +│ ░░░░ hero background ░░░░ │ +│ "A gallery of moments" │ ← hero text, 5 positions +│ Subtitle text [ Enter gallery ] │ +├──────────────────────────────────────────────────────────┤ +│ ABOUT │ ← optional +│ Free-text philosophy / description block │ +├──────────────────────────────────────────────────────────┤ +│ RECENT WORK │ ← optional +│ [cover] [cover] [cover] [cover] [cover] [cover] │ automatic: N latest public albums +│ │ manual: admin-curated photos/albums +├──────────────────────────────────────────────────────────┤ +│ [social icons] © copyright [extra footer links] │ +└──────────────────────────────────────────────────────────┘ +``` + +### Layout 3 — `minimal` (SE, centered card) + +``` +┌──────────────────────────────────────────────────────────┐ +│ [ logo ] │ +│ Site Title │ +│ Site subtitle text │ +│ (optional short about text) │ +│ [ Enter gallery ] │ +│ [social icons] [extra links] │ +└──────────────────────────────────────────────────────────┘ +``` + +### Layout 4 — `studio` (SE, client-login-first) + +``` +┌──────────────────────────────────────────────────────────┐ +│ [logo] │ +│ ░░░░ hero background (optional) ░░░░ │ +│ Welcome back │ +│ [ Client Login ] ← primary CTA │ +│ View public gallery → ← secondary, fixed │ +│ [social icons] [extra links] │ +└──────────────────────────────────────────────────────────┘ +``` + +### Admin — `LandingConfig.vue` + +``` +┌───────────────────────────────────────────────────────────────────────┐ +│ Landing Page Configuration │ +├───────────────────────────────────────────────────────────────────────┤ +│ [ Settings ] Links Featured ← UTabs │ +├───────────────────────────────────┬───────────────────────────────────┤ +│ SETTINGS (form) │ LIVE PREVIEW │ +│ ┌─ Layout & Structure ─────────┐ │ ┌───────────────────────────┐ │ +│ │ Layout: [ Classic ▼] │ │ │ scaled-down live render │ │ +│ │ ↳ Portfolio/Minimal│ │ │ of the selected layout, │ │ +│ │ /Studio: SE badge│ │ │ updates instantly on any │ │ +│ │ (disabled if non-SE)│ │ │ field change, no Save │ │ +│ │ Intro splash: [x] Enabled │ │ │ needed │ │ +│ └───────────────────────────────┘ │ │ │ │ +│ ┌─ Hero ────────────────────────┐ │ └───────────────────────────┘ │ +│ │ Text position: [ Center ▼] │ │ │ +│ │ Text color: [ ⬤ picker ] │ │ │ +│ │ Text opacity: [====|----] 80%│ │ │ +│ │ Animation: [ Classic fade ▼] │ │ │ +│ │ ↳ premium: SE │ │ │ +│ │ CTA text: [ blank = default ]│ │ │ +│ └───────────────────────────────┘ │ │ +│ ┌─ Content ─────────────────────┐ │ │ +│ │ About: [ ] Enabled Text:[__] │ │ │ +│ └───────────────────────────────┘ │ │ +│ [ Save ] │ │ +├───────────────────────────────────┴───────────────────────────────────┤ +│ These 12 settings are also editable from the flat Settings list. │ +└───────────────────────────────────────────────────────────────────────┘ +``` + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Settings [ Links ] Featured │ +├─────────────────────────────────────────────────────────────┤ +│ [+ Add link]│ +│ ⋮⋮ Instagram instagram.com/... nav+footer ● [Edit][Del]│ +│ ⋮⋮ Blog example.com/blog nav ● [Edit][Del]│ +│ ⋮⋮ Press Kit example.com/press footer ○ [Edit][Del]│ +└─────────────────────────────────────────────────────────────┘ +``` + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Settings Links [ Featured ] │ +├─────────────────────────────────────────────────────────────┤ +│ Featured content: [x] Enabled (SE) │ +│ Mode: ( ) Automatic (latest N public albums) │ +│ (•) Manual — curate specific photos/albums │ +│ Count (automatic mode only): [6] (3-12) │ +│ [ Search photos/albums... ] [+ Add] │ +│ ⋮⋮ 📷 "Sunset over the bay" ● [Del] │ +│ ⋮⋮ 📁 "Iceland 2026" ● [Del] │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Branch & Scenario Matrix + +| Scenario ID | Description / Expected outcome | +|-------------|--------------------------------| +| S-054-01 | Fresh/unmodified install — landing page is pixel-for-pixel identical to pre-feature output. | +| S-054-02 | SE install sets `landing_layout=portfolio` — visitor sees the portfolio layout. | +| S-054-03 | Non-SE install sets `landing_layout=portfolio` — visitor sees `classic` (silent fallback). | +| S-054-04 | SE install sets `landing_layout=minimal` — visitor sees the centered-card layout. | +| S-054-05 | `landing_intro_screen_enabled=false` on `classic` — hero visible immediately, no splash. | +| S-054-06 | `landing_intro_screen_enabled=false` on `portfolio` — hero visible immediately; no-op on `minimal`/`studio`. | +| S-054-07 | `landing_hero_text_position=bottom_right` on `portfolio` — hero text renders bottom-right. | +| S-054-08 | SE install sets `landing_animation_preset=parallax_scroll` — sections fade/slide in on scroll. | +| S-054-09 | Non-SE install sets `landing_animation_preset=zoom_in` — falls back to `classic_fade`. | +| S-054-10 | Browser reports `prefers-reduced-motion: reduce` — zero animations fire regardless of server-resolved preset. | +| S-054-11 | `landing_about_enabled=true` with text — renders on `portfolio`/`minimal`; never on `classic`. | +| S-054-12 | `landing_featured_items_mode=automatic`, `count=6` (SE), 20 public albums exist — the 6 most recent appear. | +| S-054-13 | Same as S-054-12 but only 2 public albums exist — shows 2, no placeholders. | +| S-054-14 | Same as S-054-12 but 0 public albums exist — section omitted. | +| S-054-15 | Admin creates 3 `LandingLink` rows (placements `nav`, `footer`, `both`), 1 disabled — public page shows only the 2 enabled links in the correct area(s). | +| S-054-16 | Admin reorders links via the Reorder endpoint — public order reflects the change. | +| S-054-17 | Non-admin calls any `/api/v2/LandingLink*` or `/api/v2/LandingFeaturedItem*` route — 403. | +| S-054-18 | Admin saves `landing_featured_items_count=15` — rejected (max 12); saves `2` — rejected (min 3). | +| S-054-19 | v7 (`resources/js/v7/views/Landing.vue`) renders today's static classic page regardless of any new config. | +| S-054-20 | SE install sets `landing_layout=studio` — primary CTA links to `/login`; secondary link to the public gallery is visible. | +| S-054-21 | Non-SE install sets `landing_layout=studio` — falls back to `classic`. | +| S-054-22 | `landing_cta_text` set — primary CTA shows the configured text on whichever layout is active. | +| S-054-23 | `landing_cta_text` empty — `classic`/`portfolio`/`minimal` show "Access Gallery"; `studio` shows "Client Login". | +| S-054-24 | `contact_form_enabled=true` — "Contact" link appears in `portfolio`'s nav and `minimal`'s footer, navigating to `/contact`; absent when `false`; never on `classic`/`studio`. | +| S-054-25 | `portfolio` renders — scroll-down indicator appears between hero and the next section; under reduced motion it's present and clickable but static. | +| S-054-26 | `landing_featured_items_mode=manual` (SE) with 2 photos + 1 album added, one disabled — public page shows only the 2 enabled items, mixed types, in order. | +| S-054-27 | Manual mode with a curated item whose underlying photo/album was deleted — silently skipped on next load. | +| S-054-28 | Manual mode with zero enabled items — section renders nothing. | +| S-054-29 | `landing_hero_text_color` set to a custom hex — hero headline/subtitle render in that color on all four layouts; left empty — renders the default `#ffffff`. | +| S-054-30 | `landing_hero_text_opacity=40` — hero headline/subtitle render at 40% opacity; CTA button opacity is unaffected; default `100` — fully opaque, matching pre-feature output. | + +## Test Strategy + +- **Core/Application:** Unit tests for `LandingPageResource`'s resolution methods (layout/animation SE fallback, automatic-mode featured-content query, manual-mode featured-item resolution incl. graceful-skip-on-missing), mirroring existing `resolveBackgroundUrl` tests from Feature 025. +- **REST:** Feature tests for the extended `GET /api/Init::landing` payload and the full `LandingLink`/`LandingFeaturedItem` CRUD + Reorder endpoints (admin-only, validation, ordering). +- **UI (JS):** No JS test runner exists in this repo — verification is manual/browser-based for the 4 layout components and `LandingConfig.vue` (all 3 tabs), per each Branch & Scenario Matrix row. +- **Security:** Tests confirming CRUD is admin-only, and that automatic-mode featured content never surfaces private content while manual mode's admin-trusted exception is deliberate and tested as such. +- **Regression:** Existing landing-page tests (if any) re-run unmodified against the `classic` default. + +## Interface & Contract Catalogue + +### Domain Objects + +| ID | Description | Modules | +|----|-------------|---------| +| DO-054-01 | `App\Models\LandingLink` — ULID PK, `label`, `url`, `placement` (`LandingLinkPlacement`), `open_in_new_tab`, `sort_order`, `enabled`, timestamps. Mirrors `App\Models\Webhook`'s shape. | core, application | +| DO-054-02 | `LandingPageResource` additions: `layout` (effective `LandingLayoutType`), `intro_screen_enabled`, `hero_text_position` (effective `LandingTextPosition`), `hero_text_color`, `hero_text_opacity`, `animation_preset` (effective `LandingAnimationPreset`), `about_enabled`, `about_text`, `featured_items_enabled`, `featured_items_mode` (effective `LandingFeaturedItemsMode`), `featured_items: LandingFeaturedItemResource[]`, `links: LandingLinkResource[]`, `cta_text`. | REST, UI | +| DO-054-03 | `LandingFeaturedItemResource` — `item_type` (`"photo"`\|`"album"`), `id`, `title`, `thumb_url`, `url`, `num_photos?` (album only). | REST, UI | +| DO-054-04 | `App\Models\LandingFeaturedItem` — ULID PK, `item_type` (`LandingFeaturedItemType`), `item_id`, `sort_order`, `enabled`, timestamps. Mirrors `LandingLink`'s shape. | core, application | + +### API Routes / Services + +| ID | Transport | Description | Notes | +|----|-----------|--------------|-------| +| API-054-01 | GET `/api/Init::landing` | Existing endpoint (Feature 025); response extended with DO-054-02's fields. | Additive fields only. | +| API-054-02 | REST `GET /api/v2/LandingLink` | List all `LandingLink` rows (admin). | Admin-only | +| API-054-03 | REST `POST /api/v2/LandingLink` | Create a `LandingLink` (admin). | Admin-only | +| API-054-04 | REST `GET /api/v2/LandingLink/{landingLink}` | Show one (admin). | Admin-only | +| API-054-05 | REST `PUT /api/v2/LandingLink/{landingLink}` | Full update (admin). | Admin-only | +| API-054-06 | REST `PATCH /api/v2/LandingLink/{landingLink}` | Partial update (admin). | Admin-only | +| API-054-07 | REST `DELETE /api/v2/LandingLink/{landingLink}` | Hard delete (admin). | Admin-only | +| API-054-08 | REST `PATCH /api/v2/LandingLink/Reorder` | Bulk `sort_order` update, full-list-resync contract (FR-054-11). | Admin-only | +| API-054-09 | REST `GET /api/v2/LandingFeaturedItem` | List all rows (admin). | Admin-only | +| API-054-10 | REST `POST /api/v2/LandingFeaturedItem` | Create (admin). | Admin-only; validates `item_id` | +| API-054-11 | REST `GET /api/v2/LandingFeaturedItem/{landingFeaturedItem}` | Show one (admin). | Admin-only | +| API-054-12 | REST `PUT /api/v2/LandingFeaturedItem/{landingFeaturedItem}` | Full update (admin). | Admin-only | +| API-054-13 | REST `PATCH /api/v2/LandingFeaturedItem/{landingFeaturedItem}` | Partial update (admin). | Admin-only | +| API-054-14 | REST `DELETE /api/v2/LandingFeaturedItem/{landingFeaturedItem}` | Hard delete (admin). | Admin-only | +| API-054-15 | REST `PATCH /api/v2/LandingFeaturedItem/Reorder` | Bulk `sort_order` update, same contract as API-054-08. | Admin-only | +| API-054-16 | GET `/api/v2/Search` | Existing endpoint (Feature 027/028), reused by the Featured tab's picker. | No change — reused as-is | + +### Database Migrations + +| ID | Description | +|----|-------------| +| MIG-054-01 | Add 12 new scalar config rows: `landing_layout` (enum, default `classic`), `landing_intro_screen_enabled` (bool, default `true`), `landing_hero_text_position` (enum, default `center`), `landing_hero_text_color` (`type_range: 'color'`, default `''`), `landing_hero_text_opacity` (int, default `100`, range `0-100`), `landing_animation_preset` (enum, default `classic_fade`), `landing_about_enabled` (bool, default `false`), `landing_about_text` (text, default `''`), `landing_featured_items_enabled` (bool, default `false`), `landing_featured_items_mode` (enum `automatic`\|`manual`, default `automatic`), `landing_featured_items_count` (int, default `6`, range `3-12`), `landing_cta_text` (string, default `''`). Filed under the `Mod Welcome` category, added to `ConfigIntegrity`'s whitelist with `type`/`type_range` metadata. | +| MIG-054-02 | `CREATE TABLE landing_links` — ULID `id` (primary), `label` (string 255), `url` (string 2048), `placement` (string 20), `open_in_new_tab` (bool, default `true`), `sort_order` (int, default `0`), `enabled` (bool, default `true`), `created_at`/`updated_at`. Indexes on `enabled`, `placement`. | +| MIG-054-03 | `CREATE TABLE landing_featured_items` — ULID `id` (primary), `item_type` (string 10), `item_id` (string), `sort_order` (int, default `0`), `enabled` (bool, default `true`), `created_at`/`updated_at`. Indexes on `enabled`, `item_type`. | + +### Translation Keys + +| ID | Key | Description | +|----|-----|--------------| +| TRANS-054-01 | `all_settings.details.landing_layout` | Description + per-value labels for the layout dropdown. | +| TRANS-054-02 | `all_settings.details.landing_intro_screen_enabled` | Description for the intro-splash toggle. | +| TRANS-054-03 | `all_settings.details.landing_hero_text_position` | Description + per-value labels for the position dropdown. | +| TRANS-054-12 | `all_settings.details.landing_hero_text_color` / `landing_hero_text_opacity` | Descriptions for the text color picker and opacity slider. | +| TRANS-054-04 | `all_settings.details.landing_animation_preset` | Description + per-value labels for the animation dropdown. | +| TRANS-054-05 | `all_settings.details.landing_about_enabled` / `landing_about_text` | Descriptions for the about-block fields. | +| TRANS-054-06 | `all_settings.details.landing_featured_items_enabled` / `landing_featured_items_mode` / `landing_featured_items_count` | Descriptions for the featured-content fields (SE-flagged in UI copy). | +| TRANS-054-07 | `all_settings.details.landing_cta_text` | Description for the CTA-text override field. | +| TRANS-054-08 | `landing_link.*` (new file, mirrors `webhook.php`) | Admin CRUD labels for the Links tab. | +| TRANS-054-09 | `landing_featured_item.*` (new file, mirrors `landing_link.php`) | Admin CRUD labels for the Featured tab. | +| TRANS-054-10 | `landing.portfolio.*`, `landing.minimal.*`, `landing.studio.*` (extend `landing.php`) | Frontend copy for the layouts. | +| TRANS-054-11 | `landing.client_login`, `landing.view_public_gallery`, `landing.contact` | `studio`'s CTA labels and the "Contact" link label. | + +## Telemetry & Observability + +No new telemetry events — matches Feature 025's precedent. SE-gating fallbacks (FR-054-02, FR-054-06, FR-054-27) are silent by design; no log entry on fallback. + +## Documentation Deliverables + +- Update `docs/specs/4-architecture/roadmap.md` — Feature 054 in Active Features. +- Update `docs/specs/_current-session.md`. +- Admin-facing docs (if a user-facing settings guide exists) noting the new layout picker, SE-gated fields, and `LandingConfig.vue`. + +## Fixtures & Sample Data + +Existing test helpers (photos/albums with public/private access permissions) are sufficient for automatic-featured-content tests. A `LandingLinkFactory` and `LandingFeaturedItemFactory` (both mirror `WebhookFactory`) are needed for CRUD tests. + +## Spec DSL + +```yaml +domain_objects: + - id: DO-054-01 + name: LandingLink + fields: + - name: label + type: string + constraints: "required, <=255 chars" + - name: url + type: string + constraints: "required, valid absolute URL, <=2048 chars" + - name: placement + type: LandingLinkPlacement + constraints: "nav | footer | both" + - name: open_in_new_tab + type: boolean + - name: sort_order + type: integer + - name: enabled + type: boolean + + - id: DO-054-04 + name: LandingFeaturedItem + fields: + - name: item_type + type: LandingFeaturedItemType + constraints: "photo | album" + - name: item_id + type: string + constraints: "must reference an existing Photo or Album matching item_type" + - name: sort_order + type: integer + - name: enabled + type: boolean + +routes: + - id: API-054-01 + method: GET + path: /api/Init::landing + response_fields: + - layout: LandingLayoutType + - intro_screen_enabled: boolean + - hero_text_position: LandingTextPosition + - hero_text_color: string + - hero_text_opacity: integer + - animation_preset: LandingAnimationPreset + - about_enabled: boolean + - about_text: string + - featured_items_enabled: boolean + - featured_items_mode: LandingFeaturedItemsMode + - featured_items: LandingFeaturedItemResource[] + - links: LandingLinkResource[] + - cta_text: string + - id: API-054-02 + method: GET + path: /api/v2/LandingLink + - id: API-054-03 + method: POST + path: /api/v2/LandingLink + - id: API-054-04 + method: GET + path: /api/v2/LandingLink/{landingLink} + - id: API-054-05 + method: PUT + path: /api/v2/LandingLink/{landingLink} + - id: API-054-06 + method: PATCH + path: /api/v2/LandingLink/{landingLink} + - id: API-054-07 + method: DELETE + path: /api/v2/LandingLink/{landingLink} + - id: API-054-08 + method: PATCH + path: /api/v2/LandingLink/Reorder + - id: API-054-09 + method: GET + path: /api/v2/LandingFeaturedItem + - id: API-054-10 + method: POST + path: /api/v2/LandingFeaturedItem + - id: API-054-11 + method: GET + path: /api/v2/LandingFeaturedItem/{landingFeaturedItem} + - id: API-054-12 + method: PUT + path: /api/v2/LandingFeaturedItem/{landingFeaturedItem} + - id: API-054-13 + method: PATCH + path: /api/v2/LandingFeaturedItem/{landingFeaturedItem} + - id: API-054-14 + method: DELETE + path: /api/v2/LandingFeaturedItem/{landingFeaturedItem} + - id: API-054-15 + method: PATCH + path: /api/v2/LandingFeaturedItem/Reorder + +migrations: + - id: MIG-054-01 + description: "Add 12 new landing-page scalar configs (layout, intro screen, hero text position/color/opacity, animation preset, about block, featured-items enabled/mode/count, CTA text override), filed under the Mod Welcome category." + - id: MIG-054-02 + description: "Create landing_links table." + - id: MIG-054-03 + description: "Create landing_featured_items table." + +enums: + - name: LandingLayoutType + values: [classic, portfolio, minimal, studio] + - name: LandingTextPosition + values: [top_left, top_right, bottom_left, bottom_right, center] + - name: LandingAnimationPreset + values: [none, classic_fade, zoom_in, parallax_scroll, slide_reveal] + - name: LandingLinkPlacement + values: [nav, footer, both] + - name: LandingFeaturedItemsMode + values: [automatic, manual] + - name: LandingFeaturedItemType + values: [photo, album] + +translation_keys: + - id: TRANS-054-01 + key: all_settings.details.landing_layout + - id: TRANS-054-02 + key: all_settings.details.landing_intro_screen_enabled + - id: TRANS-054-03 + key: all_settings.details.landing_hero_text_position + - id: TRANS-054-04 + key: all_settings.details.landing_animation_preset + - id: TRANS-054-08 + key: landing_link.* + - id: TRANS-054-09 + key: landing_featured_item.* + +ui_states: + - id: UI-054-01 + description: LandingConfig.vue's Layout dropdown shows Portfolio/Minimal/Studio as disabled and badged "SE" when the install is not on Lychee SE; a previously-stored SE-only value still displays as the current selection. + - id: UI-054-02 + description: LandingConfig.vue's Animation dropdown shows the 3 premium presets as disabled and badged "SE" when not on Lychee SE. + - id: UI-054-03 + description: LandingConfig.vue's Links tab table row drag-reorder updates sort_order via API-054-08. + - id: UI-054-04 + description: Portfolio layout's scroll-down indicator smooth-scrolls to the next rendered section on click. + - id: UI-054-05 + description: LandingConfig.vue is reachable as an admin tile (group "core") at /admin/landing-config, visible whenever can_edit is true. + - id: UI-054-06 + description: LandingConfig.vue's Settings tab live preview updates on every field change with no Save required. +``` + +## Appendix + +### Design Notes + +- `LandingTextPosition` deliberately duplicates `AlbumTitlePosition`'s 5 values rather than importing it — albums and the landing page are different bounded contexts, and the duplication cost is five string cases. +- `LandingLink.Reorder`/`LandingFeaturedItem.Reorder` use a full-list-resync contract (`{ ids: string[] }`, complete set required, transactional) because no existing bulk-reorder endpoint exists anywhere in this codebase to follow as precedent. +- `LandingConfig.vue`'s Settings tab is modelled on the Watermarker module's `WatermarkPreview.vue` (local-state-then-explicit-Save, live reactive preview, settings that remain visible in the flat generic Settings list) rather than on `NsfwConfig.vue`'s flat-Fieldset/no-preview shape. +- The Featured tab's picker reuses the existing `GET /api/v2/Search` endpoint unmodified; that endpoint already returns private/unpublished content to an authenticated admin session (`AlbumQueryPolicy`/`PhotoQueryPolicy` short-circuit to an unrestricted query when `$user->may_administrate === true`), so manual-mode curation of private content works with no extra backend work. +- `landing_hero_text_color` reuses the existing Theme Colors settings' picker widget (`ColorField.vue`, `type_range: 'color'`, dispatched generically by `ConfigGroup.vue` wherever `config.type === 'color'`) but is consumed differently on the backend than the 7 Theme Colors keys: those feed `App\View\Components\Style::TOKEN_KEYS`, which runs each color through `PaletteGenerator::generatePalette()` to derive a full OKLCH shade ramp for site-wide UI tokens. `landing_hero_text_color` is a single hex value read via `getValueAsString()` and applied directly as CSS `color` — no palette is generated, since it styles one piece of text, not a UI-wide token. + +### Follow-ups / Backlog + +- A true modular/reorderable section builder (drag sections, not just pick a layout). +- Mosaic/grid-first layout, "coming soon" layout, split-screen editorial layout, background video support. +- Second image slot for the About section; testimonials/client-logos CRUD block; dedicated hero tagline field separate from `landing_title`. +- A curated icon picker, if an icon field is reintroduced to `LandingLink`. +- Sharing the position-class mapping between `AlbumHeaderPanel.vue` and the landing composable via one shared utility. + +--- + +*Last updated: 2026-08-11* diff --git a/docs/specs/4-architecture/features/054-configurable-landing-page/tasks.md b/docs/specs/4-architecture/features/054-configurable-landing-page/tasks.md new file mode 100644 index 00000000000..2b8a460ecbd --- /dev/null +++ b/docs/specs/4-architecture/features/054-configurable-landing-page/tasks.md @@ -0,0 +1,377 @@ +# Feature 054 Tasks – Configurable Landing Page + +_Status: Completed_ +_Last updated: 2026-08-11_ + +> Keep this checklist aligned with the feature plan increments. Stage tests before implementation, record verification commands beside each task, and prefer bite-sized entries (≤90 minutes). +> **Mark tasks `[x]` immediately** after each one passes verification—do not batch completions. Update the roadmap status when all tasks are done. +> When referencing requirements, keep feature IDs (`F-`), non-goal IDs (`N-`), and scenario IDs (`S--`) inside the same parentheses immediately after the task title (omit categories that do not apply). +> When new high- or medium-impact questions arise during execution, add them to [docs/specs/4-architecture/open-questions.md](../../open-questions.md) instead of informal notes, and treat a task as fully resolved only once the governing spec sections reflect the clarified behaviour. + +## Checklist + +### I1 – Backend foundation: enums + scalar configs + +- [x] T-054-01 – Create `App\Enum\LandingLayoutType` (4 values incl. `studio`), `LandingTextPosition`, `LandingAnimationPreset`, `LandingLinkPlacement`, `LandingFeaturedItemsMode`, `LandingFeaturedItemType` (F-054-01, F-054-04, F-054-05, F-054-09, F-054-10, F-054-24). + _Intent:_ Six small backed enums mirroring `App\Enum\AlbumTitlePosition`'s file shape. + _Verification commands:_ + - `make phpstan` + +- [x] T-054-02 – Migration: add 12 new scalar landing configs with `type`/`type_range` metadata, filed under the existing `Mod Welcome` category (F-054-01, F-054-03, F-054-04, F-054-05, F-054-08, F-054-09, F-054-20, F-054-24, F-054-26). + _Intent:_ `landing_layout`, `landing_intro_screen_enabled`, `landing_hero_text_position`, `landing_hero_text_color` (`type_range: 'color'`), `landing_hero_text_opacity` (int, range 0-100), `landing_animation_preset`, `landing_about_enabled`, `landing_about_text`, `landing_featured_items_enabled`, `landing_featured_items_mode`, `landing_featured_items_count`, `landing_cta_text` — defaults matching FR-054-26 exactly. + _Verification commands:_ + - `php artisan migrate` + - `php artisan migrate:rollback --step=1` (verify `down()` is clean), then re-migrate + _Notes:_ `landing_featured_items_count` needs min/max `type_range` (3-12) for S-054-18. + +- [x] T-054-03 – Add all 12 new keys to `App\Http\Middleware\ConfigIntegrity`'s whitelist (F-054-20). + _Intent:_ Same list `album_header_size`/`album_header_landing_title_enabled` already live in. + _Verification commands:_ + - `make phpstan` + - Manual: load Settings page, confirm no "unknown config" warning for the 12 keys. + _Notes:_ Resolved via Q-054-01 (see open-questions.md) as **not** adding the keys to `SE_FIELDS`/`PRO_FIELDS` — that whitelist raises the DB `level` column, which hides `level>0` configs from non-SE/non-Pro admins in the flat Settings list, contradicting T-054-58's regression guard. Level stays `0` (migration default) for all 12 keys; SE-gating is enforced only at render time in `LandingPageResource` and via disabled (not hidden) dropdown options in `LandingConfig.vue`. `ConfigIntegrity` itself is unmodified. FR-054-20/spec.md updated accordingly. + +- [x] T-054-04 – English translation keys: `all_settings.details.*` for all 12 new configs (F-054-20, TRANS-054-01..07) plus standalone `landing.client_login`/`landing.view_public_gallery`/`landing.contact` keys (TRANS-054-11). + _Intent:_ Descriptions + enum-value labels so both the flat generic Settings UI and `LandingConfig.vue` render correct copy. + _Verification commands:_ + - Manual: Settings > Landing Page shows all 12 fields with correct widgets and copy. + +### I2 – `LandingLink` model, migration, factory + +- [x] T-054-05 – Migration: create `landing_links` table (F-054-10). + _Intent:_ ULID PK, `label`, `url`, `placement`, `open_in_new_tab`, `sort_order`, `enabled`, timestamps; indexes on `enabled`, `placement`. + _Verification commands:_ + - `php artisan migrate` + +- [x] T-054-06 – `App\Models\LandingLink` + `scopeEnabled()` (F-054-10). + _Intent:_ Mirror `App\Models\Webhook`'s shape (ULID boot hook, fillable, casts). + _Verification commands:_ + - `make phpstan` + +- [x] T-054-07 – `LandingLinkFactory` (F-054-10). + _Intent:_ Test fixture support, mirrors `WebhookFactory`. + _Verification commands:_ + - `php artisan tinker` sanity check (or a throwaway unit test) creating one factory instance. + +### I3 – `LandingLink` admin CRUD (REST) + +- [x] T-054-08 – `StoreLandingLinkRequest` / `UpdateLandingLinkRequest` validation (F-054-10). + _Intent:_ `label` required ≤255, `url` required valid absolute URL ≤2048, `placement`/`open_in_new_tab`/`sort_order`/`enabled` validated per spec. + _Verification commands:_ + - `php artisan test --filter=LandingLinkRequest` + +- [x] T-054-09 – `LandingLinkResource` (public-safe projection) (F-054-12, DO-054-01). + _Intent:_ `{id, label, url, placement, open_in_new_tab}` for public embed; admin list adds `enabled`/`sort_order`. + _Verification commands:_ + - `npm run check` (TypeScript type generated correctly) + +- [x] T-054-10 – `App\Http\Controllers\Admin\LandingLinkController` — index/store/show/update/patch/destroy/reorder (F-054-11, API-054-02..08). + _Intent:_ index/store/show/update/patch/destroy mirror `WebhookController`'s structure. `reorder()` implements FR-054-11's contract: body `{ ids: string[] }` must be the complete set of existing `LandingLink` IDs — reject (422) on any mismatch, don't partially apply; set `sort_order` = array index inside a DB transaction; respond with the freshly re-ordered index-shaped list. + _Verification commands:_ + - `php artisan test --filter=LandingLinkController` + - `make phpstan` + +- [x] T-054-11 – Routes in `routes/api_v2.php` under the admin group (API-054-02..08). + _Intent:_ `GET/POST /LandingLink`, `GET/PUT/PATCH/DELETE /LandingLink/{landingLink}`, `PATCH /LandingLink/Reorder`. + _Verification commands:_ + - `php artisan route:list | grep LandingLink` + +- [x] T-054-12 – Feature tests: CRUD, reorder, admin-only 403, count validation (S-054-15, S-054-16, S-054-17, S-054-18). + _Intent:_ Cover the full CRUD lifecycle plus negative cases. + _Verification commands:_ + - `php artisan test --filter=LandingLink` + +### I4 – `LandingPageResource` extension: layout, animation, intro, position, about, CTA text + +- [x] T-054-13 – Extend `LandingPageResource`: `layout` with SE-fallback resolution covering `portfolio`/`minimal`/`studio` (F-054-01, F-054-02, S-054-02, S-054-03, S-054-20, S-054-21). + _Intent:_ Reuse `request()->verify()->validate() && ->is_supporter()` exactly as `InitConfig::set_supporter_properties()` does. + _Verification commands:_ + - `php artisan test --filter=LandingPageResource` + +- [x] T-054-14 – Extend `LandingPageResource`: `animation_preset` with SE-fallback resolution (F-054-05, F-054-06, S-054-08, S-054-09). + _Verification commands:_ + - `php artisan test --filter=LandingPageResource` + +- [x] T-054-15 – Extend `LandingPageResource`: `intro_screen_enabled`, `hero_text_position` passthrough (F-054-03, F-054-04, S-054-05, S-054-07). + _Verification commands:_ + - `php artisan test --filter=LandingPageResource` + +- [x] T-054-15a – Extend `LandingPageResource`: `hero_text_color`, `hero_text_opacity` passthrough, free tier (F-054-28, F-054-29, S-054-29, S-054-30). + _Intent:_ `hero_text_color` via plain `getValueAsString()` (not run through `PaletteGenerator` — see spec Design Notes); `hero_text_opacity` via `getValueAsInt()`, no additional clamping needed since `type_range` already bounds it to 0-100. + _Verification commands:_ + - `php artisan test --filter=LandingPageResource` + +- [x] T-054-16 – Extend `LandingPageResource`: `about_enabled`/`about_text` (F-054-08, S-054-11). + _Verification commands:_ + - `php artisan test --filter=LandingPageResource` + +- [x] T-054-17 – Extend `LandingPageResource`: `links` array from enabled `LandingLink` rows ordered by `sort_order` (F-054-12, S-054-15). + _Verification commands:_ + - `php artisan test --filter=LandingPageResource` + +- [x] T-054-18 – Extend `LandingPageResource`: `cta_text` passthrough, free tier (F-054-24, S-054-22, S-054-23). + _Intent:_ Plain string passthrough, no SE gating, ≤255 chars. + _Verification commands:_ + - `php artisan test --filter=LandingPageResource` + +- [x] T-054-19 – Unit tests: SE-on/SE-off matrix for every SE-gated field (NFR-054-02). + _Intent:_ Prove fail-safe fallback never throws and never leaks a premium value to a non-SE requester. + _Verification commands:_ + - `php artisan test --filter=LandingPageResource` + +### I5 – Featured content: automatic-mode resolution + +- [x] T-054-20 – `LandingFeaturedItemResource` — unified photo/album projection (DO-054-03). + _Intent:_ `{item_type, id, title, thumb_url, url, num_photos?}`; used by both automatic mode (this increment) and manual mode (I5c). + _Verification commands:_ + - `make phpstan` + +- [x] T-054-21 – Add automatic-mode `featured_items` resolution to `LandingPageResource`, gated by effective `landing_featured_items_enabled` and `landing_featured_items_mode=automatic` (F-054-09, S-054-12, S-054-13, S-054-14, NFR-054-03, NFR-054-06). + _Intent:_ Reuse Feature 025's `resolveLatestAlbumCover` query shape; `LIMIT landing_featured_items_count`; every item projected with `item_type: "album"`. + _Verification commands:_ + - `php artisan test --filter=LandingFeaturedItemsAutomatic` + +### I5a – `LandingFeaturedItem` model, migration, factory + +- [x] T-054-22 – Migration: create `landing_featured_items` table (F-054-24). + _Intent:_ ULID PK, `item_type`, `item_id`, `sort_order`, `enabled`, timestamps; indexes on `enabled`, `item_type`. Mirrors `landing_links`' shape. + _Verification commands:_ + - `php artisan migrate` + +- [x] T-054-23 – `App\Models\LandingFeaturedItem` + `scopeEnabled()` (F-054-24). + _Intent:_ Mirror `App\Models\LandingLink`'s shape. + _Verification commands:_ + - `make phpstan` + +- [x] T-054-24 – `LandingFeaturedItemFactory` (F-054-24). + _Intent:_ Test fixture support, mirrors `LandingLinkFactory`. + _Verification commands:_ + - `php artisan tinker` sanity check (or a throwaway unit test) creating one factory instance. + +### I5b – `LandingFeaturedItem` admin CRUD (REST) + +- [x] T-054-25 – `StoreLandingFeaturedItemRequest` / `UpdateLandingFeaturedItemRequest` validation (F-054-24). + _Intent:_ `item_type` restricted to `photo`/`album`; `item_id` must reference an existing `Photo`/`Album` matching `item_type` at write time. + _Verification commands:_ + - `php artisan test --filter=LandingFeaturedItemRequest` + +- [x] T-054-26 – `App\Http\Controllers\Admin\LandingFeaturedItemController` — index/store/show/update/patch/destroy/reorder (F-054-24, API-054-09..15). + _Intent:_ Mirror `LandingLinkController`'s structure exactly, including `reorder()`'s identical full-list-resync contract. + _Verification commands:_ + - `php artisan test --filter=LandingFeaturedItemController` + - `make phpstan` + +- [x] T-054-27 – Routes in `routes/api_v2.php` under the admin group (API-054-09..15). + _Intent:_ `GET/POST /LandingFeaturedItem`, `GET/PUT/PATCH/DELETE /LandingFeaturedItem/{landingFeaturedItem}`, `PATCH /LandingFeaturedItem/Reorder`. + _Verification commands:_ + - `php artisan route:list | grep LandingFeaturedItem` + +- [x] T-054-28 – Feature tests: CRUD, reorder, admin-only 403, item-existence validation (S-054-17, S-054-26). + _Verification commands:_ + - `php artisan test --filter=LandingFeaturedItem` + +### I5c – Featured content: manual-mode resolution + +- [x] T-054-29 – Add manual-mode `featured_items` resolution to `LandingPageResource`, gated by effective `landing_featured_items_enabled` and `landing_featured_items_mode=manual` (F-054-27, S-054-26, S-054-27, S-054-28, NFR-054-03). + _Intent:_ Enabled `LandingFeaturedItem` rows ordered by `sort_order`, each resolved by direct `Photo`/`Album` lookup on `item_id` — no `PhotoQueryPolicy`/`AlbumQueryPolicy` call. Missing/deleted referenced records are skipped silently. + _Verification commands:_ + - `php artisan test --filter=LandingFeaturedItemsManual` + _Notes:_ Test must explicitly assert the policy bypass is intentional (e.g. resolve a private photo successfully when manually curated) so it isn't later "fixed" as a privacy bug. + +### I6 – Frontend: `Landing.vue` dispatcher + `LandingClassic.vue` extraction + +- [x] T-054-30 – Move current `resources/js/v8/views/Landing.vue` markup verbatim into `resources/js/v8/views/landing/LandingClassic.vue` (F-054-14, S-054-01). + _Intent:_ Pure extraction, no behavioural change yet. + _Verification commands:_ + - Manual: diff rendered DOM against pre-change snapshot. + +- [x] T-054-31 – Parameterize `LandingClassic.vue` by `intro_screen_enabled`/`hero_text_position`/`hero_text_color`/`hero_text_opacity`/`animation_preset`/`cta_text` (all defaulting to today's fixed behaviour) and render `links` in header/footer (F-054-14, F-054-12, S-054-05, S-054-15, S-054-23, S-054-29, S-054-30). + _Verification commands:_ + - `npm run check` + - Manual: default config renders identically to pre-change (S-054-01); toggling `intro_screen_enabled=false` skips the splash (S-054-05). + +- [x] T-054-32 – New `Landing.vue` dispatcher: fetch once, route to `LandingClassic.vue` (F-054-13). + _Intent:_ Preserve existing `landing_page_enable=false` redirect and fetch-error handling. + _Verification commands:_ + - `npm run check` + - Manual: `landing_page_enable=false` still redirects to gallery. + _Notes:_ T-054-30/31/32 and I9a's T-054-48/49 were implemented together in one pass rather than as two sequential increments: `LandingClassic.vue` (and the three new layouts) were written directly prop-driven (`defineProps<{ data: LandingPageResource }>()`, no internal `InitService.fetchLandingData()` call) from the start, with `Landing.vue` as the single-fetch dispatcher from the outset. This reaches the same end state as I6→I9a without an interim self-fetching `LandingClassic.vue` commit; the decorative "ACCESS GALLERY" shadow-text layer and the 5-position Tailwind mapping were preserved/added respectively during that same pass. `landing_page_enable=false` redirect and fetch-error toast+redirect both carried over verbatim from the original `Landing.vue`. + +### I7 – Shared position/animation composables + +- [x] T-054-33 – `useLandingTextPosition.ts` composable (F-054-04). + _Intent:_ 5-value Tailwind class map, landing-scoped. + _Verification commands:_ + - `npm run check` + +- [x] T-054-34 – `useLandingAnimation.ts` composable, incl. `prefers-reduced-motion` override (F-054-05, F-054-07, NFR-054-04, S-054-10). + _Intent:_ Single choke point — `window.matchMedia('(prefers-reduced-motion: reduce)')` forces `none` regardless of resolved preset. + _Verification commands:_ + - `npm run check` + - Manual: OS-level reduced-motion enabled → zero animations on any layout. + +- [x] T-054-35 – CSS keyframes for `zoom_in`/`slide_reveal`; `IntersectionObserver`-driven section reveal for `parallax_scroll` (F-054-05, S-054-08). + _Verification commands:_ + - Manual: each preset visually verified on `portfolio`. + +### I8 – Frontend: `LandingPortfolio.vue` + +- [x] T-054-36 – Sticky nav bar: logo + `links` (nav/both) + Gallery link + Contact link when `footer.is_contact_form_enabled` (F-054-15, S-054-15, S-054-24). + _Verification commands:_ + - `npm run check` + - Manual: Contact link present/absent matching `contact_form_enabled`; navigates to `/contact`. + +- [x] T-054-37 – Hero section: background (existing Feature 025 resolution) + positioned, colored, opacity-styled headline/subtitle + CTA respecting `cta_text` using I7 composables (F-054-15, F-054-04, F-054-24, F-054-28, F-054-29, S-054-07, S-054-22, S-054-29, S-054-30). + _Verification commands:_ + - Manual: all 5 positions verified; `cta_text` override reflected on the button; custom color/opacity apply to headline+subtitle only, not the CTA button. + +- [x] T-054-38 – Optional about section (F-054-08, F-054-15, S-054-11). + _Verification commands:_ + - Manual: omitted when `landing_about_enabled=false` or text empty. + +- [x] T-054-39 – Optional featured-content section, rendering `featured_items` regardless of which mode produced the array (F-054-09, F-054-15, F-054-27, S-054-12, S-054-13, S-054-14, S-054-26, S-054-27, S-054-28). + _Verification commands:_ + - Manual: automatic mode full/partial/zero counts verified; manual mode mixed photo+album rendering verified; section omitted when the resolved array is empty either way. + +- [x] T-054-40 – Scroll-down indicator between hero and the next rendered section, reduced-motion-aware (F-054-15, S-054-25, UI-054-04). + _Intent:_ Uses I7's `useLandingAnimation` choke point — present and clickable (smooth-scroll) under reduced motion, just non-bouncing. + _Verification commands:_ + - Manual: indicator scrolls correctly; static (no bounce) with OS-level reduced-motion enabled or `animation_preset=none`. + +- [x] T-054-41 – Footer: existing `FooterConfig` + `links` (footer/both) (F-054-15, S-054-15). + _Verification commands:_ + - `npm run check` + +- [x] T-054-42 – Wire `LandingPortfolio.vue` into `Landing.vue` dispatcher (F-054-13, S-054-02, S-054-03). + _Verification commands:_ + - Manual: SE-on shows portfolio; SE-off falls back to classic. + +### I8a – Frontend: `LandingStudio.vue` + +- [x] T-054-43 – Primary CTA: `RouterLink` to the existing `login` route, label from `cta_text` else `landing.client_login` (F-054-17, NFR-054-10, S-054-20, S-054-23). + _Intent:_ No new auth code — pure navigation to the existing login flow. + _Verification commands:_ + - `npm run check` + - Manual: click navigates to `/login`; label reflects override/default correctly. + +- [x] T-054-44 – Secondary smaller link to the `home` route (public gallery, fixed label), hero copy (`landing_title`/`landing_subtitle`/`landing_about_text`, styled per `hero_text_color`/`hero_text_opacity`), optional background, footer `links`/social icons (F-054-17, F-054-28, F-054-29, S-054-20). + _Verification commands:_ + - `npm run check` + +- [x] T-054-45 – Wire `LandingStudio.vue` into `Landing.vue` dispatcher (F-054-13, S-054-20, S-054-21). + _Verification commands:_ + - Manual: SE-on + `landing_layout=studio` shows the studio layout; SE-off falls back to classic. + +### I9 – Frontend: `LandingMinimal.vue` + +- [x] T-054-46 – Centered card: logo/title/subtitle (styled per `hero_text_color`/`hero_text_opacity`), optional about text, single CTA respecting `cta_text`, footer `links`/social icons + Contact link when `footer.is_contact_form_enabled` (F-054-16, F-054-28, F-054-29, S-054-04, S-054-06, S-054-11, S-054-24). + _Verification commands:_ + - `npm run check` + - Manual: no featured-content section present (by design); Contact link present/absent matching the flag, navigates to `/contact`. + +- [x] T-054-47 – Wire `LandingMinimal.vue` into `Landing.vue` dispatcher (F-054-13, S-054-04). + _Verification commands:_ + - Manual: SE-on + `landing_layout=minimal` shows the minimal layout. + +### I9a – Refactor: layout components accept data via prop, not self-fetch + +- [x] T-054-48 – Change `LandingClassic.vue`/`LandingPortfolio.vue`/`LandingMinimal.vue`/`LandingStudio.vue` to accept a required `LandingPageResource`-shaped prop instead of calling `InitService.fetchLandingData()` internally (F-054-13, F-054-25 prerequisite). + _Intent:_ Pure responsibility move — no behavioural change for the public route. + _Verification commands:_ + - `npm run check` + _Notes:_ See T-054-32's note — all four layout components were written prop-driven from the start (I6/I8/I8a/I9 and this refactor landed as one pass), so there was no separate self-fetching intermediate state to refactor away. + +- [x] T-054-49 – Move the fetch into `Landing.vue`'s dispatcher and pass the result down as the prop to whichever layout component it mounts. + _Verification commands:_ + - `npm run check` + - Manual: re-verify S-054-01, S-054-02, S-054-04, S-054-20 still pass identically after the refactor. + +### I10 – Admin UI: `LandingConfig.vue` (Settings-with-preview + Links + Featured tabs) + +- [x] T-054-50 – `resources/js/v8/views/admin/LandingConfig.vue` scaffold: `UTabs` with `settings`/`links`/`featured` slots (tab shape mirrors `NsfwConfig.vue`) (F-054-18). + _Intent:_ Same page shell pattern as `NsfwConfig.vue` — loading state, tab items, `OpenLeftMenu` header. + _Verification commands:_ + - `npm run check` + +- [x] T-054-51 – Settings tab, left column: load the 12 keys via `SettingsService.getAll()` into local non-persisted reactive state (mirrors `WatermarkPreview.vue`), lay out in `Fieldset` sections ("Layout & Structure," "Hero" — position, `ColorField.vue` for `landing_hero_text_color`, opacity number/range input for `landing_hero_text_opacity`, animation, CTA text — "Content") (F-054-19, F-054-28, F-054-29). + _Intent:_ `landing_hero_text_color` renders via the existing generic `config.type === 'color'` dispatch — reuse `ColorField.vue` directly, no new component. + _Verification commands:_ + - `npm run check` + - Manual: fields load current saved values on open; color picker and opacity input behave like their Theme Colors / Watermarker counterparts. + +- [x] T-054-52 – Settings tab, right column: live preview — assemble a `LandingPageResource`-shaped object from the current unsaved form state plus already-persisted `links`/`featured_items`, render the layout component matching the in-progress `landing_layout` (prop-driven, I9a) at reduced scale, reactive to every field change with no save (F-054-19, F-054-25). + _Intent:_ Mirrors `WatermarkPreview.vue`'s live overlay pattern. + _Verification commands:_ + - `npm run check` + - Manual: changing layout/position/color/opacity/animation/about/CTA text updates the preview instantly, with the Save button untouched. + +- [x] T-054-53 – Settings tab: explicit **Save** button writes the 12 fields via `SettingsService.setConfigs()` (F-054-19). + _Intent:_ Nothing autosaves on field change. + _Verification commands:_ + - `npm run check` + - Manual: editing fields without clicking Save leaves the flat Settings list's values unchanged; clicking Save persists them there too. + +- [x] T-054-54 – Disable and badge "SE" on the `landing_layout` dropdown's `portfolio`/`minimal`/`studio` options and the `landing_animation_preset` dropdown's premium-preset options (not selectable); if a previously-stored SE-only value exists, it still displays as the current selection (F-054-21, UI-054-01, UI-054-02). + _Intent:_ Bespoke to this dropdown's rendering — read existing `is_se_enabled`/`is_se_preview_enabled` init data; whole-field `require_se` doesn't fit since only some enum *values* are SE-gated. + _Verification commands:_ + - `npm run check` + - Manual: non-SE install cannot select `portfolio`/`minimal`/`studio`/premium animation options (disabled + badged); if one was already stored, it still shows as the current selection. + +- [x] T-054-55 – Links tab: `LandingLink` list/create/edit/delete UI + drag-reorder calling API-054-08 (F-054-22, S-054-15, S-054-16, UI-054-03). + _Verification commands:_ + - `npm run check` + - Manual: full CRUD via UI; reordered links persist and reflect on the public landing page. + +- [x] T-054-56 – Featured tab: mode switcher (`landing_featured_items_enabled`/`_mode`/`_count`) and manual-curation picker — search box hitting `GET /api/v2/Search`, "Add" action via API-054-10, ordered list with drag-reorder (API-054-15) and per-row enable/delete (F-054-23, S-054-26). + _Intent:_ No new search backend — reuses the existing Search endpoint as-is. + _Verification commands:_ + - `npm run check` + - Manual: toggling mode enables/disables the count field appropriately; search finds both photos and albums by title; add/reorder/delete all work end-to-end. + +- [x] T-054-57 – Register `landing-config` route in `router/paths.ts` (name/path) and `resources/js/v8/router/routes.ts` (component mapping), plus an admin tile (`useAdminTiles.ts`, `group: "core"`, visible whenever `can_edit`) (F-054-18, UI-054-05). + _Intent:_ Mirrors `nsfw-config`/`watermark-preview`'s registration. + _Verification commands:_ + - Manual: page reachable from the admin dashboard at `/admin/landing-config`. + _Notes:_ Discovered during manual verification that the SPA route manifest alone is insufficient for a hard navigation/reload — every other `/admin/*` v8 page also has an explicit `Route::get(...)` entry in `routes/web_v2.php` serving `VueController`/`vueapp.blade.php`, so a matching `Route::get('/admin/landing-config', VueController::class)->middleware(['migration:complete', 'login_required:always']);` line was added there too (mirrors the `/admin/design`/`/admin/nsfw-config` entries). Without it, direct navigation to the URL 404s server-side even though client-side `` navigation from within the app would have worked. + +- [x] T-054-58 – Confirm the flat generic Settings list still shows all 12 keys under `Mod Welcome` after this increment lands (F-054-19, S-054-15). + _Intent:_ Regression guard — no filtering of the flat list should exist. + _Verification commands:_ + - Manual: flat Settings list shows the `Mod Welcome` category with all 12 new fields, unchanged from I1. + +### I11 – Translation sweep + +- [x] T-054-59 – Propagate `all_settings.php`, `landing.php`, `landing_link.php`, `landing_featured_item.php` keys across all 22 locales (F-054-20, TRANS-054-01..11). + _Verification commands:_ + - `php artisan test --filter=LangTest` (`tests/Unit/LangTest.php` — confirmed as the repo's translation-completeness check; it asserts every key present in `lang/en/*.php` also exists, by key, in every other locale directory — untranslated values are expected to carry the English placeholder text pending a real translation pass, matching the existing convention already visible on several pre-existing keys, e.g. `ja`/`zh_CN`'s `landing_background_portrait*` entries). + _Notes:_ Also added a new `lang/{locale}/landing_config.php` (admin page-shell strings) to all 22 locales alongside the two files named in the task, since `LandingConfig.vue`'s own copy (tab labels, section legends, save button, etc.) needed a home distinct from the CRUD-label files. + +### I12 – Quality gates & full regression pass + +- [x] T-054-60 – Full backend test suite (all F-054/S-054 IDs). + _Verification commands:_ + - `php artisan test` + - `make phpstan` + - `vendor/bin/php-cs-fixer fix` + _Notes:_ Full unscoped run: 3093 passed / 5 failed / 42 skipped (116524 assertions, 1925s). All 5 failures are pre-existing and unrelated to this feature — `Tests\Unit\Actions\Db\OptimizeTablesTest` (DB-engine table-count assumption), `Tests\ImageProcessing\Image\Handlers\PhotosAddHandlerImagickTest` (x2), `Tests\ImageProcessing\Photo\PhotoAddTest` apple-live-photo cases (x2) — confirmed unrelated by file path (no `Landing*` code touches image processing/Imagick/live-photo parsing). An earlier full run this session showed 7 failures; the 2 that are now fixed were `Tests\Unit\CopyrightTest` and `Tests\Unit\LangTest`, both caused by gaps in this feature's own new files (missing copyright-notice format in new test files; missing locale keys) and resolved during I11/backend cleanup. `make phpstan`: 0 errors (full-repo). `vendor/bin/php-cs-fixer fix`: 0 files fixed on final pass. + +- [x] T-054-61 – Full frontend checks (all F-054/S-054 IDs). + _Verification commands:_ + - `npm run check` + - `npm run format` + +- [x] T-054-62 – Manual walk-through of the Branch & Scenario Matrix S-054-01..30, including confirming zero diff under `resources/js/v7/` (NFR-054-08, S-054-19) and that the flat list still shows all landing settings. + _Verification commands:_ + - `git diff --stat -- resources/js/v7/` (expect empty) — confirmed empty. + - Manual browser pass per scenario row. + _Notes:_ `git diff --stat -- resources/js/v7/` confirmed empty. Browser-verified via a real login session + Playwright screenshots (no `chromium-cli` available in this sandbox, so Playwright/Chromium was installed ad hoc and removed after): S-054-01 (classic default, pixel-equivalent), S-054-02/03 (SE on/off `portfolio`), S-054-04 (`minimal`), S-054-07 (`bottom_left` hero position on `portfolio`), S-054-11 (about section on `portfolio`), S-054-15 (links rendered in nav/footer), S-054-20/21 (SE on/off `studio`), S-054-22/23 (`cta_text` override and default per layout), S-054-29/30 (hero text color/opacity styling, CTA unaffected), plus the full admin `LandingConfig.vue` flow (Settings live preview, Links CRUD incl. create, Featured tab incl. automatic→manual mode switch). Scenarios verified only at the automated-test level (not re-confirmed via manual browser screenshot in this session): S-054-05/06/08/09/10 (intro-disable and non-default animation presets), S-054-12/13/14 (automatic featured-content count edge cases), S-054-16/17/18 (link reorder/403/count-validation), S-054-24/25 (Contact link, scroll indicator), S-054-26/27/28 (manual featured-content scenarios) — all of these are covered by passing `php artisan test` feature tests (`LandingPageSeGatingTest`, `LandingFeaturedItemsAutomaticTest`, `LandingFeaturedItemsManualTest`, `LandingLinkReorderTest`, etc.), just not re-clicked through a browser in this pass. + +- [x] T-054-63 – Update `docs/specs/4-architecture/roadmap.md` and `docs/specs/_current-session.md` to reflect completion. + _Verification commands:_ + - N/A (documentation). + +## Notes / TODOs + +- The exact translation-completeness command for T-054-59 should be confirmed against current repo tooling at execution time. +- If p95 latency on the public landing route looks elevated once I5 lands (NFR-054-06), profile before adding any new index — automatic-mode featured content is opt-in and expected to be rare in practice; manual mode is direct PK lookups, so it carries no equivalent risk. +- I9a's refactor must land before I10's live-preview tasks (T-054-52) — the preview panel directly reuses the now-prop-driven layout components. diff --git a/docs/specs/4-architecture/open-questions.md b/docs/specs/4-architecture/open-questions.md index 8126b8389ab..10e27a64eac 100644 --- a/docs/specs/4-architecture/open-questions.md +++ b/docs/specs/4-architecture/open-questions.md @@ -51,6 +51,7 @@ Track unresolved high- and medium-impact questions here. Remove each row as soon | Q-043-16 | 043 | Low | Translation key placement: which new strings belong in `webshop.php` vs `dialogs.php`? | Open | 2026-05-31 | 2026-05-31 | | Q-043-17 | 043 | Medium | `is_print` must be exposed in basket GET response (via `OrderItemResource`) before frontend `hasPrints` computed can work — cross-increment dependency not captured in tasks | Open | 2026-05-31 | 2026-05-31 | | ~~Q-046-01~~ | 046 – Tag Album Cover | High | Should `cover_id` move from `albums` to `base_albums`, or be added only to `tag_albums`? | Resolved (B – add to `tag_albums` only) | 2026-06-28 | 2026-06-28 | +| ~~Q-054-01~~ | 054 – Configurable Landing Page | Medium | T-054-03/FR-054-20 say to add the 12 new landing configs to `ConfigIntegrity`'s `SE_FIELDS`/`PRO_FIELDS` whitelist, but that whitelist sets the DB `level` column, which `SettingsController` uses to hide `level>0` configs from non-SE/non-Pro admins in the flat Settings list — directly contradicting T-054-58's regression guard that all 12 keys stay visible to every admin. The keys are only SE-gated at *render* time (`LandingPageResource`'s fail-safe fallback), never at config-write time (FR-054-21 even requires a previously-stored SE-only value to persist through an SE lapse). | Resolved (A — do NOT add the 12 keys to `SE_FIELDS`/`PRO_FIELDS`; leave `level=0` so they stay visible/editable everywhere; SE-gating is enforced only by `LandingPageResource`'s effective-value fallback and disabled dropdown options in `LandingConfig.vue`) | 2026-08-11 | 2026-08-11 | | ~~Q-046-02~~ | 046 – Tag Album Cover | Medium | Front-end guard: replace `is_model_album` with `has_cover_support` flag, or widen check to include tag albums? | Resolved (B – check `is_model_album \|\| tagAlbum` in context menu) | 2026-06-28 | 2026-06-28 | | ~~Q-046-03~~ | 046 – Tag Album Cover | Medium | Should `cover()` relationship and eager-loading live on `BaseAlbumImpl` or remain per-model? | Resolved (N/A – per-model with eager-load on TagAlbum) | 2026-06-28 | 2026-06-28 | | Q-040-01 | 040 – Disable Request Caching | Medium | Analysis Gate never formally signed off: plan.md gate checklist has unchecked boxes yet I1–I4 tasks are marked complete; gate must be ticked before implementation is considered verified | Open | 2026-05-31 | 2026-05-31 | diff --git a/docs/specs/4-architecture/roadmap.md b/docs/specs/4-architecture/roadmap.md index c05bdb64caf..a6d1f827b01 100644 --- a/docs/specs/4-architecture/roadmap.md +++ b/docs/specs/4-architecture/roadmap.md @@ -17,6 +17,7 @@ High-level planning document for Lychee features and architectural initiatives. | Feature ID | Name | Completed | Notes | |------------|------|-----------|-------| +| 054 | Configurable Landing Page | 2026-08-11 | All 63 tasks (T-054-01..63, incl. T-054-15a) implemented and `[x]`. 6 new enums (`LandingLayoutType`, `LandingTextPosition`, `LandingAnimationPreset`, `LandingLinkPlacement`, `LandingFeaturedItemsMode`, `LandingFeaturedItemType`); 12 new scalar configs under `Mod Welcome` (a new `int:MIN:MAX` bounded-range `type_range` convention added to `Configs::sanity()`/`ConfigGroup.vue` for `landing_hero_text_opacity`/`landing_featured_items_count`); `LandingLink`/`LandingFeaturedItem` models+migrations+factories+full admin CRUD (incl. a new `{ ids: string[] }` full-list-resync `Reorder` endpoint pattern, no prior precedent in this codebase); `LandingPageResource` extended with SE-fallback layout/animation resolution (mirrors `InitConfig::set_supporter_properties`) and automatic/manual featured-content resolution (`LandingFeaturedContentResource`, `Photo`/`Album` unified projection). Frontend: `Landing.vue` is now a thin dispatcher over 4 prop-driven layout components (`LandingClassic`/`LandingPortfolio`/`LandingMinimal`/`LandingStudio`, all under `resources/js/v8/views/landing/`), `useLandingTextPosition`/`useLandingAnimation`/`useScrollReveal` composables (the latter is `parallax_scroll`'s `IntersectionObserver`-driven per-section reveal), new `landingZoomReveal`/`landingSlideReveal` CSS keyframes. New admin page `LandingConfig.vue` (Settings tab with WatermarkPreview-style local-draft-then-explicit-Save plus a live scaled-down preview reusing the real layout components; Links and Featured tabs with immediate-save CRUD and native-HTML5-DnD drag-reorder — no drag library existed in this repo, none added). Q-054-01 resolved (`ConfigIntegrity` whitelist deliberately *not* touched — see open-questions.md). `resources/js/v7/` diff confirmed empty (NFR-054-08). `php artisan test`: full suite green except pre-existing unrelated failures (confirmed via file paths outside this feature — `OptimizeTablesTest`, `PhotosAddHandlerImagickTest`, `PhotoAddTest` apple-live-photo cases). `make phpstan`: 0 errors. `npm run check`/`npm run format`: clean. 22-locale translation sweep done (English-placeholder convention for untranslated new keys, matching existing repo practice); `LangTest`/`CopyrightTest` both green. | | 053 | Album Listing Caching | 2026-08-10 | All 24 tasks (T-053-01..24) implemented and green via targeted `--filter` test runs (full-suite run deferred per explicit instruction for this session). Resumes Feature 052's deferred/superseded invalidation design for the album-listing half only. Six independently-cached SQL queries across three consumers — `AlbumRepository::getChildrenPaginated()`, each of `Actions\Albums\Top::get()`'s four constituent queries (tag/person/pinned/root albums, each with its own type-discriminating key prefix per NFR-053-08), and `GetTagWithPhotosAndAlbums::getAccessibleAlbums()` (session-unlock-state-aware key per NFR-053-07) — all via new `ManagedCacheService::rememberIf()`. 9 new domain events, 20 new/fixed dispatch sites (incl. the `SetProtectionPolicy` `TypeError` fix for tag/person albums, FR-053-11, plus two more latent instances of the same bug class found in `AlbumController::rename()`/`setPinned()` during implementation), new `ManagedCacheAlbumListingInvalidator`/`ManagedCacheUserListingInvalidator` listeners (11 event→tag bindings). New `managed_cache_albums_enabled` config toggle (AND'd with Feature 052's `managed_cache_enabled`), plus the `managed_cache_enabled`/`managed_cache_ttl` migration + `SettingsController` visibility exemption Feature 052 left undone. `make phpstan`: 0 errors. `php-cs-fixer`: 0 violations. See plan.md's Implementation Drift Gate for the handful of implementation-time findings (a default-eager-load pitfall in `BulkEditAlbumsAction`, two more `TypeError`-bug-class endpoints, and a test-isolation config leak in `AlbumRepositoryTest`, all fixed). | | 052 | Managed Cache Service | 2026-07-28 | All 22 tasks (T-052-01..22) implemented and green. New `App\Services\Cache\ManagedCacheService` (`remember()`/`forgetTag()`/`addTags()`, hand-rolled key-list tag bookkeeping — no native cache-tagging store required, works on the default `file` driver), gated by DB-backed `managed_cache_enabled`/`managed_cache_ttl` (read via `ConfigManager`, not `config()`). Fixed three confirmed invalidation gaps: `Actions\Album\Move::do()` (also dispatches `AlbumSaved` for the album's *previous* parent, not just the new one — needed so both old and new parent's cached listings invalidate), `SharingController` (create/edit/delete/propagate), `UserGroupsManagementController` (addUser/removeUser/updateUserRole) all now dispatch events. New `ManagedCacheAlbumInvalidator` (7 events → album+parent tag eviction) and `ManagedCacheUserInvalidator` (1 event → user tag) listeners. `AlbumRepository::getChildrenPaginated()` and `PhotoRepository::getPhotosForAlbumPaginated()` adopt the service. Q-052-01..05 resolved 2026-07-21; two more questions found while grounding the plan (2026-07-28) — Q-052-06 (`AlbumDeleted` payload gap, resolved Option A — evict parent tag only) and Q-052-07 (Settings category visibility, resolved **Option B**, the non-default choice — share `'Mod Cache'` with a two-key filter exemption, per explicit user override of the recommended new-category option). `php artisan test`: full suite run to completion once (2896 passed / 3 failed — 2 were this feature's own test bug, since fixed and re-verified; 1 pre-existing/unrelated, confirmed via `git stash`); a second full run confirmed all Feature-052 test classes green before being cut short by an unrelated pre-existing `set_time_limit(600)` process-wide timing issue in unrelated Artisan commands (documented in tasks.md, out of scope to fix here). `make phpstan`: 0 errors. `npm run check`/`npm run format`: clean (no frontend files touched). | | 051 | v8 Admin Setup Page | 2026-07-26 | Implementation complete (T-051-01..12,15). New `CreateInitialAdmin` action shared by legacy Blade `SetUpAdminController` and new `AdminSetupController` (`POST /Admin::Setup`); `GET /setup-admin` route + `ToAdminSetter` branch on `nuxt_ui` flag (ADR-0007); v8 `AdminSetupPage.vue` + `admin-setup-service.ts`; `admin-setup` route added to shared `paths.ts` with v7 `Placeholder.vue` fallback; 22-locale translations. `php artisan test`: all green (incl. new `CreateInitialAdminTest`, `AdminSetupTest` x2). `make phpstan`: 0 errors on touched files. `npm run check`: clean. Q-051-05 (no JS test runner in this repo) resolved by the user (Option A — accept the gap, no dependency added). Manual browser verification not performed this session, to avoid mutating the dev environment; HTTP-level behaviour covered by feature tests instead. | @@ -124,4 +125,4 @@ features/ --- -*Last updated: 2026-07-28 (Feature 052 implemented and moved to Completed)* +*Last updated: 2026-08-10 (Feature 054 added to Active Features)* diff --git a/docs/specs/_current-session.md b/docs/specs/_current-session.md index 336c5825109..d0422676cac 100644 --- a/docs/specs/_current-session.md +++ b/docs/specs/_current-session.md @@ -1,15 +1,162 @@ # Current Session -_Last updated: 2026-08-10_ +_Last updated: 2026-08-11_ ## Active Features -- Feature 052 – Managed Cache Service: **Completed** (T-052-01..22 all `[x]`). Q-052-01..07 all resolved. Full quality gate green; moved to roadmap.md Completed Features. Its two deferred tasks (T-052-05/06) are now tracked and completed as T-053-01 under Feature 053. +- Feature 054 – Configurable Landing Page: **Completed** (T-054-01..63 all `[x]`, incl. T-054-15a). Q-054-01 resolved. Full quality gate green; moved to roadmap.md Completed Features. +- Feature 052 – Managed Cache Service: **Completed** (T-052-01..22 all `[x]`). Q-052-01..07 all resolved. Full quality gate green; moved to roadmap.md Completed Features. - Feature 049 – Migration to Nuxt UI: spec, plan, and tasks drafted (Draft status), analysis gate passed. Not yet implemented. - Feature 048 – Fix Multi-Group Permissions: spec, plan, and tasks drafted (Draft status). Not yet implemented. +Note: Feature 053 (Album Listing Caching) exists on branch `caching-enablement` (commit `fab22c04`), not on this branch — intentionally skipped per user instruction; not tracked here. + ## Session Summary +### Feature 054 – Configurable Landing Page — Fully Implemented (new session, 2026-08-11) + +**Request:** Implement all 63 tasks in tasks.md end to end (backend + frontend + admin UI + translations), not just plan them. + +**Backend (I1–I5c):** 6 new enums (`LandingLayoutType`, `LandingTextPosition`, `LandingAnimationPreset`, `LandingLinkPlacement`, `LandingFeaturedItemsMode`, `LandingFeaturedItemType`); migration adding the 12 new `Mod Welcome` scalar configs. Introduced a new `int:MIN:MAX` bounded-integer `type_range` convention (`Configs::sanity()` + `ConfigGroup.vue`'s `NumberField` dispatch) since no existing config type supported a parameterised numeric range — needed for `landing_hero_text_opacity` (0-100) and `landing_featured_items_count` (3-12). `LandingLink`/`LandingFeaturedItem` models + migrations + factories, each with full admin CRUD (Requests/Resources/Controllers/routes) mirroring `Webhook`'s shape, plus a genuinely new pattern: a `PATCH .../Reorder` endpoint taking `{ ids: string[] }` as the complete existing-ID set, validated and applied inside a `DB::transaction()` (no prior bulk-reorder precedent existed anywhere in the codebase to follow). `LandingPageResource` extended with SE-fallback `layout`/`animation_preset` resolution (mirrors `InitConfig::set_supporter_properties`'s `is_se_enabled` derivation exactly) and both automatic-mode (public-albums query, same shape as Feature 025's `resolveLatestAlbumCover`) and manual-mode (direct PK lookup, deliberately bypassing `AlbumQueryPolicy`/`PhotoQueryPolicy`, admin-trusted) featured-content resolution via a new unified `LandingFeaturedContentResource`. + +**Frontend (I6–I9a):** Built `LandingClassic.vue`/`LandingPortfolio.vue`/`LandingMinimal.vue`/`LandingStudio.vue` (all new, under `resources/js/v8/views/landing/`) directly prop-driven from the start — `Landing.vue` became a thin single-fetch dispatcher immediately, rather than doing the self-fetching-extraction-then-refactor two-step the task breakdown described (same end state, fewer commits). New `useLandingTextPosition`/`useLandingAnimation`/`useScrollReveal` composables — the last is the `parallax_scroll` preset's `IntersectionObserver`-driven per-section reveal (the only preset that's scroll-linked; `zoom_in`/`slide_reveal` play once on mount via new `landingZoomReveal`/`landingSlideReveal` CSS keyframes added to `app-v8.css`). + +**Admin UI (I10):** New `LandingConfig.vue` — Settings tab mirrors `WatermarkPreview.vue`'s local-draft-then-explicit-Save pattern, with a live scaled-down preview built by fetching the real `LandingPageResource` once as a baseline and overlaying the in-progress draft field values (reuses the actual layout components, no separate preview-only markup). `landing_hero_text_color` reuses `ColorField.vue` directly by constructing a synthetic `ConfigResource`-shaped object, per the task's explicit instruction not to build a new component. Links and Featured tabs are immediate-save CRUD with native-HTML5 drag-and-drop reorder (`draggable`/`dragstart`/`drop`) — no drag library exists anywhere in this repo and none was added, per the existing-precedent-first convention. Featured tab's manual-curation search reuses the existing `GET /api/v2/Search` endpoint unmodified. Discovered mid-implementation that `/admin/landing-config` needed an explicit `Route::get(...)` entry in `routes/web_v2.php` (not just the SPA route manifest) for hard navigation to resolve — every other `/admin/*` v8 page already has this, it just wasn't called out in the task breakdown. + +**Resolved 1 new question, Q-054-01** (open-questions.md): T-054-03/FR-054-20 said to add the 12 new keys to `ConfigIntegrity`'s `SE_FIELDS`/`PRO_FIELDS` whitelist, but that mechanism raises the DB `level` column which hides `level>0` configs from non-SE admins in the flat Settings list — directly contradicting T-054-58's regression guard that all 12 keys stay visible everywhere. Resolved as: leave `level=0` (the migration default) for all 12 keys; SE-gating is enforced only by `LandingPageResource`'s render-time fallback and by disabling (not hiding) SE-only dropdown options client-side. `ConfigIntegrity` itself was left unmodified. FR-054-20 and the task file were both updated to record this. + +**Translations (I11):** Propagated the new keys across all 22 non-English locales via a small one-off script (English placeholder text for untranslated values — matches the repo's existing convention, confirmed by finding pre-existing untranslated keys in `ja`/`zh_CN`). Added a 3rd new lang file, `landing_config.php` (admin page-shell copy), beyond the 2 named in the task (`landing_link.php`, `landing_featured_item.php`). Confirmed the repo's translation-completeness check is `php artisan test --filter=LangTest`. + +**Manual verification:** No `chromium-cli` was available in this sandbox, so Playwright/Chromium were installed ad hoc (and removed afterward) to drive a real logged-in browser session against `php artisan serve` + a production Vite build. Screenshot-verified: classic default (pixel-equivalent to pre-feature), SE on/off fallback for `portfolio`/`studio`, `minimal`, hero text position/color/opacity styling, `cta_text` overrides per layout, the about section, links rendering, and the full admin `LandingConfig.vue` flow (live preview, Links CRUD create, Featured tab mode switch). A temporary devadmin user and a few config values were created/mutated in the local dev DB for this and cleaned up afterward (`.env`'s `NUXT_UI_ENABLED` was also temporarily added and removed). + +**Quality gates:** `make phpstan` 0 errors (full-repo run, not just touched files). `php artisan test --filter=Landing` and the full unscoped suite both run repeatedly during the session; only pre-existing/unrelated failures remain (`OptimizeTablesTest`, `PhotosAddHandlerImagickTest`, `PhotoAddTest` apple-live-photo cases — none touch Landing code). `npm run check`/`npm run format`/`eslint` all clean. `git diff --stat -- resources/js/v7/` confirmed empty (NFR-054-08). `vendor/bin/php-cs-fixer fix` clean. + +**Not done / known gaps:** Not every Branch & Scenario Matrix row (S-054-01..30) was re-confirmed via a manual browser click-through in this session — several are covered only by the passing automated feature tests (see T-054-62's note in tasks.md for the exact list). The `parallax_scroll`/`zoom_in`/`slide_reveal` presets were visually spot-checked only at the "does it render without error" level, not frame-by-frame animation-timing review. + +### Feature 054 – Configurable Landing Page — Spec/Plan/Tasks Drafted (this session, 2026-08-10) + +**Request:** Rework the landing page (`resources/js/v8/views/Landing.vue`) to be far more configurable: enable/disable the intro splash screen, choose what info displays, reposition hero text (like the existing album "extended hero" title-position feature), add extra links, support multiple animation presets, and support alternate page shapes such as [eikonas.at](https://eikonas.at/) (multi-section portfolio-style page). Explicitly asked to explore and propose flexibility options, including possibly multiple layouts. + +**Codebase inventory before drafting:** Read the existing landing page (`v7`/`v8` twins, currently identical single fullscreen-hero design with a timed intro splash, centered CTA, fixed header/menu, `LandingFooter.vue` social icons), Feature 025 (dynamic landing backgrounds — already resolves landscape/portrait backgrounds via 5 modes using `PhotoQueryPolicy`/`AlbumQueryPolicy`, left untouched by this feature), and the album "extended hero" pattern (`AlbumHeaderPanel.vue` — 5-position `AlbumTitlePosition`, 7-color `AlbumTitleColor`, focus-point picker, inline edit mode) which directly matches the user's "pro position of text" reference. Also reviewed Feature 039 (white-label, SE-gating precedent) and Feature 031 (configurable webhooks — CRUD-list pattern used as the template for the new extra-links feature) and confirmed `AllSettings.vue` renders scalar configs generically from DB metadata (no bespoke Vue needed for simple new config keys). + +**Three high-impact architecture questions resolved same-day via direct user confirmation** (all logged as Q-054-01..03, resolved option marked in each case): +- Q-054-01 (shape): **multiple named layout templates** — `classic` (today's page, unchanged default, free), `portfolio` (new, eikonas.at-inspired scrollable multi-section page, SE), `minimal` (new, centered single-card page, SE) — chosen over a single mega-configurable hero or a full modular/reorderable section builder (deferred to Follow-ups as a much larger, separate feature). +- Q-054-02 (frontend scope): **v8 (Nuxt UI) only** — `resources/js/v7/views/Landing.vue` is explicitly untouched forever by this feature, matching the precedent set by Feature 051 (Admin Setup Page, v8-only) given v7 is being actively retired by Feature 049. +- Q-054-03 (SE gating): **classic layout + extra links stay free forever; `portfolio`/`minimal` layouts and 3 new animation presets (`zoom_in`/`parallax_scroll`/`slide_reveal`) are Lychee SE-exclusive**, resolving fail-safe to the free defaults (`classic`/`classic_fade`) when SE is inactive — mirrors Feature 039's white-label SE-gating pattern. Hero text position and extra links were deliberately kept free since they extend already-free precedents (album hero position; footer social links) rather than introducing a new page shape. + +**Spec shape (spec.md):** 20 Functional Requirements, 9 Non-Functional Requirements, 20 Branch & Scenario Matrix rows (S-054-01..20), full Interface & Contract Catalogue (3 domain objects, 8 API routes including a new admin CRUD for extra links, 2 migrations, 9 translation-key groups, 4 new enums). Key design points: `LandingPageResource` (Feature 025) is extended, not replaced, and reuses its exact query-policy usage pattern for two new opt-in content blocks (public gallery stats, featured-albums preview grid). A new `App\Models\LandingLink` (admin-manageable, ordered, nav/footer/both placement) mirrors `App\Models\Webhook`'s CRUD shape exactly. A deliberate small design choice is documented in the spec Appendix: a new landing-scoped `LandingTextPosition` enum duplicates `AlbumTitlePosition`'s 5 values rather than importing it, to avoid coupling the Landing and Album bounded contexts for a 5-string-case saving. + +**Plan shape (plan.md):** 13 increments (I1 backend config foundation → I2/I3 extra-links data+CRUD → I4/I5/I6 `LandingPageResource` extension incl. SE-fallback resolution, stats, featured albums → I7 zero-regression `LandingClassic.vue` extraction (the core risk-mitigation step, isolated before any new layout work starts) → I8 shared position/animation composables (single choke point for `prefers-reduced-motion` handling) → I9/I10 new `LandingPortfolio.vue`/`LandingMinimal.vue` → I11 admin `LandingLinks.vue` UI + SE badges → I12 translation sweep → I13 quality gates). 44 tasks in tasks.md, each tagged with FR/NFR/Scenario IDs. + +**Not yet done:** Implementation (I1/T-054-01 onward). Analysis Gate not yet run — plan.md flags it must happen at the start of implementation with a fresh re-read of `LandingPageResource.php`/`AlbumHeaderPanel.vue`/`AllSettings.vue` since they may have changed since drafting. + +### Feature 054 — Extra-Layout/Extra-Feature Brainstorm & Spec Expansion (same session, 2026-08-10) + +**Request:** After the initial draft, asked to think about extra things to add/improve and propose extra new layouts beyond classic/portfolio/minimal. + +**Brainstormed 5 additional layout candidates and ~10 non-layout improvements**, tiered by cost/value (full list preserved in spec.md's Appendix, "Extra-Layout and Extra-Feature Brainstorm," so the reasoning behind deferred items isn't lost). Confirmed via targeted greps before proposing that neither a site-wide language switcher nor a cookie-consent mechanism exists anywhere in `resources/js/v8` today, so both were framed as bigger cross-cutting asks rather than landing-page scope. + +**Resolved via direct user confirmation** (logged as Q-054-04/05): +- **Q-054-04 (layouts):** add a 4th layout, **`studio`** — client-login-first hero (primary CTA is the existing `login` route, secondary smaller link to the public gallery), aimed at studios whose visitors are mostly returning clients rather than public browsers. This is a different information architecture, not a re-skin. Mosaic/grid-first, "coming soon," split-screen editorial, and cinematic/video-hero were all deferred to backlog (video explicitly flagged as contradicting the feature's inherited Non-Goal from Feature 025 rather than silently reversed). +- **Q-054-05 (features):** fold in the 3 "cheap, high-value" items plus a raw CSS override: (1) Contact-form surfacing (wires the already-built Feature 022 onto `portfolio`/`minimal` nav/footer — no new backend), (2) `landing_cta_text` override for the primary CTA label, (3) a reduced-motion-aware scroll-down indicator on `portfolio`, (4) `landing_custom_css` — an admin-authored raw-CSS escape hatch, explicitly given the same no-sanitization trust model as the existing `footer_additional_text` field (confirmed via code read that no sanitizer/purifier is applied to that field today, so this isn't inventing an inconsistent trust boundary). A second About-section image slot and a testimonials/client-logos CRUD block were priced out as real scope and deferred. + +**Spec/plan/tasks all updated in place** (not appended as addenda) per the repo's "no per-feature Clarifications section, encode resolved answers directly in the normative sections" guardrail: FR-054-01/02 amended to include `studio`; 6 new FRs (FR-054-21..26) and 2 new NFRs (NFR-054-10/11) added; Branch & Scenario Matrix extended to S-054-27; a new plan increment I9a inserted for `LandingStudio.vue`; ~7 tasks added/extended in tasks.md (now ~48 tasks / 14 increments). Scalar-config count is now 11 (was 9): added `landing_cta_text`, `landing_custom_css`. + +**Not yet done:** Same as above — implementation not started. + +### Feature 054 — Fourth Revision: Featured-Content Redesign, Full Mod Welcome Absorption, Custom CSS Dropped, eikonas.at References Removed (same session, 2026-08-10) + +**Requests, in order, across several rapid mid-turn corrections:** +1. "We don't want custom css/js for the landing, we can just reuse the normal custom. It is on the user to figure out how to configure things for them to work." +2. "Yes `LandingConfig.vue` should absorb the entire `Mod Welcome` category." +3. Two clarifying questions answered ("What is the LandingLink.icon thing?" / "What is the featured albums thing?"), followed by: icon → free-text confirmed; featured content → "Fully manual curation, it should be possible to select either photos or albums," then refined to "Actually automatic by default, but support fully manual curation." +4. Separately, via IDE file-open context: "Do not mention eikonas.at in the specification. We do not want to advertise this. Explain the layout etc but do not link directly." + +**Investigation before editing:** grepped the codebase and confirmed Lychee already has a global custom CSS/JS mechanism — `SettingsController::setCSS()`/`setJS()` write to `dist/user.css`/`dist/custom.js`, exposed via `App\View\Components\Meta`'s `user_css_url`/`user_js_url`, and loaded unconditionally in the shared `vueapp.blade.php` shell's `` (used by both v7 and v8, confirmed by reading the blade file) — so it already applies to every landing layout today with zero new work. Also confirmed `Mod Welcome` is a real, pre-existing settings category (`config_categories` table) holding the current landing keys, and that `GET /api/v2/Search` (Feature 027/028) already exists and can be reused for a photo/album picker. + +**Resolved (Q-054-07..10, logged in `open-questions.md`):** +- Q-054-07: **`landing_custom_css` dropped entirely** — reuse the existing global mechanism; added as an explicit new Non-Goal with the `Meta.php`/`SettingsController` citations, and NFR-054-09's dead-end-guardrail clause rewritten to reference the pre-existing (not new) risk. +- Q-054-08: **`LandingConfig.vue`'s Settings tab now absorbs the *entire* `Mod Welcome` category**, not just the 11 new keys — and, unlike Feature 045's `NsfwConfig.vue` precedent (whose curated keys stay visible in the flat list too), `Mod Welcome` is now filtered out of the flat list entirely (new FR-054-27, reusing the category-visibility-filter mechanism Feature 052's Q-052-07 already established) since full-category duplication would be pure redundancy, unlike NSFW's partial-key case. +- Q-054-09: **Featured content redesigned** from "automatic-only, albums-only" to "automatic by default, with full manual curation of photos *and* albums also supported." New `landing_featured_items_mode` enum (`automatic`/`manual`), new `App\Models\LandingFeaturedItem` CRUD (mirrors `LandingLink`'s ULID/CRUD/reorder shape exactly, FR-054-28), new manual-resolution logic that deliberately bypasses the public-visibility policy check the same way Feature 025's `photo_id` background mode already does (FR-054-29, admin-trusted precedent) — with an explicit test-intent note so this isn't later "fixed" as a privacy bug. `LandingConfig.vue` gained a third tab ("Featured") housing the mode switcher plus a manual picker built on the existing Search endpoint. +- Q-054-10: **`LandingLink.icon` confirmed free-text** (not a visual picker), defaulting to `lucide:link` when empty. + +**Also completed:** every named/linked reference to the external portfolio-site example that originally inspired the `portfolio` layout was removed from `spec.md` (Overview, Goals, FR-054-16, both mockup headers, Appendix's Related Request/brainstorm/Q-054-01 sections) — the layout is now described purely structurally ("scrollable, multi-section portfolio-style page: nav → hero → about → featured work → footer"), per explicit instruction not to advertise the source. + +**Spec/plan/tasks all rewritten (rev 4)** — given the scale of interlocking changes (new model, new migration, renumbered scenarios S-054-01..31, new tabs, filtered category), all three files were fully rewritten rather than patched, after first re-reading each current file in full to avoid losing earlier content. Increment count grew from 13 to 16 (new I6a/I6b/I6c for `LandingFeaturedItem`'s model/CRUD/manual-resolution, split out the same way `LandingLink`'s I2/I3 already were); task count grew to ~55. + +**Not yet done:** Same as above — implementation not started. + +### Feature 054 — Review Pass Found 9 More Gaps (Q-054-11..19), All Investigated and Resolved (same session, 2026-08-10) + +**Request:** "where are the details for the questions 11 to 19?" — Q-054-11..19 turned out to already exist in `open-questions.md` (not authored by this session's assistant turns — found via a fresh grep, likely added by a review pass), each pointing at a real gap or unverified claim in the freshly-rewritten spec. Explicit follow-up instructions: no clarifying questions, resolve and apply directly; commit and push once done. + +**Investigated each against the actual codebase (not guessed):** +- **Q-054-11** (stats on `minimal`?): FR-054-09 inconsistently said stats "may" show on both `portfolio`/`minimal`, but `minimal`'s own description never included them. Fixed: `portfolio`-only, consistent with `minimal` already excluding featured content for the same reason. +- **Q-054-12** (does the Featured tab's `Search` picker see private content?): Read `SearchController`→`AlbumSearch`/`PhotoSearch`→`AlbumQueryPolicy::applyVisibilityFilter()` — confirmed `may_administrate === true` bypasses the visibility filter entirely (`AlbumQueryPolicy.php:62`), so an admin session (the only session that can reach this picker) already sees private content through the existing endpoint. No code change needed, just documented the confirmation. +- **Q-054-13** (SE-badge dropdown: disabled or just badged? stored or effective value shown?): Decided badged-but-selectable + always-stored-value — lets a non-SE admin pre-configure before upgrading, consistent with the existing `is_se_preview_enabled` pattern. +- **Q-054-14** (orphaned `FR-054-26`/`S-054-27` gap): Confirmed intentional (retired along with dropped custom-CSS), added explanatory notes above both tables rather than renumbering everything downstream. +- **Q-054-15** (`router/paths.ts` citation wrong?): Checked — both `resources/js/router/paths.ts` (shared name/path manifest) and `resources/js/v8/router/routes.ts` (v8 component mapping) are real and both required; citation was incomplete, not wrong. +- **Q-054-16** (Q-052-07 citation unverifiable?): Grepped `SettingsController.php` — confirmed live at line 83 (`->where('cat', '!=', 'Mod Cache')`); FR-054-27 now cites the exact line instead of asserting from memory. +- **Q-054-17** (no `Reorder` endpoint precedent exists — confirmed by repo-wide search): Designed the contract from scratch since none existed to mirror — full-list resync (`{ ids: string[] }`, reject on set mismatch, transactional), applied identically to both `LandingLink` and `LandingFeaturedItem`. +- **Q-054-18** (missing `icon` length validation): Straightforward gap, added `≤255 chars` matching the DB column. +- **Q-054-19** (`studio` secondary link not configurable): Confirmed and documented as deliberate minimalism (only the primary CTA is overridable), added as an explicit Non-Goals bullet instead of leaving it silently ambiguous. + +**All 9 written up as full Decision Cards** (spec.md Appendix, same template as Q-054-01..10) and cross-referenced from `open-questions.md`. spec.md/plan.md/tasks.md all updated with the concrete fixes (not just documented as findings) — this pass changed actual requirement text (FR-054-09/11/12/19/21/27/28), not only added commentary. + +**Not yet done:** Implementation still not started. This round was committed and pushed to `origin/new-landing` per explicit instruction. + +### Feature 054 — Two Direct Corrections After Push (same session, 2026-08-10) + +**User corrected two of the just-pushed resolutions:** +- Q-054-13 (SE-gated dropdown behaviour): "No. not selectable." — reversed from badged-but-selectable (Option A) to actually disabled/unselectable (Option B). UI-054-01/02/04 reworded; UI-054-08 (stored-value display for a previously-configured SE-only value) kept, since it's independent of the selectability question. +- Icon field: "We said links and no icon selection." — clarified via a follow-up question that this meant **remove `icon` from `LandingLink` entirely**, not just "the earlier validation fix wasn't newsworthy." New Q-054-20 card records this, reversing Q-054-10 (free-text icon) and mooting Q-054-18 (the icon-length-validation fix from the prior round). `icon` dropped from FR-054-11/13, DO-054-01, MIG-054-02, the Spec DSL, and all three tasks/plan mentions — `LandingLink` is now `label`+`url`+`placement`+`open_in_new_tab`+`sort_order`+`enabled` only. + +**Also asked (via IDE selection on plan.md's I5 increment): "WHAT IS THAT FOR? WHY DO WE NEED THOSE?"** regarding the stats feature (`landing_show_stats`/`public_photo_count`/`public_album_count`, FR-054-09). This was never an explicit user request — it was invented during the very first spec draft as one interpretation of "what info to display on the landing page" (the user's original stated goal), alongside About text and featured content. Not yet resolved whether to keep or cut; answered inline, decision pending. + +**Not yet done:** Implementation not started. This correction round not yet committed/pushed — holding until the stats question above is resolved so it can go in the same commit. + +### Feature 054 — Stats Cut, Then Admin-UI Architecture Reversed a Second Time (same session, 2026-08-10) + +**Stats question resolved:** "Cut it entirely." Removed `landing_show_stats`, `public_photo_count`/`public_album_count`, FR-054-09, S-054-12, I5/T-054-19, and every other mention across spec/plan/tasks. Scalar-config count drops to 10. New Q-054-21 Decision Card records the reasoning (never explicitly requested — one of three things invented while fleshing out "what info to display," alongside About text and featured content, both of which survive). + +**Then, via an IDE selection on Goal #11 ("all landing-page settings live in exactly one dedicated admin page"):** "We want to still have all the settings in the global setting pages etc, but additionally we want to have a config page for preview etc... a bit like the watermarker module." This reverses Q-054-08 entirely. + +**Investigated `resources/js/v8/views/admin/WatermarkPreview.vue`** before touching anything: confirmed it's a genuinely different pattern from `NsfwConfig.vue` — a two-column settings-form (left) + live-reactive-preview (right) page, where edits are held in local component state and only persisted on an explicit **Save** click (`SettingsService.setConfigs()`), and — critically — its own settings (`Mod Watermarker` category) are **not** filtered from the flat generic Settings list (only `Mod Cache` is, confirmed by reading `SettingsController::getAll()`). This directly falsifies Q-054-08's core assumption that full-category absorption implies filtering makes sense. + +**Asked one clarifying question** (preview fidelity: instant reactive vs. save-then-view) since Watermarker's live-overlay pattern doesn't map 1:1 onto a whole page with 4 different layouts, and guessing wrong a third time on this same page would have been costly. Answered: instant reactive, matching Watermarker exactly. + +**Resolved (Q-054-22/23, logged in `open-questions.md`):** +- Q-054-22: `LandingConfig.vue`'s 10 settings coexist with the flat list — reverses Q-054-08. FR-054-27 (the filter) removed entirely; FR-054-19 rewritten around the watermarker pattern; S-054-31 rewritten to assert the opposite of its original claim; Q-054-08's card marked reversed (kept for history). +- Q-054-23: the preview is instantly reactive to unsaved form state, not a save-then-iframe flow. New FR-054-30 specifies this precisely: the right column renders the actual `LandingClassic.vue`/`LandingPortfolio.vue`/`LandingMinimal.vue`/`LandingStudio.vue` component (whichever matches the in-progress layout selection) fed via a new `preview` prop assembled from unsaved form state plus already-persisted links/featured-items. This requires a new plan increment, **I10a**, refactoring all 4 layout components from self-fetching (`InitService.fetchLandingData()`) to accepting their data via prop — the public `Landing.vue` dispatcher keeps fetching and now passes the result down, so both the real route and the preview panel share one component contract. + +**Spec/plan/tasks updated in place again** — FR-054-19 rewritten, FR-054-27 removed (gap documented alongside FR-054-09/26), new FR-054-30, Goal #11 rewritten, Overview paragraph rewritten, the `LandingConfig.vue` mockup rebuilt as a two-column watermarker-style layout, S-054-31 reversed, Q-054-08/16 cards marked reversed/moot, two new Decision Cards (Q-054-22/23) added. plan.md's I11 rebuilt around the two-column form+preview design with an explicit Save step; new I10a increment added before it. tasks.md: I11's tasks rebuilt (T-054-36a-d), new I10a tasks (T-054-35a/b), T-054-38a added as an explicit regression guard confirming the flat list still shows everything. + +**Not yet done:** Implementation not started. Multiple large reversals landed this session (custom CSS dropped, icon field dropped, stats dropped, admin-UI architecture reversed twice) — holding this whole accumulated diff for explicit review/commit confirmation before pushing again, given how much has changed since the last push. + +### Feature 054 — Decision Cards Added for All 10 Resolved Questions (same session, 2026-08-10) + +**Request:** "Great to add the open questions, but what about the details???" — after the Q-054-07..10 rows were added to `open-questions.md` as one-line summaries. + +**Gap found:** `docs/specs/4-architecture/spec-guidelines/open-questions-format.md` defines a formal "Decision Card" template (Question, lettered options each with Idea/Spec impact/Pros/Cons, preferred option, Next action) intended for exactly this — but the spec.md Appendix only had loose prose bullets for Q-054-01..10, and `open-questions.md`'s table has no options column at all (confirmed this is true of every existing row in the file, not just this feature's — a repo-wide gap between the documented format and actual practice, not something introduced this session). + +**Fix:** Replaced spec.md's "Resolved Scope Decisions"/"Admin UI Architecture Decision" prose sections with full Decision Cards for all 10 questions (Q-054-01..10), following the template exactly — including Q-054-03 and Q-054-09, where the resolved option was **not** the one originally recommended (Q-054-03: user picked the SE-gated option over the recommended free-for-everyone one, same override pattern as Q-052-07; Q-054-09 went through two rounds of refinement, C→B→A, all three stages documented as options with the evolution explained in "Next action"). Also updated `open-questions.md`'s 10 Q-054 rows to cross-reference the cards' A/B/C lettering and preferred option, per the format guide's stated relationship between the index and the cards. + +**Not yet done:** Same as above — implementation not started. + +### Feature 054 — Admin UI Architecture Revision (same session, 2026-08-10) + +**Request:** "I think we may want to have a configuration page similar to the NSFW classifier." + +**Read `resources/js/v8/views/admin/NsfwConfig.vue` (Feature 045)** to confirm the exact pattern being referenced: a dedicated admin page (`UTabs`, curated `Fieldset` sections per tab) that still reads/writes through the same generic `SettingsService.getAll()`/`setConfigs()` API the flat settings list uses — no new config backend, just a better-organized UI on top. Confirmed (no `SettingsController` filtering found for NSFW's keys) that curated-page keys are *not* hidden from the flat generic list — both views coexist. + +**Resolved as Q-054-06:** replaced FR-054-18/19's original "no bespoke Vue needed, flat generic settings list is sufficient" with a dedicated `resources/js/v8/views/admin/LandingConfig.vue` page — a Settings tab (11 keys in 4 curated `Fieldset` groups: Layout & Structure, Hero, Content, Advanced) plus a Links tab (the `LandingLink` CRUD, folded in here instead of the previously-planned standalone `LandingLinks.vue` route — mirrors how `NsfwConfig.vue`'s own second tab, "Presets," shows a related-but-distinct view alongside "Settings"). Registered as an admin tile (`group: "core"`, alongside `settings`/`design-system`) at `/admin/landing-config`. The flat generic list keeps working unfiltered in parallel, matching NSFW's precedent. + +**Spec/plan/tasks updated in place** (rev 3): FR-054-18/19 rewritten; new UI-054-06; plan's I11 rebuilt in full (was "generic UI + separate LandingLinks.vue page," now "LandingConfig.vue scaffold → Settings tab → SE badges → Links tab → reorder → route/tile registration"); tasks.md's I11 block rewritten (T-054-36/36a/36b/37/37a/38). Task/increment counts unchanged in shape, only I11's content changed. + +**Not yet done:** Same as above — implementation not started. + + + ### Feature 053 – Album Listing Caching — Spec/Plan/Tasks drafted (this session, 2026-08-08) **Request:** Add caching support for sub-album/album listings, building on Feature 052's `ManagedCacheService`. Explicit requirement: find every place in the codebase that would need a cache refresh, and use proper domain events dispatched at mutation sites — not Eloquent model observers, since many mutations bypass Eloquent's event system entirely (raw `DB::table()`, bulk `Model::query()->update()`, pivot `attach()`/`detach()`, vendor nested-set internals). @@ -135,6 +282,23 @@ _Last updated: 2026-08-10_ ## Next Steps +<<<<<<< HEAD +1. Feature 054 is done — no follow-up required unless the deferred-to-backlog items (true modular section builder, mosaic/grid-first layout, second About image slot, testimonials block, shared `AlbumHeaderPanel.vue`/landing position-class utility) are picked up as a future feature. See tasks.md's T-054-62 note for the small set of Branch & Scenario Matrix rows verified only at the automated-test level rather than re-clicked through a browser. +2. Feature 052 is done — no follow-up required unless broader `ManagedCacheService` adoption (deferred per spec Non-Goals) is picked up as a future feature. +2. Confirm dependency approvals (`@nuxt/ui`, `@iconify-json/prime`) with the user, then start Feature 049 implementation at T-049-01 (install Nuxt UI in standalone Vue mode) — see [tasks.md](4-architecture/features/049-nuxt-ui-migration/tasks.md). +3. Alternatively/in parallel across sessions: start Feature 048 implementation at T-048-01 (repo-wide caller sweep) then T-048-02/03 (unit tests reproducing the bug) — see [tasks.md](4-architecture/features/048-fix-multi-group-permissions/tasks.md). +4. Feature 047 (Person Smart Album) remains drafted but not implemented — no active work this session. +5. Feature 042 Part B (I7–I10, admin maintenance photo title links) remains outstanding from a prior session — see [tasks.md](4-architecture/features/042-webshop-order-item-display/tasks.md) T-042-16 to T-042-20. + +## Open Questions + +None blocking. Q-054-01..19 resolved 2026-08-10 (spec-drafting session, see spec.md Appendix for full Decision Cards); the implementation-session Q-054-01 (open-questions.md's `ConfigIntegrity` whitelist question, distinct numbering — logged in open-questions.md, not spec.md's Decision Cards) resolved 2026-08-11. Q-052-01..07 all resolved (01-05 on 2026-07-21, 06-07 on 2026-07-28 — see spec.md and open-questions.md for full rationale, including Q-052-07's non-default Option B resolution). Q-049-01, Q-049-02, Q-049-03 resolved 2026-07-02 (ADR-0005). Q-048-01 resolved 2026-07-01. + +## Key Artefacts + +- Feature 054: [spec.md](4-architecture/features/054-configurable-landing-page/spec.md) · [plan.md](4-architecture/features/054-configurable-landing-page/plan.md) · [tasks.md](4-architecture/features/054-configurable-landing-page/tasks.md) (implemented, T-054-01..63 all `[x]`) +- Feature 052: [spec.md](4-architecture/features/052-managed-cache-service/spec.md) · [plan.md](4-architecture/features/052-managed-cache-service/plan.md) · [tasks.md](4-architecture/features/052-managed-cache-service/tasks.md) (implemented, T-052-01..22 all `[x]`) +======= 1. Feature 053 is implemented and quality-gated (phpstan/cs-fixer green via targeted tests) — a full non-`--filter` `php artisan test` run is still recommended before treating it as fully end-to-end verified (deferred this session per explicit instruction). 2. Feature 052 is otherwise done — no further follow-up unless broader `ManagedCacheService` adoption beyond Feature 053 (e.g. photo-listing caching, deferred per Feature 053's Non-Goals) is picked up later. 3. Confirm dependency approvals (`@nuxt/ui`, `@iconify-json/prime`) with the user, then start Feature 049 implementation at T-049-01 (install Nuxt UI in standalone Vue mode) — see [tasks.md](4-architecture/features/049-nuxt-ui-migration/tasks.md). @@ -150,6 +314,7 @@ None blocking. Q-053-01..13 all resolved 2026-08-08/09 (see spec.md and open-que - Feature 053: [spec.md](4-architecture/features/053-album-listing-caching/spec.md) · [plan.md](4-architecture/features/053-album-listing-caching/plan.md) · [tasks.md](4-architecture/features/053-album-listing-caching/tasks.md) (implemented, T-053-01..24 all `[x]`; full-suite `php artisan test` run still outstanding) - Feature 052: [spec.md](4-architecture/features/052-managed-cache-service/spec.md) · [plan.md](4-architecture/features/052-managed-cache-service/plan.md) · [tasks.md](4-architecture/features/052-managed-cache-service/tasks.md) (implemented, T-052-01..22 all `[x]`; T-052-05/06 resumed and completed as Feature 053's T-053-01) +>>>>>>> master - Feature 049: [spec.md](4-architecture/features/049-nuxt-ui-migration/spec.md) · [plan.md](4-architecture/features/049-nuxt-ui-migration/plan.md) · [tasks.md](4-architecture/features/049-nuxt-ui-migration/tasks.md) · [ADR-0005](6-decisions/ADR-0005-nuxt-ui-migration.md) - Feature 048: [spec.md](4-architecture/features/048-fix-multi-group-permissions/spec.md) · [plan.md](4-architecture/features/048-fix-multi-group-permissions/plan.md) · [tasks.md](4-architecture/features/048-fix-multi-group-permissions/tasks.md) - Open questions: [open-questions.md](4-architecture/open-questions.md) (Q-053-01..13, Q-052-01..07, Q-049-01..03, Q-048-01 — all resolved) diff --git a/lang/ar/all_settings.php b/lang/ar/all_settings.php index 83b0245d573..6f89faea299 100644 --- a/lang/ar/all_settings.php +++ b/lang/ar/all_settings.php @@ -216,6 +216,18 @@ 'landing_background_portrait_mode' => 'وضع الخلفية الرأسية', 'landing_background_landscape' => 'قيمة الخلفية الأفقية (رابط، معرّف صورة، أو معرّف ألبوم)', 'landing_background_portrait' => 'قيمة الخلفية الرأسية (رابط، معرّف صورة، أو معرّف ألبوم)', + 'landing_layout' => 'Landing page layout', + 'landing_intro_screen_enabled' => 'Enable the intro splash screen', + 'landing_hero_text_position' => 'Hero text position', + 'landing_hero_text_color' => 'Hero text color', + 'landing_hero_text_opacity' => 'Hero text opacity (%)', + 'landing_animation_preset' => 'Landing page animation preset', + 'landing_about_enabled' => 'Enable the about section', + 'landing_about_text' => 'About section text', + 'landing_featured_items_enabled' => 'Enable the featured content section', + 'landing_featured_items_mode' => 'Featured content mode', + 'landing_featured_items_count' => 'Number of automatic featured items', + 'landing_cta_text' => 'Call-to-action text override', 'metrics_enabled' => 'تفعيل الإحصائيات على الصور والألبومات', 'metrics_logged_in_users_enabed' => 'تفعيل الإحصائيات للمستخدمين المسجلين', 'metrics_access' => 'مستوى الوصول لإحصائيات الألبوم/الصورة', @@ -613,6 +625,18 @@ 'landing_background_portrait_mode' => 'الخيارات: static (رابط)، photo_id (صورة محددة)، random (صورة عامة عشوائية)، latest_album_cover (أحدث غلاف ألبوم)، random_from_album (عشوائي من ألبوم).', 'landing_background_landscape' => 'يعتمد على الوضع: رابط لـ static، معرّف صورة لـ photo_id، معرّف ألبوم لـ random_from_album. تُستخدم هذه الصورة أيضًا عند مشاركة رابط المعرض مباشرة.', 'landing_background_portrait' => 'يعتمد على الوضع: رابط لـ static، معرّف صورة لـ photo_id، معرّف ألبوم لـ random_from_album.', + 'landing_layout' => 'Options: classic (default, free), portfolio/minimal/studio (require Lychee SE). Classic is unchanged and remains free forever.', + 'landing_intro_screen_enabled' => 'Applies to the classic and portfolio layouts. Disable to skip straight to the hero.', + 'landing_hero_text_position' => 'Options: top_left, top_right, bottom_left, bottom_right, center. Applies to the classic and portfolio layouts.', + 'landing_hero_text_color' => 'Leave empty to use the default white. Applies to the headline and subtitle text only, on every layout.', + 'landing_hero_text_opacity' => 'Range 0-100. Applies to the headline and subtitle text only, on every layout.', + 'landing_animation_preset' => 'Options: none/classic_fade (free), zoom_in/parallax_scroll/slide_reveal (require Lychee SE).', + 'landing_about_enabled' => 'Applies to the portfolio and minimal layouts.', + 'landing_about_text' => 'Admin-authored HTML, rendered verbatim (same trust model as the footer additional text).', + 'landing_featured_items_enabled' => 'Applies to the portfolio layout only. Requires Lychee SE to take effect.', + 'landing_featured_items_mode' => 'Automatic: most recently published public albums. Manual: admin-curated photos/albums.', + 'landing_featured_items_count' => 'Range 3-12. Only used in automatic featured content mode.', + 'landing_cta_text' => 'Leave empty to use each layout\'s default label.', 'metrics_enabled' => 'عند التفعيل، سيتم قياس نشاط المستخدمين المجهولين.', 'metrics_logged_in_users_enabed' => 'عند التفعيل، سيتم قياس نشاط المستخدمين المسجلين أيضًا (لا يُقاس نشاط المسؤولين).', 'metrics_access' => '', diff --git a/lang/ar/landing.php b/lang/ar/landing.php index be6edae1e40..7de5899dcbc 100644 --- a/lang/ar/landing.php +++ b/lang/ar/landing.php @@ -10,4 +10,20 @@ 'access_gallery' => 'الوصول إلى المعرض', 'Powered_by_Lychee' => 'مدعوم من Lychee', 'copyright' => 'جميع الصور على هذا الموقع تخضع لحقوق النشر بواسطة %1$s © %2$s', + 'client_login' => 'Client Login', + 'view_public_gallery' => 'View public gallery', + 'contact' => 'Contact', + 'portfolio' => [ + 'about' => 'About', + 'featured' => 'Recent Work', + 'scroll_down' => 'Scroll down', + ], + 'meridian' => [ + 'explore_label' => 'Explore', + 'explore_caption' => 'View the gallery', + 'contact_caption' => 'Get in touch', + ], + 'studio' => [ + 'welcome_back' => 'Welcome back', + ], ]; diff --git a/lang/ar/landing_config.php b/lang/ar/landing_config.php new file mode 100644 index 00000000000..02f332b1335 --- /dev/null +++ b/lang/ar/landing_config.php @@ -0,0 +1,103 @@ + 'Landing Page', + 'tab_settings' => 'Settings', + 'tab_links' => 'Links', + 'tab_featured' => 'Featured', + + 'section_layout' => 'Layout & Structure', + 'section_hero' => 'Hero', + 'section_background_landscape' => 'Background (Landscape)', + 'section_background_portrait' => 'Background (Portrait)', + 'section_cta_position' => 'Call-to-Action Position', + 'section_content' => 'Content', + + 'field_layout' => 'Layout', + 'field_intro_screen_enabled' => 'Intro splash screen', + 'field_hero_text_position' => 'Hero text position', + 'field_hero_text_color' => 'Hero text color', + 'field_hero_text_opacity' => 'Hero text opacity', + 'field_animation_preset' => 'Animation preset', + 'field_cta_text' => 'text', + 'field_cta_text_placeholder' => 'Leave empty for the layout default', + 'field_about_enabled' => 'About section', + 'field_about_text' => 'About text', + + 'field_background_mode' => 'Source', + 'background_mode_options' => [ + 'static' => 'URL', + 'photo_id' => 'Photo ID', + 'random' => 'Random public photo', + 'latest_album_cover' => 'Latest album cover', + 'random_from_album' => 'Random photo from album', + ], + 'field_background_url' => 'Image URL', + 'field_background_url_placeholder' => 'https://…', + 'field_background_photo_id' => 'Photo ID', + 'field_background_photo_id_placeholder' => '24-character photo ID', + 'field_background_photo_id_hint' => 'Photo ID of the image to use. Open a photo and copy the last 24 characters from the URL.', + 'field_background_album_id' => 'Album ID', + 'field_background_album_id_placeholder' => '24-character album ID', + 'background_load_error' => 'Could not load photo. Make sure the ID is correct and you have access to it.', + 'background_mode_hint' => [ + 'random' => 'A random public photo is picked on every page load.', + 'latest_album_cover' => 'The cover of the most recently published public album is used.', + 'random_from_album' => 'A random photo from this album is picked on every page load. The preview only updates after saving.', + ], + + 'preview_orientation_landscape' => 'Preview landscape background', + 'preview_orientation_portrait' => 'Preview portrait background', + + 'field_cta_position' => 'Position', + 'cta_position_options' => [ + 'top-left' => 'Top Left', + 'top' => 'Top Center', + 'top-right' => 'Top Right', + 'left' => 'Middle Left', + 'center' => 'Center', + 'right' => 'Middle Right', + 'bottom-left' => 'Bottom Left', + 'bottom' => 'Bottom Center', + 'bottom-right' => 'Bottom Right', + ], + 'field_cta_shift_type' => 'Shift unit', + 'cta_shift_type_options' => [ + 'relative' => 'Relative (%)', + 'absolute' => 'Absolute (px)', + ], + 'cta_shift_type_hint' => 'Relative shifts are a percentage of the viewport size; absolute shifts are a fixed number of pixels.', + 'field_cta_shift_x' => 'Horizontal Shift (:value)', + 'cta_shift_x_direction_options' => [ + 'left' => 'Left', + 'right' => 'Right', + ], + 'field_cta_shift_y' => 'Vertical Shift (:value)', + 'cta_shift_y_direction_options' => [ + 'up' => 'Up', + 'down' => 'Down', + ], + 'reset_to_zero' => 'Reset to 0', + + 'preview_title' => 'Live Preview', + 'preview_hint' => 'Updates instantly as you edit — nothing is saved until you click Save.', + 'flat_list_hint' => 'These settings are also editable from the flat generic Settings list.', + + 'save' => 'Save', + 'saved' => 'Landing page settings saved.', + 'save_error' => 'Failed to save landing page settings.', + + 'se_required' => 'This layout/preset requires Lychee SE.', +]; diff --git a/lang/ar/landing_featured_item.php b/lang/ar/landing_featured_item.php new file mode 100644 index 00000000000..3ee73cbc4cc --- /dev/null +++ b/lang/ar/landing_featured_item.php @@ -0,0 +1,51 @@ + 'Featured Content', + 'description' => 'Curate specific photos and albums to feature on the landing page.', + + 'mode_automatic' => 'Automatic (latest published public albums)', + 'mode_manual' => 'Manual (curate specific photos/albums)', + + 'field_enabled' => 'Enable featured content', + 'field_mode' => 'Mode', + 'field_count' => 'Number of automatic items', + 'field_count_hint' => 'Between 3 and 12.', + + 'search_placeholder' => 'Search photos/albums by title…', + 'search_no_results' => 'No results.', + 'add' => 'Add', + + 'no_items' => 'No items curated yet.', + 'col_preview' => 'Preview', + 'col_title' => 'Title', + 'col_type' => 'Type', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + 'type_photo' => 'Photo', + 'type_album' => 'Album', + + 'confirm_delete_header' => 'Remove Featured Item', + 'confirm_delete_message' => 'Are you sure you want to remove ":title" from the featured content?', + + 'added' => 'Item added successfully.', + 'deleted' => 'Item removed successfully.', + 'reordered' => 'Featured items reordered successfully.', + 'error_load' => 'Failed to load featured items.', + 'error_search' => 'Failed to search.', + 'error_add' => 'Failed to add item.', + 'error_delete' => 'Failed to remove item.', + 'error_reorder' => 'Failed to reorder featured items.', +]; diff --git a/lang/ar/landing_link.php b/lang/ar/landing_link.php new file mode 100644 index 00000000000..cf37252cb5f --- /dev/null +++ b/lang/ar/landing_link.php @@ -0,0 +1,73 @@ + 'Extra Links', + 'description' => 'Manage an arbitrary, ordered list of extra links shown on the nav and/or footer of the landing page.', + + // Empty state + 'no_links' => 'No extra links configured yet.', + 'create_first' => 'Create your first link', + + // Table columns + 'col_label' => 'Label', + 'col_url' => 'URL', + 'col_placement' => 'Placement', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + + // Placement labels + 'placement_nav' => 'Nav', + 'placement_footer' => 'Footer', + 'placement_both' => 'Nav & Footer', + + // Built-in links (Gallery, Contact) + 'badge_built_in' => 'Built-in', + 'built_in_cannot_delete' => 'This built-in link cannot be deleted. Disable it instead, or reorder it like any other link.', + 'built_in_url_hint' => 'This is a built-in link and its target cannot be changed.', + + // Buttons + 'create' => 'Create Link', + 'edit' => 'Edit', + 'delete' => 'Delete', + 'cancel' => 'Cancel', + 'save' => 'Save', + + // Form fields + 'field_label' => 'Label', + 'field_label_placeholder' => 'e.g. Instagram', + 'field_url' => 'URL', + 'field_url_placeholder' => 'https://example.com', + 'field_placement' => 'Placement', + 'field_open_in_new_tab' => 'Open in new tab', + 'field_enabled' => 'Enabled', + + // Modal titles + 'modal_create_title' => 'Create Link', + 'modal_edit_title' => 'Edit Link', + + // Delete confirmation + 'confirm_delete_header' => 'Delete Link', + 'confirm_delete_message' => 'Are you sure you want to delete the link ":label"? This action cannot be undone.', + + // Toasts + 'created' => 'Link created successfully.', + 'updated' => 'Link updated successfully.', + 'deleted' => 'Link deleted successfully.', + 'reordered' => 'Links reordered successfully.', + 'error_load' => 'Failed to load links.', + 'error_save' => 'Failed to save link.', + 'error_delete' => 'Failed to delete link.', + 'error_reorder' => 'Failed to reorder links.', +]; diff --git a/lang/ar/watermark.php b/lang/ar/watermark.php index 3c5738e58ae..7c7a6be5971 100644 --- a/lang/ar/watermark.php +++ b/lang/ar/watermark.php @@ -57,6 +57,7 @@ 'up' => 'أعلى', 'down' => 'أسفل', ], + 'reset_to_zero' => 'Reset to 0', 'save' => 'حفظ الإعدادات', 'saved' => 'تم حفظ إعدادات العلامة المائية.', diff --git a/lang/bg/all_settings.php b/lang/bg/all_settings.php index 031f3da4d98..47d7cc1a666 100644 --- a/lang/bg/all_settings.php +++ b/lang/bg/all_settings.php @@ -214,6 +214,18 @@ 'landing_background_portrait_mode' => 'Mode for portrait background', 'landing_background_landscape' => 'Value for landscape background (URL, photo ID, or album ID)', 'landing_background_portrait' => 'Value for portrait background (URL, photo ID, or album ID)', + 'landing_layout' => 'Landing page layout', + 'landing_intro_screen_enabled' => 'Enable the intro splash screen', + 'landing_hero_text_position' => 'Hero text position', + 'landing_hero_text_color' => 'Hero text color', + 'landing_hero_text_opacity' => 'Hero text opacity (%)', + 'landing_animation_preset' => 'Landing page animation preset', + 'landing_about_enabled' => 'Enable the about section', + 'landing_about_text' => 'About section text', + 'landing_featured_items_enabled' => 'Enable the featured content section', + 'landing_featured_items_mode' => 'Featured content mode', + 'landing_featured_items_count' => 'Number of automatic featured items', + 'landing_cta_text' => 'Call-to-action text override', 'metrics_enabled' => 'Активиране на статистика за снимки и албуми', 'metrics_logged_in_users_enabed' => 'Активиране на статистика за влезли потребители', 'metrics_access' => 'Ниво на достъп до статистиката на албума/снимката', @@ -611,6 +623,18 @@ 'landing_background_portrait_mode' => 'Options: static (URL), photo_id (specific photo), random (random public photo), latest_album_cover (latest album cover), random_from_album (random from album).', 'landing_background_landscape' => 'Depends on mode: URL for static, photo ID for photo_id, album ID for random_from_album. This image is also used when sharing the gallery link directly.', 'landing_background_portrait' => 'Depends on mode: URL for static, photo ID for photo_id, album ID for random_from_album.', + 'landing_layout' => 'Options: classic (default, free), portfolio/minimal/studio (require Lychee SE). Classic is unchanged and remains free forever.', + 'landing_intro_screen_enabled' => 'Applies to the classic and portfolio layouts. Disable to skip straight to the hero.', + 'landing_hero_text_position' => 'Options: top_left, top_right, bottom_left, bottom_right, center. Applies to the classic and portfolio layouts.', + 'landing_hero_text_color' => 'Leave empty to use the default white. Applies to the headline and subtitle text only, on every layout.', + 'landing_hero_text_opacity' => 'Range 0-100. Applies to the headline and subtitle text only, on every layout.', + 'landing_animation_preset' => 'Options: none/classic_fade (free), zoom_in/parallax_scroll/slide_reveal (require Lychee SE).', + 'landing_about_enabled' => 'Applies to the portfolio and minimal layouts.', + 'landing_about_text' => 'Admin-authored HTML, rendered verbatim (same trust model as the footer additional text).', + 'landing_featured_items_enabled' => 'Applies to the portfolio layout only. Requires Lychee SE to take effect.', + 'landing_featured_items_mode' => 'Automatic: most recently published public albums. Manual: admin-curated photos/albums.', + 'landing_featured_items_count' => 'Range 3-12. Only used in automatic featured content mode.', + 'landing_cta_text' => 'Leave empty to use each layout\'s default label.', 'metrics_enabled' => 'Ако е активирано, анонимните потребители ще бъдат измервани.', 'metrics_logged_in_users_enabed' => 'Ако е активирано, влезлите потребители също ще бъдат измервани (администраторите не се измерват).', 'metrics_access' => '', diff --git a/lang/bg/landing.php b/lang/bg/landing.php index bcc1a3bf09b..ca92e833395 100644 --- a/lang/bg/landing.php +++ b/lang/bg/landing.php @@ -10,4 +10,20 @@ 'access_gallery' => 'Достъп до галерията', 'Powered_by_Lychee' => 'Задвижвано от Lychee', 'copyright' => 'Всички изображения на този уебсайт са защитени с авторски права от %1$s © %2$s', + 'client_login' => 'Client Login', + 'view_public_gallery' => 'View public gallery', + 'contact' => 'Contact', + 'portfolio' => [ + 'about' => 'About', + 'featured' => 'Recent Work', + 'scroll_down' => 'Scroll down', + ], + 'meridian' => [ + 'explore_label' => 'Explore', + 'explore_caption' => 'View the gallery', + 'contact_caption' => 'Get in touch', + ], + 'studio' => [ + 'welcome_back' => 'Welcome back', + ], ]; diff --git a/lang/bg/landing_config.php b/lang/bg/landing_config.php new file mode 100644 index 00000000000..02f332b1335 --- /dev/null +++ b/lang/bg/landing_config.php @@ -0,0 +1,103 @@ + 'Landing Page', + 'tab_settings' => 'Settings', + 'tab_links' => 'Links', + 'tab_featured' => 'Featured', + + 'section_layout' => 'Layout & Structure', + 'section_hero' => 'Hero', + 'section_background_landscape' => 'Background (Landscape)', + 'section_background_portrait' => 'Background (Portrait)', + 'section_cta_position' => 'Call-to-Action Position', + 'section_content' => 'Content', + + 'field_layout' => 'Layout', + 'field_intro_screen_enabled' => 'Intro splash screen', + 'field_hero_text_position' => 'Hero text position', + 'field_hero_text_color' => 'Hero text color', + 'field_hero_text_opacity' => 'Hero text opacity', + 'field_animation_preset' => 'Animation preset', + 'field_cta_text' => 'text', + 'field_cta_text_placeholder' => 'Leave empty for the layout default', + 'field_about_enabled' => 'About section', + 'field_about_text' => 'About text', + + 'field_background_mode' => 'Source', + 'background_mode_options' => [ + 'static' => 'URL', + 'photo_id' => 'Photo ID', + 'random' => 'Random public photo', + 'latest_album_cover' => 'Latest album cover', + 'random_from_album' => 'Random photo from album', + ], + 'field_background_url' => 'Image URL', + 'field_background_url_placeholder' => 'https://…', + 'field_background_photo_id' => 'Photo ID', + 'field_background_photo_id_placeholder' => '24-character photo ID', + 'field_background_photo_id_hint' => 'Photo ID of the image to use. Open a photo and copy the last 24 characters from the URL.', + 'field_background_album_id' => 'Album ID', + 'field_background_album_id_placeholder' => '24-character album ID', + 'background_load_error' => 'Could not load photo. Make sure the ID is correct and you have access to it.', + 'background_mode_hint' => [ + 'random' => 'A random public photo is picked on every page load.', + 'latest_album_cover' => 'The cover of the most recently published public album is used.', + 'random_from_album' => 'A random photo from this album is picked on every page load. The preview only updates after saving.', + ], + + 'preview_orientation_landscape' => 'Preview landscape background', + 'preview_orientation_portrait' => 'Preview portrait background', + + 'field_cta_position' => 'Position', + 'cta_position_options' => [ + 'top-left' => 'Top Left', + 'top' => 'Top Center', + 'top-right' => 'Top Right', + 'left' => 'Middle Left', + 'center' => 'Center', + 'right' => 'Middle Right', + 'bottom-left' => 'Bottom Left', + 'bottom' => 'Bottom Center', + 'bottom-right' => 'Bottom Right', + ], + 'field_cta_shift_type' => 'Shift unit', + 'cta_shift_type_options' => [ + 'relative' => 'Relative (%)', + 'absolute' => 'Absolute (px)', + ], + 'cta_shift_type_hint' => 'Relative shifts are a percentage of the viewport size; absolute shifts are a fixed number of pixels.', + 'field_cta_shift_x' => 'Horizontal Shift (:value)', + 'cta_shift_x_direction_options' => [ + 'left' => 'Left', + 'right' => 'Right', + ], + 'field_cta_shift_y' => 'Vertical Shift (:value)', + 'cta_shift_y_direction_options' => [ + 'up' => 'Up', + 'down' => 'Down', + ], + 'reset_to_zero' => 'Reset to 0', + + 'preview_title' => 'Live Preview', + 'preview_hint' => 'Updates instantly as you edit — nothing is saved until you click Save.', + 'flat_list_hint' => 'These settings are also editable from the flat generic Settings list.', + + 'save' => 'Save', + 'saved' => 'Landing page settings saved.', + 'save_error' => 'Failed to save landing page settings.', + + 'se_required' => 'This layout/preset requires Lychee SE.', +]; diff --git a/lang/bg/landing_featured_item.php b/lang/bg/landing_featured_item.php new file mode 100644 index 00000000000..3ee73cbc4cc --- /dev/null +++ b/lang/bg/landing_featured_item.php @@ -0,0 +1,51 @@ + 'Featured Content', + 'description' => 'Curate specific photos and albums to feature on the landing page.', + + 'mode_automatic' => 'Automatic (latest published public albums)', + 'mode_manual' => 'Manual (curate specific photos/albums)', + + 'field_enabled' => 'Enable featured content', + 'field_mode' => 'Mode', + 'field_count' => 'Number of automatic items', + 'field_count_hint' => 'Between 3 and 12.', + + 'search_placeholder' => 'Search photos/albums by title…', + 'search_no_results' => 'No results.', + 'add' => 'Add', + + 'no_items' => 'No items curated yet.', + 'col_preview' => 'Preview', + 'col_title' => 'Title', + 'col_type' => 'Type', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + 'type_photo' => 'Photo', + 'type_album' => 'Album', + + 'confirm_delete_header' => 'Remove Featured Item', + 'confirm_delete_message' => 'Are you sure you want to remove ":title" from the featured content?', + + 'added' => 'Item added successfully.', + 'deleted' => 'Item removed successfully.', + 'reordered' => 'Featured items reordered successfully.', + 'error_load' => 'Failed to load featured items.', + 'error_search' => 'Failed to search.', + 'error_add' => 'Failed to add item.', + 'error_delete' => 'Failed to remove item.', + 'error_reorder' => 'Failed to reorder featured items.', +]; diff --git a/lang/bg/landing_link.php b/lang/bg/landing_link.php new file mode 100644 index 00000000000..cf37252cb5f --- /dev/null +++ b/lang/bg/landing_link.php @@ -0,0 +1,73 @@ + 'Extra Links', + 'description' => 'Manage an arbitrary, ordered list of extra links shown on the nav and/or footer of the landing page.', + + // Empty state + 'no_links' => 'No extra links configured yet.', + 'create_first' => 'Create your first link', + + // Table columns + 'col_label' => 'Label', + 'col_url' => 'URL', + 'col_placement' => 'Placement', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + + // Placement labels + 'placement_nav' => 'Nav', + 'placement_footer' => 'Footer', + 'placement_both' => 'Nav & Footer', + + // Built-in links (Gallery, Contact) + 'badge_built_in' => 'Built-in', + 'built_in_cannot_delete' => 'This built-in link cannot be deleted. Disable it instead, or reorder it like any other link.', + 'built_in_url_hint' => 'This is a built-in link and its target cannot be changed.', + + // Buttons + 'create' => 'Create Link', + 'edit' => 'Edit', + 'delete' => 'Delete', + 'cancel' => 'Cancel', + 'save' => 'Save', + + // Form fields + 'field_label' => 'Label', + 'field_label_placeholder' => 'e.g. Instagram', + 'field_url' => 'URL', + 'field_url_placeholder' => 'https://example.com', + 'field_placement' => 'Placement', + 'field_open_in_new_tab' => 'Open in new tab', + 'field_enabled' => 'Enabled', + + // Modal titles + 'modal_create_title' => 'Create Link', + 'modal_edit_title' => 'Edit Link', + + // Delete confirmation + 'confirm_delete_header' => 'Delete Link', + 'confirm_delete_message' => 'Are you sure you want to delete the link ":label"? This action cannot be undone.', + + // Toasts + 'created' => 'Link created successfully.', + 'updated' => 'Link updated successfully.', + 'deleted' => 'Link deleted successfully.', + 'reordered' => 'Links reordered successfully.', + 'error_load' => 'Failed to load links.', + 'error_save' => 'Failed to save link.', + 'error_delete' => 'Failed to delete link.', + 'error_reorder' => 'Failed to reorder links.', +]; diff --git a/lang/bg/watermark.php b/lang/bg/watermark.php index 6860c862127..be6175d3e5b 100644 --- a/lang/bg/watermark.php +++ b/lang/bg/watermark.php @@ -57,6 +57,7 @@ 'up' => 'Up', 'down' => 'Down', ], + 'reset_to_zero' => 'Reset to 0', 'save' => 'Save Settings', 'saved' => 'Watermark settings saved.', diff --git a/lang/cz/all_settings.php b/lang/cz/all_settings.php index 675888b9f27..cd5bd900e09 100644 --- a/lang/cz/all_settings.php +++ b/lang/cz/all_settings.php @@ -216,6 +216,18 @@ 'landing_background_portrait_mode' => 'Mode for portrait background', 'landing_background_landscape' => 'Value for landscape background (URL, photo ID, or album ID)', 'landing_background_portrait' => 'Value for portrait background (URL, photo ID, or album ID)', + 'landing_layout' => 'Landing page layout', + 'landing_intro_screen_enabled' => 'Enable the intro splash screen', + 'landing_hero_text_position' => 'Hero text position', + 'landing_hero_text_color' => 'Hero text color', + 'landing_hero_text_opacity' => 'Hero text opacity (%)', + 'landing_animation_preset' => 'Landing page animation preset', + 'landing_about_enabled' => 'Enable the about section', + 'landing_about_text' => 'About section text', + 'landing_featured_items_enabled' => 'Enable the featured content section', + 'landing_featured_items_mode' => 'Featured content mode', + 'landing_featured_items_count' => 'Number of automatic featured items', + 'landing_cta_text' => 'Call-to-action text override', 'metrics_enabled' => 'Enable statistics on photos & albums', 'metrics_logged_in_users_enabed' => 'Enable statistics for logged-in users', 'metrics_access' => 'Access level for statistics of the album/photo', @@ -613,6 +625,18 @@ 'landing_background_portrait_mode' => 'Options: static (URL), photo_id (specific photo), random (random public photo), latest_album_cover (latest album cover), random_from_album (random from album).', 'landing_background_landscape' => 'Depends on mode: URL for static, photo ID for photo_id, album ID for random_from_album. This image is also used when sharing the gallery link directly.', 'landing_background_portrait' => 'Depends on mode: URL for static, photo ID for photo_id, album ID for random_from_album.', + 'landing_layout' => 'Options: classic (default, free), portfolio/minimal/studio (require Lychee SE). Classic is unchanged and remains free forever.', + 'landing_intro_screen_enabled' => 'Applies to the classic and portfolio layouts. Disable to skip straight to the hero.', + 'landing_hero_text_position' => 'Options: top_left, top_right, bottom_left, bottom_right, center. Applies to the classic and portfolio layouts.', + 'landing_hero_text_color' => 'Leave empty to use the default white. Applies to the headline and subtitle text only, on every layout.', + 'landing_hero_text_opacity' => 'Range 0-100. Applies to the headline and subtitle text only, on every layout.', + 'landing_animation_preset' => 'Options: none/classic_fade (free), zoom_in/parallax_scroll/slide_reveal (require Lychee SE).', + 'landing_about_enabled' => 'Applies to the portfolio and minimal layouts.', + 'landing_about_text' => 'Admin-authored HTML, rendered verbatim (same trust model as the footer additional text).', + 'landing_featured_items_enabled' => 'Applies to the portfolio layout only. Requires Lychee SE to take effect.', + 'landing_featured_items_mode' => 'Automatic: most recently published public albums. Manual: admin-curated photos/albums.', + 'landing_featured_items_count' => 'Range 3-12. Only used in automatic featured content mode.', + 'landing_cta_text' => 'Leave empty to use each layout\'s default label.', 'metrics_enabled' => 'If enabled, anonymours users will be measured.', 'metrics_logged_in_users_enabed' => 'If enabled, logged-in users will be measured as well (admin users are not measured).', 'metrics_access' => '', diff --git a/lang/cz/landing.php b/lang/cz/landing.php index 9913d863cb6..706b2893724 100644 --- a/lang/cz/landing.php +++ b/lang/cz/landing.php @@ -10,4 +10,20 @@ 'access_gallery' => 'Vstup do Galerie', 'Powered_by_Lychee' => 'Běží na platformě Lychee', 'copyright' => 'Všechny obrázky na této webové stránce jsou chráněny autorským právem %1$s © %2$s', + 'client_login' => 'Client Login', + 'view_public_gallery' => 'View public gallery', + 'contact' => 'Contact', + 'portfolio' => [ + 'about' => 'About', + 'featured' => 'Recent Work', + 'scroll_down' => 'Scroll down', + ], + 'meridian' => [ + 'explore_label' => 'Explore', + 'explore_caption' => 'View the gallery', + 'contact_caption' => 'Get in touch', + ], + 'studio' => [ + 'welcome_back' => 'Welcome back', + ], ]; diff --git a/lang/cz/landing_config.php b/lang/cz/landing_config.php new file mode 100644 index 00000000000..02f332b1335 --- /dev/null +++ b/lang/cz/landing_config.php @@ -0,0 +1,103 @@ + 'Landing Page', + 'tab_settings' => 'Settings', + 'tab_links' => 'Links', + 'tab_featured' => 'Featured', + + 'section_layout' => 'Layout & Structure', + 'section_hero' => 'Hero', + 'section_background_landscape' => 'Background (Landscape)', + 'section_background_portrait' => 'Background (Portrait)', + 'section_cta_position' => 'Call-to-Action Position', + 'section_content' => 'Content', + + 'field_layout' => 'Layout', + 'field_intro_screen_enabled' => 'Intro splash screen', + 'field_hero_text_position' => 'Hero text position', + 'field_hero_text_color' => 'Hero text color', + 'field_hero_text_opacity' => 'Hero text opacity', + 'field_animation_preset' => 'Animation preset', + 'field_cta_text' => 'text', + 'field_cta_text_placeholder' => 'Leave empty for the layout default', + 'field_about_enabled' => 'About section', + 'field_about_text' => 'About text', + + 'field_background_mode' => 'Source', + 'background_mode_options' => [ + 'static' => 'URL', + 'photo_id' => 'Photo ID', + 'random' => 'Random public photo', + 'latest_album_cover' => 'Latest album cover', + 'random_from_album' => 'Random photo from album', + ], + 'field_background_url' => 'Image URL', + 'field_background_url_placeholder' => 'https://…', + 'field_background_photo_id' => 'Photo ID', + 'field_background_photo_id_placeholder' => '24-character photo ID', + 'field_background_photo_id_hint' => 'Photo ID of the image to use. Open a photo and copy the last 24 characters from the URL.', + 'field_background_album_id' => 'Album ID', + 'field_background_album_id_placeholder' => '24-character album ID', + 'background_load_error' => 'Could not load photo. Make sure the ID is correct and you have access to it.', + 'background_mode_hint' => [ + 'random' => 'A random public photo is picked on every page load.', + 'latest_album_cover' => 'The cover of the most recently published public album is used.', + 'random_from_album' => 'A random photo from this album is picked on every page load. The preview only updates after saving.', + ], + + 'preview_orientation_landscape' => 'Preview landscape background', + 'preview_orientation_portrait' => 'Preview portrait background', + + 'field_cta_position' => 'Position', + 'cta_position_options' => [ + 'top-left' => 'Top Left', + 'top' => 'Top Center', + 'top-right' => 'Top Right', + 'left' => 'Middle Left', + 'center' => 'Center', + 'right' => 'Middle Right', + 'bottom-left' => 'Bottom Left', + 'bottom' => 'Bottom Center', + 'bottom-right' => 'Bottom Right', + ], + 'field_cta_shift_type' => 'Shift unit', + 'cta_shift_type_options' => [ + 'relative' => 'Relative (%)', + 'absolute' => 'Absolute (px)', + ], + 'cta_shift_type_hint' => 'Relative shifts are a percentage of the viewport size; absolute shifts are a fixed number of pixels.', + 'field_cta_shift_x' => 'Horizontal Shift (:value)', + 'cta_shift_x_direction_options' => [ + 'left' => 'Left', + 'right' => 'Right', + ], + 'field_cta_shift_y' => 'Vertical Shift (:value)', + 'cta_shift_y_direction_options' => [ + 'up' => 'Up', + 'down' => 'Down', + ], + 'reset_to_zero' => 'Reset to 0', + + 'preview_title' => 'Live Preview', + 'preview_hint' => 'Updates instantly as you edit — nothing is saved until you click Save.', + 'flat_list_hint' => 'These settings are also editable from the flat generic Settings list.', + + 'save' => 'Save', + 'saved' => 'Landing page settings saved.', + 'save_error' => 'Failed to save landing page settings.', + + 'se_required' => 'This layout/preset requires Lychee SE.', +]; diff --git a/lang/cz/landing_featured_item.php b/lang/cz/landing_featured_item.php new file mode 100644 index 00000000000..3ee73cbc4cc --- /dev/null +++ b/lang/cz/landing_featured_item.php @@ -0,0 +1,51 @@ + 'Featured Content', + 'description' => 'Curate specific photos and albums to feature on the landing page.', + + 'mode_automatic' => 'Automatic (latest published public albums)', + 'mode_manual' => 'Manual (curate specific photos/albums)', + + 'field_enabled' => 'Enable featured content', + 'field_mode' => 'Mode', + 'field_count' => 'Number of automatic items', + 'field_count_hint' => 'Between 3 and 12.', + + 'search_placeholder' => 'Search photos/albums by title…', + 'search_no_results' => 'No results.', + 'add' => 'Add', + + 'no_items' => 'No items curated yet.', + 'col_preview' => 'Preview', + 'col_title' => 'Title', + 'col_type' => 'Type', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + 'type_photo' => 'Photo', + 'type_album' => 'Album', + + 'confirm_delete_header' => 'Remove Featured Item', + 'confirm_delete_message' => 'Are you sure you want to remove ":title" from the featured content?', + + 'added' => 'Item added successfully.', + 'deleted' => 'Item removed successfully.', + 'reordered' => 'Featured items reordered successfully.', + 'error_load' => 'Failed to load featured items.', + 'error_search' => 'Failed to search.', + 'error_add' => 'Failed to add item.', + 'error_delete' => 'Failed to remove item.', + 'error_reorder' => 'Failed to reorder featured items.', +]; diff --git a/lang/cz/landing_link.php b/lang/cz/landing_link.php new file mode 100644 index 00000000000..cf37252cb5f --- /dev/null +++ b/lang/cz/landing_link.php @@ -0,0 +1,73 @@ + 'Extra Links', + 'description' => 'Manage an arbitrary, ordered list of extra links shown on the nav and/or footer of the landing page.', + + // Empty state + 'no_links' => 'No extra links configured yet.', + 'create_first' => 'Create your first link', + + // Table columns + 'col_label' => 'Label', + 'col_url' => 'URL', + 'col_placement' => 'Placement', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + + // Placement labels + 'placement_nav' => 'Nav', + 'placement_footer' => 'Footer', + 'placement_both' => 'Nav & Footer', + + // Built-in links (Gallery, Contact) + 'badge_built_in' => 'Built-in', + 'built_in_cannot_delete' => 'This built-in link cannot be deleted. Disable it instead, or reorder it like any other link.', + 'built_in_url_hint' => 'This is a built-in link and its target cannot be changed.', + + // Buttons + 'create' => 'Create Link', + 'edit' => 'Edit', + 'delete' => 'Delete', + 'cancel' => 'Cancel', + 'save' => 'Save', + + // Form fields + 'field_label' => 'Label', + 'field_label_placeholder' => 'e.g. Instagram', + 'field_url' => 'URL', + 'field_url_placeholder' => 'https://example.com', + 'field_placement' => 'Placement', + 'field_open_in_new_tab' => 'Open in new tab', + 'field_enabled' => 'Enabled', + + // Modal titles + 'modal_create_title' => 'Create Link', + 'modal_edit_title' => 'Edit Link', + + // Delete confirmation + 'confirm_delete_header' => 'Delete Link', + 'confirm_delete_message' => 'Are you sure you want to delete the link ":label"? This action cannot be undone.', + + // Toasts + 'created' => 'Link created successfully.', + 'updated' => 'Link updated successfully.', + 'deleted' => 'Link deleted successfully.', + 'reordered' => 'Links reordered successfully.', + 'error_load' => 'Failed to load links.', + 'error_save' => 'Failed to save link.', + 'error_delete' => 'Failed to delete link.', + 'error_reorder' => 'Failed to reorder links.', +]; diff --git a/lang/cz/watermark.php b/lang/cz/watermark.php index 6860c862127..be6175d3e5b 100644 --- a/lang/cz/watermark.php +++ b/lang/cz/watermark.php @@ -57,6 +57,7 @@ 'up' => 'Up', 'down' => 'Down', ], + 'reset_to_zero' => 'Reset to 0', 'save' => 'Save Settings', 'saved' => 'Watermark settings saved.', diff --git a/lang/de/all_settings.php b/lang/de/all_settings.php index 2b222b80149..7b350751870 100644 --- a/lang/de/all_settings.php +++ b/lang/de/all_settings.php @@ -214,6 +214,18 @@ 'landing_background_portrait_mode' => 'Modus für Hintergrund im Hochformat', 'landing_background_landscape' => 'Hintergrund im Querformat (URL, Foto-ID oder Album-ID)', 'landing_background_portrait' => 'Hintergrund im Hochformat (URL, Foto-ID oder Album-ID)', + 'landing_layout' => 'Landing page layout', + 'landing_intro_screen_enabled' => 'Enable the intro splash screen', + 'landing_hero_text_position' => 'Hero text position', + 'landing_hero_text_color' => 'Hero text color', + 'landing_hero_text_opacity' => 'Hero text opacity (%)', + 'landing_animation_preset' => 'Landing page animation preset', + 'landing_about_enabled' => 'Enable the about section', + 'landing_about_text' => 'About section text', + 'landing_featured_items_enabled' => 'Enable the featured content section', + 'landing_featured_items_mode' => 'Featured content mode', + 'landing_featured_items_count' => 'Number of automatic featured items', + 'landing_cta_text' => 'Call-to-action text override', 'metrics_enabled' => 'Statistiken für Fotos und Alben aktivieren', 'metrics_logged_in_users_enabed' => 'Statistiken für angemeldete Benutzer aktivieren', 'metrics_access' => 'Zugriffsebene für Album- und Fotostatistiken', @@ -611,6 +623,18 @@ 'landing_background_portrait_mode' => 'Optionen: static (URL), photo_id (spezifisches Foto), random (zufälliges öffentliches Foto), latest_album_cover (aktuellstes Albumcover), random_from_album (Zufallsbild aus Album).', 'landing_background_landscape' => 'Abhängig vom Modus: URL für „static“, Foto-ID für „photo_id“, Album-ID für „random_from_album“. Dieses Bild wird auch beim direkten Teilen des Galerie-Links verwendet.', 'landing_background_portrait' => 'Abhängig vom Modus: URL für „static“, Foto-ID für „photo_id“, Album-ID für „random_from_album“.', + 'landing_layout' => 'Options: classic (default, free), portfolio/minimal/studio (require Lychee SE). Classic is unchanged and remains free forever.', + 'landing_intro_screen_enabled' => 'Applies to the classic and portfolio layouts. Disable to skip straight to the hero.', + 'landing_hero_text_position' => 'Options: top_left, top_right, bottom_left, bottom_right, center. Applies to the classic and portfolio layouts.', + 'landing_hero_text_color' => 'Leave empty to use the default white. Applies to the headline and subtitle text only, on every layout.', + 'landing_hero_text_opacity' => 'Range 0-100. Applies to the headline and subtitle text only, on every layout.', + 'landing_animation_preset' => 'Options: none/classic_fade (free), zoom_in/parallax_scroll/slide_reveal (require Lychee SE).', + 'landing_about_enabled' => 'Applies to the portfolio and minimal layouts.', + 'landing_about_text' => 'Admin-authored HTML, rendered verbatim (same trust model as the footer additional text).', + 'landing_featured_items_enabled' => 'Applies to the portfolio layout only. Requires Lychee SE to take effect.', + 'landing_featured_items_mode' => 'Automatic: most recently published public albums. Manual: admin-curated photos/albums.', + 'landing_featured_items_count' => 'Range 3-12. Only used in automatic featured content mode.', + 'landing_cta_text' => 'Leave empty to use each layout\'s default label.', 'metrics_enabled' => 'Wenn aktiviert, werden anonyme Benutzer statistisch erfasst.', 'metrics_logged_in_users_enabed' => 'Wenn aktiviert, werden auch angemeldete Benutzer erfasst (Administratoren ausgenommen).', 'metrics_access' => '', diff --git a/lang/de/landing.php b/lang/de/landing.php index b458dbc802d..48ed657e1ea 100644 --- a/lang/de/landing.php +++ b/lang/de/landing.php @@ -10,4 +10,20 @@ 'access_gallery' => 'Zugang zur Galerie', 'Powered_by_Lychee' => 'Unterstützt von Lychee', 'copyright' => 'Alle Bilder auf dieser Website unterliegen dem Copyright von %1$s © %2$s', + 'client_login' => 'Client Login', + 'view_public_gallery' => 'View public gallery', + 'contact' => 'Contact', + 'portfolio' => [ + 'about' => 'About', + 'featured' => 'Recent Work', + 'scroll_down' => 'Scroll down', + ], + 'meridian' => [ + 'explore_label' => 'Explore', + 'explore_caption' => 'View the gallery', + 'contact_caption' => 'Get in touch', + ], + 'studio' => [ + 'welcome_back' => 'Welcome back', + ], ]; diff --git a/lang/de/landing_config.php b/lang/de/landing_config.php new file mode 100644 index 00000000000..02f332b1335 --- /dev/null +++ b/lang/de/landing_config.php @@ -0,0 +1,103 @@ + 'Landing Page', + 'tab_settings' => 'Settings', + 'tab_links' => 'Links', + 'tab_featured' => 'Featured', + + 'section_layout' => 'Layout & Structure', + 'section_hero' => 'Hero', + 'section_background_landscape' => 'Background (Landscape)', + 'section_background_portrait' => 'Background (Portrait)', + 'section_cta_position' => 'Call-to-Action Position', + 'section_content' => 'Content', + + 'field_layout' => 'Layout', + 'field_intro_screen_enabled' => 'Intro splash screen', + 'field_hero_text_position' => 'Hero text position', + 'field_hero_text_color' => 'Hero text color', + 'field_hero_text_opacity' => 'Hero text opacity', + 'field_animation_preset' => 'Animation preset', + 'field_cta_text' => 'text', + 'field_cta_text_placeholder' => 'Leave empty for the layout default', + 'field_about_enabled' => 'About section', + 'field_about_text' => 'About text', + + 'field_background_mode' => 'Source', + 'background_mode_options' => [ + 'static' => 'URL', + 'photo_id' => 'Photo ID', + 'random' => 'Random public photo', + 'latest_album_cover' => 'Latest album cover', + 'random_from_album' => 'Random photo from album', + ], + 'field_background_url' => 'Image URL', + 'field_background_url_placeholder' => 'https://…', + 'field_background_photo_id' => 'Photo ID', + 'field_background_photo_id_placeholder' => '24-character photo ID', + 'field_background_photo_id_hint' => 'Photo ID of the image to use. Open a photo and copy the last 24 characters from the URL.', + 'field_background_album_id' => 'Album ID', + 'field_background_album_id_placeholder' => '24-character album ID', + 'background_load_error' => 'Could not load photo. Make sure the ID is correct and you have access to it.', + 'background_mode_hint' => [ + 'random' => 'A random public photo is picked on every page load.', + 'latest_album_cover' => 'The cover of the most recently published public album is used.', + 'random_from_album' => 'A random photo from this album is picked on every page load. The preview only updates after saving.', + ], + + 'preview_orientation_landscape' => 'Preview landscape background', + 'preview_orientation_portrait' => 'Preview portrait background', + + 'field_cta_position' => 'Position', + 'cta_position_options' => [ + 'top-left' => 'Top Left', + 'top' => 'Top Center', + 'top-right' => 'Top Right', + 'left' => 'Middle Left', + 'center' => 'Center', + 'right' => 'Middle Right', + 'bottom-left' => 'Bottom Left', + 'bottom' => 'Bottom Center', + 'bottom-right' => 'Bottom Right', + ], + 'field_cta_shift_type' => 'Shift unit', + 'cta_shift_type_options' => [ + 'relative' => 'Relative (%)', + 'absolute' => 'Absolute (px)', + ], + 'cta_shift_type_hint' => 'Relative shifts are a percentage of the viewport size; absolute shifts are a fixed number of pixels.', + 'field_cta_shift_x' => 'Horizontal Shift (:value)', + 'cta_shift_x_direction_options' => [ + 'left' => 'Left', + 'right' => 'Right', + ], + 'field_cta_shift_y' => 'Vertical Shift (:value)', + 'cta_shift_y_direction_options' => [ + 'up' => 'Up', + 'down' => 'Down', + ], + 'reset_to_zero' => 'Reset to 0', + + 'preview_title' => 'Live Preview', + 'preview_hint' => 'Updates instantly as you edit — nothing is saved until you click Save.', + 'flat_list_hint' => 'These settings are also editable from the flat generic Settings list.', + + 'save' => 'Save', + 'saved' => 'Landing page settings saved.', + 'save_error' => 'Failed to save landing page settings.', + + 'se_required' => 'This layout/preset requires Lychee SE.', +]; diff --git a/lang/de/landing_featured_item.php b/lang/de/landing_featured_item.php new file mode 100644 index 00000000000..3ee73cbc4cc --- /dev/null +++ b/lang/de/landing_featured_item.php @@ -0,0 +1,51 @@ + 'Featured Content', + 'description' => 'Curate specific photos and albums to feature on the landing page.', + + 'mode_automatic' => 'Automatic (latest published public albums)', + 'mode_manual' => 'Manual (curate specific photos/albums)', + + 'field_enabled' => 'Enable featured content', + 'field_mode' => 'Mode', + 'field_count' => 'Number of automatic items', + 'field_count_hint' => 'Between 3 and 12.', + + 'search_placeholder' => 'Search photos/albums by title…', + 'search_no_results' => 'No results.', + 'add' => 'Add', + + 'no_items' => 'No items curated yet.', + 'col_preview' => 'Preview', + 'col_title' => 'Title', + 'col_type' => 'Type', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + 'type_photo' => 'Photo', + 'type_album' => 'Album', + + 'confirm_delete_header' => 'Remove Featured Item', + 'confirm_delete_message' => 'Are you sure you want to remove ":title" from the featured content?', + + 'added' => 'Item added successfully.', + 'deleted' => 'Item removed successfully.', + 'reordered' => 'Featured items reordered successfully.', + 'error_load' => 'Failed to load featured items.', + 'error_search' => 'Failed to search.', + 'error_add' => 'Failed to add item.', + 'error_delete' => 'Failed to remove item.', + 'error_reorder' => 'Failed to reorder featured items.', +]; diff --git a/lang/de/landing_link.php b/lang/de/landing_link.php new file mode 100644 index 00000000000..cf37252cb5f --- /dev/null +++ b/lang/de/landing_link.php @@ -0,0 +1,73 @@ + 'Extra Links', + 'description' => 'Manage an arbitrary, ordered list of extra links shown on the nav and/or footer of the landing page.', + + // Empty state + 'no_links' => 'No extra links configured yet.', + 'create_first' => 'Create your first link', + + // Table columns + 'col_label' => 'Label', + 'col_url' => 'URL', + 'col_placement' => 'Placement', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + + // Placement labels + 'placement_nav' => 'Nav', + 'placement_footer' => 'Footer', + 'placement_both' => 'Nav & Footer', + + // Built-in links (Gallery, Contact) + 'badge_built_in' => 'Built-in', + 'built_in_cannot_delete' => 'This built-in link cannot be deleted. Disable it instead, or reorder it like any other link.', + 'built_in_url_hint' => 'This is a built-in link and its target cannot be changed.', + + // Buttons + 'create' => 'Create Link', + 'edit' => 'Edit', + 'delete' => 'Delete', + 'cancel' => 'Cancel', + 'save' => 'Save', + + // Form fields + 'field_label' => 'Label', + 'field_label_placeholder' => 'e.g. Instagram', + 'field_url' => 'URL', + 'field_url_placeholder' => 'https://example.com', + 'field_placement' => 'Placement', + 'field_open_in_new_tab' => 'Open in new tab', + 'field_enabled' => 'Enabled', + + // Modal titles + 'modal_create_title' => 'Create Link', + 'modal_edit_title' => 'Edit Link', + + // Delete confirmation + 'confirm_delete_header' => 'Delete Link', + 'confirm_delete_message' => 'Are you sure you want to delete the link ":label"? This action cannot be undone.', + + // Toasts + 'created' => 'Link created successfully.', + 'updated' => 'Link updated successfully.', + 'deleted' => 'Link deleted successfully.', + 'reordered' => 'Links reordered successfully.', + 'error_load' => 'Failed to load links.', + 'error_save' => 'Failed to save link.', + 'error_delete' => 'Failed to delete link.', + 'error_reorder' => 'Failed to reorder links.', +]; diff --git a/lang/de/watermark.php b/lang/de/watermark.php index 6860c862127..be6175d3e5b 100644 --- a/lang/de/watermark.php +++ b/lang/de/watermark.php @@ -57,6 +57,7 @@ 'up' => 'Up', 'down' => 'Down', ], + 'reset_to_zero' => 'Reset to 0', 'save' => 'Save Settings', 'saved' => 'Watermark settings saved.', diff --git a/lang/el/all_settings.php b/lang/el/all_settings.php index 5c29cd5d1db..f0d0034ff3d 100644 --- a/lang/el/all_settings.php +++ b/lang/el/all_settings.php @@ -216,6 +216,18 @@ 'landing_background_portrait_mode' => 'Mode for portrait background', 'landing_background_landscape' => 'Value for landscape background (URL, photo ID, or album ID)', 'landing_background_portrait' => 'Value for portrait background (URL, photo ID, or album ID)', + 'landing_layout' => 'Landing page layout', + 'landing_intro_screen_enabled' => 'Enable the intro splash screen', + 'landing_hero_text_position' => 'Hero text position', + 'landing_hero_text_color' => 'Hero text color', + 'landing_hero_text_opacity' => 'Hero text opacity (%)', + 'landing_animation_preset' => 'Landing page animation preset', + 'landing_about_enabled' => 'Enable the about section', + 'landing_about_text' => 'About section text', + 'landing_featured_items_enabled' => 'Enable the featured content section', + 'landing_featured_items_mode' => 'Featured content mode', + 'landing_featured_items_count' => 'Number of automatic featured items', + 'landing_cta_text' => 'Call-to-action text override', 'metrics_enabled' => 'Enable statistics on photos & albums', 'metrics_logged_in_users_enabed' => 'Enable statistics for logged-in users', 'metrics_access' => 'Access level for statistics of the album/photo', @@ -613,6 +625,18 @@ 'landing_background_portrait_mode' => 'Options: static (URL), photo_id (specific photo), random (random public photo), latest_album_cover (latest album cover), random_from_album (random from album).', 'landing_background_landscape' => 'Depends on mode: URL for static, photo ID for photo_id, album ID for random_from_album. This image is also used when sharing the gallery link directly.', 'landing_background_portrait' => 'Depends on mode: URL for static, photo ID for photo_id, album ID for random_from_album.', + 'landing_layout' => 'Options: classic (default, free), portfolio/minimal/studio (require Lychee SE). Classic is unchanged and remains free forever.', + 'landing_intro_screen_enabled' => 'Applies to the classic and portfolio layouts. Disable to skip straight to the hero.', + 'landing_hero_text_position' => 'Options: top_left, top_right, bottom_left, bottom_right, center. Applies to the classic and portfolio layouts.', + 'landing_hero_text_color' => 'Leave empty to use the default white. Applies to the headline and subtitle text only, on every layout.', + 'landing_hero_text_opacity' => 'Range 0-100. Applies to the headline and subtitle text only, on every layout.', + 'landing_animation_preset' => 'Options: none/classic_fade (free), zoom_in/parallax_scroll/slide_reveal (require Lychee SE).', + 'landing_about_enabled' => 'Applies to the portfolio and minimal layouts.', + 'landing_about_text' => 'Admin-authored HTML, rendered verbatim (same trust model as the footer additional text).', + 'landing_featured_items_enabled' => 'Applies to the portfolio layout only. Requires Lychee SE to take effect.', + 'landing_featured_items_mode' => 'Automatic: most recently published public albums. Manual: admin-curated photos/albums.', + 'landing_featured_items_count' => 'Range 3-12. Only used in automatic featured content mode.', + 'landing_cta_text' => 'Leave empty to use each layout\'s default label.', 'metrics_enabled' => 'If enabled, anonymours users will be measured.', 'metrics_logged_in_users_enabed' => 'If enabled, logged-in users will be measured as well (admin users are not measured).', 'metrics_access' => '', diff --git a/lang/el/landing.php b/lang/el/landing.php index 11e4c89933a..7619d8b0ad6 100644 --- a/lang/el/landing.php +++ b/lang/el/landing.php @@ -10,4 +10,20 @@ 'access_gallery' => 'Access the gallery', 'Powered_by_Lychee' => 'Powered by Lychee', 'copyright' => 'All images on this website are subject to copyright by %1$s © %2$s', + 'client_login' => 'Client Login', + 'view_public_gallery' => 'View public gallery', + 'contact' => 'Contact', + 'portfolio' => [ + 'about' => 'About', + 'featured' => 'Recent Work', + 'scroll_down' => 'Scroll down', + ], + 'meridian' => [ + 'explore_label' => 'Explore', + 'explore_caption' => 'View the gallery', + 'contact_caption' => 'Get in touch', + ], + 'studio' => [ + 'welcome_back' => 'Welcome back', + ], ]; diff --git a/lang/el/landing_config.php b/lang/el/landing_config.php new file mode 100644 index 00000000000..02f332b1335 --- /dev/null +++ b/lang/el/landing_config.php @@ -0,0 +1,103 @@ + 'Landing Page', + 'tab_settings' => 'Settings', + 'tab_links' => 'Links', + 'tab_featured' => 'Featured', + + 'section_layout' => 'Layout & Structure', + 'section_hero' => 'Hero', + 'section_background_landscape' => 'Background (Landscape)', + 'section_background_portrait' => 'Background (Portrait)', + 'section_cta_position' => 'Call-to-Action Position', + 'section_content' => 'Content', + + 'field_layout' => 'Layout', + 'field_intro_screen_enabled' => 'Intro splash screen', + 'field_hero_text_position' => 'Hero text position', + 'field_hero_text_color' => 'Hero text color', + 'field_hero_text_opacity' => 'Hero text opacity', + 'field_animation_preset' => 'Animation preset', + 'field_cta_text' => 'text', + 'field_cta_text_placeholder' => 'Leave empty for the layout default', + 'field_about_enabled' => 'About section', + 'field_about_text' => 'About text', + + 'field_background_mode' => 'Source', + 'background_mode_options' => [ + 'static' => 'URL', + 'photo_id' => 'Photo ID', + 'random' => 'Random public photo', + 'latest_album_cover' => 'Latest album cover', + 'random_from_album' => 'Random photo from album', + ], + 'field_background_url' => 'Image URL', + 'field_background_url_placeholder' => 'https://…', + 'field_background_photo_id' => 'Photo ID', + 'field_background_photo_id_placeholder' => '24-character photo ID', + 'field_background_photo_id_hint' => 'Photo ID of the image to use. Open a photo and copy the last 24 characters from the URL.', + 'field_background_album_id' => 'Album ID', + 'field_background_album_id_placeholder' => '24-character album ID', + 'background_load_error' => 'Could not load photo. Make sure the ID is correct and you have access to it.', + 'background_mode_hint' => [ + 'random' => 'A random public photo is picked on every page load.', + 'latest_album_cover' => 'The cover of the most recently published public album is used.', + 'random_from_album' => 'A random photo from this album is picked on every page load. The preview only updates after saving.', + ], + + 'preview_orientation_landscape' => 'Preview landscape background', + 'preview_orientation_portrait' => 'Preview portrait background', + + 'field_cta_position' => 'Position', + 'cta_position_options' => [ + 'top-left' => 'Top Left', + 'top' => 'Top Center', + 'top-right' => 'Top Right', + 'left' => 'Middle Left', + 'center' => 'Center', + 'right' => 'Middle Right', + 'bottom-left' => 'Bottom Left', + 'bottom' => 'Bottom Center', + 'bottom-right' => 'Bottom Right', + ], + 'field_cta_shift_type' => 'Shift unit', + 'cta_shift_type_options' => [ + 'relative' => 'Relative (%)', + 'absolute' => 'Absolute (px)', + ], + 'cta_shift_type_hint' => 'Relative shifts are a percentage of the viewport size; absolute shifts are a fixed number of pixels.', + 'field_cta_shift_x' => 'Horizontal Shift (:value)', + 'cta_shift_x_direction_options' => [ + 'left' => 'Left', + 'right' => 'Right', + ], + 'field_cta_shift_y' => 'Vertical Shift (:value)', + 'cta_shift_y_direction_options' => [ + 'up' => 'Up', + 'down' => 'Down', + ], + 'reset_to_zero' => 'Reset to 0', + + 'preview_title' => 'Live Preview', + 'preview_hint' => 'Updates instantly as you edit — nothing is saved until you click Save.', + 'flat_list_hint' => 'These settings are also editable from the flat generic Settings list.', + + 'save' => 'Save', + 'saved' => 'Landing page settings saved.', + 'save_error' => 'Failed to save landing page settings.', + + 'se_required' => 'This layout/preset requires Lychee SE.', +]; diff --git a/lang/el/landing_featured_item.php b/lang/el/landing_featured_item.php new file mode 100644 index 00000000000..3ee73cbc4cc --- /dev/null +++ b/lang/el/landing_featured_item.php @@ -0,0 +1,51 @@ + 'Featured Content', + 'description' => 'Curate specific photos and albums to feature on the landing page.', + + 'mode_automatic' => 'Automatic (latest published public albums)', + 'mode_manual' => 'Manual (curate specific photos/albums)', + + 'field_enabled' => 'Enable featured content', + 'field_mode' => 'Mode', + 'field_count' => 'Number of automatic items', + 'field_count_hint' => 'Between 3 and 12.', + + 'search_placeholder' => 'Search photos/albums by title…', + 'search_no_results' => 'No results.', + 'add' => 'Add', + + 'no_items' => 'No items curated yet.', + 'col_preview' => 'Preview', + 'col_title' => 'Title', + 'col_type' => 'Type', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + 'type_photo' => 'Photo', + 'type_album' => 'Album', + + 'confirm_delete_header' => 'Remove Featured Item', + 'confirm_delete_message' => 'Are you sure you want to remove ":title" from the featured content?', + + 'added' => 'Item added successfully.', + 'deleted' => 'Item removed successfully.', + 'reordered' => 'Featured items reordered successfully.', + 'error_load' => 'Failed to load featured items.', + 'error_search' => 'Failed to search.', + 'error_add' => 'Failed to add item.', + 'error_delete' => 'Failed to remove item.', + 'error_reorder' => 'Failed to reorder featured items.', +]; diff --git a/lang/el/landing_link.php b/lang/el/landing_link.php new file mode 100644 index 00000000000..cf37252cb5f --- /dev/null +++ b/lang/el/landing_link.php @@ -0,0 +1,73 @@ + 'Extra Links', + 'description' => 'Manage an arbitrary, ordered list of extra links shown on the nav and/or footer of the landing page.', + + // Empty state + 'no_links' => 'No extra links configured yet.', + 'create_first' => 'Create your first link', + + // Table columns + 'col_label' => 'Label', + 'col_url' => 'URL', + 'col_placement' => 'Placement', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + + // Placement labels + 'placement_nav' => 'Nav', + 'placement_footer' => 'Footer', + 'placement_both' => 'Nav & Footer', + + // Built-in links (Gallery, Contact) + 'badge_built_in' => 'Built-in', + 'built_in_cannot_delete' => 'This built-in link cannot be deleted. Disable it instead, or reorder it like any other link.', + 'built_in_url_hint' => 'This is a built-in link and its target cannot be changed.', + + // Buttons + 'create' => 'Create Link', + 'edit' => 'Edit', + 'delete' => 'Delete', + 'cancel' => 'Cancel', + 'save' => 'Save', + + // Form fields + 'field_label' => 'Label', + 'field_label_placeholder' => 'e.g. Instagram', + 'field_url' => 'URL', + 'field_url_placeholder' => 'https://example.com', + 'field_placement' => 'Placement', + 'field_open_in_new_tab' => 'Open in new tab', + 'field_enabled' => 'Enabled', + + // Modal titles + 'modal_create_title' => 'Create Link', + 'modal_edit_title' => 'Edit Link', + + // Delete confirmation + 'confirm_delete_header' => 'Delete Link', + 'confirm_delete_message' => 'Are you sure you want to delete the link ":label"? This action cannot be undone.', + + // Toasts + 'created' => 'Link created successfully.', + 'updated' => 'Link updated successfully.', + 'deleted' => 'Link deleted successfully.', + 'reordered' => 'Links reordered successfully.', + 'error_load' => 'Failed to load links.', + 'error_save' => 'Failed to save link.', + 'error_delete' => 'Failed to delete link.', + 'error_reorder' => 'Failed to reorder links.', +]; diff --git a/lang/el/watermark.php b/lang/el/watermark.php index 6860c862127..be6175d3e5b 100644 --- a/lang/el/watermark.php +++ b/lang/el/watermark.php @@ -57,6 +57,7 @@ 'up' => 'Up', 'down' => 'Down', ], + 'reset_to_zero' => 'Reset to 0', 'save' => 'Save Settings', 'saved' => 'Watermark settings saved.', diff --git a/lang/en/all_settings.php b/lang/en/all_settings.php index 5a0c1e5daed..f736560b68f 100644 --- a/lang/en/all_settings.php +++ b/lang/en/all_settings.php @@ -216,6 +216,18 @@ 'landing_background_portrait_mode' => 'Mode for portrait background', 'landing_background_landscape' => 'Value for landscape background (URL, photo ID, or album ID)', 'landing_background_portrait' => 'Value for portrait background (URL, photo ID, or album ID)', + 'landing_layout' => 'Landing page layout', + 'landing_intro_screen_enabled' => 'Enable the intro splash screen', + 'landing_hero_text_position' => 'Hero text position', + 'landing_hero_text_color' => 'Hero text color', + 'landing_hero_text_opacity' => 'Hero text opacity (%)', + 'landing_animation_preset' => 'Landing page animation preset', + 'landing_about_enabled' => 'Enable the about section', + 'landing_about_text' => 'About section text', + 'landing_featured_items_enabled' => 'Enable the featured content section', + 'landing_featured_items_mode' => 'Featured content mode', + 'landing_featured_items_count' => 'Number of automatic featured items', + 'landing_cta_text' => 'Call-to-action text override', 'metrics_enabled' => 'Enable statistics on photos & albums', 'metrics_logged_in_users_enabed' => 'Enable statistics for logged-in users', 'metrics_access' => 'Access level for statistics of the album/photo', @@ -613,6 +625,18 @@ 'landing_background_portrait_mode' => 'Options: static (URL), photo_id (specific photo), random (random public photo), latest_album_cover (latest album cover), random_from_album (random from album).', 'landing_background_landscape' => 'Depends on mode: URL for static, photo ID for photo_id, album ID for random_from_album. This image is also used when sharing the gallery link directly.', 'landing_background_portrait' => 'Depends on mode: URL for static, photo ID for photo_id, album ID for random_from_album.', + 'landing_layout' => 'Options: classic (default, free), portfolio/minimal/studio (require Lychee SE). Classic is unchanged and remains free forever.', + 'landing_intro_screen_enabled' => 'Applies to the classic and portfolio layouts. Disable to skip straight to the hero.', + 'landing_hero_text_position' => 'Options: top_left, top_right, bottom_left, bottom_right, center. Applies to the classic and portfolio layouts.', + 'landing_hero_text_color' => 'Leave empty to use the default white. Applies to the headline and subtitle text only, on every layout.', + 'landing_hero_text_opacity' => 'Range 0-100. Applies to the headline and subtitle text only, on every layout.', + 'landing_animation_preset' => 'Options: none/classic_fade (free), zoom_in/parallax_scroll/slide_reveal (require Lychee SE).', + 'landing_about_enabled' => 'Applies to the portfolio and minimal layouts.', + 'landing_about_text' => 'Admin-authored HTML, rendered verbatim (same trust model as the footer additional text).', + 'landing_featured_items_enabled' => 'Applies to the portfolio layout only. Requires Lychee SE to take effect.', + 'landing_featured_items_mode' => 'Automatic: most recently published public albums. Manual: admin-curated photos/albums.', + 'landing_featured_items_count' => 'Range 3-12. Only used in automatic featured content mode.', + 'landing_cta_text' => 'Leave empty to use each layout\'s default label.', 'metrics_enabled' => 'If enabled, anonymours users will be measured.', 'metrics_logged_in_users_enabed' => 'If enabled, logged-in users will be measured as well (admin users are not measured).', 'metrics_access' => '', diff --git a/lang/en/landing.php b/lang/en/landing.php index 11e4c89933a..f4e1c1bd073 100644 --- a/lang/en/landing.php +++ b/lang/en/landing.php @@ -10,4 +10,21 @@ 'access_gallery' => 'Access the gallery', 'Powered_by_Lychee' => 'Powered by Lychee', 'copyright' => 'All images on this website are subject to copyright by %1$s © %2$s', + 'client_login' => 'Client Login', + 'view_public_gallery' => 'View public gallery', + 'contact' => 'Contact', + + 'portfolio' => [ + 'about' => 'About', + 'featured' => 'Recent Work', + 'scroll_down' => 'Scroll down', + ], + 'studio' => [ + 'welcome_back' => 'Welcome back', + ], + 'meridian' => [ + 'explore_label' => 'Explore', + 'explore_caption' => 'View the gallery', + 'contact_caption' => 'Get in touch', + ], ]; diff --git a/lang/en/landing_config.php b/lang/en/landing_config.php new file mode 100644 index 00000000000..02f332b1335 --- /dev/null +++ b/lang/en/landing_config.php @@ -0,0 +1,103 @@ + 'Landing Page', + 'tab_settings' => 'Settings', + 'tab_links' => 'Links', + 'tab_featured' => 'Featured', + + 'section_layout' => 'Layout & Structure', + 'section_hero' => 'Hero', + 'section_background_landscape' => 'Background (Landscape)', + 'section_background_portrait' => 'Background (Portrait)', + 'section_cta_position' => 'Call-to-Action Position', + 'section_content' => 'Content', + + 'field_layout' => 'Layout', + 'field_intro_screen_enabled' => 'Intro splash screen', + 'field_hero_text_position' => 'Hero text position', + 'field_hero_text_color' => 'Hero text color', + 'field_hero_text_opacity' => 'Hero text opacity', + 'field_animation_preset' => 'Animation preset', + 'field_cta_text' => 'text', + 'field_cta_text_placeholder' => 'Leave empty for the layout default', + 'field_about_enabled' => 'About section', + 'field_about_text' => 'About text', + + 'field_background_mode' => 'Source', + 'background_mode_options' => [ + 'static' => 'URL', + 'photo_id' => 'Photo ID', + 'random' => 'Random public photo', + 'latest_album_cover' => 'Latest album cover', + 'random_from_album' => 'Random photo from album', + ], + 'field_background_url' => 'Image URL', + 'field_background_url_placeholder' => 'https://…', + 'field_background_photo_id' => 'Photo ID', + 'field_background_photo_id_placeholder' => '24-character photo ID', + 'field_background_photo_id_hint' => 'Photo ID of the image to use. Open a photo and copy the last 24 characters from the URL.', + 'field_background_album_id' => 'Album ID', + 'field_background_album_id_placeholder' => '24-character album ID', + 'background_load_error' => 'Could not load photo. Make sure the ID is correct and you have access to it.', + 'background_mode_hint' => [ + 'random' => 'A random public photo is picked on every page load.', + 'latest_album_cover' => 'The cover of the most recently published public album is used.', + 'random_from_album' => 'A random photo from this album is picked on every page load. The preview only updates after saving.', + ], + + 'preview_orientation_landscape' => 'Preview landscape background', + 'preview_orientation_portrait' => 'Preview portrait background', + + 'field_cta_position' => 'Position', + 'cta_position_options' => [ + 'top-left' => 'Top Left', + 'top' => 'Top Center', + 'top-right' => 'Top Right', + 'left' => 'Middle Left', + 'center' => 'Center', + 'right' => 'Middle Right', + 'bottom-left' => 'Bottom Left', + 'bottom' => 'Bottom Center', + 'bottom-right' => 'Bottom Right', + ], + 'field_cta_shift_type' => 'Shift unit', + 'cta_shift_type_options' => [ + 'relative' => 'Relative (%)', + 'absolute' => 'Absolute (px)', + ], + 'cta_shift_type_hint' => 'Relative shifts are a percentage of the viewport size; absolute shifts are a fixed number of pixels.', + 'field_cta_shift_x' => 'Horizontal Shift (:value)', + 'cta_shift_x_direction_options' => [ + 'left' => 'Left', + 'right' => 'Right', + ], + 'field_cta_shift_y' => 'Vertical Shift (:value)', + 'cta_shift_y_direction_options' => [ + 'up' => 'Up', + 'down' => 'Down', + ], + 'reset_to_zero' => 'Reset to 0', + + 'preview_title' => 'Live Preview', + 'preview_hint' => 'Updates instantly as you edit — nothing is saved until you click Save.', + 'flat_list_hint' => 'These settings are also editable from the flat generic Settings list.', + + 'save' => 'Save', + 'saved' => 'Landing page settings saved.', + 'save_error' => 'Failed to save landing page settings.', + + 'se_required' => 'This layout/preset requires Lychee SE.', +]; diff --git a/lang/en/landing_featured_item.php b/lang/en/landing_featured_item.php new file mode 100644 index 00000000000..3ee73cbc4cc --- /dev/null +++ b/lang/en/landing_featured_item.php @@ -0,0 +1,51 @@ + 'Featured Content', + 'description' => 'Curate specific photos and albums to feature on the landing page.', + + 'mode_automatic' => 'Automatic (latest published public albums)', + 'mode_manual' => 'Manual (curate specific photos/albums)', + + 'field_enabled' => 'Enable featured content', + 'field_mode' => 'Mode', + 'field_count' => 'Number of automatic items', + 'field_count_hint' => 'Between 3 and 12.', + + 'search_placeholder' => 'Search photos/albums by title…', + 'search_no_results' => 'No results.', + 'add' => 'Add', + + 'no_items' => 'No items curated yet.', + 'col_preview' => 'Preview', + 'col_title' => 'Title', + 'col_type' => 'Type', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + 'type_photo' => 'Photo', + 'type_album' => 'Album', + + 'confirm_delete_header' => 'Remove Featured Item', + 'confirm_delete_message' => 'Are you sure you want to remove ":title" from the featured content?', + + 'added' => 'Item added successfully.', + 'deleted' => 'Item removed successfully.', + 'reordered' => 'Featured items reordered successfully.', + 'error_load' => 'Failed to load featured items.', + 'error_search' => 'Failed to search.', + 'error_add' => 'Failed to add item.', + 'error_delete' => 'Failed to remove item.', + 'error_reorder' => 'Failed to reorder featured items.', +]; diff --git a/lang/en/landing_link.php b/lang/en/landing_link.php new file mode 100644 index 00000000000..cf37252cb5f --- /dev/null +++ b/lang/en/landing_link.php @@ -0,0 +1,73 @@ + 'Extra Links', + 'description' => 'Manage an arbitrary, ordered list of extra links shown on the nav and/or footer of the landing page.', + + // Empty state + 'no_links' => 'No extra links configured yet.', + 'create_first' => 'Create your first link', + + // Table columns + 'col_label' => 'Label', + 'col_url' => 'URL', + 'col_placement' => 'Placement', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + + // Placement labels + 'placement_nav' => 'Nav', + 'placement_footer' => 'Footer', + 'placement_both' => 'Nav & Footer', + + // Built-in links (Gallery, Contact) + 'badge_built_in' => 'Built-in', + 'built_in_cannot_delete' => 'This built-in link cannot be deleted. Disable it instead, or reorder it like any other link.', + 'built_in_url_hint' => 'This is a built-in link and its target cannot be changed.', + + // Buttons + 'create' => 'Create Link', + 'edit' => 'Edit', + 'delete' => 'Delete', + 'cancel' => 'Cancel', + 'save' => 'Save', + + // Form fields + 'field_label' => 'Label', + 'field_label_placeholder' => 'e.g. Instagram', + 'field_url' => 'URL', + 'field_url_placeholder' => 'https://example.com', + 'field_placement' => 'Placement', + 'field_open_in_new_tab' => 'Open in new tab', + 'field_enabled' => 'Enabled', + + // Modal titles + 'modal_create_title' => 'Create Link', + 'modal_edit_title' => 'Edit Link', + + // Delete confirmation + 'confirm_delete_header' => 'Delete Link', + 'confirm_delete_message' => 'Are you sure you want to delete the link ":label"? This action cannot be undone.', + + // Toasts + 'created' => 'Link created successfully.', + 'updated' => 'Link updated successfully.', + 'deleted' => 'Link deleted successfully.', + 'reordered' => 'Links reordered successfully.', + 'error_load' => 'Failed to load links.', + 'error_save' => 'Failed to save link.', + 'error_delete' => 'Failed to delete link.', + 'error_reorder' => 'Failed to reorder links.', +]; diff --git a/lang/en/watermark.php b/lang/en/watermark.php index 6860c862127..be6175d3e5b 100644 --- a/lang/en/watermark.php +++ b/lang/en/watermark.php @@ -57,6 +57,7 @@ 'up' => 'Up', 'down' => 'Down', ], + 'reset_to_zero' => 'Reset to 0', 'save' => 'Save Settings', 'saved' => 'Watermark settings saved.', diff --git a/lang/es/all_settings.php b/lang/es/all_settings.php index e3058dc5851..493981905fc 100644 --- a/lang/es/all_settings.php +++ b/lang/es/all_settings.php @@ -214,6 +214,18 @@ 'landing_background_portrait_mode' => 'Modo para fondo de retrato', 'landing_background_landscape' => 'Value for landscape background (URL, photo ID, or album ID)', 'landing_background_portrait' => 'Value for portrait background (URL, photo ID, or album ID)', + 'landing_layout' => 'Landing page layout', + 'landing_intro_screen_enabled' => 'Enable the intro splash screen', + 'landing_hero_text_position' => 'Hero text position', + 'landing_hero_text_color' => 'Hero text color', + 'landing_hero_text_opacity' => 'Hero text opacity (%)', + 'landing_animation_preset' => 'Landing page animation preset', + 'landing_about_enabled' => 'Enable the about section', + 'landing_about_text' => 'About section text', + 'landing_featured_items_enabled' => 'Enable the featured content section', + 'landing_featured_items_mode' => 'Featured content mode', + 'landing_featured_items_count' => 'Number of automatic featured items', + 'landing_cta_text' => 'Call-to-action text override', 'metrics_enabled' => 'Activar estadísticas en fotos y álbumes', 'metrics_logged_in_users_enabed' => 'Habilitar las estadísticas para los usuarios que hayan iniciado sesión', 'metrics_access' => 'Nivel de acceso a las estadísticas del álbum o la foto', @@ -611,6 +623,18 @@ 'landing_background_portrait_mode' => 'Opciones: static (URL), photo_id (foto concreta), random (foto pública aleatoria), latest_album_cover (portada del último álbum), random_from_album (foto aleatoria del álbum).', 'landing_background_landscape' => 'Depends on mode: URL for static, photo ID for photo_id, album ID for random_from_album. This image is also used when sharing the gallery link directly.', 'landing_background_portrait' => 'Depends on mode: URL for static, photo ID for photo_id, album ID for random_from_album.', + 'landing_layout' => 'Options: classic (default, free), portfolio/minimal/studio (require Lychee SE). Classic is unchanged and remains free forever.', + 'landing_intro_screen_enabled' => 'Applies to the classic and portfolio layouts. Disable to skip straight to the hero.', + 'landing_hero_text_position' => 'Options: top_left, top_right, bottom_left, bottom_right, center. Applies to the classic and portfolio layouts.', + 'landing_hero_text_color' => 'Leave empty to use the default white. Applies to the headline and subtitle text only, on every layout.', + 'landing_hero_text_opacity' => 'Range 0-100. Applies to the headline and subtitle text only, on every layout.', + 'landing_animation_preset' => 'Options: none/classic_fade (free), zoom_in/parallax_scroll/slide_reveal (require Lychee SE).', + 'landing_about_enabled' => 'Applies to the portfolio and minimal layouts.', + 'landing_about_text' => 'Admin-authored HTML, rendered verbatim (same trust model as the footer additional text).', + 'landing_featured_items_enabled' => 'Applies to the portfolio layout only. Requires Lychee SE to take effect.', + 'landing_featured_items_mode' => 'Automatic: most recently published public albums. Manual: admin-curated photos/albums.', + 'landing_featured_items_count' => 'Range 3-12. Only used in automatic featured content mode.', + 'landing_cta_text' => 'Leave empty to use each layout\'s default label.', 'metrics_enabled' => 'Si se activa esta opción, se recopilarán datos de los usuarios anónimos.', 'metrics_logged_in_users_enabed' => 'Si se activa esta opción, también se registrarán los datos de los usuarios que hayan iniciado sesión (no se registrarán los datos de los usuarios administradores).', 'metrics_access' => '', diff --git a/lang/es/landing.php b/lang/es/landing.php index 28a91e6d7d6..215c775415e 100644 --- a/lang/es/landing.php +++ b/lang/es/landing.php @@ -10,4 +10,20 @@ 'access_gallery' => 'Acceder a la galería', 'Powered_by_Lychee' => 'Desarrollado por Lychee', 'copyright' => 'Todas las imágenes de este sitio web están sujetas a derechos de autor por %1$s © %2$s', + 'client_login' => 'Client Login', + 'view_public_gallery' => 'View public gallery', + 'contact' => 'Contact', + 'portfolio' => [ + 'about' => 'About', + 'featured' => 'Recent Work', + 'scroll_down' => 'Scroll down', + ], + 'meridian' => [ + 'explore_label' => 'Explore', + 'explore_caption' => 'View the gallery', + 'contact_caption' => 'Get in touch', + ], + 'studio' => [ + 'welcome_back' => 'Welcome back', + ], ]; diff --git a/lang/es/landing_config.php b/lang/es/landing_config.php new file mode 100644 index 00000000000..02f332b1335 --- /dev/null +++ b/lang/es/landing_config.php @@ -0,0 +1,103 @@ + 'Landing Page', + 'tab_settings' => 'Settings', + 'tab_links' => 'Links', + 'tab_featured' => 'Featured', + + 'section_layout' => 'Layout & Structure', + 'section_hero' => 'Hero', + 'section_background_landscape' => 'Background (Landscape)', + 'section_background_portrait' => 'Background (Portrait)', + 'section_cta_position' => 'Call-to-Action Position', + 'section_content' => 'Content', + + 'field_layout' => 'Layout', + 'field_intro_screen_enabled' => 'Intro splash screen', + 'field_hero_text_position' => 'Hero text position', + 'field_hero_text_color' => 'Hero text color', + 'field_hero_text_opacity' => 'Hero text opacity', + 'field_animation_preset' => 'Animation preset', + 'field_cta_text' => 'text', + 'field_cta_text_placeholder' => 'Leave empty for the layout default', + 'field_about_enabled' => 'About section', + 'field_about_text' => 'About text', + + 'field_background_mode' => 'Source', + 'background_mode_options' => [ + 'static' => 'URL', + 'photo_id' => 'Photo ID', + 'random' => 'Random public photo', + 'latest_album_cover' => 'Latest album cover', + 'random_from_album' => 'Random photo from album', + ], + 'field_background_url' => 'Image URL', + 'field_background_url_placeholder' => 'https://…', + 'field_background_photo_id' => 'Photo ID', + 'field_background_photo_id_placeholder' => '24-character photo ID', + 'field_background_photo_id_hint' => 'Photo ID of the image to use. Open a photo and copy the last 24 characters from the URL.', + 'field_background_album_id' => 'Album ID', + 'field_background_album_id_placeholder' => '24-character album ID', + 'background_load_error' => 'Could not load photo. Make sure the ID is correct and you have access to it.', + 'background_mode_hint' => [ + 'random' => 'A random public photo is picked on every page load.', + 'latest_album_cover' => 'The cover of the most recently published public album is used.', + 'random_from_album' => 'A random photo from this album is picked on every page load. The preview only updates after saving.', + ], + + 'preview_orientation_landscape' => 'Preview landscape background', + 'preview_orientation_portrait' => 'Preview portrait background', + + 'field_cta_position' => 'Position', + 'cta_position_options' => [ + 'top-left' => 'Top Left', + 'top' => 'Top Center', + 'top-right' => 'Top Right', + 'left' => 'Middle Left', + 'center' => 'Center', + 'right' => 'Middle Right', + 'bottom-left' => 'Bottom Left', + 'bottom' => 'Bottom Center', + 'bottom-right' => 'Bottom Right', + ], + 'field_cta_shift_type' => 'Shift unit', + 'cta_shift_type_options' => [ + 'relative' => 'Relative (%)', + 'absolute' => 'Absolute (px)', + ], + 'cta_shift_type_hint' => 'Relative shifts are a percentage of the viewport size; absolute shifts are a fixed number of pixels.', + 'field_cta_shift_x' => 'Horizontal Shift (:value)', + 'cta_shift_x_direction_options' => [ + 'left' => 'Left', + 'right' => 'Right', + ], + 'field_cta_shift_y' => 'Vertical Shift (:value)', + 'cta_shift_y_direction_options' => [ + 'up' => 'Up', + 'down' => 'Down', + ], + 'reset_to_zero' => 'Reset to 0', + + 'preview_title' => 'Live Preview', + 'preview_hint' => 'Updates instantly as you edit — nothing is saved until you click Save.', + 'flat_list_hint' => 'These settings are also editable from the flat generic Settings list.', + + 'save' => 'Save', + 'saved' => 'Landing page settings saved.', + 'save_error' => 'Failed to save landing page settings.', + + 'se_required' => 'This layout/preset requires Lychee SE.', +]; diff --git a/lang/es/landing_featured_item.php b/lang/es/landing_featured_item.php new file mode 100644 index 00000000000..3ee73cbc4cc --- /dev/null +++ b/lang/es/landing_featured_item.php @@ -0,0 +1,51 @@ + 'Featured Content', + 'description' => 'Curate specific photos and albums to feature on the landing page.', + + 'mode_automatic' => 'Automatic (latest published public albums)', + 'mode_manual' => 'Manual (curate specific photos/albums)', + + 'field_enabled' => 'Enable featured content', + 'field_mode' => 'Mode', + 'field_count' => 'Number of automatic items', + 'field_count_hint' => 'Between 3 and 12.', + + 'search_placeholder' => 'Search photos/albums by title…', + 'search_no_results' => 'No results.', + 'add' => 'Add', + + 'no_items' => 'No items curated yet.', + 'col_preview' => 'Preview', + 'col_title' => 'Title', + 'col_type' => 'Type', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + 'type_photo' => 'Photo', + 'type_album' => 'Album', + + 'confirm_delete_header' => 'Remove Featured Item', + 'confirm_delete_message' => 'Are you sure you want to remove ":title" from the featured content?', + + 'added' => 'Item added successfully.', + 'deleted' => 'Item removed successfully.', + 'reordered' => 'Featured items reordered successfully.', + 'error_load' => 'Failed to load featured items.', + 'error_search' => 'Failed to search.', + 'error_add' => 'Failed to add item.', + 'error_delete' => 'Failed to remove item.', + 'error_reorder' => 'Failed to reorder featured items.', +]; diff --git a/lang/es/landing_link.php b/lang/es/landing_link.php new file mode 100644 index 00000000000..cf37252cb5f --- /dev/null +++ b/lang/es/landing_link.php @@ -0,0 +1,73 @@ + 'Extra Links', + 'description' => 'Manage an arbitrary, ordered list of extra links shown on the nav and/or footer of the landing page.', + + // Empty state + 'no_links' => 'No extra links configured yet.', + 'create_first' => 'Create your first link', + + // Table columns + 'col_label' => 'Label', + 'col_url' => 'URL', + 'col_placement' => 'Placement', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + + // Placement labels + 'placement_nav' => 'Nav', + 'placement_footer' => 'Footer', + 'placement_both' => 'Nav & Footer', + + // Built-in links (Gallery, Contact) + 'badge_built_in' => 'Built-in', + 'built_in_cannot_delete' => 'This built-in link cannot be deleted. Disable it instead, or reorder it like any other link.', + 'built_in_url_hint' => 'This is a built-in link and its target cannot be changed.', + + // Buttons + 'create' => 'Create Link', + 'edit' => 'Edit', + 'delete' => 'Delete', + 'cancel' => 'Cancel', + 'save' => 'Save', + + // Form fields + 'field_label' => 'Label', + 'field_label_placeholder' => 'e.g. Instagram', + 'field_url' => 'URL', + 'field_url_placeholder' => 'https://example.com', + 'field_placement' => 'Placement', + 'field_open_in_new_tab' => 'Open in new tab', + 'field_enabled' => 'Enabled', + + // Modal titles + 'modal_create_title' => 'Create Link', + 'modal_edit_title' => 'Edit Link', + + // Delete confirmation + 'confirm_delete_header' => 'Delete Link', + 'confirm_delete_message' => 'Are you sure you want to delete the link ":label"? This action cannot be undone.', + + // Toasts + 'created' => 'Link created successfully.', + 'updated' => 'Link updated successfully.', + 'deleted' => 'Link deleted successfully.', + 'reordered' => 'Links reordered successfully.', + 'error_load' => 'Failed to load links.', + 'error_save' => 'Failed to save link.', + 'error_delete' => 'Failed to delete link.', + 'error_reorder' => 'Failed to reorder links.', +]; diff --git a/lang/es/watermark.php b/lang/es/watermark.php index 6860c862127..be6175d3e5b 100644 --- a/lang/es/watermark.php +++ b/lang/es/watermark.php @@ -57,6 +57,7 @@ 'up' => 'Up', 'down' => 'Down', ], + 'reset_to_zero' => 'Reset to 0', 'save' => 'Save Settings', 'saved' => 'Watermark settings saved.', diff --git a/lang/fa/all_settings.php b/lang/fa/all_settings.php index a7caf2003ae..77460116fb4 100644 --- a/lang/fa/all_settings.php +++ b/lang/fa/all_settings.php @@ -216,6 +216,18 @@ 'landing_background_portrait_mode' => 'حالت پس‌زمینه عمودی', 'landing_background_landscape' => 'مقدار پس‌زمینه افقی (URL، شناسه عکس یا شناسه آلبوم)', 'landing_background_portrait' => 'مقدار پس‌زمینه عمودی (URL، شناسه عکس یا شناسه آلبوم)', + 'landing_layout' => 'Landing page layout', + 'landing_intro_screen_enabled' => 'Enable the intro splash screen', + 'landing_hero_text_position' => 'Hero text position', + 'landing_hero_text_color' => 'Hero text color', + 'landing_hero_text_opacity' => 'Hero text opacity (%)', + 'landing_animation_preset' => 'Landing page animation preset', + 'landing_about_enabled' => 'Enable the about section', + 'landing_about_text' => 'About section text', + 'landing_featured_items_enabled' => 'Enable the featured content section', + 'landing_featured_items_mode' => 'Featured content mode', + 'landing_featured_items_count' => 'Number of automatic featured items', + 'landing_cta_text' => 'Call-to-action text override', 'metrics_enabled' => 'فعال‌سازی آمار عکس‌ها و آلبوم‌ها', 'metrics_logged_in_users_enabed' => 'فعال‌سازی آمار برای کاربران واردشده', 'metrics_access' => 'سطح دسترسی برای آمار آلبوم/عکس', @@ -613,6 +625,18 @@ 'landing_background_portrait_mode' => 'گزینه‌ها: static (نشانی اینترنتی)، photo_id (عکس مشخص)، random (عکس عمومی تصادفی)، latest_album_cover (آخرین جلد آلبوم)، random_from_album (تصادفی از یک آلبوم).', 'landing_background_landscape' => 'بسته به حالت: نشانی اینترنتی برای static، شناسه عکس برای photo_id، شناسه آلبوم برای random_from_album. این تصویر هنگام اشتراک‌گذاری مستقیم پیوند گالری نیز استفاده می‌شود.', 'landing_background_portrait' => 'بسته به حالت: نشانی اینترنتی برای static، شناسه عکس برای photo_id، شناسه آلبوم برای random_from_album.', + 'landing_layout' => 'Options: classic (default, free), portfolio/minimal/studio (require Lychee SE). Classic is unchanged and remains free forever.', + 'landing_intro_screen_enabled' => 'Applies to the classic and portfolio layouts. Disable to skip straight to the hero.', + 'landing_hero_text_position' => 'Options: top_left, top_right, bottom_left, bottom_right, center. Applies to the classic and portfolio layouts.', + 'landing_hero_text_color' => 'Leave empty to use the default white. Applies to the headline and subtitle text only, on every layout.', + 'landing_hero_text_opacity' => 'Range 0-100. Applies to the headline and subtitle text only, on every layout.', + 'landing_animation_preset' => 'Options: none/classic_fade (free), zoom_in/parallax_scroll/slide_reveal (require Lychee SE).', + 'landing_about_enabled' => 'Applies to the portfolio and minimal layouts.', + 'landing_about_text' => 'Admin-authored HTML, rendered verbatim (same trust model as the footer additional text).', + 'landing_featured_items_enabled' => 'Applies to the portfolio layout only. Requires Lychee SE to take effect.', + 'landing_featured_items_mode' => 'Automatic: most recently published public albums. Manual: admin-curated photos/albums.', + 'landing_featured_items_count' => 'Range 3-12. Only used in automatic featured content mode.', + 'landing_cta_text' => 'Leave empty to use each layout\'s default label.', 'metrics_enabled' => 'در صورت فعال بودن، کاربران ناشناس نیز اندازه‌گیری خواهند شد.', 'metrics_logged_in_users_enabed' => 'در صورت فعال بودن، کاربران واردشده نیز اندازه‌گیری خواهند شد (کاربران مدیر اندازه‌گیری نمی‌شوند).', 'metrics_access' => '', diff --git a/lang/fa/landing.php b/lang/fa/landing.php index cdbbed540a4..fe5126348e9 100644 --- a/lang/fa/landing.php +++ b/lang/fa/landing.php @@ -10,4 +10,20 @@ 'access_gallery' => 'دسترسی به گالری', 'Powered_by_Lychee' => 'توسعه داده شده توسط Lychee', 'copyright' => 'تمام تصاویر این وب سایت ذیل قانون حقوق مولفین %1$s © %2$s هستند', + 'client_login' => 'Client Login', + 'view_public_gallery' => 'View public gallery', + 'contact' => 'Contact', + 'portfolio' => [ + 'about' => 'About', + 'featured' => 'Recent Work', + 'scroll_down' => 'Scroll down', + ], + 'meridian' => [ + 'explore_label' => 'Explore', + 'explore_caption' => 'View the gallery', + 'contact_caption' => 'Get in touch', + ], + 'studio' => [ + 'welcome_back' => 'Welcome back', + ], ]; diff --git a/lang/fa/landing_config.php b/lang/fa/landing_config.php new file mode 100644 index 00000000000..02f332b1335 --- /dev/null +++ b/lang/fa/landing_config.php @@ -0,0 +1,103 @@ + 'Landing Page', + 'tab_settings' => 'Settings', + 'tab_links' => 'Links', + 'tab_featured' => 'Featured', + + 'section_layout' => 'Layout & Structure', + 'section_hero' => 'Hero', + 'section_background_landscape' => 'Background (Landscape)', + 'section_background_portrait' => 'Background (Portrait)', + 'section_cta_position' => 'Call-to-Action Position', + 'section_content' => 'Content', + + 'field_layout' => 'Layout', + 'field_intro_screen_enabled' => 'Intro splash screen', + 'field_hero_text_position' => 'Hero text position', + 'field_hero_text_color' => 'Hero text color', + 'field_hero_text_opacity' => 'Hero text opacity', + 'field_animation_preset' => 'Animation preset', + 'field_cta_text' => 'text', + 'field_cta_text_placeholder' => 'Leave empty for the layout default', + 'field_about_enabled' => 'About section', + 'field_about_text' => 'About text', + + 'field_background_mode' => 'Source', + 'background_mode_options' => [ + 'static' => 'URL', + 'photo_id' => 'Photo ID', + 'random' => 'Random public photo', + 'latest_album_cover' => 'Latest album cover', + 'random_from_album' => 'Random photo from album', + ], + 'field_background_url' => 'Image URL', + 'field_background_url_placeholder' => 'https://…', + 'field_background_photo_id' => 'Photo ID', + 'field_background_photo_id_placeholder' => '24-character photo ID', + 'field_background_photo_id_hint' => 'Photo ID of the image to use. Open a photo and copy the last 24 characters from the URL.', + 'field_background_album_id' => 'Album ID', + 'field_background_album_id_placeholder' => '24-character album ID', + 'background_load_error' => 'Could not load photo. Make sure the ID is correct and you have access to it.', + 'background_mode_hint' => [ + 'random' => 'A random public photo is picked on every page load.', + 'latest_album_cover' => 'The cover of the most recently published public album is used.', + 'random_from_album' => 'A random photo from this album is picked on every page load. The preview only updates after saving.', + ], + + 'preview_orientation_landscape' => 'Preview landscape background', + 'preview_orientation_portrait' => 'Preview portrait background', + + 'field_cta_position' => 'Position', + 'cta_position_options' => [ + 'top-left' => 'Top Left', + 'top' => 'Top Center', + 'top-right' => 'Top Right', + 'left' => 'Middle Left', + 'center' => 'Center', + 'right' => 'Middle Right', + 'bottom-left' => 'Bottom Left', + 'bottom' => 'Bottom Center', + 'bottom-right' => 'Bottom Right', + ], + 'field_cta_shift_type' => 'Shift unit', + 'cta_shift_type_options' => [ + 'relative' => 'Relative (%)', + 'absolute' => 'Absolute (px)', + ], + 'cta_shift_type_hint' => 'Relative shifts are a percentage of the viewport size; absolute shifts are a fixed number of pixels.', + 'field_cta_shift_x' => 'Horizontal Shift (:value)', + 'cta_shift_x_direction_options' => [ + 'left' => 'Left', + 'right' => 'Right', + ], + 'field_cta_shift_y' => 'Vertical Shift (:value)', + 'cta_shift_y_direction_options' => [ + 'up' => 'Up', + 'down' => 'Down', + ], + 'reset_to_zero' => 'Reset to 0', + + 'preview_title' => 'Live Preview', + 'preview_hint' => 'Updates instantly as you edit — nothing is saved until you click Save.', + 'flat_list_hint' => 'These settings are also editable from the flat generic Settings list.', + + 'save' => 'Save', + 'saved' => 'Landing page settings saved.', + 'save_error' => 'Failed to save landing page settings.', + + 'se_required' => 'This layout/preset requires Lychee SE.', +]; diff --git a/lang/fa/landing_featured_item.php b/lang/fa/landing_featured_item.php new file mode 100644 index 00000000000..3ee73cbc4cc --- /dev/null +++ b/lang/fa/landing_featured_item.php @@ -0,0 +1,51 @@ + 'Featured Content', + 'description' => 'Curate specific photos and albums to feature on the landing page.', + + 'mode_automatic' => 'Automatic (latest published public albums)', + 'mode_manual' => 'Manual (curate specific photos/albums)', + + 'field_enabled' => 'Enable featured content', + 'field_mode' => 'Mode', + 'field_count' => 'Number of automatic items', + 'field_count_hint' => 'Between 3 and 12.', + + 'search_placeholder' => 'Search photos/albums by title…', + 'search_no_results' => 'No results.', + 'add' => 'Add', + + 'no_items' => 'No items curated yet.', + 'col_preview' => 'Preview', + 'col_title' => 'Title', + 'col_type' => 'Type', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + 'type_photo' => 'Photo', + 'type_album' => 'Album', + + 'confirm_delete_header' => 'Remove Featured Item', + 'confirm_delete_message' => 'Are you sure you want to remove ":title" from the featured content?', + + 'added' => 'Item added successfully.', + 'deleted' => 'Item removed successfully.', + 'reordered' => 'Featured items reordered successfully.', + 'error_load' => 'Failed to load featured items.', + 'error_search' => 'Failed to search.', + 'error_add' => 'Failed to add item.', + 'error_delete' => 'Failed to remove item.', + 'error_reorder' => 'Failed to reorder featured items.', +]; diff --git a/lang/fa/landing_link.php b/lang/fa/landing_link.php new file mode 100644 index 00000000000..cf37252cb5f --- /dev/null +++ b/lang/fa/landing_link.php @@ -0,0 +1,73 @@ + 'Extra Links', + 'description' => 'Manage an arbitrary, ordered list of extra links shown on the nav and/or footer of the landing page.', + + // Empty state + 'no_links' => 'No extra links configured yet.', + 'create_first' => 'Create your first link', + + // Table columns + 'col_label' => 'Label', + 'col_url' => 'URL', + 'col_placement' => 'Placement', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + + // Placement labels + 'placement_nav' => 'Nav', + 'placement_footer' => 'Footer', + 'placement_both' => 'Nav & Footer', + + // Built-in links (Gallery, Contact) + 'badge_built_in' => 'Built-in', + 'built_in_cannot_delete' => 'This built-in link cannot be deleted. Disable it instead, or reorder it like any other link.', + 'built_in_url_hint' => 'This is a built-in link and its target cannot be changed.', + + // Buttons + 'create' => 'Create Link', + 'edit' => 'Edit', + 'delete' => 'Delete', + 'cancel' => 'Cancel', + 'save' => 'Save', + + // Form fields + 'field_label' => 'Label', + 'field_label_placeholder' => 'e.g. Instagram', + 'field_url' => 'URL', + 'field_url_placeholder' => 'https://example.com', + 'field_placement' => 'Placement', + 'field_open_in_new_tab' => 'Open in new tab', + 'field_enabled' => 'Enabled', + + // Modal titles + 'modal_create_title' => 'Create Link', + 'modal_edit_title' => 'Edit Link', + + // Delete confirmation + 'confirm_delete_header' => 'Delete Link', + 'confirm_delete_message' => 'Are you sure you want to delete the link ":label"? This action cannot be undone.', + + // Toasts + 'created' => 'Link created successfully.', + 'updated' => 'Link updated successfully.', + 'deleted' => 'Link deleted successfully.', + 'reordered' => 'Links reordered successfully.', + 'error_load' => 'Failed to load links.', + 'error_save' => 'Failed to save link.', + 'error_delete' => 'Failed to delete link.', + 'error_reorder' => 'Failed to reorder links.', +]; diff --git a/lang/fa/watermark.php b/lang/fa/watermark.php index 6860c862127..be6175d3e5b 100644 --- a/lang/fa/watermark.php +++ b/lang/fa/watermark.php @@ -57,6 +57,7 @@ 'up' => 'Up', 'down' => 'Down', ], + 'reset_to_zero' => 'Reset to 0', 'save' => 'Save Settings', 'saved' => 'Watermark settings saved.', diff --git a/lang/fr/all_settings.php b/lang/fr/all_settings.php index ff1dfab5d13..1d4204eee1b 100644 --- a/lang/fr/all_settings.php +++ b/lang/fr/all_settings.php @@ -214,6 +214,18 @@ 'landing_background_portrait_mode' => 'Options : static (URL), photo_id (photo spécifique), random (photo publique aléatoire), latest_album_cover (couverture du dernier album), random_from_album (aléatoire depuis un album).', 'landing_background_landscape' => 'Dépend du mode : URL pour statique, ID de photo pour photo_id, ID d’album pour random_from_album. Cette image est également utilisée lors du partage direct du lien de la galerie.', 'landing_background_portrait' => 'Dépend du mode : URL pour statique, ID de photo pour photo_id, ID d’album pour random_from_album.', + 'landing_layout' => 'Landing page layout', + 'landing_intro_screen_enabled' => 'Enable the intro splash screen', + 'landing_hero_text_position' => 'Hero text position', + 'landing_hero_text_color' => 'Hero text color', + 'landing_hero_text_opacity' => 'Hero text opacity (%)', + 'landing_animation_preset' => 'Landing page animation preset', + 'landing_about_enabled' => 'Enable the about section', + 'landing_about_text' => 'About section text', + 'landing_featured_items_enabled' => 'Enable the featured content section', + 'landing_featured_items_mode' => 'Featured content mode', + 'landing_featured_items_count' => 'Number of automatic featured items', + 'landing_cta_text' => 'Call-to-action text override', 'metrics_enabled' => 'Si activé, les utilisateurs anonymes seront comptabilisés.', 'metrics_logged_in_users_enabed' => 'Si activé, les utilisateurs connectés seront comptabilisés (sauf les administrateurs).', 'metrics_access' => '', @@ -611,6 +623,18 @@ 'landing_background_portrait_mode' => 'Mode pour l’arrière-plan portrait', 'landing_background_landscape' => 'Valeur pour l’arrière-plan paysage (URL, ID de photo ou ID d’album)', 'landing_background_portrait' => 'Valeur pour l’arrière-plan portrait (URL, ID de photo ou ID d’album)', + 'landing_layout' => 'Options: classic (default, free), portfolio/minimal/studio (require Lychee SE). Classic is unchanged and remains free forever.', + 'landing_intro_screen_enabled' => 'Applies to the classic and portfolio layouts. Disable to skip straight to the hero.', + 'landing_hero_text_position' => 'Options: top_left, top_right, bottom_left, bottom_right, center. Applies to the classic and portfolio layouts.', + 'landing_hero_text_color' => 'Leave empty to use the default white. Applies to the headline and subtitle text only, on every layout.', + 'landing_hero_text_opacity' => 'Range 0-100. Applies to the headline and subtitle text only, on every layout.', + 'landing_animation_preset' => 'Options: none/classic_fade (free), zoom_in/parallax_scroll/slide_reveal (require Lychee SE).', + 'landing_about_enabled' => 'Applies to the portfolio and minimal layouts.', + 'landing_about_text' => 'Admin-authored HTML, rendered verbatim (same trust model as the footer additional text).', + 'landing_featured_items_enabled' => 'Applies to the portfolio layout only. Requires Lychee SE to take effect.', + 'landing_featured_items_mode' => 'Automatic: most recently published public albums. Manual: admin-curated photos/albums.', + 'landing_featured_items_count' => 'Range 3-12. Only used in automatic featured content mode.', + 'landing_cta_text' => 'Leave empty to use each layout\'s default label.', 'metrics_enabled' => 'Activer les statistiques sur les photos et les albums', 'metrics_logged_in_users_enabed' => 'Activer les statistiques pour les utilisateurs connectés', 'metrics_access' => 'Niveau d’accès aux statistiques pour l’album/la photo', diff --git a/lang/fr/landing.php b/lang/fr/landing.php index be28cf27790..7bff8ae5cc5 100644 --- a/lang/fr/landing.php +++ b/lang/fr/landing.php @@ -10,4 +10,20 @@ 'access_gallery' => 'Accéder à la galerie', 'Powered_by_Lychee' => 'Propulsé avec Lychee', 'copyright' => 'Toutes les images de ce site sont protégées par le droit d’auteur de %1$s © %2$s', + 'client_login' => 'Client Login', + 'view_public_gallery' => 'View public gallery', + 'contact' => 'Contact', + 'portfolio' => [ + 'about' => 'About', + 'featured' => 'Recent Work', + 'scroll_down' => 'Scroll down', + ], + 'meridian' => [ + 'explore_label' => 'Explore', + 'explore_caption' => 'View the gallery', + 'contact_caption' => 'Get in touch', + ], + 'studio' => [ + 'welcome_back' => 'Welcome back', + ], ]; diff --git a/lang/fr/landing_config.php b/lang/fr/landing_config.php new file mode 100644 index 00000000000..02f332b1335 --- /dev/null +++ b/lang/fr/landing_config.php @@ -0,0 +1,103 @@ + 'Landing Page', + 'tab_settings' => 'Settings', + 'tab_links' => 'Links', + 'tab_featured' => 'Featured', + + 'section_layout' => 'Layout & Structure', + 'section_hero' => 'Hero', + 'section_background_landscape' => 'Background (Landscape)', + 'section_background_portrait' => 'Background (Portrait)', + 'section_cta_position' => 'Call-to-Action Position', + 'section_content' => 'Content', + + 'field_layout' => 'Layout', + 'field_intro_screen_enabled' => 'Intro splash screen', + 'field_hero_text_position' => 'Hero text position', + 'field_hero_text_color' => 'Hero text color', + 'field_hero_text_opacity' => 'Hero text opacity', + 'field_animation_preset' => 'Animation preset', + 'field_cta_text' => 'text', + 'field_cta_text_placeholder' => 'Leave empty for the layout default', + 'field_about_enabled' => 'About section', + 'field_about_text' => 'About text', + + 'field_background_mode' => 'Source', + 'background_mode_options' => [ + 'static' => 'URL', + 'photo_id' => 'Photo ID', + 'random' => 'Random public photo', + 'latest_album_cover' => 'Latest album cover', + 'random_from_album' => 'Random photo from album', + ], + 'field_background_url' => 'Image URL', + 'field_background_url_placeholder' => 'https://…', + 'field_background_photo_id' => 'Photo ID', + 'field_background_photo_id_placeholder' => '24-character photo ID', + 'field_background_photo_id_hint' => 'Photo ID of the image to use. Open a photo and copy the last 24 characters from the URL.', + 'field_background_album_id' => 'Album ID', + 'field_background_album_id_placeholder' => '24-character album ID', + 'background_load_error' => 'Could not load photo. Make sure the ID is correct and you have access to it.', + 'background_mode_hint' => [ + 'random' => 'A random public photo is picked on every page load.', + 'latest_album_cover' => 'The cover of the most recently published public album is used.', + 'random_from_album' => 'A random photo from this album is picked on every page load. The preview only updates after saving.', + ], + + 'preview_orientation_landscape' => 'Preview landscape background', + 'preview_orientation_portrait' => 'Preview portrait background', + + 'field_cta_position' => 'Position', + 'cta_position_options' => [ + 'top-left' => 'Top Left', + 'top' => 'Top Center', + 'top-right' => 'Top Right', + 'left' => 'Middle Left', + 'center' => 'Center', + 'right' => 'Middle Right', + 'bottom-left' => 'Bottom Left', + 'bottom' => 'Bottom Center', + 'bottom-right' => 'Bottom Right', + ], + 'field_cta_shift_type' => 'Shift unit', + 'cta_shift_type_options' => [ + 'relative' => 'Relative (%)', + 'absolute' => 'Absolute (px)', + ], + 'cta_shift_type_hint' => 'Relative shifts are a percentage of the viewport size; absolute shifts are a fixed number of pixels.', + 'field_cta_shift_x' => 'Horizontal Shift (:value)', + 'cta_shift_x_direction_options' => [ + 'left' => 'Left', + 'right' => 'Right', + ], + 'field_cta_shift_y' => 'Vertical Shift (:value)', + 'cta_shift_y_direction_options' => [ + 'up' => 'Up', + 'down' => 'Down', + ], + 'reset_to_zero' => 'Reset to 0', + + 'preview_title' => 'Live Preview', + 'preview_hint' => 'Updates instantly as you edit — nothing is saved until you click Save.', + 'flat_list_hint' => 'These settings are also editable from the flat generic Settings list.', + + 'save' => 'Save', + 'saved' => 'Landing page settings saved.', + 'save_error' => 'Failed to save landing page settings.', + + 'se_required' => 'This layout/preset requires Lychee SE.', +]; diff --git a/lang/fr/landing_featured_item.php b/lang/fr/landing_featured_item.php new file mode 100644 index 00000000000..3ee73cbc4cc --- /dev/null +++ b/lang/fr/landing_featured_item.php @@ -0,0 +1,51 @@ + 'Featured Content', + 'description' => 'Curate specific photos and albums to feature on the landing page.', + + 'mode_automatic' => 'Automatic (latest published public albums)', + 'mode_manual' => 'Manual (curate specific photos/albums)', + + 'field_enabled' => 'Enable featured content', + 'field_mode' => 'Mode', + 'field_count' => 'Number of automatic items', + 'field_count_hint' => 'Between 3 and 12.', + + 'search_placeholder' => 'Search photos/albums by title…', + 'search_no_results' => 'No results.', + 'add' => 'Add', + + 'no_items' => 'No items curated yet.', + 'col_preview' => 'Preview', + 'col_title' => 'Title', + 'col_type' => 'Type', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + 'type_photo' => 'Photo', + 'type_album' => 'Album', + + 'confirm_delete_header' => 'Remove Featured Item', + 'confirm_delete_message' => 'Are you sure you want to remove ":title" from the featured content?', + + 'added' => 'Item added successfully.', + 'deleted' => 'Item removed successfully.', + 'reordered' => 'Featured items reordered successfully.', + 'error_load' => 'Failed to load featured items.', + 'error_search' => 'Failed to search.', + 'error_add' => 'Failed to add item.', + 'error_delete' => 'Failed to remove item.', + 'error_reorder' => 'Failed to reorder featured items.', +]; diff --git a/lang/fr/landing_link.php b/lang/fr/landing_link.php new file mode 100644 index 00000000000..cf37252cb5f --- /dev/null +++ b/lang/fr/landing_link.php @@ -0,0 +1,73 @@ + 'Extra Links', + 'description' => 'Manage an arbitrary, ordered list of extra links shown on the nav and/or footer of the landing page.', + + // Empty state + 'no_links' => 'No extra links configured yet.', + 'create_first' => 'Create your first link', + + // Table columns + 'col_label' => 'Label', + 'col_url' => 'URL', + 'col_placement' => 'Placement', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + + // Placement labels + 'placement_nav' => 'Nav', + 'placement_footer' => 'Footer', + 'placement_both' => 'Nav & Footer', + + // Built-in links (Gallery, Contact) + 'badge_built_in' => 'Built-in', + 'built_in_cannot_delete' => 'This built-in link cannot be deleted. Disable it instead, or reorder it like any other link.', + 'built_in_url_hint' => 'This is a built-in link and its target cannot be changed.', + + // Buttons + 'create' => 'Create Link', + 'edit' => 'Edit', + 'delete' => 'Delete', + 'cancel' => 'Cancel', + 'save' => 'Save', + + // Form fields + 'field_label' => 'Label', + 'field_label_placeholder' => 'e.g. Instagram', + 'field_url' => 'URL', + 'field_url_placeholder' => 'https://example.com', + 'field_placement' => 'Placement', + 'field_open_in_new_tab' => 'Open in new tab', + 'field_enabled' => 'Enabled', + + // Modal titles + 'modal_create_title' => 'Create Link', + 'modal_edit_title' => 'Edit Link', + + // Delete confirmation + 'confirm_delete_header' => 'Delete Link', + 'confirm_delete_message' => 'Are you sure you want to delete the link ":label"? This action cannot be undone.', + + // Toasts + 'created' => 'Link created successfully.', + 'updated' => 'Link updated successfully.', + 'deleted' => 'Link deleted successfully.', + 'reordered' => 'Links reordered successfully.', + 'error_load' => 'Failed to load links.', + 'error_save' => 'Failed to save link.', + 'error_delete' => 'Failed to delete link.', + 'error_reorder' => 'Failed to reorder links.', +]; diff --git a/lang/fr/watermark.php b/lang/fr/watermark.php index bcc99796a39..4a6f338053e 100644 --- a/lang/fr/watermark.php +++ b/lang/fr/watermark.php @@ -57,6 +57,7 @@ 'up' => 'Haut', 'down' => 'Bas', ], + 'reset_to_zero' => 'Reset to 0', 'save' => 'Enregistrer les paramètres', 'saved' => 'Paramètres du filigrane enregistrés.', diff --git a/lang/hu/all_settings.php b/lang/hu/all_settings.php index f0f3e5b5b83..f44322491cc 100644 --- a/lang/hu/all_settings.php +++ b/lang/hu/all_settings.php @@ -216,6 +216,18 @@ 'landing_background_portrait_mode' => 'Mode for portrait background', 'landing_background_landscape' => 'Value for landscape background (URL, photo ID, or album ID)', 'landing_background_portrait' => 'Value for portrait background (URL, photo ID, or album ID)', + 'landing_layout' => 'Landing page layout', + 'landing_intro_screen_enabled' => 'Enable the intro splash screen', + 'landing_hero_text_position' => 'Hero text position', + 'landing_hero_text_color' => 'Hero text color', + 'landing_hero_text_opacity' => 'Hero text opacity (%)', + 'landing_animation_preset' => 'Landing page animation preset', + 'landing_about_enabled' => 'Enable the about section', + 'landing_about_text' => 'About section text', + 'landing_featured_items_enabled' => 'Enable the featured content section', + 'landing_featured_items_mode' => 'Featured content mode', + 'landing_featured_items_count' => 'Number of automatic featured items', + 'landing_cta_text' => 'Call-to-action text override', 'metrics_enabled' => 'Enable statistics on photos & albums', 'metrics_logged_in_users_enabed' => 'Enable statistics for logged-in users', 'metrics_access' => 'Access level for statistics of the album/photo', @@ -613,6 +625,18 @@ 'landing_background_portrait_mode' => 'Options: static (URL), photo_id (specific photo), random (random public photo), latest_album_cover (latest album cover), random_from_album (random from album).', 'landing_background_landscape' => 'Depends on mode: URL for static, photo ID for photo_id, album ID for random_from_album. This image is also used when sharing the gallery link directly.', 'landing_background_portrait' => 'Depends on mode: URL for static, photo ID for photo_id, album ID for random_from_album.', + 'landing_layout' => 'Options: classic (default, free), portfolio/minimal/studio (require Lychee SE). Classic is unchanged and remains free forever.', + 'landing_intro_screen_enabled' => 'Applies to the classic and portfolio layouts. Disable to skip straight to the hero.', + 'landing_hero_text_position' => 'Options: top_left, top_right, bottom_left, bottom_right, center. Applies to the classic and portfolio layouts.', + 'landing_hero_text_color' => 'Leave empty to use the default white. Applies to the headline and subtitle text only, on every layout.', + 'landing_hero_text_opacity' => 'Range 0-100. Applies to the headline and subtitle text only, on every layout.', + 'landing_animation_preset' => 'Options: none/classic_fade (free), zoom_in/parallax_scroll/slide_reveal (require Lychee SE).', + 'landing_about_enabled' => 'Applies to the portfolio and minimal layouts.', + 'landing_about_text' => 'Admin-authored HTML, rendered verbatim (same trust model as the footer additional text).', + 'landing_featured_items_enabled' => 'Applies to the portfolio layout only. Requires Lychee SE to take effect.', + 'landing_featured_items_mode' => 'Automatic: most recently published public albums. Manual: admin-curated photos/albums.', + 'landing_featured_items_count' => 'Range 3-12. Only used in automatic featured content mode.', + 'landing_cta_text' => 'Leave empty to use each layout\'s default label.', 'metrics_enabled' => 'If enabled, anonymours users will be measured.', 'metrics_logged_in_users_enabed' => 'If enabled, logged-in users will be measured as well (admin users are not measured).', 'metrics_access' => '', diff --git a/lang/hu/landing.php b/lang/hu/landing.php index 11e4c89933a..7619d8b0ad6 100644 --- a/lang/hu/landing.php +++ b/lang/hu/landing.php @@ -10,4 +10,20 @@ 'access_gallery' => 'Access the gallery', 'Powered_by_Lychee' => 'Powered by Lychee', 'copyright' => 'All images on this website are subject to copyright by %1$s © %2$s', + 'client_login' => 'Client Login', + 'view_public_gallery' => 'View public gallery', + 'contact' => 'Contact', + 'portfolio' => [ + 'about' => 'About', + 'featured' => 'Recent Work', + 'scroll_down' => 'Scroll down', + ], + 'meridian' => [ + 'explore_label' => 'Explore', + 'explore_caption' => 'View the gallery', + 'contact_caption' => 'Get in touch', + ], + 'studio' => [ + 'welcome_back' => 'Welcome back', + ], ]; diff --git a/lang/hu/landing_config.php b/lang/hu/landing_config.php new file mode 100644 index 00000000000..02f332b1335 --- /dev/null +++ b/lang/hu/landing_config.php @@ -0,0 +1,103 @@ + 'Landing Page', + 'tab_settings' => 'Settings', + 'tab_links' => 'Links', + 'tab_featured' => 'Featured', + + 'section_layout' => 'Layout & Structure', + 'section_hero' => 'Hero', + 'section_background_landscape' => 'Background (Landscape)', + 'section_background_portrait' => 'Background (Portrait)', + 'section_cta_position' => 'Call-to-Action Position', + 'section_content' => 'Content', + + 'field_layout' => 'Layout', + 'field_intro_screen_enabled' => 'Intro splash screen', + 'field_hero_text_position' => 'Hero text position', + 'field_hero_text_color' => 'Hero text color', + 'field_hero_text_opacity' => 'Hero text opacity', + 'field_animation_preset' => 'Animation preset', + 'field_cta_text' => 'text', + 'field_cta_text_placeholder' => 'Leave empty for the layout default', + 'field_about_enabled' => 'About section', + 'field_about_text' => 'About text', + + 'field_background_mode' => 'Source', + 'background_mode_options' => [ + 'static' => 'URL', + 'photo_id' => 'Photo ID', + 'random' => 'Random public photo', + 'latest_album_cover' => 'Latest album cover', + 'random_from_album' => 'Random photo from album', + ], + 'field_background_url' => 'Image URL', + 'field_background_url_placeholder' => 'https://…', + 'field_background_photo_id' => 'Photo ID', + 'field_background_photo_id_placeholder' => '24-character photo ID', + 'field_background_photo_id_hint' => 'Photo ID of the image to use. Open a photo and copy the last 24 characters from the URL.', + 'field_background_album_id' => 'Album ID', + 'field_background_album_id_placeholder' => '24-character album ID', + 'background_load_error' => 'Could not load photo. Make sure the ID is correct and you have access to it.', + 'background_mode_hint' => [ + 'random' => 'A random public photo is picked on every page load.', + 'latest_album_cover' => 'The cover of the most recently published public album is used.', + 'random_from_album' => 'A random photo from this album is picked on every page load. The preview only updates after saving.', + ], + + 'preview_orientation_landscape' => 'Preview landscape background', + 'preview_orientation_portrait' => 'Preview portrait background', + + 'field_cta_position' => 'Position', + 'cta_position_options' => [ + 'top-left' => 'Top Left', + 'top' => 'Top Center', + 'top-right' => 'Top Right', + 'left' => 'Middle Left', + 'center' => 'Center', + 'right' => 'Middle Right', + 'bottom-left' => 'Bottom Left', + 'bottom' => 'Bottom Center', + 'bottom-right' => 'Bottom Right', + ], + 'field_cta_shift_type' => 'Shift unit', + 'cta_shift_type_options' => [ + 'relative' => 'Relative (%)', + 'absolute' => 'Absolute (px)', + ], + 'cta_shift_type_hint' => 'Relative shifts are a percentage of the viewport size; absolute shifts are a fixed number of pixels.', + 'field_cta_shift_x' => 'Horizontal Shift (:value)', + 'cta_shift_x_direction_options' => [ + 'left' => 'Left', + 'right' => 'Right', + ], + 'field_cta_shift_y' => 'Vertical Shift (:value)', + 'cta_shift_y_direction_options' => [ + 'up' => 'Up', + 'down' => 'Down', + ], + 'reset_to_zero' => 'Reset to 0', + + 'preview_title' => 'Live Preview', + 'preview_hint' => 'Updates instantly as you edit — nothing is saved until you click Save.', + 'flat_list_hint' => 'These settings are also editable from the flat generic Settings list.', + + 'save' => 'Save', + 'saved' => 'Landing page settings saved.', + 'save_error' => 'Failed to save landing page settings.', + + 'se_required' => 'This layout/preset requires Lychee SE.', +]; diff --git a/lang/hu/landing_featured_item.php b/lang/hu/landing_featured_item.php new file mode 100644 index 00000000000..3ee73cbc4cc --- /dev/null +++ b/lang/hu/landing_featured_item.php @@ -0,0 +1,51 @@ + 'Featured Content', + 'description' => 'Curate specific photos and albums to feature on the landing page.', + + 'mode_automatic' => 'Automatic (latest published public albums)', + 'mode_manual' => 'Manual (curate specific photos/albums)', + + 'field_enabled' => 'Enable featured content', + 'field_mode' => 'Mode', + 'field_count' => 'Number of automatic items', + 'field_count_hint' => 'Between 3 and 12.', + + 'search_placeholder' => 'Search photos/albums by title…', + 'search_no_results' => 'No results.', + 'add' => 'Add', + + 'no_items' => 'No items curated yet.', + 'col_preview' => 'Preview', + 'col_title' => 'Title', + 'col_type' => 'Type', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + 'type_photo' => 'Photo', + 'type_album' => 'Album', + + 'confirm_delete_header' => 'Remove Featured Item', + 'confirm_delete_message' => 'Are you sure you want to remove ":title" from the featured content?', + + 'added' => 'Item added successfully.', + 'deleted' => 'Item removed successfully.', + 'reordered' => 'Featured items reordered successfully.', + 'error_load' => 'Failed to load featured items.', + 'error_search' => 'Failed to search.', + 'error_add' => 'Failed to add item.', + 'error_delete' => 'Failed to remove item.', + 'error_reorder' => 'Failed to reorder featured items.', +]; diff --git a/lang/hu/landing_link.php b/lang/hu/landing_link.php new file mode 100644 index 00000000000..cf37252cb5f --- /dev/null +++ b/lang/hu/landing_link.php @@ -0,0 +1,73 @@ + 'Extra Links', + 'description' => 'Manage an arbitrary, ordered list of extra links shown on the nav and/or footer of the landing page.', + + // Empty state + 'no_links' => 'No extra links configured yet.', + 'create_first' => 'Create your first link', + + // Table columns + 'col_label' => 'Label', + 'col_url' => 'URL', + 'col_placement' => 'Placement', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + + // Placement labels + 'placement_nav' => 'Nav', + 'placement_footer' => 'Footer', + 'placement_both' => 'Nav & Footer', + + // Built-in links (Gallery, Contact) + 'badge_built_in' => 'Built-in', + 'built_in_cannot_delete' => 'This built-in link cannot be deleted. Disable it instead, or reorder it like any other link.', + 'built_in_url_hint' => 'This is a built-in link and its target cannot be changed.', + + // Buttons + 'create' => 'Create Link', + 'edit' => 'Edit', + 'delete' => 'Delete', + 'cancel' => 'Cancel', + 'save' => 'Save', + + // Form fields + 'field_label' => 'Label', + 'field_label_placeholder' => 'e.g. Instagram', + 'field_url' => 'URL', + 'field_url_placeholder' => 'https://example.com', + 'field_placement' => 'Placement', + 'field_open_in_new_tab' => 'Open in new tab', + 'field_enabled' => 'Enabled', + + // Modal titles + 'modal_create_title' => 'Create Link', + 'modal_edit_title' => 'Edit Link', + + // Delete confirmation + 'confirm_delete_header' => 'Delete Link', + 'confirm_delete_message' => 'Are you sure you want to delete the link ":label"? This action cannot be undone.', + + // Toasts + 'created' => 'Link created successfully.', + 'updated' => 'Link updated successfully.', + 'deleted' => 'Link deleted successfully.', + 'reordered' => 'Links reordered successfully.', + 'error_load' => 'Failed to load links.', + 'error_save' => 'Failed to save link.', + 'error_delete' => 'Failed to delete link.', + 'error_reorder' => 'Failed to reorder links.', +]; diff --git a/lang/hu/watermark.php b/lang/hu/watermark.php index 6860c862127..be6175d3e5b 100644 --- a/lang/hu/watermark.php +++ b/lang/hu/watermark.php @@ -57,6 +57,7 @@ 'up' => 'Up', 'down' => 'Down', ], + 'reset_to_zero' => 'Reset to 0', 'save' => 'Save Settings', 'saved' => 'Watermark settings saved.', diff --git a/lang/it/all_settings.php b/lang/it/all_settings.php index 43c34e29ab5..3420d57cf4b 100644 --- a/lang/it/all_settings.php +++ b/lang/it/all_settings.php @@ -216,6 +216,18 @@ 'landing_background_portrait_mode' => 'Mode for portrait background', 'landing_background_landscape' => 'Value for landscape background (URL, photo ID, or album ID)', 'landing_background_portrait' => 'Value for portrait background (URL, photo ID, or album ID)', + 'landing_layout' => 'Landing page layout', + 'landing_intro_screen_enabled' => 'Enable the intro splash screen', + 'landing_hero_text_position' => 'Hero text position', + 'landing_hero_text_color' => 'Hero text color', + 'landing_hero_text_opacity' => 'Hero text opacity (%)', + 'landing_animation_preset' => 'Landing page animation preset', + 'landing_about_enabled' => 'Enable the about section', + 'landing_about_text' => 'About section text', + 'landing_featured_items_enabled' => 'Enable the featured content section', + 'landing_featured_items_mode' => 'Featured content mode', + 'landing_featured_items_count' => 'Number of automatic featured items', + 'landing_cta_text' => 'Call-to-action text override', 'metrics_enabled' => 'Enable statistics on photos & albums', 'metrics_logged_in_users_enabed' => 'Enable statistics for logged-in users', 'metrics_access' => 'Access level for statistics of the album/photo', @@ -613,6 +625,18 @@ 'landing_background_portrait_mode' => 'Options: static (URL), photo_id (specific photo), random (random public photo), latest_album_cover (latest album cover), random_from_album (random from album).', 'landing_background_landscape' => 'Depends on mode: URL for static, photo ID for photo_id, album ID for random_from_album. This image is also used when sharing the gallery link directly.', 'landing_background_portrait' => 'Depends on mode: URL for static, photo ID for photo_id, album ID for random_from_album.', + 'landing_layout' => 'Options: classic (default, free), portfolio/minimal/studio (require Lychee SE). Classic is unchanged and remains free forever.', + 'landing_intro_screen_enabled' => 'Applies to the classic and portfolio layouts. Disable to skip straight to the hero.', + 'landing_hero_text_position' => 'Options: top_left, top_right, bottom_left, bottom_right, center. Applies to the classic and portfolio layouts.', + 'landing_hero_text_color' => 'Leave empty to use the default white. Applies to the headline and subtitle text only, on every layout.', + 'landing_hero_text_opacity' => 'Range 0-100. Applies to the headline and subtitle text only, on every layout.', + 'landing_animation_preset' => 'Options: none/classic_fade (free), zoom_in/parallax_scroll/slide_reveal (require Lychee SE).', + 'landing_about_enabled' => 'Applies to the portfolio and minimal layouts.', + 'landing_about_text' => 'Admin-authored HTML, rendered verbatim (same trust model as the footer additional text).', + 'landing_featured_items_enabled' => 'Applies to the portfolio layout only. Requires Lychee SE to take effect.', + 'landing_featured_items_mode' => 'Automatic: most recently published public albums. Manual: admin-curated photos/albums.', + 'landing_featured_items_count' => 'Range 3-12. Only used in automatic featured content mode.', + 'landing_cta_text' => 'Leave empty to use each layout\'s default label.', 'metrics_enabled' => 'If enabled, anonymours users will be measured.', 'metrics_logged_in_users_enabed' => 'If enabled, logged-in users will be measured as well (admin users are not measured).', 'metrics_access' => '', diff --git a/lang/it/landing.php b/lang/it/landing.php index 11e4c89933a..7619d8b0ad6 100644 --- a/lang/it/landing.php +++ b/lang/it/landing.php @@ -10,4 +10,20 @@ 'access_gallery' => 'Access the gallery', 'Powered_by_Lychee' => 'Powered by Lychee', 'copyright' => 'All images on this website are subject to copyright by %1$s © %2$s', + 'client_login' => 'Client Login', + 'view_public_gallery' => 'View public gallery', + 'contact' => 'Contact', + 'portfolio' => [ + 'about' => 'About', + 'featured' => 'Recent Work', + 'scroll_down' => 'Scroll down', + ], + 'meridian' => [ + 'explore_label' => 'Explore', + 'explore_caption' => 'View the gallery', + 'contact_caption' => 'Get in touch', + ], + 'studio' => [ + 'welcome_back' => 'Welcome back', + ], ]; diff --git a/lang/it/landing_config.php b/lang/it/landing_config.php new file mode 100644 index 00000000000..02f332b1335 --- /dev/null +++ b/lang/it/landing_config.php @@ -0,0 +1,103 @@ + 'Landing Page', + 'tab_settings' => 'Settings', + 'tab_links' => 'Links', + 'tab_featured' => 'Featured', + + 'section_layout' => 'Layout & Structure', + 'section_hero' => 'Hero', + 'section_background_landscape' => 'Background (Landscape)', + 'section_background_portrait' => 'Background (Portrait)', + 'section_cta_position' => 'Call-to-Action Position', + 'section_content' => 'Content', + + 'field_layout' => 'Layout', + 'field_intro_screen_enabled' => 'Intro splash screen', + 'field_hero_text_position' => 'Hero text position', + 'field_hero_text_color' => 'Hero text color', + 'field_hero_text_opacity' => 'Hero text opacity', + 'field_animation_preset' => 'Animation preset', + 'field_cta_text' => 'text', + 'field_cta_text_placeholder' => 'Leave empty for the layout default', + 'field_about_enabled' => 'About section', + 'field_about_text' => 'About text', + + 'field_background_mode' => 'Source', + 'background_mode_options' => [ + 'static' => 'URL', + 'photo_id' => 'Photo ID', + 'random' => 'Random public photo', + 'latest_album_cover' => 'Latest album cover', + 'random_from_album' => 'Random photo from album', + ], + 'field_background_url' => 'Image URL', + 'field_background_url_placeholder' => 'https://…', + 'field_background_photo_id' => 'Photo ID', + 'field_background_photo_id_placeholder' => '24-character photo ID', + 'field_background_photo_id_hint' => 'Photo ID of the image to use. Open a photo and copy the last 24 characters from the URL.', + 'field_background_album_id' => 'Album ID', + 'field_background_album_id_placeholder' => '24-character album ID', + 'background_load_error' => 'Could not load photo. Make sure the ID is correct and you have access to it.', + 'background_mode_hint' => [ + 'random' => 'A random public photo is picked on every page load.', + 'latest_album_cover' => 'The cover of the most recently published public album is used.', + 'random_from_album' => 'A random photo from this album is picked on every page load. The preview only updates after saving.', + ], + + 'preview_orientation_landscape' => 'Preview landscape background', + 'preview_orientation_portrait' => 'Preview portrait background', + + 'field_cta_position' => 'Position', + 'cta_position_options' => [ + 'top-left' => 'Top Left', + 'top' => 'Top Center', + 'top-right' => 'Top Right', + 'left' => 'Middle Left', + 'center' => 'Center', + 'right' => 'Middle Right', + 'bottom-left' => 'Bottom Left', + 'bottom' => 'Bottom Center', + 'bottom-right' => 'Bottom Right', + ], + 'field_cta_shift_type' => 'Shift unit', + 'cta_shift_type_options' => [ + 'relative' => 'Relative (%)', + 'absolute' => 'Absolute (px)', + ], + 'cta_shift_type_hint' => 'Relative shifts are a percentage of the viewport size; absolute shifts are a fixed number of pixels.', + 'field_cta_shift_x' => 'Horizontal Shift (:value)', + 'cta_shift_x_direction_options' => [ + 'left' => 'Left', + 'right' => 'Right', + ], + 'field_cta_shift_y' => 'Vertical Shift (:value)', + 'cta_shift_y_direction_options' => [ + 'up' => 'Up', + 'down' => 'Down', + ], + 'reset_to_zero' => 'Reset to 0', + + 'preview_title' => 'Live Preview', + 'preview_hint' => 'Updates instantly as you edit — nothing is saved until you click Save.', + 'flat_list_hint' => 'These settings are also editable from the flat generic Settings list.', + + 'save' => 'Save', + 'saved' => 'Landing page settings saved.', + 'save_error' => 'Failed to save landing page settings.', + + 'se_required' => 'This layout/preset requires Lychee SE.', +]; diff --git a/lang/it/landing_featured_item.php b/lang/it/landing_featured_item.php new file mode 100644 index 00000000000..3ee73cbc4cc --- /dev/null +++ b/lang/it/landing_featured_item.php @@ -0,0 +1,51 @@ + 'Featured Content', + 'description' => 'Curate specific photos and albums to feature on the landing page.', + + 'mode_automatic' => 'Automatic (latest published public albums)', + 'mode_manual' => 'Manual (curate specific photos/albums)', + + 'field_enabled' => 'Enable featured content', + 'field_mode' => 'Mode', + 'field_count' => 'Number of automatic items', + 'field_count_hint' => 'Between 3 and 12.', + + 'search_placeholder' => 'Search photos/albums by title…', + 'search_no_results' => 'No results.', + 'add' => 'Add', + + 'no_items' => 'No items curated yet.', + 'col_preview' => 'Preview', + 'col_title' => 'Title', + 'col_type' => 'Type', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + 'type_photo' => 'Photo', + 'type_album' => 'Album', + + 'confirm_delete_header' => 'Remove Featured Item', + 'confirm_delete_message' => 'Are you sure you want to remove ":title" from the featured content?', + + 'added' => 'Item added successfully.', + 'deleted' => 'Item removed successfully.', + 'reordered' => 'Featured items reordered successfully.', + 'error_load' => 'Failed to load featured items.', + 'error_search' => 'Failed to search.', + 'error_add' => 'Failed to add item.', + 'error_delete' => 'Failed to remove item.', + 'error_reorder' => 'Failed to reorder featured items.', +]; diff --git a/lang/it/landing_link.php b/lang/it/landing_link.php new file mode 100644 index 00000000000..cf37252cb5f --- /dev/null +++ b/lang/it/landing_link.php @@ -0,0 +1,73 @@ + 'Extra Links', + 'description' => 'Manage an arbitrary, ordered list of extra links shown on the nav and/or footer of the landing page.', + + // Empty state + 'no_links' => 'No extra links configured yet.', + 'create_first' => 'Create your first link', + + // Table columns + 'col_label' => 'Label', + 'col_url' => 'URL', + 'col_placement' => 'Placement', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + + // Placement labels + 'placement_nav' => 'Nav', + 'placement_footer' => 'Footer', + 'placement_both' => 'Nav & Footer', + + // Built-in links (Gallery, Contact) + 'badge_built_in' => 'Built-in', + 'built_in_cannot_delete' => 'This built-in link cannot be deleted. Disable it instead, or reorder it like any other link.', + 'built_in_url_hint' => 'This is a built-in link and its target cannot be changed.', + + // Buttons + 'create' => 'Create Link', + 'edit' => 'Edit', + 'delete' => 'Delete', + 'cancel' => 'Cancel', + 'save' => 'Save', + + // Form fields + 'field_label' => 'Label', + 'field_label_placeholder' => 'e.g. Instagram', + 'field_url' => 'URL', + 'field_url_placeholder' => 'https://example.com', + 'field_placement' => 'Placement', + 'field_open_in_new_tab' => 'Open in new tab', + 'field_enabled' => 'Enabled', + + // Modal titles + 'modal_create_title' => 'Create Link', + 'modal_edit_title' => 'Edit Link', + + // Delete confirmation + 'confirm_delete_header' => 'Delete Link', + 'confirm_delete_message' => 'Are you sure you want to delete the link ":label"? This action cannot be undone.', + + // Toasts + 'created' => 'Link created successfully.', + 'updated' => 'Link updated successfully.', + 'deleted' => 'Link deleted successfully.', + 'reordered' => 'Links reordered successfully.', + 'error_load' => 'Failed to load links.', + 'error_save' => 'Failed to save link.', + 'error_delete' => 'Failed to delete link.', + 'error_reorder' => 'Failed to reorder links.', +]; diff --git a/lang/it/watermark.php b/lang/it/watermark.php index 6860c862127..be6175d3e5b 100644 --- a/lang/it/watermark.php +++ b/lang/it/watermark.php @@ -57,6 +57,7 @@ 'up' => 'Up', 'down' => 'Down', ], + 'reset_to_zero' => 'Reset to 0', 'save' => 'Save Settings', 'saved' => 'Watermark settings saved.', diff --git a/lang/ja/all_settings.php b/lang/ja/all_settings.php index f3c0000beb7..c4fd6a90537 100644 --- a/lang/ja/all_settings.php +++ b/lang/ja/all_settings.php @@ -216,6 +216,18 @@ 'landing_background_portrait_mode' => 'Mode for portrait background', 'landing_background_landscape' => 'Value for landscape background (URL, photo ID, or album ID)', 'landing_background_portrait' => 'Value for portrait background (URL, photo ID, or album ID)', + 'landing_layout' => 'Landing page layout', + 'landing_intro_screen_enabled' => 'Enable the intro splash screen', + 'landing_hero_text_position' => 'Hero text position', + 'landing_hero_text_color' => 'Hero text color', + 'landing_hero_text_opacity' => 'Hero text opacity (%)', + 'landing_animation_preset' => 'Landing page animation preset', + 'landing_about_enabled' => 'Enable the about section', + 'landing_about_text' => 'About section text', + 'landing_featured_items_enabled' => 'Enable the featured content section', + 'landing_featured_items_mode' => 'Featured content mode', + 'landing_featured_items_count' => 'Number of automatic featured items', + 'landing_cta_text' => 'Call-to-action text override', 'metrics_enabled' => 'Enable statistics on photos & albums', 'metrics_logged_in_users_enabed' => 'Enable statistics for logged-in users', 'metrics_access' => 'Access level for statistics of the album/photo', @@ -613,6 +625,18 @@ 'landing_background_portrait_mode' => 'Options: static (URL), photo_id (specific photo), random (random public photo), latest_album_cover (latest album cover), random_from_album (random from album).', 'landing_background_landscape' => 'Depends on mode: URL for static, photo ID for photo_id, album ID for random_from_album. This image is also used when sharing the gallery link directly.', 'landing_background_portrait' => 'Depends on mode: URL for static, photo ID for photo_id, album ID for random_from_album.', + 'landing_layout' => 'Options: classic (default, free), portfolio/minimal/studio (require Lychee SE). Classic is unchanged and remains free forever.', + 'landing_intro_screen_enabled' => 'Applies to the classic and portfolio layouts. Disable to skip straight to the hero.', + 'landing_hero_text_position' => 'Options: top_left, top_right, bottom_left, bottom_right, center. Applies to the classic and portfolio layouts.', + 'landing_hero_text_color' => 'Leave empty to use the default white. Applies to the headline and subtitle text only, on every layout.', + 'landing_hero_text_opacity' => 'Range 0-100. Applies to the headline and subtitle text only, on every layout.', + 'landing_animation_preset' => 'Options: none/classic_fade (free), zoom_in/parallax_scroll/slide_reveal (require Lychee SE).', + 'landing_about_enabled' => 'Applies to the portfolio and minimal layouts.', + 'landing_about_text' => 'Admin-authored HTML, rendered verbatim (same trust model as the footer additional text).', + 'landing_featured_items_enabled' => 'Applies to the portfolio layout only. Requires Lychee SE to take effect.', + 'landing_featured_items_mode' => 'Automatic: most recently published public albums. Manual: admin-curated photos/albums.', + 'landing_featured_items_count' => 'Range 3-12. Only used in automatic featured content mode.', + 'landing_cta_text' => 'Leave empty to use each layout\'s default label.', 'metrics_enabled' => 'If enabled, anonymours users will be measured.', 'metrics_logged_in_users_enabed' => 'If enabled, logged-in users will be measured as well (admin users are not measured).', 'metrics_access' => '', diff --git a/lang/ja/landing.php b/lang/ja/landing.php index 11e4c89933a..7619d8b0ad6 100644 --- a/lang/ja/landing.php +++ b/lang/ja/landing.php @@ -10,4 +10,20 @@ 'access_gallery' => 'Access the gallery', 'Powered_by_Lychee' => 'Powered by Lychee', 'copyright' => 'All images on this website are subject to copyright by %1$s © %2$s', + 'client_login' => 'Client Login', + 'view_public_gallery' => 'View public gallery', + 'contact' => 'Contact', + 'portfolio' => [ + 'about' => 'About', + 'featured' => 'Recent Work', + 'scroll_down' => 'Scroll down', + ], + 'meridian' => [ + 'explore_label' => 'Explore', + 'explore_caption' => 'View the gallery', + 'contact_caption' => 'Get in touch', + ], + 'studio' => [ + 'welcome_back' => 'Welcome back', + ], ]; diff --git a/lang/ja/landing_config.php b/lang/ja/landing_config.php new file mode 100644 index 00000000000..02f332b1335 --- /dev/null +++ b/lang/ja/landing_config.php @@ -0,0 +1,103 @@ + 'Landing Page', + 'tab_settings' => 'Settings', + 'tab_links' => 'Links', + 'tab_featured' => 'Featured', + + 'section_layout' => 'Layout & Structure', + 'section_hero' => 'Hero', + 'section_background_landscape' => 'Background (Landscape)', + 'section_background_portrait' => 'Background (Portrait)', + 'section_cta_position' => 'Call-to-Action Position', + 'section_content' => 'Content', + + 'field_layout' => 'Layout', + 'field_intro_screen_enabled' => 'Intro splash screen', + 'field_hero_text_position' => 'Hero text position', + 'field_hero_text_color' => 'Hero text color', + 'field_hero_text_opacity' => 'Hero text opacity', + 'field_animation_preset' => 'Animation preset', + 'field_cta_text' => 'text', + 'field_cta_text_placeholder' => 'Leave empty for the layout default', + 'field_about_enabled' => 'About section', + 'field_about_text' => 'About text', + + 'field_background_mode' => 'Source', + 'background_mode_options' => [ + 'static' => 'URL', + 'photo_id' => 'Photo ID', + 'random' => 'Random public photo', + 'latest_album_cover' => 'Latest album cover', + 'random_from_album' => 'Random photo from album', + ], + 'field_background_url' => 'Image URL', + 'field_background_url_placeholder' => 'https://…', + 'field_background_photo_id' => 'Photo ID', + 'field_background_photo_id_placeholder' => '24-character photo ID', + 'field_background_photo_id_hint' => 'Photo ID of the image to use. Open a photo and copy the last 24 characters from the URL.', + 'field_background_album_id' => 'Album ID', + 'field_background_album_id_placeholder' => '24-character album ID', + 'background_load_error' => 'Could not load photo. Make sure the ID is correct and you have access to it.', + 'background_mode_hint' => [ + 'random' => 'A random public photo is picked on every page load.', + 'latest_album_cover' => 'The cover of the most recently published public album is used.', + 'random_from_album' => 'A random photo from this album is picked on every page load. The preview only updates after saving.', + ], + + 'preview_orientation_landscape' => 'Preview landscape background', + 'preview_orientation_portrait' => 'Preview portrait background', + + 'field_cta_position' => 'Position', + 'cta_position_options' => [ + 'top-left' => 'Top Left', + 'top' => 'Top Center', + 'top-right' => 'Top Right', + 'left' => 'Middle Left', + 'center' => 'Center', + 'right' => 'Middle Right', + 'bottom-left' => 'Bottom Left', + 'bottom' => 'Bottom Center', + 'bottom-right' => 'Bottom Right', + ], + 'field_cta_shift_type' => 'Shift unit', + 'cta_shift_type_options' => [ + 'relative' => 'Relative (%)', + 'absolute' => 'Absolute (px)', + ], + 'cta_shift_type_hint' => 'Relative shifts are a percentage of the viewport size; absolute shifts are a fixed number of pixels.', + 'field_cta_shift_x' => 'Horizontal Shift (:value)', + 'cta_shift_x_direction_options' => [ + 'left' => 'Left', + 'right' => 'Right', + ], + 'field_cta_shift_y' => 'Vertical Shift (:value)', + 'cta_shift_y_direction_options' => [ + 'up' => 'Up', + 'down' => 'Down', + ], + 'reset_to_zero' => 'Reset to 0', + + 'preview_title' => 'Live Preview', + 'preview_hint' => 'Updates instantly as you edit — nothing is saved until you click Save.', + 'flat_list_hint' => 'These settings are also editable from the flat generic Settings list.', + + 'save' => 'Save', + 'saved' => 'Landing page settings saved.', + 'save_error' => 'Failed to save landing page settings.', + + 'se_required' => 'This layout/preset requires Lychee SE.', +]; diff --git a/lang/ja/landing_featured_item.php b/lang/ja/landing_featured_item.php new file mode 100644 index 00000000000..3ee73cbc4cc --- /dev/null +++ b/lang/ja/landing_featured_item.php @@ -0,0 +1,51 @@ + 'Featured Content', + 'description' => 'Curate specific photos and albums to feature on the landing page.', + + 'mode_automatic' => 'Automatic (latest published public albums)', + 'mode_manual' => 'Manual (curate specific photos/albums)', + + 'field_enabled' => 'Enable featured content', + 'field_mode' => 'Mode', + 'field_count' => 'Number of automatic items', + 'field_count_hint' => 'Between 3 and 12.', + + 'search_placeholder' => 'Search photos/albums by title…', + 'search_no_results' => 'No results.', + 'add' => 'Add', + + 'no_items' => 'No items curated yet.', + 'col_preview' => 'Preview', + 'col_title' => 'Title', + 'col_type' => 'Type', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + 'type_photo' => 'Photo', + 'type_album' => 'Album', + + 'confirm_delete_header' => 'Remove Featured Item', + 'confirm_delete_message' => 'Are you sure you want to remove ":title" from the featured content?', + + 'added' => 'Item added successfully.', + 'deleted' => 'Item removed successfully.', + 'reordered' => 'Featured items reordered successfully.', + 'error_load' => 'Failed to load featured items.', + 'error_search' => 'Failed to search.', + 'error_add' => 'Failed to add item.', + 'error_delete' => 'Failed to remove item.', + 'error_reorder' => 'Failed to reorder featured items.', +]; diff --git a/lang/ja/landing_link.php b/lang/ja/landing_link.php new file mode 100644 index 00000000000..cf37252cb5f --- /dev/null +++ b/lang/ja/landing_link.php @@ -0,0 +1,73 @@ + 'Extra Links', + 'description' => 'Manage an arbitrary, ordered list of extra links shown on the nav and/or footer of the landing page.', + + // Empty state + 'no_links' => 'No extra links configured yet.', + 'create_first' => 'Create your first link', + + // Table columns + 'col_label' => 'Label', + 'col_url' => 'URL', + 'col_placement' => 'Placement', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + + // Placement labels + 'placement_nav' => 'Nav', + 'placement_footer' => 'Footer', + 'placement_both' => 'Nav & Footer', + + // Built-in links (Gallery, Contact) + 'badge_built_in' => 'Built-in', + 'built_in_cannot_delete' => 'This built-in link cannot be deleted. Disable it instead, or reorder it like any other link.', + 'built_in_url_hint' => 'This is a built-in link and its target cannot be changed.', + + // Buttons + 'create' => 'Create Link', + 'edit' => 'Edit', + 'delete' => 'Delete', + 'cancel' => 'Cancel', + 'save' => 'Save', + + // Form fields + 'field_label' => 'Label', + 'field_label_placeholder' => 'e.g. Instagram', + 'field_url' => 'URL', + 'field_url_placeholder' => 'https://example.com', + 'field_placement' => 'Placement', + 'field_open_in_new_tab' => 'Open in new tab', + 'field_enabled' => 'Enabled', + + // Modal titles + 'modal_create_title' => 'Create Link', + 'modal_edit_title' => 'Edit Link', + + // Delete confirmation + 'confirm_delete_header' => 'Delete Link', + 'confirm_delete_message' => 'Are you sure you want to delete the link ":label"? This action cannot be undone.', + + // Toasts + 'created' => 'Link created successfully.', + 'updated' => 'Link updated successfully.', + 'deleted' => 'Link deleted successfully.', + 'reordered' => 'Links reordered successfully.', + 'error_load' => 'Failed to load links.', + 'error_save' => 'Failed to save link.', + 'error_delete' => 'Failed to delete link.', + 'error_reorder' => 'Failed to reorder links.', +]; diff --git a/lang/ja/watermark.php b/lang/ja/watermark.php index 6860c862127..be6175d3e5b 100644 --- a/lang/ja/watermark.php +++ b/lang/ja/watermark.php @@ -57,6 +57,7 @@ 'up' => 'Up', 'down' => 'Down', ], + 'reset_to_zero' => 'Reset to 0', 'save' => 'Save Settings', 'saved' => 'Watermark settings saved.', diff --git a/lang/nl/all_settings.php b/lang/nl/all_settings.php index b70dd04342e..46b06db5406 100644 --- a/lang/nl/all_settings.php +++ b/lang/nl/all_settings.php @@ -216,6 +216,18 @@ 'landing_background_portrait_mode' => 'Modus voor staande achtergrond', 'landing_background_landscape' => 'Waarde voor liggende achtergrond (URL, foto-ID of album-ID)', 'landing_background_portrait' => 'Waarde voor staande achtergrond (URL, foto-ID of album-ID)', + 'landing_layout' => 'Landing page layout', + 'landing_intro_screen_enabled' => 'Enable the intro splash screen', + 'landing_hero_text_position' => 'Hero text position', + 'landing_hero_text_color' => 'Hero text color', + 'landing_hero_text_opacity' => 'Hero text opacity (%)', + 'landing_animation_preset' => 'Landing page animation preset', + 'landing_about_enabled' => 'Enable the about section', + 'landing_about_text' => 'About section text', + 'landing_featured_items_enabled' => 'Enable the featured content section', + 'landing_featured_items_mode' => 'Featured content mode', + 'landing_featured_items_count' => 'Number of automatic featured items', + 'landing_cta_text' => 'Call-to-action text override', 'metrics_enabled' => 'Schakel statistieken voor foto\'s en albums in', 'metrics_logged_in_users_enabed' => 'Schakel statistieken voor ingelogde gebruikers in', 'metrics_access' => 'Toegangsniveau voor statistieken van het album/de foto', @@ -613,6 +625,18 @@ 'landing_background_portrait_mode' => 'Opties: static (URL), photo_id (specifieke foto), random (willekeurige openbare foto), latest_album_cover (nieuwste albumhoes), random_from_album (willekeurig uit een album).', 'landing_background_landscape' => 'Afhankelijk van de modus: URL voor static, foto-ID voor photo_id, album-ID voor random_from_album. Deze afbeelding wordt ook gebruikt wanneer de galerijlink rechtstreeks wordt gedeeld.', 'landing_background_portrait' => 'Afhankelijk van de modus: URL voor static, foto-ID voor photo_id, album-ID voor random_from_album.', + 'landing_layout' => 'Options: classic (default, free), portfolio/minimal/studio (require Lychee SE). Classic is unchanged and remains free forever.', + 'landing_intro_screen_enabled' => 'Applies to the classic and portfolio layouts. Disable to skip straight to the hero.', + 'landing_hero_text_position' => 'Options: top_left, top_right, bottom_left, bottom_right, center. Applies to the classic and portfolio layouts.', + 'landing_hero_text_color' => 'Leave empty to use the default white. Applies to the headline and subtitle text only, on every layout.', + 'landing_hero_text_opacity' => 'Range 0-100. Applies to the headline and subtitle text only, on every layout.', + 'landing_animation_preset' => 'Options: none/classic_fade (free), zoom_in/parallax_scroll/slide_reveal (require Lychee SE).', + 'landing_about_enabled' => 'Applies to the portfolio and minimal layouts.', + 'landing_about_text' => 'Admin-authored HTML, rendered verbatim (same trust model as the footer additional text).', + 'landing_featured_items_enabled' => 'Applies to the portfolio layout only. Requires Lychee SE to take effect.', + 'landing_featured_items_mode' => 'Automatic: most recently published public albums. Manual: admin-curated photos/albums.', + 'landing_featured_items_count' => 'Range 3-12. Only used in automatic featured content mode.', + 'landing_cta_text' => 'Leave empty to use each layout\'s default label.', 'metrics_enabled' => 'Indien ingeschakeld, worden ook anonieme gebruikers gemeten.', 'metrics_logged_in_users_enabed' => 'Indien ingeschakeld, worden ook ingelogde gebruikers gemeten (beheerders worden niet gemeten).', 'metrics_access' => '', diff --git a/lang/nl/landing.php b/lang/nl/landing.php index 35feb6c2be2..321a5601d93 100644 --- a/lang/nl/landing.php +++ b/lang/nl/landing.php @@ -10,4 +10,20 @@ 'access_gallery' => 'Naar de galerij', 'Powered_by_Lychee' => 'Mogelijk gemaakt door Lychee', 'copyright' => 'Alle afbeeldingen op deze website zijn auteursrechtelijk beschermd door %1$s © %2$s', + 'client_login' => 'Client Login', + 'view_public_gallery' => 'View public gallery', + 'contact' => 'Contact', + 'portfolio' => [ + 'about' => 'About', + 'featured' => 'Recent Work', + 'scroll_down' => 'Scroll down', + ], + 'meridian' => [ + 'explore_label' => 'Explore', + 'explore_caption' => 'View the gallery', + 'contact_caption' => 'Get in touch', + ], + 'studio' => [ + 'welcome_back' => 'Welcome back', + ], ]; diff --git a/lang/nl/landing_config.php b/lang/nl/landing_config.php new file mode 100644 index 00000000000..02f332b1335 --- /dev/null +++ b/lang/nl/landing_config.php @@ -0,0 +1,103 @@ + 'Landing Page', + 'tab_settings' => 'Settings', + 'tab_links' => 'Links', + 'tab_featured' => 'Featured', + + 'section_layout' => 'Layout & Structure', + 'section_hero' => 'Hero', + 'section_background_landscape' => 'Background (Landscape)', + 'section_background_portrait' => 'Background (Portrait)', + 'section_cta_position' => 'Call-to-Action Position', + 'section_content' => 'Content', + + 'field_layout' => 'Layout', + 'field_intro_screen_enabled' => 'Intro splash screen', + 'field_hero_text_position' => 'Hero text position', + 'field_hero_text_color' => 'Hero text color', + 'field_hero_text_opacity' => 'Hero text opacity', + 'field_animation_preset' => 'Animation preset', + 'field_cta_text' => 'text', + 'field_cta_text_placeholder' => 'Leave empty for the layout default', + 'field_about_enabled' => 'About section', + 'field_about_text' => 'About text', + + 'field_background_mode' => 'Source', + 'background_mode_options' => [ + 'static' => 'URL', + 'photo_id' => 'Photo ID', + 'random' => 'Random public photo', + 'latest_album_cover' => 'Latest album cover', + 'random_from_album' => 'Random photo from album', + ], + 'field_background_url' => 'Image URL', + 'field_background_url_placeholder' => 'https://…', + 'field_background_photo_id' => 'Photo ID', + 'field_background_photo_id_placeholder' => '24-character photo ID', + 'field_background_photo_id_hint' => 'Photo ID of the image to use. Open a photo and copy the last 24 characters from the URL.', + 'field_background_album_id' => 'Album ID', + 'field_background_album_id_placeholder' => '24-character album ID', + 'background_load_error' => 'Could not load photo. Make sure the ID is correct and you have access to it.', + 'background_mode_hint' => [ + 'random' => 'A random public photo is picked on every page load.', + 'latest_album_cover' => 'The cover of the most recently published public album is used.', + 'random_from_album' => 'A random photo from this album is picked on every page load. The preview only updates after saving.', + ], + + 'preview_orientation_landscape' => 'Preview landscape background', + 'preview_orientation_portrait' => 'Preview portrait background', + + 'field_cta_position' => 'Position', + 'cta_position_options' => [ + 'top-left' => 'Top Left', + 'top' => 'Top Center', + 'top-right' => 'Top Right', + 'left' => 'Middle Left', + 'center' => 'Center', + 'right' => 'Middle Right', + 'bottom-left' => 'Bottom Left', + 'bottom' => 'Bottom Center', + 'bottom-right' => 'Bottom Right', + ], + 'field_cta_shift_type' => 'Shift unit', + 'cta_shift_type_options' => [ + 'relative' => 'Relative (%)', + 'absolute' => 'Absolute (px)', + ], + 'cta_shift_type_hint' => 'Relative shifts are a percentage of the viewport size; absolute shifts are a fixed number of pixels.', + 'field_cta_shift_x' => 'Horizontal Shift (:value)', + 'cta_shift_x_direction_options' => [ + 'left' => 'Left', + 'right' => 'Right', + ], + 'field_cta_shift_y' => 'Vertical Shift (:value)', + 'cta_shift_y_direction_options' => [ + 'up' => 'Up', + 'down' => 'Down', + ], + 'reset_to_zero' => 'Reset to 0', + + 'preview_title' => 'Live Preview', + 'preview_hint' => 'Updates instantly as you edit — nothing is saved until you click Save.', + 'flat_list_hint' => 'These settings are also editable from the flat generic Settings list.', + + 'save' => 'Save', + 'saved' => 'Landing page settings saved.', + 'save_error' => 'Failed to save landing page settings.', + + 'se_required' => 'This layout/preset requires Lychee SE.', +]; diff --git a/lang/nl/landing_featured_item.php b/lang/nl/landing_featured_item.php new file mode 100644 index 00000000000..3ee73cbc4cc --- /dev/null +++ b/lang/nl/landing_featured_item.php @@ -0,0 +1,51 @@ + 'Featured Content', + 'description' => 'Curate specific photos and albums to feature on the landing page.', + + 'mode_automatic' => 'Automatic (latest published public albums)', + 'mode_manual' => 'Manual (curate specific photos/albums)', + + 'field_enabled' => 'Enable featured content', + 'field_mode' => 'Mode', + 'field_count' => 'Number of automatic items', + 'field_count_hint' => 'Between 3 and 12.', + + 'search_placeholder' => 'Search photos/albums by title…', + 'search_no_results' => 'No results.', + 'add' => 'Add', + + 'no_items' => 'No items curated yet.', + 'col_preview' => 'Preview', + 'col_title' => 'Title', + 'col_type' => 'Type', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + 'type_photo' => 'Photo', + 'type_album' => 'Album', + + 'confirm_delete_header' => 'Remove Featured Item', + 'confirm_delete_message' => 'Are you sure you want to remove ":title" from the featured content?', + + 'added' => 'Item added successfully.', + 'deleted' => 'Item removed successfully.', + 'reordered' => 'Featured items reordered successfully.', + 'error_load' => 'Failed to load featured items.', + 'error_search' => 'Failed to search.', + 'error_add' => 'Failed to add item.', + 'error_delete' => 'Failed to remove item.', + 'error_reorder' => 'Failed to reorder featured items.', +]; diff --git a/lang/nl/landing_link.php b/lang/nl/landing_link.php new file mode 100644 index 00000000000..cf37252cb5f --- /dev/null +++ b/lang/nl/landing_link.php @@ -0,0 +1,73 @@ + 'Extra Links', + 'description' => 'Manage an arbitrary, ordered list of extra links shown on the nav and/or footer of the landing page.', + + // Empty state + 'no_links' => 'No extra links configured yet.', + 'create_first' => 'Create your first link', + + // Table columns + 'col_label' => 'Label', + 'col_url' => 'URL', + 'col_placement' => 'Placement', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + + // Placement labels + 'placement_nav' => 'Nav', + 'placement_footer' => 'Footer', + 'placement_both' => 'Nav & Footer', + + // Built-in links (Gallery, Contact) + 'badge_built_in' => 'Built-in', + 'built_in_cannot_delete' => 'This built-in link cannot be deleted. Disable it instead, or reorder it like any other link.', + 'built_in_url_hint' => 'This is a built-in link and its target cannot be changed.', + + // Buttons + 'create' => 'Create Link', + 'edit' => 'Edit', + 'delete' => 'Delete', + 'cancel' => 'Cancel', + 'save' => 'Save', + + // Form fields + 'field_label' => 'Label', + 'field_label_placeholder' => 'e.g. Instagram', + 'field_url' => 'URL', + 'field_url_placeholder' => 'https://example.com', + 'field_placement' => 'Placement', + 'field_open_in_new_tab' => 'Open in new tab', + 'field_enabled' => 'Enabled', + + // Modal titles + 'modal_create_title' => 'Create Link', + 'modal_edit_title' => 'Edit Link', + + // Delete confirmation + 'confirm_delete_header' => 'Delete Link', + 'confirm_delete_message' => 'Are you sure you want to delete the link ":label"? This action cannot be undone.', + + // Toasts + 'created' => 'Link created successfully.', + 'updated' => 'Link updated successfully.', + 'deleted' => 'Link deleted successfully.', + 'reordered' => 'Links reordered successfully.', + 'error_load' => 'Failed to load links.', + 'error_save' => 'Failed to save link.', + 'error_delete' => 'Failed to delete link.', + 'error_reorder' => 'Failed to reorder links.', +]; diff --git a/lang/nl/watermark.php b/lang/nl/watermark.php index 38cd8a1d7f7..909d7b0b361 100644 --- a/lang/nl/watermark.php +++ b/lang/nl/watermark.php @@ -57,6 +57,7 @@ 'up' => 'Omhoog', 'down' => 'Omlaag', ], + 'reset_to_zero' => 'Reset to 0', 'save' => 'Instellingen opslaan', 'saved' => 'Watermerkinstellingen opgeslagen.', diff --git a/lang/no/all_settings.php b/lang/no/all_settings.php index 1a8e7362eac..fbaee9fe888 100644 --- a/lang/no/all_settings.php +++ b/lang/no/all_settings.php @@ -214,6 +214,18 @@ 'landing_background_portrait_mode' => 'Modus for stående bakgrunn', 'landing_background_landscape' => 'Verdi for liggende bakgrunn (URL, bilde-ID eller album-ID)', 'landing_background_portrait' => 'Verdi for stående bakgrunn (URL, bilde-ID eller album-ID)', + 'landing_layout' => 'Landing page layout', + 'landing_intro_screen_enabled' => 'Enable the intro splash screen', + 'landing_hero_text_position' => 'Hero text position', + 'landing_hero_text_color' => 'Hero text color', + 'landing_hero_text_opacity' => 'Hero text opacity (%)', + 'landing_animation_preset' => 'Landing page animation preset', + 'landing_about_enabled' => 'Enable the about section', + 'landing_about_text' => 'About section text', + 'landing_featured_items_enabled' => 'Enable the featured content section', + 'landing_featured_items_mode' => 'Featured content mode', + 'landing_featured_items_count' => 'Number of automatic featured items', + 'landing_cta_text' => 'Call-to-action text override', 'metrics_enabled' => 'Aktiver statistikk for bilder og album', 'metrics_logged_in_users_enabed' => 'Aktiver statistikk for innloggede brukere', 'metrics_access' => 'Tilgangsnivå for statistikk for album/bilde', @@ -611,6 +623,18 @@ 'landing_background_portrait_mode' => 'Alternativer: static (URL), photo_id (spesifikt bilde), random (tilfeldig offentlig bilde), latest_album_cover (nyeste albumforside), random_from_album (tilfeldig fra album).', 'landing_background_landscape' => 'Avhenger av modus: URL for static, bilde-ID for photo_id, album-ID for random_from_album. Dette bildet brukes også når galleri-lenken deles direkte.', 'landing_background_portrait' => 'Avhenger av modus: URL for static, bilde-ID for photo_id, album-ID for random_from_album.', + 'landing_layout' => 'Options: classic (default, free), portfolio/minimal/studio (require Lychee SE). Classic is unchanged and remains free forever.', + 'landing_intro_screen_enabled' => 'Applies to the classic and portfolio layouts. Disable to skip straight to the hero.', + 'landing_hero_text_position' => 'Options: top_left, top_right, bottom_left, bottom_right, center. Applies to the classic and portfolio layouts.', + 'landing_hero_text_color' => 'Leave empty to use the default white. Applies to the headline and subtitle text only, on every layout.', + 'landing_hero_text_opacity' => 'Range 0-100. Applies to the headline and subtitle text only, on every layout.', + 'landing_animation_preset' => 'Options: none/classic_fade (free), zoom_in/parallax_scroll/slide_reveal (require Lychee SE).', + 'landing_about_enabled' => 'Applies to the portfolio and minimal layouts.', + 'landing_about_text' => 'Admin-authored HTML, rendered verbatim (same trust model as the footer additional text).', + 'landing_featured_items_enabled' => 'Applies to the portfolio layout only. Requires Lychee SE to take effect.', + 'landing_featured_items_mode' => 'Automatic: most recently published public albums. Manual: admin-curated photos/albums.', + 'landing_featured_items_count' => 'Range 3-12. Only used in automatic featured content mode.', + 'landing_cta_text' => 'Leave empty to use each layout\'s default label.', 'metrics_enabled' => 'Hvis aktivert, vil anonyme brukere bli målt.', 'metrics_logged_in_users_enabed' => 'Hvis aktivert, vil innloggede brukere også bli målt (administratorer måles ikke).', 'metrics_access' => '', diff --git a/lang/no/landing.php b/lang/no/landing.php index 606079ff849..d6019cc72f8 100644 --- a/lang/no/landing.php +++ b/lang/no/landing.php @@ -10,4 +10,20 @@ 'access_gallery' => 'Få tilgang til galleriet', 'Powered_by_Lychee' => 'Drevet av Lychee', 'copyright' => 'Alle bilder på denne nettsiden er opphavsrettslig beskyttet av %1$s © %2$s', + 'client_login' => 'Client Login', + 'view_public_gallery' => 'View public gallery', + 'contact' => 'Contact', + 'portfolio' => [ + 'about' => 'About', + 'featured' => 'Recent Work', + 'scroll_down' => 'Scroll down', + ], + 'meridian' => [ + 'explore_label' => 'Explore', + 'explore_caption' => 'View the gallery', + 'contact_caption' => 'Get in touch', + ], + 'studio' => [ + 'welcome_back' => 'Welcome back', + ], ]; diff --git a/lang/no/landing_config.php b/lang/no/landing_config.php new file mode 100644 index 00000000000..02f332b1335 --- /dev/null +++ b/lang/no/landing_config.php @@ -0,0 +1,103 @@ + 'Landing Page', + 'tab_settings' => 'Settings', + 'tab_links' => 'Links', + 'tab_featured' => 'Featured', + + 'section_layout' => 'Layout & Structure', + 'section_hero' => 'Hero', + 'section_background_landscape' => 'Background (Landscape)', + 'section_background_portrait' => 'Background (Portrait)', + 'section_cta_position' => 'Call-to-Action Position', + 'section_content' => 'Content', + + 'field_layout' => 'Layout', + 'field_intro_screen_enabled' => 'Intro splash screen', + 'field_hero_text_position' => 'Hero text position', + 'field_hero_text_color' => 'Hero text color', + 'field_hero_text_opacity' => 'Hero text opacity', + 'field_animation_preset' => 'Animation preset', + 'field_cta_text' => 'text', + 'field_cta_text_placeholder' => 'Leave empty for the layout default', + 'field_about_enabled' => 'About section', + 'field_about_text' => 'About text', + + 'field_background_mode' => 'Source', + 'background_mode_options' => [ + 'static' => 'URL', + 'photo_id' => 'Photo ID', + 'random' => 'Random public photo', + 'latest_album_cover' => 'Latest album cover', + 'random_from_album' => 'Random photo from album', + ], + 'field_background_url' => 'Image URL', + 'field_background_url_placeholder' => 'https://…', + 'field_background_photo_id' => 'Photo ID', + 'field_background_photo_id_placeholder' => '24-character photo ID', + 'field_background_photo_id_hint' => 'Photo ID of the image to use. Open a photo and copy the last 24 characters from the URL.', + 'field_background_album_id' => 'Album ID', + 'field_background_album_id_placeholder' => '24-character album ID', + 'background_load_error' => 'Could not load photo. Make sure the ID is correct and you have access to it.', + 'background_mode_hint' => [ + 'random' => 'A random public photo is picked on every page load.', + 'latest_album_cover' => 'The cover of the most recently published public album is used.', + 'random_from_album' => 'A random photo from this album is picked on every page load. The preview only updates after saving.', + ], + + 'preview_orientation_landscape' => 'Preview landscape background', + 'preview_orientation_portrait' => 'Preview portrait background', + + 'field_cta_position' => 'Position', + 'cta_position_options' => [ + 'top-left' => 'Top Left', + 'top' => 'Top Center', + 'top-right' => 'Top Right', + 'left' => 'Middle Left', + 'center' => 'Center', + 'right' => 'Middle Right', + 'bottom-left' => 'Bottom Left', + 'bottom' => 'Bottom Center', + 'bottom-right' => 'Bottom Right', + ], + 'field_cta_shift_type' => 'Shift unit', + 'cta_shift_type_options' => [ + 'relative' => 'Relative (%)', + 'absolute' => 'Absolute (px)', + ], + 'cta_shift_type_hint' => 'Relative shifts are a percentage of the viewport size; absolute shifts are a fixed number of pixels.', + 'field_cta_shift_x' => 'Horizontal Shift (:value)', + 'cta_shift_x_direction_options' => [ + 'left' => 'Left', + 'right' => 'Right', + ], + 'field_cta_shift_y' => 'Vertical Shift (:value)', + 'cta_shift_y_direction_options' => [ + 'up' => 'Up', + 'down' => 'Down', + ], + 'reset_to_zero' => 'Reset to 0', + + 'preview_title' => 'Live Preview', + 'preview_hint' => 'Updates instantly as you edit — nothing is saved until you click Save.', + 'flat_list_hint' => 'These settings are also editable from the flat generic Settings list.', + + 'save' => 'Save', + 'saved' => 'Landing page settings saved.', + 'save_error' => 'Failed to save landing page settings.', + + 'se_required' => 'This layout/preset requires Lychee SE.', +]; diff --git a/lang/no/landing_featured_item.php b/lang/no/landing_featured_item.php new file mode 100644 index 00000000000..3ee73cbc4cc --- /dev/null +++ b/lang/no/landing_featured_item.php @@ -0,0 +1,51 @@ + 'Featured Content', + 'description' => 'Curate specific photos and albums to feature on the landing page.', + + 'mode_automatic' => 'Automatic (latest published public albums)', + 'mode_manual' => 'Manual (curate specific photos/albums)', + + 'field_enabled' => 'Enable featured content', + 'field_mode' => 'Mode', + 'field_count' => 'Number of automatic items', + 'field_count_hint' => 'Between 3 and 12.', + + 'search_placeholder' => 'Search photos/albums by title…', + 'search_no_results' => 'No results.', + 'add' => 'Add', + + 'no_items' => 'No items curated yet.', + 'col_preview' => 'Preview', + 'col_title' => 'Title', + 'col_type' => 'Type', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + 'type_photo' => 'Photo', + 'type_album' => 'Album', + + 'confirm_delete_header' => 'Remove Featured Item', + 'confirm_delete_message' => 'Are you sure you want to remove ":title" from the featured content?', + + 'added' => 'Item added successfully.', + 'deleted' => 'Item removed successfully.', + 'reordered' => 'Featured items reordered successfully.', + 'error_load' => 'Failed to load featured items.', + 'error_search' => 'Failed to search.', + 'error_add' => 'Failed to add item.', + 'error_delete' => 'Failed to remove item.', + 'error_reorder' => 'Failed to reorder featured items.', +]; diff --git a/lang/no/landing_link.php b/lang/no/landing_link.php new file mode 100644 index 00000000000..cf37252cb5f --- /dev/null +++ b/lang/no/landing_link.php @@ -0,0 +1,73 @@ + 'Extra Links', + 'description' => 'Manage an arbitrary, ordered list of extra links shown on the nav and/or footer of the landing page.', + + // Empty state + 'no_links' => 'No extra links configured yet.', + 'create_first' => 'Create your first link', + + // Table columns + 'col_label' => 'Label', + 'col_url' => 'URL', + 'col_placement' => 'Placement', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + + // Placement labels + 'placement_nav' => 'Nav', + 'placement_footer' => 'Footer', + 'placement_both' => 'Nav & Footer', + + // Built-in links (Gallery, Contact) + 'badge_built_in' => 'Built-in', + 'built_in_cannot_delete' => 'This built-in link cannot be deleted. Disable it instead, or reorder it like any other link.', + 'built_in_url_hint' => 'This is a built-in link and its target cannot be changed.', + + // Buttons + 'create' => 'Create Link', + 'edit' => 'Edit', + 'delete' => 'Delete', + 'cancel' => 'Cancel', + 'save' => 'Save', + + // Form fields + 'field_label' => 'Label', + 'field_label_placeholder' => 'e.g. Instagram', + 'field_url' => 'URL', + 'field_url_placeholder' => 'https://example.com', + 'field_placement' => 'Placement', + 'field_open_in_new_tab' => 'Open in new tab', + 'field_enabled' => 'Enabled', + + // Modal titles + 'modal_create_title' => 'Create Link', + 'modal_edit_title' => 'Edit Link', + + // Delete confirmation + 'confirm_delete_header' => 'Delete Link', + 'confirm_delete_message' => 'Are you sure you want to delete the link ":label"? This action cannot be undone.', + + // Toasts + 'created' => 'Link created successfully.', + 'updated' => 'Link updated successfully.', + 'deleted' => 'Link deleted successfully.', + 'reordered' => 'Links reordered successfully.', + 'error_load' => 'Failed to load links.', + 'error_save' => 'Failed to save link.', + 'error_delete' => 'Failed to delete link.', + 'error_reorder' => 'Failed to reorder links.', +]; diff --git a/lang/no/watermark.php b/lang/no/watermark.php index 8102dcce25f..0f5f92a96e3 100644 --- a/lang/no/watermark.php +++ b/lang/no/watermark.php @@ -57,6 +57,7 @@ 'up' => 'Opp', 'down' => 'Ned', ], + 'reset_to_zero' => 'Reset to 0', 'save' => 'Lagre innstillinger', 'saved' => 'Vannmerkeinnstillinger lagret.', diff --git a/lang/pl/all_settings.php b/lang/pl/all_settings.php index aefe7d3fc87..91ab1d44ec9 100644 --- a/lang/pl/all_settings.php +++ b/lang/pl/all_settings.php @@ -216,6 +216,18 @@ 'landing_background_portrait_mode' => 'Mode for portrait background', 'landing_background_landscape' => 'Value for landscape background (URL, photo ID, or album ID)', 'landing_background_portrait' => 'Value for portrait background (URL, photo ID, or album ID)', + 'landing_layout' => 'Landing page layout', + 'landing_intro_screen_enabled' => 'Enable the intro splash screen', + 'landing_hero_text_position' => 'Hero text position', + 'landing_hero_text_color' => 'Hero text color', + 'landing_hero_text_opacity' => 'Hero text opacity (%)', + 'landing_animation_preset' => 'Landing page animation preset', + 'landing_about_enabled' => 'Enable the about section', + 'landing_about_text' => 'About section text', + 'landing_featured_items_enabled' => 'Enable the featured content section', + 'landing_featured_items_mode' => 'Featured content mode', + 'landing_featured_items_count' => 'Number of automatic featured items', + 'landing_cta_text' => 'Call-to-action text override', 'metrics_enabled' => 'Enable statistics on photos & albums', 'metrics_logged_in_users_enabed' => 'Enable statistics for logged-in users', 'metrics_access' => 'Access level for statistics of the album/photo', @@ -613,6 +625,18 @@ 'landing_background_portrait_mode' => 'Options: static (URL), photo_id (specific photo), random (random public photo), latest_album_cover (latest album cover), random_from_album (random from album).', 'landing_background_landscape' => 'Depends on mode: URL for static, photo ID for photo_id, album ID for random_from_album. This image is also used when sharing the gallery link directly.', 'landing_background_portrait' => 'Depends on mode: URL for static, photo ID for photo_id, album ID for random_from_album.', + 'landing_layout' => 'Options: classic (default, free), portfolio/minimal/studio (require Lychee SE). Classic is unchanged and remains free forever.', + 'landing_intro_screen_enabled' => 'Applies to the classic and portfolio layouts. Disable to skip straight to the hero.', + 'landing_hero_text_position' => 'Options: top_left, top_right, bottom_left, bottom_right, center. Applies to the classic and portfolio layouts.', + 'landing_hero_text_color' => 'Leave empty to use the default white. Applies to the headline and subtitle text only, on every layout.', + 'landing_hero_text_opacity' => 'Range 0-100. Applies to the headline and subtitle text only, on every layout.', + 'landing_animation_preset' => 'Options: none/classic_fade (free), zoom_in/parallax_scroll/slide_reveal (require Lychee SE).', + 'landing_about_enabled' => 'Applies to the portfolio and minimal layouts.', + 'landing_about_text' => 'Admin-authored HTML, rendered verbatim (same trust model as the footer additional text).', + 'landing_featured_items_enabled' => 'Applies to the portfolio layout only. Requires Lychee SE to take effect.', + 'landing_featured_items_mode' => 'Automatic: most recently published public albums. Manual: admin-curated photos/albums.', + 'landing_featured_items_count' => 'Range 3-12. Only used in automatic featured content mode.', + 'landing_cta_text' => 'Leave empty to use each layout\'s default label.', 'metrics_enabled' => 'If enabled, anonymours users will be measured.', 'metrics_logged_in_users_enabed' => 'If enabled, logged-in users will be measured as well (admin users are not measured).', 'metrics_access' => '', diff --git a/lang/pl/landing.php b/lang/pl/landing.php index 0d2f1972589..b094df915d1 100644 --- a/lang/pl/landing.php +++ b/lang/pl/landing.php @@ -10,4 +10,20 @@ 'access_gallery' => 'Dostęp do galerii', 'Powered_by_Lychee' => 'Powered by Lychee', 'copyright' => 'Wszystkie obrazy na tej stronie podlegają prawom autorskim %1$s © %2$s', + 'client_login' => 'Client Login', + 'view_public_gallery' => 'View public gallery', + 'contact' => 'Contact', + 'portfolio' => [ + 'about' => 'About', + 'featured' => 'Recent Work', + 'scroll_down' => 'Scroll down', + ], + 'meridian' => [ + 'explore_label' => 'Explore', + 'explore_caption' => 'View the gallery', + 'contact_caption' => 'Get in touch', + ], + 'studio' => [ + 'welcome_back' => 'Welcome back', + ], ]; diff --git a/lang/pl/landing_config.php b/lang/pl/landing_config.php new file mode 100644 index 00000000000..02f332b1335 --- /dev/null +++ b/lang/pl/landing_config.php @@ -0,0 +1,103 @@ + 'Landing Page', + 'tab_settings' => 'Settings', + 'tab_links' => 'Links', + 'tab_featured' => 'Featured', + + 'section_layout' => 'Layout & Structure', + 'section_hero' => 'Hero', + 'section_background_landscape' => 'Background (Landscape)', + 'section_background_portrait' => 'Background (Portrait)', + 'section_cta_position' => 'Call-to-Action Position', + 'section_content' => 'Content', + + 'field_layout' => 'Layout', + 'field_intro_screen_enabled' => 'Intro splash screen', + 'field_hero_text_position' => 'Hero text position', + 'field_hero_text_color' => 'Hero text color', + 'field_hero_text_opacity' => 'Hero text opacity', + 'field_animation_preset' => 'Animation preset', + 'field_cta_text' => 'text', + 'field_cta_text_placeholder' => 'Leave empty for the layout default', + 'field_about_enabled' => 'About section', + 'field_about_text' => 'About text', + + 'field_background_mode' => 'Source', + 'background_mode_options' => [ + 'static' => 'URL', + 'photo_id' => 'Photo ID', + 'random' => 'Random public photo', + 'latest_album_cover' => 'Latest album cover', + 'random_from_album' => 'Random photo from album', + ], + 'field_background_url' => 'Image URL', + 'field_background_url_placeholder' => 'https://…', + 'field_background_photo_id' => 'Photo ID', + 'field_background_photo_id_placeholder' => '24-character photo ID', + 'field_background_photo_id_hint' => 'Photo ID of the image to use. Open a photo and copy the last 24 characters from the URL.', + 'field_background_album_id' => 'Album ID', + 'field_background_album_id_placeholder' => '24-character album ID', + 'background_load_error' => 'Could not load photo. Make sure the ID is correct and you have access to it.', + 'background_mode_hint' => [ + 'random' => 'A random public photo is picked on every page load.', + 'latest_album_cover' => 'The cover of the most recently published public album is used.', + 'random_from_album' => 'A random photo from this album is picked on every page load. The preview only updates after saving.', + ], + + 'preview_orientation_landscape' => 'Preview landscape background', + 'preview_orientation_portrait' => 'Preview portrait background', + + 'field_cta_position' => 'Position', + 'cta_position_options' => [ + 'top-left' => 'Top Left', + 'top' => 'Top Center', + 'top-right' => 'Top Right', + 'left' => 'Middle Left', + 'center' => 'Center', + 'right' => 'Middle Right', + 'bottom-left' => 'Bottom Left', + 'bottom' => 'Bottom Center', + 'bottom-right' => 'Bottom Right', + ], + 'field_cta_shift_type' => 'Shift unit', + 'cta_shift_type_options' => [ + 'relative' => 'Relative (%)', + 'absolute' => 'Absolute (px)', + ], + 'cta_shift_type_hint' => 'Relative shifts are a percentage of the viewport size; absolute shifts are a fixed number of pixels.', + 'field_cta_shift_x' => 'Horizontal Shift (:value)', + 'cta_shift_x_direction_options' => [ + 'left' => 'Left', + 'right' => 'Right', + ], + 'field_cta_shift_y' => 'Vertical Shift (:value)', + 'cta_shift_y_direction_options' => [ + 'up' => 'Up', + 'down' => 'Down', + ], + 'reset_to_zero' => 'Reset to 0', + + 'preview_title' => 'Live Preview', + 'preview_hint' => 'Updates instantly as you edit — nothing is saved until you click Save.', + 'flat_list_hint' => 'These settings are also editable from the flat generic Settings list.', + + 'save' => 'Save', + 'saved' => 'Landing page settings saved.', + 'save_error' => 'Failed to save landing page settings.', + + 'se_required' => 'This layout/preset requires Lychee SE.', +]; diff --git a/lang/pl/landing_featured_item.php b/lang/pl/landing_featured_item.php new file mode 100644 index 00000000000..3ee73cbc4cc --- /dev/null +++ b/lang/pl/landing_featured_item.php @@ -0,0 +1,51 @@ + 'Featured Content', + 'description' => 'Curate specific photos and albums to feature on the landing page.', + + 'mode_automatic' => 'Automatic (latest published public albums)', + 'mode_manual' => 'Manual (curate specific photos/albums)', + + 'field_enabled' => 'Enable featured content', + 'field_mode' => 'Mode', + 'field_count' => 'Number of automatic items', + 'field_count_hint' => 'Between 3 and 12.', + + 'search_placeholder' => 'Search photos/albums by title…', + 'search_no_results' => 'No results.', + 'add' => 'Add', + + 'no_items' => 'No items curated yet.', + 'col_preview' => 'Preview', + 'col_title' => 'Title', + 'col_type' => 'Type', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + 'type_photo' => 'Photo', + 'type_album' => 'Album', + + 'confirm_delete_header' => 'Remove Featured Item', + 'confirm_delete_message' => 'Are you sure you want to remove ":title" from the featured content?', + + 'added' => 'Item added successfully.', + 'deleted' => 'Item removed successfully.', + 'reordered' => 'Featured items reordered successfully.', + 'error_load' => 'Failed to load featured items.', + 'error_search' => 'Failed to search.', + 'error_add' => 'Failed to add item.', + 'error_delete' => 'Failed to remove item.', + 'error_reorder' => 'Failed to reorder featured items.', +]; diff --git a/lang/pl/landing_link.php b/lang/pl/landing_link.php new file mode 100644 index 00000000000..cf37252cb5f --- /dev/null +++ b/lang/pl/landing_link.php @@ -0,0 +1,73 @@ + 'Extra Links', + 'description' => 'Manage an arbitrary, ordered list of extra links shown on the nav and/or footer of the landing page.', + + // Empty state + 'no_links' => 'No extra links configured yet.', + 'create_first' => 'Create your first link', + + // Table columns + 'col_label' => 'Label', + 'col_url' => 'URL', + 'col_placement' => 'Placement', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + + // Placement labels + 'placement_nav' => 'Nav', + 'placement_footer' => 'Footer', + 'placement_both' => 'Nav & Footer', + + // Built-in links (Gallery, Contact) + 'badge_built_in' => 'Built-in', + 'built_in_cannot_delete' => 'This built-in link cannot be deleted. Disable it instead, or reorder it like any other link.', + 'built_in_url_hint' => 'This is a built-in link and its target cannot be changed.', + + // Buttons + 'create' => 'Create Link', + 'edit' => 'Edit', + 'delete' => 'Delete', + 'cancel' => 'Cancel', + 'save' => 'Save', + + // Form fields + 'field_label' => 'Label', + 'field_label_placeholder' => 'e.g. Instagram', + 'field_url' => 'URL', + 'field_url_placeholder' => 'https://example.com', + 'field_placement' => 'Placement', + 'field_open_in_new_tab' => 'Open in new tab', + 'field_enabled' => 'Enabled', + + // Modal titles + 'modal_create_title' => 'Create Link', + 'modal_edit_title' => 'Edit Link', + + // Delete confirmation + 'confirm_delete_header' => 'Delete Link', + 'confirm_delete_message' => 'Are you sure you want to delete the link ":label"? This action cannot be undone.', + + // Toasts + 'created' => 'Link created successfully.', + 'updated' => 'Link updated successfully.', + 'deleted' => 'Link deleted successfully.', + 'reordered' => 'Links reordered successfully.', + 'error_load' => 'Failed to load links.', + 'error_save' => 'Failed to save link.', + 'error_delete' => 'Failed to delete link.', + 'error_reorder' => 'Failed to reorder links.', +]; diff --git a/lang/pl/watermark.php b/lang/pl/watermark.php index 6860c862127..be6175d3e5b 100644 --- a/lang/pl/watermark.php +++ b/lang/pl/watermark.php @@ -57,6 +57,7 @@ 'up' => 'Up', 'down' => 'Down', ], + 'reset_to_zero' => 'Reset to 0', 'save' => 'Save Settings', 'saved' => 'Watermark settings saved.', diff --git a/lang/pt/all_settings.php b/lang/pt/all_settings.php index 005343fd794..81a533f70c2 100644 --- a/lang/pt/all_settings.php +++ b/lang/pt/all_settings.php @@ -216,6 +216,18 @@ 'landing_background_portrait_mode' => 'Mode for portrait background', 'landing_background_landscape' => 'Value for landscape background (URL, photo ID, or album ID)', 'landing_background_portrait' => 'Value for portrait background (URL, photo ID, or album ID)', + 'landing_layout' => 'Landing page layout', + 'landing_intro_screen_enabled' => 'Enable the intro splash screen', + 'landing_hero_text_position' => 'Hero text position', + 'landing_hero_text_color' => 'Hero text color', + 'landing_hero_text_opacity' => 'Hero text opacity (%)', + 'landing_animation_preset' => 'Landing page animation preset', + 'landing_about_enabled' => 'Enable the about section', + 'landing_about_text' => 'About section text', + 'landing_featured_items_enabled' => 'Enable the featured content section', + 'landing_featured_items_mode' => 'Featured content mode', + 'landing_featured_items_count' => 'Number of automatic featured items', + 'landing_cta_text' => 'Call-to-action text override', 'metrics_enabled' => 'Enable statistics on photos & albums', 'metrics_logged_in_users_enabed' => 'Enable statistics for logged-in users', 'metrics_access' => 'Access level for statistics of the album/photo', @@ -613,6 +625,18 @@ 'landing_background_portrait_mode' => 'Options: static (URL), photo_id (specific photo), random (random public photo), latest_album_cover (latest album cover), random_from_album (random from album).', 'landing_background_landscape' => 'Depends on mode: URL for static, photo ID for photo_id, album ID for random_from_album. This image is also used when sharing the gallery link directly.', 'landing_background_portrait' => 'Depends on mode: URL for static, photo ID for photo_id, album ID for random_from_album.', + 'landing_layout' => 'Options: classic (default, free), portfolio/minimal/studio (require Lychee SE). Classic is unchanged and remains free forever.', + 'landing_intro_screen_enabled' => 'Applies to the classic and portfolio layouts. Disable to skip straight to the hero.', + 'landing_hero_text_position' => 'Options: top_left, top_right, bottom_left, bottom_right, center. Applies to the classic and portfolio layouts.', + 'landing_hero_text_color' => 'Leave empty to use the default white. Applies to the headline and subtitle text only, on every layout.', + 'landing_hero_text_opacity' => 'Range 0-100. Applies to the headline and subtitle text only, on every layout.', + 'landing_animation_preset' => 'Options: none/classic_fade (free), zoom_in/parallax_scroll/slide_reveal (require Lychee SE).', + 'landing_about_enabled' => 'Applies to the portfolio and minimal layouts.', + 'landing_about_text' => 'Admin-authored HTML, rendered verbatim (same trust model as the footer additional text).', + 'landing_featured_items_enabled' => 'Applies to the portfolio layout only. Requires Lychee SE to take effect.', + 'landing_featured_items_mode' => 'Automatic: most recently published public albums. Manual: admin-curated photos/albums.', + 'landing_featured_items_count' => 'Range 3-12. Only used in automatic featured content mode.', + 'landing_cta_text' => 'Leave empty to use each layout\'s default label.', 'metrics_enabled' => 'If enabled, anonymours users will be measured.', 'metrics_logged_in_users_enabed' => 'If enabled, logged-in users will be measured as well (admin users are not measured).', 'metrics_access' => '', diff --git a/lang/pt/landing.php b/lang/pt/landing.php index 11e4c89933a..7619d8b0ad6 100644 --- a/lang/pt/landing.php +++ b/lang/pt/landing.php @@ -10,4 +10,20 @@ 'access_gallery' => 'Access the gallery', 'Powered_by_Lychee' => 'Powered by Lychee', 'copyright' => 'All images on this website are subject to copyright by %1$s © %2$s', + 'client_login' => 'Client Login', + 'view_public_gallery' => 'View public gallery', + 'contact' => 'Contact', + 'portfolio' => [ + 'about' => 'About', + 'featured' => 'Recent Work', + 'scroll_down' => 'Scroll down', + ], + 'meridian' => [ + 'explore_label' => 'Explore', + 'explore_caption' => 'View the gallery', + 'contact_caption' => 'Get in touch', + ], + 'studio' => [ + 'welcome_back' => 'Welcome back', + ], ]; diff --git a/lang/pt/landing_config.php b/lang/pt/landing_config.php new file mode 100644 index 00000000000..02f332b1335 --- /dev/null +++ b/lang/pt/landing_config.php @@ -0,0 +1,103 @@ + 'Landing Page', + 'tab_settings' => 'Settings', + 'tab_links' => 'Links', + 'tab_featured' => 'Featured', + + 'section_layout' => 'Layout & Structure', + 'section_hero' => 'Hero', + 'section_background_landscape' => 'Background (Landscape)', + 'section_background_portrait' => 'Background (Portrait)', + 'section_cta_position' => 'Call-to-Action Position', + 'section_content' => 'Content', + + 'field_layout' => 'Layout', + 'field_intro_screen_enabled' => 'Intro splash screen', + 'field_hero_text_position' => 'Hero text position', + 'field_hero_text_color' => 'Hero text color', + 'field_hero_text_opacity' => 'Hero text opacity', + 'field_animation_preset' => 'Animation preset', + 'field_cta_text' => 'text', + 'field_cta_text_placeholder' => 'Leave empty for the layout default', + 'field_about_enabled' => 'About section', + 'field_about_text' => 'About text', + + 'field_background_mode' => 'Source', + 'background_mode_options' => [ + 'static' => 'URL', + 'photo_id' => 'Photo ID', + 'random' => 'Random public photo', + 'latest_album_cover' => 'Latest album cover', + 'random_from_album' => 'Random photo from album', + ], + 'field_background_url' => 'Image URL', + 'field_background_url_placeholder' => 'https://…', + 'field_background_photo_id' => 'Photo ID', + 'field_background_photo_id_placeholder' => '24-character photo ID', + 'field_background_photo_id_hint' => 'Photo ID of the image to use. Open a photo and copy the last 24 characters from the URL.', + 'field_background_album_id' => 'Album ID', + 'field_background_album_id_placeholder' => '24-character album ID', + 'background_load_error' => 'Could not load photo. Make sure the ID is correct and you have access to it.', + 'background_mode_hint' => [ + 'random' => 'A random public photo is picked on every page load.', + 'latest_album_cover' => 'The cover of the most recently published public album is used.', + 'random_from_album' => 'A random photo from this album is picked on every page load. The preview only updates after saving.', + ], + + 'preview_orientation_landscape' => 'Preview landscape background', + 'preview_orientation_portrait' => 'Preview portrait background', + + 'field_cta_position' => 'Position', + 'cta_position_options' => [ + 'top-left' => 'Top Left', + 'top' => 'Top Center', + 'top-right' => 'Top Right', + 'left' => 'Middle Left', + 'center' => 'Center', + 'right' => 'Middle Right', + 'bottom-left' => 'Bottom Left', + 'bottom' => 'Bottom Center', + 'bottom-right' => 'Bottom Right', + ], + 'field_cta_shift_type' => 'Shift unit', + 'cta_shift_type_options' => [ + 'relative' => 'Relative (%)', + 'absolute' => 'Absolute (px)', + ], + 'cta_shift_type_hint' => 'Relative shifts are a percentage of the viewport size; absolute shifts are a fixed number of pixels.', + 'field_cta_shift_x' => 'Horizontal Shift (:value)', + 'cta_shift_x_direction_options' => [ + 'left' => 'Left', + 'right' => 'Right', + ], + 'field_cta_shift_y' => 'Vertical Shift (:value)', + 'cta_shift_y_direction_options' => [ + 'up' => 'Up', + 'down' => 'Down', + ], + 'reset_to_zero' => 'Reset to 0', + + 'preview_title' => 'Live Preview', + 'preview_hint' => 'Updates instantly as you edit — nothing is saved until you click Save.', + 'flat_list_hint' => 'These settings are also editable from the flat generic Settings list.', + + 'save' => 'Save', + 'saved' => 'Landing page settings saved.', + 'save_error' => 'Failed to save landing page settings.', + + 'se_required' => 'This layout/preset requires Lychee SE.', +]; diff --git a/lang/pt/landing_featured_item.php b/lang/pt/landing_featured_item.php new file mode 100644 index 00000000000..3ee73cbc4cc --- /dev/null +++ b/lang/pt/landing_featured_item.php @@ -0,0 +1,51 @@ + 'Featured Content', + 'description' => 'Curate specific photos and albums to feature on the landing page.', + + 'mode_automatic' => 'Automatic (latest published public albums)', + 'mode_manual' => 'Manual (curate specific photos/albums)', + + 'field_enabled' => 'Enable featured content', + 'field_mode' => 'Mode', + 'field_count' => 'Number of automatic items', + 'field_count_hint' => 'Between 3 and 12.', + + 'search_placeholder' => 'Search photos/albums by title…', + 'search_no_results' => 'No results.', + 'add' => 'Add', + + 'no_items' => 'No items curated yet.', + 'col_preview' => 'Preview', + 'col_title' => 'Title', + 'col_type' => 'Type', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + 'type_photo' => 'Photo', + 'type_album' => 'Album', + + 'confirm_delete_header' => 'Remove Featured Item', + 'confirm_delete_message' => 'Are you sure you want to remove ":title" from the featured content?', + + 'added' => 'Item added successfully.', + 'deleted' => 'Item removed successfully.', + 'reordered' => 'Featured items reordered successfully.', + 'error_load' => 'Failed to load featured items.', + 'error_search' => 'Failed to search.', + 'error_add' => 'Failed to add item.', + 'error_delete' => 'Failed to remove item.', + 'error_reorder' => 'Failed to reorder featured items.', +]; diff --git a/lang/pt/landing_link.php b/lang/pt/landing_link.php new file mode 100644 index 00000000000..cf37252cb5f --- /dev/null +++ b/lang/pt/landing_link.php @@ -0,0 +1,73 @@ + 'Extra Links', + 'description' => 'Manage an arbitrary, ordered list of extra links shown on the nav and/or footer of the landing page.', + + // Empty state + 'no_links' => 'No extra links configured yet.', + 'create_first' => 'Create your first link', + + // Table columns + 'col_label' => 'Label', + 'col_url' => 'URL', + 'col_placement' => 'Placement', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + + // Placement labels + 'placement_nav' => 'Nav', + 'placement_footer' => 'Footer', + 'placement_both' => 'Nav & Footer', + + // Built-in links (Gallery, Contact) + 'badge_built_in' => 'Built-in', + 'built_in_cannot_delete' => 'This built-in link cannot be deleted. Disable it instead, or reorder it like any other link.', + 'built_in_url_hint' => 'This is a built-in link and its target cannot be changed.', + + // Buttons + 'create' => 'Create Link', + 'edit' => 'Edit', + 'delete' => 'Delete', + 'cancel' => 'Cancel', + 'save' => 'Save', + + // Form fields + 'field_label' => 'Label', + 'field_label_placeholder' => 'e.g. Instagram', + 'field_url' => 'URL', + 'field_url_placeholder' => 'https://example.com', + 'field_placement' => 'Placement', + 'field_open_in_new_tab' => 'Open in new tab', + 'field_enabled' => 'Enabled', + + // Modal titles + 'modal_create_title' => 'Create Link', + 'modal_edit_title' => 'Edit Link', + + // Delete confirmation + 'confirm_delete_header' => 'Delete Link', + 'confirm_delete_message' => 'Are you sure you want to delete the link ":label"? This action cannot be undone.', + + // Toasts + 'created' => 'Link created successfully.', + 'updated' => 'Link updated successfully.', + 'deleted' => 'Link deleted successfully.', + 'reordered' => 'Links reordered successfully.', + 'error_load' => 'Failed to load links.', + 'error_save' => 'Failed to save link.', + 'error_delete' => 'Failed to delete link.', + 'error_reorder' => 'Failed to reorder links.', +]; diff --git a/lang/pt/watermark.php b/lang/pt/watermark.php index 6860c862127..be6175d3e5b 100644 --- a/lang/pt/watermark.php +++ b/lang/pt/watermark.php @@ -57,6 +57,7 @@ 'up' => 'Up', 'down' => 'Down', ], + 'reset_to_zero' => 'Reset to 0', 'save' => 'Save Settings', 'saved' => 'Watermark settings saved.', diff --git a/lang/ru/all_settings.php b/lang/ru/all_settings.php index 798954a80fd..e063f7d2db4 100644 --- a/lang/ru/all_settings.php +++ b/lang/ru/all_settings.php @@ -216,6 +216,18 @@ 'landing_background_portrait_mode' => 'Mode for portrait background', 'landing_background_landscape' => 'Value for landscape background (URL, photo ID, or album ID)', 'landing_background_portrait' => 'Value for portrait background (URL, photo ID, or album ID)', + 'landing_layout' => 'Landing page layout', + 'landing_intro_screen_enabled' => 'Enable the intro splash screen', + 'landing_hero_text_position' => 'Hero text position', + 'landing_hero_text_color' => 'Hero text color', + 'landing_hero_text_opacity' => 'Hero text opacity (%)', + 'landing_animation_preset' => 'Landing page animation preset', + 'landing_about_enabled' => 'Enable the about section', + 'landing_about_text' => 'About section text', + 'landing_featured_items_enabled' => 'Enable the featured content section', + 'landing_featured_items_mode' => 'Featured content mode', + 'landing_featured_items_count' => 'Number of automatic featured items', + 'landing_cta_text' => 'Call-to-action text override', 'metrics_enabled' => 'Enable statistics on photos & albums', 'metrics_logged_in_users_enabed' => 'Enable statistics for logged-in users', 'metrics_access' => 'Access level for statistics of the album/photo', @@ -613,6 +625,18 @@ 'landing_background_portrait_mode' => 'Options: static (URL), photo_id (specific photo), random (random public photo), latest_album_cover (latest album cover), random_from_album (random from album).', 'landing_background_landscape' => 'Depends on mode: URL for static, photo ID for photo_id, album ID for random_from_album. This image is also used when sharing the gallery link directly.', 'landing_background_portrait' => 'Depends on mode: URL for static, photo ID for photo_id, album ID for random_from_album.', + 'landing_layout' => 'Options: classic (default, free), portfolio/minimal/studio (require Lychee SE). Classic is unchanged and remains free forever.', + 'landing_intro_screen_enabled' => 'Applies to the classic and portfolio layouts. Disable to skip straight to the hero.', + 'landing_hero_text_position' => 'Options: top_left, top_right, bottom_left, bottom_right, center. Applies to the classic and portfolio layouts.', + 'landing_hero_text_color' => 'Leave empty to use the default white. Applies to the headline and subtitle text only, on every layout.', + 'landing_hero_text_opacity' => 'Range 0-100. Applies to the headline and subtitle text only, on every layout.', + 'landing_animation_preset' => 'Options: none/classic_fade (free), zoom_in/parallax_scroll/slide_reveal (require Lychee SE).', + 'landing_about_enabled' => 'Applies to the portfolio and minimal layouts.', + 'landing_about_text' => 'Admin-authored HTML, rendered verbatim (same trust model as the footer additional text).', + 'landing_featured_items_enabled' => 'Applies to the portfolio layout only. Requires Lychee SE to take effect.', + 'landing_featured_items_mode' => 'Automatic: most recently published public albums. Manual: admin-curated photos/albums.', + 'landing_featured_items_count' => 'Range 3-12. Only used in automatic featured content mode.', + 'landing_cta_text' => 'Leave empty to use each layout\'s default label.', 'metrics_enabled' => 'If enabled, anonymours users will be measured.', 'metrics_logged_in_users_enabed' => 'If enabled, logged-in users will be measured as well (admin users are not measured).', 'metrics_access' => '', diff --git a/lang/ru/landing.php b/lang/ru/landing.php index e9f2a888ea5..6e2353f6e5b 100644 --- a/lang/ru/landing.php +++ b/lang/ru/landing.php @@ -10,4 +10,20 @@ 'access_gallery' => 'Доступ к галерее', 'Powered_by_Lychee' => 'Размещено с Lychee', 'copyright' => 'Все изображения на этом сайте защищены авторским правом %1$s © %2$s', + 'client_login' => 'Client Login', + 'view_public_gallery' => 'View public gallery', + 'contact' => 'Contact', + 'portfolio' => [ + 'about' => 'About', + 'featured' => 'Recent Work', + 'scroll_down' => 'Scroll down', + ], + 'meridian' => [ + 'explore_label' => 'Explore', + 'explore_caption' => 'View the gallery', + 'contact_caption' => 'Get in touch', + ], + 'studio' => [ + 'welcome_back' => 'Welcome back', + ], ]; diff --git a/lang/ru/landing_config.php b/lang/ru/landing_config.php new file mode 100644 index 00000000000..02f332b1335 --- /dev/null +++ b/lang/ru/landing_config.php @@ -0,0 +1,103 @@ + 'Landing Page', + 'tab_settings' => 'Settings', + 'tab_links' => 'Links', + 'tab_featured' => 'Featured', + + 'section_layout' => 'Layout & Structure', + 'section_hero' => 'Hero', + 'section_background_landscape' => 'Background (Landscape)', + 'section_background_portrait' => 'Background (Portrait)', + 'section_cta_position' => 'Call-to-Action Position', + 'section_content' => 'Content', + + 'field_layout' => 'Layout', + 'field_intro_screen_enabled' => 'Intro splash screen', + 'field_hero_text_position' => 'Hero text position', + 'field_hero_text_color' => 'Hero text color', + 'field_hero_text_opacity' => 'Hero text opacity', + 'field_animation_preset' => 'Animation preset', + 'field_cta_text' => 'text', + 'field_cta_text_placeholder' => 'Leave empty for the layout default', + 'field_about_enabled' => 'About section', + 'field_about_text' => 'About text', + + 'field_background_mode' => 'Source', + 'background_mode_options' => [ + 'static' => 'URL', + 'photo_id' => 'Photo ID', + 'random' => 'Random public photo', + 'latest_album_cover' => 'Latest album cover', + 'random_from_album' => 'Random photo from album', + ], + 'field_background_url' => 'Image URL', + 'field_background_url_placeholder' => 'https://…', + 'field_background_photo_id' => 'Photo ID', + 'field_background_photo_id_placeholder' => '24-character photo ID', + 'field_background_photo_id_hint' => 'Photo ID of the image to use. Open a photo and copy the last 24 characters from the URL.', + 'field_background_album_id' => 'Album ID', + 'field_background_album_id_placeholder' => '24-character album ID', + 'background_load_error' => 'Could not load photo. Make sure the ID is correct and you have access to it.', + 'background_mode_hint' => [ + 'random' => 'A random public photo is picked on every page load.', + 'latest_album_cover' => 'The cover of the most recently published public album is used.', + 'random_from_album' => 'A random photo from this album is picked on every page load. The preview only updates after saving.', + ], + + 'preview_orientation_landscape' => 'Preview landscape background', + 'preview_orientation_portrait' => 'Preview portrait background', + + 'field_cta_position' => 'Position', + 'cta_position_options' => [ + 'top-left' => 'Top Left', + 'top' => 'Top Center', + 'top-right' => 'Top Right', + 'left' => 'Middle Left', + 'center' => 'Center', + 'right' => 'Middle Right', + 'bottom-left' => 'Bottom Left', + 'bottom' => 'Bottom Center', + 'bottom-right' => 'Bottom Right', + ], + 'field_cta_shift_type' => 'Shift unit', + 'cta_shift_type_options' => [ + 'relative' => 'Relative (%)', + 'absolute' => 'Absolute (px)', + ], + 'cta_shift_type_hint' => 'Relative shifts are a percentage of the viewport size; absolute shifts are a fixed number of pixels.', + 'field_cta_shift_x' => 'Horizontal Shift (:value)', + 'cta_shift_x_direction_options' => [ + 'left' => 'Left', + 'right' => 'Right', + ], + 'field_cta_shift_y' => 'Vertical Shift (:value)', + 'cta_shift_y_direction_options' => [ + 'up' => 'Up', + 'down' => 'Down', + ], + 'reset_to_zero' => 'Reset to 0', + + 'preview_title' => 'Live Preview', + 'preview_hint' => 'Updates instantly as you edit — nothing is saved until you click Save.', + 'flat_list_hint' => 'These settings are also editable from the flat generic Settings list.', + + 'save' => 'Save', + 'saved' => 'Landing page settings saved.', + 'save_error' => 'Failed to save landing page settings.', + + 'se_required' => 'This layout/preset requires Lychee SE.', +]; diff --git a/lang/ru/landing_featured_item.php b/lang/ru/landing_featured_item.php new file mode 100644 index 00000000000..3ee73cbc4cc --- /dev/null +++ b/lang/ru/landing_featured_item.php @@ -0,0 +1,51 @@ + 'Featured Content', + 'description' => 'Curate specific photos and albums to feature on the landing page.', + + 'mode_automatic' => 'Automatic (latest published public albums)', + 'mode_manual' => 'Manual (curate specific photos/albums)', + + 'field_enabled' => 'Enable featured content', + 'field_mode' => 'Mode', + 'field_count' => 'Number of automatic items', + 'field_count_hint' => 'Between 3 and 12.', + + 'search_placeholder' => 'Search photos/albums by title…', + 'search_no_results' => 'No results.', + 'add' => 'Add', + + 'no_items' => 'No items curated yet.', + 'col_preview' => 'Preview', + 'col_title' => 'Title', + 'col_type' => 'Type', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + 'type_photo' => 'Photo', + 'type_album' => 'Album', + + 'confirm_delete_header' => 'Remove Featured Item', + 'confirm_delete_message' => 'Are you sure you want to remove ":title" from the featured content?', + + 'added' => 'Item added successfully.', + 'deleted' => 'Item removed successfully.', + 'reordered' => 'Featured items reordered successfully.', + 'error_load' => 'Failed to load featured items.', + 'error_search' => 'Failed to search.', + 'error_add' => 'Failed to add item.', + 'error_delete' => 'Failed to remove item.', + 'error_reorder' => 'Failed to reorder featured items.', +]; diff --git a/lang/ru/landing_link.php b/lang/ru/landing_link.php new file mode 100644 index 00000000000..cf37252cb5f --- /dev/null +++ b/lang/ru/landing_link.php @@ -0,0 +1,73 @@ + 'Extra Links', + 'description' => 'Manage an arbitrary, ordered list of extra links shown on the nav and/or footer of the landing page.', + + // Empty state + 'no_links' => 'No extra links configured yet.', + 'create_first' => 'Create your first link', + + // Table columns + 'col_label' => 'Label', + 'col_url' => 'URL', + 'col_placement' => 'Placement', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + + // Placement labels + 'placement_nav' => 'Nav', + 'placement_footer' => 'Footer', + 'placement_both' => 'Nav & Footer', + + // Built-in links (Gallery, Contact) + 'badge_built_in' => 'Built-in', + 'built_in_cannot_delete' => 'This built-in link cannot be deleted. Disable it instead, or reorder it like any other link.', + 'built_in_url_hint' => 'This is a built-in link and its target cannot be changed.', + + // Buttons + 'create' => 'Create Link', + 'edit' => 'Edit', + 'delete' => 'Delete', + 'cancel' => 'Cancel', + 'save' => 'Save', + + // Form fields + 'field_label' => 'Label', + 'field_label_placeholder' => 'e.g. Instagram', + 'field_url' => 'URL', + 'field_url_placeholder' => 'https://example.com', + 'field_placement' => 'Placement', + 'field_open_in_new_tab' => 'Open in new tab', + 'field_enabled' => 'Enabled', + + // Modal titles + 'modal_create_title' => 'Create Link', + 'modal_edit_title' => 'Edit Link', + + // Delete confirmation + 'confirm_delete_header' => 'Delete Link', + 'confirm_delete_message' => 'Are you sure you want to delete the link ":label"? This action cannot be undone.', + + // Toasts + 'created' => 'Link created successfully.', + 'updated' => 'Link updated successfully.', + 'deleted' => 'Link deleted successfully.', + 'reordered' => 'Links reordered successfully.', + 'error_load' => 'Failed to load links.', + 'error_save' => 'Failed to save link.', + 'error_delete' => 'Failed to delete link.', + 'error_reorder' => 'Failed to reorder links.', +]; diff --git a/lang/ru/watermark.php b/lang/ru/watermark.php index 6860c862127..be6175d3e5b 100644 --- a/lang/ru/watermark.php +++ b/lang/ru/watermark.php @@ -57,6 +57,7 @@ 'up' => 'Up', 'down' => 'Down', ], + 'reset_to_zero' => 'Reset to 0', 'save' => 'Save Settings', 'saved' => 'Watermark settings saved.', diff --git a/lang/sk/all_settings.php b/lang/sk/all_settings.php index 57af665d209..0db2a9febd4 100644 --- a/lang/sk/all_settings.php +++ b/lang/sk/all_settings.php @@ -216,6 +216,18 @@ 'landing_background_portrait_mode' => 'Mode for portrait background', 'landing_background_landscape' => 'Value for landscape background (URL, photo ID, or album ID)', 'landing_background_portrait' => 'Value for portrait background (URL, photo ID, or album ID)', + 'landing_layout' => 'Landing page layout', + 'landing_intro_screen_enabled' => 'Enable the intro splash screen', + 'landing_hero_text_position' => 'Hero text position', + 'landing_hero_text_color' => 'Hero text color', + 'landing_hero_text_opacity' => 'Hero text opacity (%)', + 'landing_animation_preset' => 'Landing page animation preset', + 'landing_about_enabled' => 'Enable the about section', + 'landing_about_text' => 'About section text', + 'landing_featured_items_enabled' => 'Enable the featured content section', + 'landing_featured_items_mode' => 'Featured content mode', + 'landing_featured_items_count' => 'Number of automatic featured items', + 'landing_cta_text' => 'Call-to-action text override', 'metrics_enabled' => 'Enable statistics on photos & albums', 'metrics_logged_in_users_enabed' => 'Enable statistics for logged-in users', 'metrics_access' => 'Access level for statistics of the album/photo', @@ -613,6 +625,18 @@ 'landing_background_portrait_mode' => 'Options: static (URL), photo_id (specific photo), random (random public photo), latest_album_cover (latest album cover), random_from_album (random from album).', 'landing_background_landscape' => 'Depends on mode: URL for static, photo ID for photo_id, album ID for random_from_album. This image is also used when sharing the gallery link directly.', 'landing_background_portrait' => 'Depends on mode: URL for static, photo ID for photo_id, album ID for random_from_album.', + 'landing_layout' => 'Options: classic (default, free), portfolio/minimal/studio (require Lychee SE). Classic is unchanged and remains free forever.', + 'landing_intro_screen_enabled' => 'Applies to the classic and portfolio layouts. Disable to skip straight to the hero.', + 'landing_hero_text_position' => 'Options: top_left, top_right, bottom_left, bottom_right, center. Applies to the classic and portfolio layouts.', + 'landing_hero_text_color' => 'Leave empty to use the default white. Applies to the headline and subtitle text only, on every layout.', + 'landing_hero_text_opacity' => 'Range 0-100. Applies to the headline and subtitle text only, on every layout.', + 'landing_animation_preset' => 'Options: none/classic_fade (free), zoom_in/parallax_scroll/slide_reveal (require Lychee SE).', + 'landing_about_enabled' => 'Applies to the portfolio and minimal layouts.', + 'landing_about_text' => 'Admin-authored HTML, rendered verbatim (same trust model as the footer additional text).', + 'landing_featured_items_enabled' => 'Applies to the portfolio layout only. Requires Lychee SE to take effect.', + 'landing_featured_items_mode' => 'Automatic: most recently published public albums. Manual: admin-curated photos/albums.', + 'landing_featured_items_count' => 'Range 3-12. Only used in automatic featured content mode.', + 'landing_cta_text' => 'Leave empty to use each layout\'s default label.', 'metrics_enabled' => 'If enabled, anonymours users will be measured.', 'metrics_logged_in_users_enabed' => 'If enabled, logged-in users will be measured as well (admin users are not measured).', 'metrics_access' => '', diff --git a/lang/sk/landing.php b/lang/sk/landing.php index 11e4c89933a..7619d8b0ad6 100644 --- a/lang/sk/landing.php +++ b/lang/sk/landing.php @@ -10,4 +10,20 @@ 'access_gallery' => 'Access the gallery', 'Powered_by_Lychee' => 'Powered by Lychee', 'copyright' => 'All images on this website are subject to copyright by %1$s © %2$s', + 'client_login' => 'Client Login', + 'view_public_gallery' => 'View public gallery', + 'contact' => 'Contact', + 'portfolio' => [ + 'about' => 'About', + 'featured' => 'Recent Work', + 'scroll_down' => 'Scroll down', + ], + 'meridian' => [ + 'explore_label' => 'Explore', + 'explore_caption' => 'View the gallery', + 'contact_caption' => 'Get in touch', + ], + 'studio' => [ + 'welcome_back' => 'Welcome back', + ], ]; diff --git a/lang/sk/landing_config.php b/lang/sk/landing_config.php new file mode 100644 index 00000000000..02f332b1335 --- /dev/null +++ b/lang/sk/landing_config.php @@ -0,0 +1,103 @@ + 'Landing Page', + 'tab_settings' => 'Settings', + 'tab_links' => 'Links', + 'tab_featured' => 'Featured', + + 'section_layout' => 'Layout & Structure', + 'section_hero' => 'Hero', + 'section_background_landscape' => 'Background (Landscape)', + 'section_background_portrait' => 'Background (Portrait)', + 'section_cta_position' => 'Call-to-Action Position', + 'section_content' => 'Content', + + 'field_layout' => 'Layout', + 'field_intro_screen_enabled' => 'Intro splash screen', + 'field_hero_text_position' => 'Hero text position', + 'field_hero_text_color' => 'Hero text color', + 'field_hero_text_opacity' => 'Hero text opacity', + 'field_animation_preset' => 'Animation preset', + 'field_cta_text' => 'text', + 'field_cta_text_placeholder' => 'Leave empty for the layout default', + 'field_about_enabled' => 'About section', + 'field_about_text' => 'About text', + + 'field_background_mode' => 'Source', + 'background_mode_options' => [ + 'static' => 'URL', + 'photo_id' => 'Photo ID', + 'random' => 'Random public photo', + 'latest_album_cover' => 'Latest album cover', + 'random_from_album' => 'Random photo from album', + ], + 'field_background_url' => 'Image URL', + 'field_background_url_placeholder' => 'https://…', + 'field_background_photo_id' => 'Photo ID', + 'field_background_photo_id_placeholder' => '24-character photo ID', + 'field_background_photo_id_hint' => 'Photo ID of the image to use. Open a photo and copy the last 24 characters from the URL.', + 'field_background_album_id' => 'Album ID', + 'field_background_album_id_placeholder' => '24-character album ID', + 'background_load_error' => 'Could not load photo. Make sure the ID is correct and you have access to it.', + 'background_mode_hint' => [ + 'random' => 'A random public photo is picked on every page load.', + 'latest_album_cover' => 'The cover of the most recently published public album is used.', + 'random_from_album' => 'A random photo from this album is picked on every page load. The preview only updates after saving.', + ], + + 'preview_orientation_landscape' => 'Preview landscape background', + 'preview_orientation_portrait' => 'Preview portrait background', + + 'field_cta_position' => 'Position', + 'cta_position_options' => [ + 'top-left' => 'Top Left', + 'top' => 'Top Center', + 'top-right' => 'Top Right', + 'left' => 'Middle Left', + 'center' => 'Center', + 'right' => 'Middle Right', + 'bottom-left' => 'Bottom Left', + 'bottom' => 'Bottom Center', + 'bottom-right' => 'Bottom Right', + ], + 'field_cta_shift_type' => 'Shift unit', + 'cta_shift_type_options' => [ + 'relative' => 'Relative (%)', + 'absolute' => 'Absolute (px)', + ], + 'cta_shift_type_hint' => 'Relative shifts are a percentage of the viewport size; absolute shifts are a fixed number of pixels.', + 'field_cta_shift_x' => 'Horizontal Shift (:value)', + 'cta_shift_x_direction_options' => [ + 'left' => 'Left', + 'right' => 'Right', + ], + 'field_cta_shift_y' => 'Vertical Shift (:value)', + 'cta_shift_y_direction_options' => [ + 'up' => 'Up', + 'down' => 'Down', + ], + 'reset_to_zero' => 'Reset to 0', + + 'preview_title' => 'Live Preview', + 'preview_hint' => 'Updates instantly as you edit — nothing is saved until you click Save.', + 'flat_list_hint' => 'These settings are also editable from the flat generic Settings list.', + + 'save' => 'Save', + 'saved' => 'Landing page settings saved.', + 'save_error' => 'Failed to save landing page settings.', + + 'se_required' => 'This layout/preset requires Lychee SE.', +]; diff --git a/lang/sk/landing_featured_item.php b/lang/sk/landing_featured_item.php new file mode 100644 index 00000000000..3ee73cbc4cc --- /dev/null +++ b/lang/sk/landing_featured_item.php @@ -0,0 +1,51 @@ + 'Featured Content', + 'description' => 'Curate specific photos and albums to feature on the landing page.', + + 'mode_automatic' => 'Automatic (latest published public albums)', + 'mode_manual' => 'Manual (curate specific photos/albums)', + + 'field_enabled' => 'Enable featured content', + 'field_mode' => 'Mode', + 'field_count' => 'Number of automatic items', + 'field_count_hint' => 'Between 3 and 12.', + + 'search_placeholder' => 'Search photos/albums by title…', + 'search_no_results' => 'No results.', + 'add' => 'Add', + + 'no_items' => 'No items curated yet.', + 'col_preview' => 'Preview', + 'col_title' => 'Title', + 'col_type' => 'Type', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + 'type_photo' => 'Photo', + 'type_album' => 'Album', + + 'confirm_delete_header' => 'Remove Featured Item', + 'confirm_delete_message' => 'Are you sure you want to remove ":title" from the featured content?', + + 'added' => 'Item added successfully.', + 'deleted' => 'Item removed successfully.', + 'reordered' => 'Featured items reordered successfully.', + 'error_load' => 'Failed to load featured items.', + 'error_search' => 'Failed to search.', + 'error_add' => 'Failed to add item.', + 'error_delete' => 'Failed to remove item.', + 'error_reorder' => 'Failed to reorder featured items.', +]; diff --git a/lang/sk/landing_link.php b/lang/sk/landing_link.php new file mode 100644 index 00000000000..cf37252cb5f --- /dev/null +++ b/lang/sk/landing_link.php @@ -0,0 +1,73 @@ + 'Extra Links', + 'description' => 'Manage an arbitrary, ordered list of extra links shown on the nav and/or footer of the landing page.', + + // Empty state + 'no_links' => 'No extra links configured yet.', + 'create_first' => 'Create your first link', + + // Table columns + 'col_label' => 'Label', + 'col_url' => 'URL', + 'col_placement' => 'Placement', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + + // Placement labels + 'placement_nav' => 'Nav', + 'placement_footer' => 'Footer', + 'placement_both' => 'Nav & Footer', + + // Built-in links (Gallery, Contact) + 'badge_built_in' => 'Built-in', + 'built_in_cannot_delete' => 'This built-in link cannot be deleted. Disable it instead, or reorder it like any other link.', + 'built_in_url_hint' => 'This is a built-in link and its target cannot be changed.', + + // Buttons + 'create' => 'Create Link', + 'edit' => 'Edit', + 'delete' => 'Delete', + 'cancel' => 'Cancel', + 'save' => 'Save', + + // Form fields + 'field_label' => 'Label', + 'field_label_placeholder' => 'e.g. Instagram', + 'field_url' => 'URL', + 'field_url_placeholder' => 'https://example.com', + 'field_placement' => 'Placement', + 'field_open_in_new_tab' => 'Open in new tab', + 'field_enabled' => 'Enabled', + + // Modal titles + 'modal_create_title' => 'Create Link', + 'modal_edit_title' => 'Edit Link', + + // Delete confirmation + 'confirm_delete_header' => 'Delete Link', + 'confirm_delete_message' => 'Are you sure you want to delete the link ":label"? This action cannot be undone.', + + // Toasts + 'created' => 'Link created successfully.', + 'updated' => 'Link updated successfully.', + 'deleted' => 'Link deleted successfully.', + 'reordered' => 'Links reordered successfully.', + 'error_load' => 'Failed to load links.', + 'error_save' => 'Failed to save link.', + 'error_delete' => 'Failed to delete link.', + 'error_reorder' => 'Failed to reorder links.', +]; diff --git a/lang/sk/watermark.php b/lang/sk/watermark.php index 6860c862127..be6175d3e5b 100644 --- a/lang/sk/watermark.php +++ b/lang/sk/watermark.php @@ -57,6 +57,7 @@ 'up' => 'Up', 'down' => 'Down', ], + 'reset_to_zero' => 'Reset to 0', 'save' => 'Save Settings', 'saved' => 'Watermark settings saved.', diff --git a/lang/sv/all_settings.php b/lang/sv/all_settings.php index 007b99a8b7a..75eb8879362 100644 --- a/lang/sv/all_settings.php +++ b/lang/sv/all_settings.php @@ -216,6 +216,18 @@ 'landing_background_portrait_mode' => 'Mode for portrait background', 'landing_background_landscape' => 'Value for landscape background (URL, photo ID, or album ID)', 'landing_background_portrait' => 'Value for portrait background (URL, photo ID, or album ID)', + 'landing_layout' => 'Landing page layout', + 'landing_intro_screen_enabled' => 'Enable the intro splash screen', + 'landing_hero_text_position' => 'Hero text position', + 'landing_hero_text_color' => 'Hero text color', + 'landing_hero_text_opacity' => 'Hero text opacity (%)', + 'landing_animation_preset' => 'Landing page animation preset', + 'landing_about_enabled' => 'Enable the about section', + 'landing_about_text' => 'About section text', + 'landing_featured_items_enabled' => 'Enable the featured content section', + 'landing_featured_items_mode' => 'Featured content mode', + 'landing_featured_items_count' => 'Number of automatic featured items', + 'landing_cta_text' => 'Call-to-action text override', 'metrics_enabled' => 'Enable statistics on photos & albums', 'metrics_logged_in_users_enabed' => 'Enable statistics for logged-in users', 'metrics_access' => 'Access level for statistics of the album/photo', @@ -613,6 +625,18 @@ 'landing_background_portrait_mode' => 'Options: static (URL), photo_id (specific photo), random (random public photo), latest_album_cover (latest album cover), random_from_album (random from album).', 'landing_background_landscape' => 'Depends on mode: URL for static, photo ID for photo_id, album ID for random_from_album. This image is also used when sharing the gallery link directly.', 'landing_background_portrait' => 'Depends on mode: URL for static, photo ID for photo_id, album ID for random_from_album.', + 'landing_layout' => 'Options: classic (default, free), portfolio/minimal/studio (require Lychee SE). Classic is unchanged and remains free forever.', + 'landing_intro_screen_enabled' => 'Applies to the classic and portfolio layouts. Disable to skip straight to the hero.', + 'landing_hero_text_position' => 'Options: top_left, top_right, bottom_left, bottom_right, center. Applies to the classic and portfolio layouts.', + 'landing_hero_text_color' => 'Leave empty to use the default white. Applies to the headline and subtitle text only, on every layout.', + 'landing_hero_text_opacity' => 'Range 0-100. Applies to the headline and subtitle text only, on every layout.', + 'landing_animation_preset' => 'Options: none/classic_fade (free), zoom_in/parallax_scroll/slide_reveal (require Lychee SE).', + 'landing_about_enabled' => 'Applies to the portfolio and minimal layouts.', + 'landing_about_text' => 'Admin-authored HTML, rendered verbatim (same trust model as the footer additional text).', + 'landing_featured_items_enabled' => 'Applies to the portfolio layout only. Requires Lychee SE to take effect.', + 'landing_featured_items_mode' => 'Automatic: most recently published public albums. Manual: admin-curated photos/albums.', + 'landing_featured_items_count' => 'Range 3-12. Only used in automatic featured content mode.', + 'landing_cta_text' => 'Leave empty to use each layout\'s default label.', 'metrics_enabled' => 'If enabled, anonymours users will be measured.', 'metrics_logged_in_users_enabed' => 'If enabled, logged-in users will be measured as well (admin users are not measured).', 'metrics_access' => '', diff --git a/lang/sv/landing.php b/lang/sv/landing.php index 11e4c89933a..7619d8b0ad6 100644 --- a/lang/sv/landing.php +++ b/lang/sv/landing.php @@ -10,4 +10,20 @@ 'access_gallery' => 'Access the gallery', 'Powered_by_Lychee' => 'Powered by Lychee', 'copyright' => 'All images on this website are subject to copyright by %1$s © %2$s', + 'client_login' => 'Client Login', + 'view_public_gallery' => 'View public gallery', + 'contact' => 'Contact', + 'portfolio' => [ + 'about' => 'About', + 'featured' => 'Recent Work', + 'scroll_down' => 'Scroll down', + ], + 'meridian' => [ + 'explore_label' => 'Explore', + 'explore_caption' => 'View the gallery', + 'contact_caption' => 'Get in touch', + ], + 'studio' => [ + 'welcome_back' => 'Welcome back', + ], ]; diff --git a/lang/sv/landing_config.php b/lang/sv/landing_config.php new file mode 100644 index 00000000000..02f332b1335 --- /dev/null +++ b/lang/sv/landing_config.php @@ -0,0 +1,103 @@ + 'Landing Page', + 'tab_settings' => 'Settings', + 'tab_links' => 'Links', + 'tab_featured' => 'Featured', + + 'section_layout' => 'Layout & Structure', + 'section_hero' => 'Hero', + 'section_background_landscape' => 'Background (Landscape)', + 'section_background_portrait' => 'Background (Portrait)', + 'section_cta_position' => 'Call-to-Action Position', + 'section_content' => 'Content', + + 'field_layout' => 'Layout', + 'field_intro_screen_enabled' => 'Intro splash screen', + 'field_hero_text_position' => 'Hero text position', + 'field_hero_text_color' => 'Hero text color', + 'field_hero_text_opacity' => 'Hero text opacity', + 'field_animation_preset' => 'Animation preset', + 'field_cta_text' => 'text', + 'field_cta_text_placeholder' => 'Leave empty for the layout default', + 'field_about_enabled' => 'About section', + 'field_about_text' => 'About text', + + 'field_background_mode' => 'Source', + 'background_mode_options' => [ + 'static' => 'URL', + 'photo_id' => 'Photo ID', + 'random' => 'Random public photo', + 'latest_album_cover' => 'Latest album cover', + 'random_from_album' => 'Random photo from album', + ], + 'field_background_url' => 'Image URL', + 'field_background_url_placeholder' => 'https://…', + 'field_background_photo_id' => 'Photo ID', + 'field_background_photo_id_placeholder' => '24-character photo ID', + 'field_background_photo_id_hint' => 'Photo ID of the image to use. Open a photo and copy the last 24 characters from the URL.', + 'field_background_album_id' => 'Album ID', + 'field_background_album_id_placeholder' => '24-character album ID', + 'background_load_error' => 'Could not load photo. Make sure the ID is correct and you have access to it.', + 'background_mode_hint' => [ + 'random' => 'A random public photo is picked on every page load.', + 'latest_album_cover' => 'The cover of the most recently published public album is used.', + 'random_from_album' => 'A random photo from this album is picked on every page load. The preview only updates after saving.', + ], + + 'preview_orientation_landscape' => 'Preview landscape background', + 'preview_orientation_portrait' => 'Preview portrait background', + + 'field_cta_position' => 'Position', + 'cta_position_options' => [ + 'top-left' => 'Top Left', + 'top' => 'Top Center', + 'top-right' => 'Top Right', + 'left' => 'Middle Left', + 'center' => 'Center', + 'right' => 'Middle Right', + 'bottom-left' => 'Bottom Left', + 'bottom' => 'Bottom Center', + 'bottom-right' => 'Bottom Right', + ], + 'field_cta_shift_type' => 'Shift unit', + 'cta_shift_type_options' => [ + 'relative' => 'Relative (%)', + 'absolute' => 'Absolute (px)', + ], + 'cta_shift_type_hint' => 'Relative shifts are a percentage of the viewport size; absolute shifts are a fixed number of pixels.', + 'field_cta_shift_x' => 'Horizontal Shift (:value)', + 'cta_shift_x_direction_options' => [ + 'left' => 'Left', + 'right' => 'Right', + ], + 'field_cta_shift_y' => 'Vertical Shift (:value)', + 'cta_shift_y_direction_options' => [ + 'up' => 'Up', + 'down' => 'Down', + ], + 'reset_to_zero' => 'Reset to 0', + + 'preview_title' => 'Live Preview', + 'preview_hint' => 'Updates instantly as you edit — nothing is saved until you click Save.', + 'flat_list_hint' => 'These settings are also editable from the flat generic Settings list.', + + 'save' => 'Save', + 'saved' => 'Landing page settings saved.', + 'save_error' => 'Failed to save landing page settings.', + + 'se_required' => 'This layout/preset requires Lychee SE.', +]; diff --git a/lang/sv/landing_featured_item.php b/lang/sv/landing_featured_item.php new file mode 100644 index 00000000000..3ee73cbc4cc --- /dev/null +++ b/lang/sv/landing_featured_item.php @@ -0,0 +1,51 @@ + 'Featured Content', + 'description' => 'Curate specific photos and albums to feature on the landing page.', + + 'mode_automatic' => 'Automatic (latest published public albums)', + 'mode_manual' => 'Manual (curate specific photos/albums)', + + 'field_enabled' => 'Enable featured content', + 'field_mode' => 'Mode', + 'field_count' => 'Number of automatic items', + 'field_count_hint' => 'Between 3 and 12.', + + 'search_placeholder' => 'Search photos/albums by title…', + 'search_no_results' => 'No results.', + 'add' => 'Add', + + 'no_items' => 'No items curated yet.', + 'col_preview' => 'Preview', + 'col_title' => 'Title', + 'col_type' => 'Type', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + 'type_photo' => 'Photo', + 'type_album' => 'Album', + + 'confirm_delete_header' => 'Remove Featured Item', + 'confirm_delete_message' => 'Are you sure you want to remove ":title" from the featured content?', + + 'added' => 'Item added successfully.', + 'deleted' => 'Item removed successfully.', + 'reordered' => 'Featured items reordered successfully.', + 'error_load' => 'Failed to load featured items.', + 'error_search' => 'Failed to search.', + 'error_add' => 'Failed to add item.', + 'error_delete' => 'Failed to remove item.', + 'error_reorder' => 'Failed to reorder featured items.', +]; diff --git a/lang/sv/landing_link.php b/lang/sv/landing_link.php new file mode 100644 index 00000000000..cf37252cb5f --- /dev/null +++ b/lang/sv/landing_link.php @@ -0,0 +1,73 @@ + 'Extra Links', + 'description' => 'Manage an arbitrary, ordered list of extra links shown on the nav and/or footer of the landing page.', + + // Empty state + 'no_links' => 'No extra links configured yet.', + 'create_first' => 'Create your first link', + + // Table columns + 'col_label' => 'Label', + 'col_url' => 'URL', + 'col_placement' => 'Placement', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + + // Placement labels + 'placement_nav' => 'Nav', + 'placement_footer' => 'Footer', + 'placement_both' => 'Nav & Footer', + + // Built-in links (Gallery, Contact) + 'badge_built_in' => 'Built-in', + 'built_in_cannot_delete' => 'This built-in link cannot be deleted. Disable it instead, or reorder it like any other link.', + 'built_in_url_hint' => 'This is a built-in link and its target cannot be changed.', + + // Buttons + 'create' => 'Create Link', + 'edit' => 'Edit', + 'delete' => 'Delete', + 'cancel' => 'Cancel', + 'save' => 'Save', + + // Form fields + 'field_label' => 'Label', + 'field_label_placeholder' => 'e.g. Instagram', + 'field_url' => 'URL', + 'field_url_placeholder' => 'https://example.com', + 'field_placement' => 'Placement', + 'field_open_in_new_tab' => 'Open in new tab', + 'field_enabled' => 'Enabled', + + // Modal titles + 'modal_create_title' => 'Create Link', + 'modal_edit_title' => 'Edit Link', + + // Delete confirmation + 'confirm_delete_header' => 'Delete Link', + 'confirm_delete_message' => 'Are you sure you want to delete the link ":label"? This action cannot be undone.', + + // Toasts + 'created' => 'Link created successfully.', + 'updated' => 'Link updated successfully.', + 'deleted' => 'Link deleted successfully.', + 'reordered' => 'Links reordered successfully.', + 'error_load' => 'Failed to load links.', + 'error_save' => 'Failed to save link.', + 'error_delete' => 'Failed to delete link.', + 'error_reorder' => 'Failed to reorder links.', +]; diff --git a/lang/sv/watermark.php b/lang/sv/watermark.php index 6860c862127..be6175d3e5b 100644 --- a/lang/sv/watermark.php +++ b/lang/sv/watermark.php @@ -57,6 +57,7 @@ 'up' => 'Up', 'down' => 'Down', ], + 'reset_to_zero' => 'Reset to 0', 'save' => 'Save Settings', 'saved' => 'Watermark settings saved.', diff --git a/lang/tr/all_settings.php b/lang/tr/all_settings.php index 276744515f4..b3b1338de93 100644 --- a/lang/tr/all_settings.php +++ b/lang/tr/all_settings.php @@ -215,6 +215,18 @@ 'landing_background_portrait_mode' => 'Mode for portrait background', 'landing_background_landscape' => 'Value for landscape background (URL, photo ID, or album ID)', 'landing_background_portrait' => 'Value for portrait background (URL, photo ID, or album ID)', + 'landing_layout' => 'Landing page layout', + 'landing_intro_screen_enabled' => 'Enable the intro splash screen', + 'landing_hero_text_position' => 'Hero text position', + 'landing_hero_text_color' => 'Hero text color', + 'landing_hero_text_opacity' => 'Hero text opacity (%)', + 'landing_animation_preset' => 'Landing page animation preset', + 'landing_about_enabled' => 'Enable the about section', + 'landing_about_text' => 'About section text', + 'landing_featured_items_enabled' => 'Enable the featured content section', + 'landing_featured_items_mode' => 'Featured content mode', + 'landing_featured_items_count' => 'Number of automatic featured items', + 'landing_cta_text' => 'Call-to-action text override', 'metrics_enabled' => 'Enable statistics on photos & albums', 'metrics_logged_in_users_enabed' => 'Enable statistics for logged-in users', 'metrics_access' => 'Access level for statistics of the album/photo', @@ -612,6 +624,18 @@ 'landing_background_portrait_mode' => 'Options: static (URL), photo_id (specific photo), random (random public photo), latest_album_cover (latest album cover), random_from_album (random from album).', 'landing_background_landscape' => 'Depends on mode: URL for static, photo ID for photo_id, album ID for random_from_album. This image is also used when sharing the gallery link directly.', 'landing_background_portrait' => 'Depends on mode: URL for static, photo ID for photo_id, album ID for random_from_album.', + 'landing_layout' => 'Options: classic (default, free), portfolio/minimal/studio (require Lychee SE). Classic is unchanged and remains free forever.', + 'landing_intro_screen_enabled' => 'Applies to the classic and portfolio layouts. Disable to skip straight to the hero.', + 'landing_hero_text_position' => 'Options: top_left, top_right, bottom_left, bottom_right, center. Applies to the classic and portfolio layouts.', + 'landing_hero_text_color' => 'Leave empty to use the default white. Applies to the headline and subtitle text only, on every layout.', + 'landing_hero_text_opacity' => 'Range 0-100. Applies to the headline and subtitle text only, on every layout.', + 'landing_animation_preset' => 'Options: none/classic_fade (free), zoom_in/parallax_scroll/slide_reveal (require Lychee SE).', + 'landing_about_enabled' => 'Applies to the portfolio and minimal layouts.', + 'landing_about_text' => 'Admin-authored HTML, rendered verbatim (same trust model as the footer additional text).', + 'landing_featured_items_enabled' => 'Applies to the portfolio layout only. Requires Lychee SE to take effect.', + 'landing_featured_items_mode' => 'Automatic: most recently published public albums. Manual: admin-curated photos/albums.', + 'landing_featured_items_count' => 'Range 3-12. Only used in automatic featured content mode.', + 'landing_cta_text' => 'Leave empty to use each layout\'s default label.', 'metrics_enabled' => 'If enabled, anonymours users will be measured.', 'metrics_logged_in_users_enabed' => 'If enabled, logged-in users will be measured as well (admin users are not measured).', 'metrics_access' => '', diff --git a/lang/tr/landing.php b/lang/tr/landing.php index 8ab41072943..7fe7e492918 100644 --- a/lang/tr/landing.php +++ b/lang/tr/landing.php @@ -10,4 +10,20 @@ 'access_gallery' => 'Galeriye eriş', 'Powered_by_Lychee' => 'Powered by Lychee', 'copyright' => 'Bu web sitesindeki tüm görsellerin telif hakkı %1$s © %2$s\'ye aittir', + 'client_login' => 'Client Login', + 'view_public_gallery' => 'View public gallery', + 'contact' => 'Contact', + 'portfolio' => [ + 'about' => 'About', + 'featured' => 'Recent Work', + 'scroll_down' => 'Scroll down', + ], + 'meridian' => [ + 'explore_label' => 'Explore', + 'explore_caption' => 'View the gallery', + 'contact_caption' => 'Get in touch', + ], + 'studio' => [ + 'welcome_back' => 'Welcome back', + ], ]; diff --git a/lang/tr/landing_config.php b/lang/tr/landing_config.php new file mode 100644 index 00000000000..02f332b1335 --- /dev/null +++ b/lang/tr/landing_config.php @@ -0,0 +1,103 @@ + 'Landing Page', + 'tab_settings' => 'Settings', + 'tab_links' => 'Links', + 'tab_featured' => 'Featured', + + 'section_layout' => 'Layout & Structure', + 'section_hero' => 'Hero', + 'section_background_landscape' => 'Background (Landscape)', + 'section_background_portrait' => 'Background (Portrait)', + 'section_cta_position' => 'Call-to-Action Position', + 'section_content' => 'Content', + + 'field_layout' => 'Layout', + 'field_intro_screen_enabled' => 'Intro splash screen', + 'field_hero_text_position' => 'Hero text position', + 'field_hero_text_color' => 'Hero text color', + 'field_hero_text_opacity' => 'Hero text opacity', + 'field_animation_preset' => 'Animation preset', + 'field_cta_text' => 'text', + 'field_cta_text_placeholder' => 'Leave empty for the layout default', + 'field_about_enabled' => 'About section', + 'field_about_text' => 'About text', + + 'field_background_mode' => 'Source', + 'background_mode_options' => [ + 'static' => 'URL', + 'photo_id' => 'Photo ID', + 'random' => 'Random public photo', + 'latest_album_cover' => 'Latest album cover', + 'random_from_album' => 'Random photo from album', + ], + 'field_background_url' => 'Image URL', + 'field_background_url_placeholder' => 'https://…', + 'field_background_photo_id' => 'Photo ID', + 'field_background_photo_id_placeholder' => '24-character photo ID', + 'field_background_photo_id_hint' => 'Photo ID of the image to use. Open a photo and copy the last 24 characters from the URL.', + 'field_background_album_id' => 'Album ID', + 'field_background_album_id_placeholder' => '24-character album ID', + 'background_load_error' => 'Could not load photo. Make sure the ID is correct and you have access to it.', + 'background_mode_hint' => [ + 'random' => 'A random public photo is picked on every page load.', + 'latest_album_cover' => 'The cover of the most recently published public album is used.', + 'random_from_album' => 'A random photo from this album is picked on every page load. The preview only updates after saving.', + ], + + 'preview_orientation_landscape' => 'Preview landscape background', + 'preview_orientation_portrait' => 'Preview portrait background', + + 'field_cta_position' => 'Position', + 'cta_position_options' => [ + 'top-left' => 'Top Left', + 'top' => 'Top Center', + 'top-right' => 'Top Right', + 'left' => 'Middle Left', + 'center' => 'Center', + 'right' => 'Middle Right', + 'bottom-left' => 'Bottom Left', + 'bottom' => 'Bottom Center', + 'bottom-right' => 'Bottom Right', + ], + 'field_cta_shift_type' => 'Shift unit', + 'cta_shift_type_options' => [ + 'relative' => 'Relative (%)', + 'absolute' => 'Absolute (px)', + ], + 'cta_shift_type_hint' => 'Relative shifts are a percentage of the viewport size; absolute shifts are a fixed number of pixels.', + 'field_cta_shift_x' => 'Horizontal Shift (:value)', + 'cta_shift_x_direction_options' => [ + 'left' => 'Left', + 'right' => 'Right', + ], + 'field_cta_shift_y' => 'Vertical Shift (:value)', + 'cta_shift_y_direction_options' => [ + 'up' => 'Up', + 'down' => 'Down', + ], + 'reset_to_zero' => 'Reset to 0', + + 'preview_title' => 'Live Preview', + 'preview_hint' => 'Updates instantly as you edit — nothing is saved until you click Save.', + 'flat_list_hint' => 'These settings are also editable from the flat generic Settings list.', + + 'save' => 'Save', + 'saved' => 'Landing page settings saved.', + 'save_error' => 'Failed to save landing page settings.', + + 'se_required' => 'This layout/preset requires Lychee SE.', +]; diff --git a/lang/tr/landing_featured_item.php b/lang/tr/landing_featured_item.php new file mode 100644 index 00000000000..3ee73cbc4cc --- /dev/null +++ b/lang/tr/landing_featured_item.php @@ -0,0 +1,51 @@ + 'Featured Content', + 'description' => 'Curate specific photos and albums to feature on the landing page.', + + 'mode_automatic' => 'Automatic (latest published public albums)', + 'mode_manual' => 'Manual (curate specific photos/albums)', + + 'field_enabled' => 'Enable featured content', + 'field_mode' => 'Mode', + 'field_count' => 'Number of automatic items', + 'field_count_hint' => 'Between 3 and 12.', + + 'search_placeholder' => 'Search photos/albums by title…', + 'search_no_results' => 'No results.', + 'add' => 'Add', + + 'no_items' => 'No items curated yet.', + 'col_preview' => 'Preview', + 'col_title' => 'Title', + 'col_type' => 'Type', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + 'type_photo' => 'Photo', + 'type_album' => 'Album', + + 'confirm_delete_header' => 'Remove Featured Item', + 'confirm_delete_message' => 'Are you sure you want to remove ":title" from the featured content?', + + 'added' => 'Item added successfully.', + 'deleted' => 'Item removed successfully.', + 'reordered' => 'Featured items reordered successfully.', + 'error_load' => 'Failed to load featured items.', + 'error_search' => 'Failed to search.', + 'error_add' => 'Failed to add item.', + 'error_delete' => 'Failed to remove item.', + 'error_reorder' => 'Failed to reorder featured items.', +]; diff --git a/lang/tr/landing_link.php b/lang/tr/landing_link.php new file mode 100644 index 00000000000..cf37252cb5f --- /dev/null +++ b/lang/tr/landing_link.php @@ -0,0 +1,73 @@ + 'Extra Links', + 'description' => 'Manage an arbitrary, ordered list of extra links shown on the nav and/or footer of the landing page.', + + // Empty state + 'no_links' => 'No extra links configured yet.', + 'create_first' => 'Create your first link', + + // Table columns + 'col_label' => 'Label', + 'col_url' => 'URL', + 'col_placement' => 'Placement', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + + // Placement labels + 'placement_nav' => 'Nav', + 'placement_footer' => 'Footer', + 'placement_both' => 'Nav & Footer', + + // Built-in links (Gallery, Contact) + 'badge_built_in' => 'Built-in', + 'built_in_cannot_delete' => 'This built-in link cannot be deleted. Disable it instead, or reorder it like any other link.', + 'built_in_url_hint' => 'This is a built-in link and its target cannot be changed.', + + // Buttons + 'create' => 'Create Link', + 'edit' => 'Edit', + 'delete' => 'Delete', + 'cancel' => 'Cancel', + 'save' => 'Save', + + // Form fields + 'field_label' => 'Label', + 'field_label_placeholder' => 'e.g. Instagram', + 'field_url' => 'URL', + 'field_url_placeholder' => 'https://example.com', + 'field_placement' => 'Placement', + 'field_open_in_new_tab' => 'Open in new tab', + 'field_enabled' => 'Enabled', + + // Modal titles + 'modal_create_title' => 'Create Link', + 'modal_edit_title' => 'Edit Link', + + // Delete confirmation + 'confirm_delete_header' => 'Delete Link', + 'confirm_delete_message' => 'Are you sure you want to delete the link ":label"? This action cannot be undone.', + + // Toasts + 'created' => 'Link created successfully.', + 'updated' => 'Link updated successfully.', + 'deleted' => 'Link deleted successfully.', + 'reordered' => 'Links reordered successfully.', + 'error_load' => 'Failed to load links.', + 'error_save' => 'Failed to save link.', + 'error_delete' => 'Failed to delete link.', + 'error_reorder' => 'Failed to reorder links.', +]; diff --git a/lang/tr/watermark.php b/lang/tr/watermark.php index 6860c862127..be6175d3e5b 100644 --- a/lang/tr/watermark.php +++ b/lang/tr/watermark.php @@ -57,6 +57,7 @@ 'up' => 'Up', 'down' => 'Down', ], + 'reset_to_zero' => 'Reset to 0', 'save' => 'Save Settings', 'saved' => 'Watermark settings saved.', diff --git a/lang/vi/all_settings.php b/lang/vi/all_settings.php index 1aac5b37479..7ac49d11687 100644 --- a/lang/vi/all_settings.php +++ b/lang/vi/all_settings.php @@ -216,6 +216,18 @@ 'landing_background_portrait_mode' => 'Mode for portrait background', 'landing_background_landscape' => 'Value for landscape background (URL, photo ID, or album ID)', 'landing_background_portrait' => 'Value for portrait background (URL, photo ID, or album ID)', + 'landing_layout' => 'Landing page layout', + 'landing_intro_screen_enabled' => 'Enable the intro splash screen', + 'landing_hero_text_position' => 'Hero text position', + 'landing_hero_text_color' => 'Hero text color', + 'landing_hero_text_opacity' => 'Hero text opacity (%)', + 'landing_animation_preset' => 'Landing page animation preset', + 'landing_about_enabled' => 'Enable the about section', + 'landing_about_text' => 'About section text', + 'landing_featured_items_enabled' => 'Enable the featured content section', + 'landing_featured_items_mode' => 'Featured content mode', + 'landing_featured_items_count' => 'Number of automatic featured items', + 'landing_cta_text' => 'Call-to-action text override', 'metrics_enabled' => 'Enable statistics on photos & albums', 'metrics_logged_in_users_enabed' => 'Enable statistics for logged-in users', 'metrics_access' => 'Access level for statistics of the album/photo', @@ -613,6 +625,18 @@ 'landing_background_portrait_mode' => 'Options: static (URL), photo_id (specific photo), random (random public photo), latest_album_cover (latest album cover), random_from_album (random from album).', 'landing_background_landscape' => 'Depends on mode: URL for static, photo ID for photo_id, album ID for random_from_album. This image is also used when sharing the gallery link directly.', 'landing_background_portrait' => 'Depends on mode: URL for static, photo ID for photo_id, album ID for random_from_album.', + 'landing_layout' => 'Options: classic (default, free), portfolio/minimal/studio (require Lychee SE). Classic is unchanged and remains free forever.', + 'landing_intro_screen_enabled' => 'Applies to the classic and portfolio layouts. Disable to skip straight to the hero.', + 'landing_hero_text_position' => 'Options: top_left, top_right, bottom_left, bottom_right, center. Applies to the classic and portfolio layouts.', + 'landing_hero_text_color' => 'Leave empty to use the default white. Applies to the headline and subtitle text only, on every layout.', + 'landing_hero_text_opacity' => 'Range 0-100. Applies to the headline and subtitle text only, on every layout.', + 'landing_animation_preset' => 'Options: none/classic_fade (free), zoom_in/parallax_scroll/slide_reveal (require Lychee SE).', + 'landing_about_enabled' => 'Applies to the portfolio and minimal layouts.', + 'landing_about_text' => 'Admin-authored HTML, rendered verbatim (same trust model as the footer additional text).', + 'landing_featured_items_enabled' => 'Applies to the portfolio layout only. Requires Lychee SE to take effect.', + 'landing_featured_items_mode' => 'Automatic: most recently published public albums. Manual: admin-curated photos/albums.', + 'landing_featured_items_count' => 'Range 3-12. Only used in automatic featured content mode.', + 'landing_cta_text' => 'Leave empty to use each layout\'s default label.', 'metrics_enabled' => 'If enabled, anonymours users will be measured.', 'metrics_logged_in_users_enabed' => 'If enabled, logged-in users will be measured as well (admin users are not measured).', 'metrics_access' => '', diff --git a/lang/vi/landing.php b/lang/vi/landing.php index 11e4c89933a..7619d8b0ad6 100644 --- a/lang/vi/landing.php +++ b/lang/vi/landing.php @@ -10,4 +10,20 @@ 'access_gallery' => 'Access the gallery', 'Powered_by_Lychee' => 'Powered by Lychee', 'copyright' => 'All images on this website are subject to copyright by %1$s © %2$s', + 'client_login' => 'Client Login', + 'view_public_gallery' => 'View public gallery', + 'contact' => 'Contact', + 'portfolio' => [ + 'about' => 'About', + 'featured' => 'Recent Work', + 'scroll_down' => 'Scroll down', + ], + 'meridian' => [ + 'explore_label' => 'Explore', + 'explore_caption' => 'View the gallery', + 'contact_caption' => 'Get in touch', + ], + 'studio' => [ + 'welcome_back' => 'Welcome back', + ], ]; diff --git a/lang/vi/landing_config.php b/lang/vi/landing_config.php new file mode 100644 index 00000000000..02f332b1335 --- /dev/null +++ b/lang/vi/landing_config.php @@ -0,0 +1,103 @@ + 'Landing Page', + 'tab_settings' => 'Settings', + 'tab_links' => 'Links', + 'tab_featured' => 'Featured', + + 'section_layout' => 'Layout & Structure', + 'section_hero' => 'Hero', + 'section_background_landscape' => 'Background (Landscape)', + 'section_background_portrait' => 'Background (Portrait)', + 'section_cta_position' => 'Call-to-Action Position', + 'section_content' => 'Content', + + 'field_layout' => 'Layout', + 'field_intro_screen_enabled' => 'Intro splash screen', + 'field_hero_text_position' => 'Hero text position', + 'field_hero_text_color' => 'Hero text color', + 'field_hero_text_opacity' => 'Hero text opacity', + 'field_animation_preset' => 'Animation preset', + 'field_cta_text' => 'text', + 'field_cta_text_placeholder' => 'Leave empty for the layout default', + 'field_about_enabled' => 'About section', + 'field_about_text' => 'About text', + + 'field_background_mode' => 'Source', + 'background_mode_options' => [ + 'static' => 'URL', + 'photo_id' => 'Photo ID', + 'random' => 'Random public photo', + 'latest_album_cover' => 'Latest album cover', + 'random_from_album' => 'Random photo from album', + ], + 'field_background_url' => 'Image URL', + 'field_background_url_placeholder' => 'https://…', + 'field_background_photo_id' => 'Photo ID', + 'field_background_photo_id_placeholder' => '24-character photo ID', + 'field_background_photo_id_hint' => 'Photo ID of the image to use. Open a photo and copy the last 24 characters from the URL.', + 'field_background_album_id' => 'Album ID', + 'field_background_album_id_placeholder' => '24-character album ID', + 'background_load_error' => 'Could not load photo. Make sure the ID is correct and you have access to it.', + 'background_mode_hint' => [ + 'random' => 'A random public photo is picked on every page load.', + 'latest_album_cover' => 'The cover of the most recently published public album is used.', + 'random_from_album' => 'A random photo from this album is picked on every page load. The preview only updates after saving.', + ], + + 'preview_orientation_landscape' => 'Preview landscape background', + 'preview_orientation_portrait' => 'Preview portrait background', + + 'field_cta_position' => 'Position', + 'cta_position_options' => [ + 'top-left' => 'Top Left', + 'top' => 'Top Center', + 'top-right' => 'Top Right', + 'left' => 'Middle Left', + 'center' => 'Center', + 'right' => 'Middle Right', + 'bottom-left' => 'Bottom Left', + 'bottom' => 'Bottom Center', + 'bottom-right' => 'Bottom Right', + ], + 'field_cta_shift_type' => 'Shift unit', + 'cta_shift_type_options' => [ + 'relative' => 'Relative (%)', + 'absolute' => 'Absolute (px)', + ], + 'cta_shift_type_hint' => 'Relative shifts are a percentage of the viewport size; absolute shifts are a fixed number of pixels.', + 'field_cta_shift_x' => 'Horizontal Shift (:value)', + 'cta_shift_x_direction_options' => [ + 'left' => 'Left', + 'right' => 'Right', + ], + 'field_cta_shift_y' => 'Vertical Shift (:value)', + 'cta_shift_y_direction_options' => [ + 'up' => 'Up', + 'down' => 'Down', + ], + 'reset_to_zero' => 'Reset to 0', + + 'preview_title' => 'Live Preview', + 'preview_hint' => 'Updates instantly as you edit — nothing is saved until you click Save.', + 'flat_list_hint' => 'These settings are also editable from the flat generic Settings list.', + + 'save' => 'Save', + 'saved' => 'Landing page settings saved.', + 'save_error' => 'Failed to save landing page settings.', + + 'se_required' => 'This layout/preset requires Lychee SE.', +]; diff --git a/lang/vi/landing_featured_item.php b/lang/vi/landing_featured_item.php new file mode 100644 index 00000000000..3ee73cbc4cc --- /dev/null +++ b/lang/vi/landing_featured_item.php @@ -0,0 +1,51 @@ + 'Featured Content', + 'description' => 'Curate specific photos and albums to feature on the landing page.', + + 'mode_automatic' => 'Automatic (latest published public albums)', + 'mode_manual' => 'Manual (curate specific photos/albums)', + + 'field_enabled' => 'Enable featured content', + 'field_mode' => 'Mode', + 'field_count' => 'Number of automatic items', + 'field_count_hint' => 'Between 3 and 12.', + + 'search_placeholder' => 'Search photos/albums by title…', + 'search_no_results' => 'No results.', + 'add' => 'Add', + + 'no_items' => 'No items curated yet.', + 'col_preview' => 'Preview', + 'col_title' => 'Title', + 'col_type' => 'Type', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + 'type_photo' => 'Photo', + 'type_album' => 'Album', + + 'confirm_delete_header' => 'Remove Featured Item', + 'confirm_delete_message' => 'Are you sure you want to remove ":title" from the featured content?', + + 'added' => 'Item added successfully.', + 'deleted' => 'Item removed successfully.', + 'reordered' => 'Featured items reordered successfully.', + 'error_load' => 'Failed to load featured items.', + 'error_search' => 'Failed to search.', + 'error_add' => 'Failed to add item.', + 'error_delete' => 'Failed to remove item.', + 'error_reorder' => 'Failed to reorder featured items.', +]; diff --git a/lang/vi/landing_link.php b/lang/vi/landing_link.php new file mode 100644 index 00000000000..cf37252cb5f --- /dev/null +++ b/lang/vi/landing_link.php @@ -0,0 +1,73 @@ + 'Extra Links', + 'description' => 'Manage an arbitrary, ordered list of extra links shown on the nav and/or footer of the landing page.', + + // Empty state + 'no_links' => 'No extra links configured yet.', + 'create_first' => 'Create your first link', + + // Table columns + 'col_label' => 'Label', + 'col_url' => 'URL', + 'col_placement' => 'Placement', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + + // Placement labels + 'placement_nav' => 'Nav', + 'placement_footer' => 'Footer', + 'placement_both' => 'Nav & Footer', + + // Built-in links (Gallery, Contact) + 'badge_built_in' => 'Built-in', + 'built_in_cannot_delete' => 'This built-in link cannot be deleted. Disable it instead, or reorder it like any other link.', + 'built_in_url_hint' => 'This is a built-in link and its target cannot be changed.', + + // Buttons + 'create' => 'Create Link', + 'edit' => 'Edit', + 'delete' => 'Delete', + 'cancel' => 'Cancel', + 'save' => 'Save', + + // Form fields + 'field_label' => 'Label', + 'field_label_placeholder' => 'e.g. Instagram', + 'field_url' => 'URL', + 'field_url_placeholder' => 'https://example.com', + 'field_placement' => 'Placement', + 'field_open_in_new_tab' => 'Open in new tab', + 'field_enabled' => 'Enabled', + + // Modal titles + 'modal_create_title' => 'Create Link', + 'modal_edit_title' => 'Edit Link', + + // Delete confirmation + 'confirm_delete_header' => 'Delete Link', + 'confirm_delete_message' => 'Are you sure you want to delete the link ":label"? This action cannot be undone.', + + // Toasts + 'created' => 'Link created successfully.', + 'updated' => 'Link updated successfully.', + 'deleted' => 'Link deleted successfully.', + 'reordered' => 'Links reordered successfully.', + 'error_load' => 'Failed to load links.', + 'error_save' => 'Failed to save link.', + 'error_delete' => 'Failed to delete link.', + 'error_reorder' => 'Failed to reorder links.', +]; diff --git a/lang/vi/watermark.php b/lang/vi/watermark.php index 6860c862127..be6175d3e5b 100644 --- a/lang/vi/watermark.php +++ b/lang/vi/watermark.php @@ -57,6 +57,7 @@ 'up' => 'Up', 'down' => 'Down', ], + 'reset_to_zero' => 'Reset to 0', 'save' => 'Save Settings', 'saved' => 'Watermark settings saved.', diff --git a/lang/zh_CN/all_settings.php b/lang/zh_CN/all_settings.php index f0086560fbb..4f7e6a46a2b 100644 --- a/lang/zh_CN/all_settings.php +++ b/lang/zh_CN/all_settings.php @@ -216,6 +216,18 @@ 'landing_background_portrait_mode' => 'Mode for portrait background', 'landing_background_landscape' => 'Value for landscape background (URL, photo ID, or album ID)', 'landing_background_portrait' => 'Value for portrait background (URL, photo ID, or album ID)', + 'landing_layout' => 'Landing page layout', + 'landing_intro_screen_enabled' => 'Enable the intro splash screen', + 'landing_hero_text_position' => 'Hero text position', + 'landing_hero_text_color' => 'Hero text color', + 'landing_hero_text_opacity' => 'Hero text opacity (%)', + 'landing_animation_preset' => 'Landing page animation preset', + 'landing_about_enabled' => 'Enable the about section', + 'landing_about_text' => 'About section text', + 'landing_featured_items_enabled' => 'Enable the featured content section', + 'landing_featured_items_mode' => 'Featured content mode', + 'landing_featured_items_count' => 'Number of automatic featured items', + 'landing_cta_text' => 'Call-to-action text override', 'metrics_enabled' => 'Enable statistics on photos & albums', 'metrics_logged_in_users_enabed' => 'Enable statistics for logged-in users', 'metrics_access' => 'Access level for statistics of the album/photo', @@ -613,6 +625,18 @@ 'landing_background_portrait_mode' => 'Options: static (URL), photo_id (specific photo), random (random public photo), latest_album_cover (latest album cover), random_from_album (random from album).', 'landing_background_landscape' => 'Depends on mode: URL for static, photo ID for photo_id, album ID for random_from_album. This image is also used when sharing the gallery link directly.', 'landing_background_portrait' => 'Depends on mode: URL for static, photo ID for photo_id, album ID for random_from_album.', + 'landing_layout' => 'Options: classic (default, free), portfolio/minimal/studio (require Lychee SE). Classic is unchanged and remains free forever.', + 'landing_intro_screen_enabled' => 'Applies to the classic and portfolio layouts. Disable to skip straight to the hero.', + 'landing_hero_text_position' => 'Options: top_left, top_right, bottom_left, bottom_right, center. Applies to the classic and portfolio layouts.', + 'landing_hero_text_color' => 'Leave empty to use the default white. Applies to the headline and subtitle text only, on every layout.', + 'landing_hero_text_opacity' => 'Range 0-100. Applies to the headline and subtitle text only, on every layout.', + 'landing_animation_preset' => 'Options: none/classic_fade (free), zoom_in/parallax_scroll/slide_reveal (require Lychee SE).', + 'landing_about_enabled' => 'Applies to the portfolio and minimal layouts.', + 'landing_about_text' => 'Admin-authored HTML, rendered verbatim (same trust model as the footer additional text).', + 'landing_featured_items_enabled' => 'Applies to the portfolio layout only. Requires Lychee SE to take effect.', + 'landing_featured_items_mode' => 'Automatic: most recently published public albums. Manual: admin-curated photos/albums.', + 'landing_featured_items_count' => 'Range 3-12. Only used in automatic featured content mode.', + 'landing_cta_text' => 'Leave empty to use each layout\'s default label.', 'metrics_enabled' => 'If enabled, anonymours users will be measured.', 'metrics_logged_in_users_enabed' => 'If enabled, logged-in users will be measured as well (admin users are not measured).', 'metrics_access' => '', diff --git a/lang/zh_CN/landing.php b/lang/zh_CN/landing.php index ef2b07d7afe..8d20350b3d2 100644 --- a/lang/zh_CN/landing.php +++ b/lang/zh_CN/landing.php @@ -10,4 +10,20 @@ 'access_gallery' => '访问相册', 'Powered_by_Lychee' => '由 Lychee 提供支持', 'copyright' => '本网站所有图片版权归 %1$s 所有 © %2$s', + 'client_login' => 'Client Login', + 'view_public_gallery' => 'View public gallery', + 'contact' => 'Contact', + 'portfolio' => [ + 'about' => 'About', + 'featured' => 'Recent Work', + 'scroll_down' => 'Scroll down', + ], + 'meridian' => [ + 'explore_label' => 'Explore', + 'explore_caption' => 'View the gallery', + 'contact_caption' => 'Get in touch', + ], + 'studio' => [ + 'welcome_back' => 'Welcome back', + ], ]; diff --git a/lang/zh_CN/landing_config.php b/lang/zh_CN/landing_config.php new file mode 100644 index 00000000000..02f332b1335 --- /dev/null +++ b/lang/zh_CN/landing_config.php @@ -0,0 +1,103 @@ + 'Landing Page', + 'tab_settings' => 'Settings', + 'tab_links' => 'Links', + 'tab_featured' => 'Featured', + + 'section_layout' => 'Layout & Structure', + 'section_hero' => 'Hero', + 'section_background_landscape' => 'Background (Landscape)', + 'section_background_portrait' => 'Background (Portrait)', + 'section_cta_position' => 'Call-to-Action Position', + 'section_content' => 'Content', + + 'field_layout' => 'Layout', + 'field_intro_screen_enabled' => 'Intro splash screen', + 'field_hero_text_position' => 'Hero text position', + 'field_hero_text_color' => 'Hero text color', + 'field_hero_text_opacity' => 'Hero text opacity', + 'field_animation_preset' => 'Animation preset', + 'field_cta_text' => 'text', + 'field_cta_text_placeholder' => 'Leave empty for the layout default', + 'field_about_enabled' => 'About section', + 'field_about_text' => 'About text', + + 'field_background_mode' => 'Source', + 'background_mode_options' => [ + 'static' => 'URL', + 'photo_id' => 'Photo ID', + 'random' => 'Random public photo', + 'latest_album_cover' => 'Latest album cover', + 'random_from_album' => 'Random photo from album', + ], + 'field_background_url' => 'Image URL', + 'field_background_url_placeholder' => 'https://…', + 'field_background_photo_id' => 'Photo ID', + 'field_background_photo_id_placeholder' => '24-character photo ID', + 'field_background_photo_id_hint' => 'Photo ID of the image to use. Open a photo and copy the last 24 characters from the URL.', + 'field_background_album_id' => 'Album ID', + 'field_background_album_id_placeholder' => '24-character album ID', + 'background_load_error' => 'Could not load photo. Make sure the ID is correct and you have access to it.', + 'background_mode_hint' => [ + 'random' => 'A random public photo is picked on every page load.', + 'latest_album_cover' => 'The cover of the most recently published public album is used.', + 'random_from_album' => 'A random photo from this album is picked on every page load. The preview only updates after saving.', + ], + + 'preview_orientation_landscape' => 'Preview landscape background', + 'preview_orientation_portrait' => 'Preview portrait background', + + 'field_cta_position' => 'Position', + 'cta_position_options' => [ + 'top-left' => 'Top Left', + 'top' => 'Top Center', + 'top-right' => 'Top Right', + 'left' => 'Middle Left', + 'center' => 'Center', + 'right' => 'Middle Right', + 'bottom-left' => 'Bottom Left', + 'bottom' => 'Bottom Center', + 'bottom-right' => 'Bottom Right', + ], + 'field_cta_shift_type' => 'Shift unit', + 'cta_shift_type_options' => [ + 'relative' => 'Relative (%)', + 'absolute' => 'Absolute (px)', + ], + 'cta_shift_type_hint' => 'Relative shifts are a percentage of the viewport size; absolute shifts are a fixed number of pixels.', + 'field_cta_shift_x' => 'Horizontal Shift (:value)', + 'cta_shift_x_direction_options' => [ + 'left' => 'Left', + 'right' => 'Right', + ], + 'field_cta_shift_y' => 'Vertical Shift (:value)', + 'cta_shift_y_direction_options' => [ + 'up' => 'Up', + 'down' => 'Down', + ], + 'reset_to_zero' => 'Reset to 0', + + 'preview_title' => 'Live Preview', + 'preview_hint' => 'Updates instantly as you edit — nothing is saved until you click Save.', + 'flat_list_hint' => 'These settings are also editable from the flat generic Settings list.', + + 'save' => 'Save', + 'saved' => 'Landing page settings saved.', + 'save_error' => 'Failed to save landing page settings.', + + 'se_required' => 'This layout/preset requires Lychee SE.', +]; diff --git a/lang/zh_CN/landing_featured_item.php b/lang/zh_CN/landing_featured_item.php new file mode 100644 index 00000000000..3ee73cbc4cc --- /dev/null +++ b/lang/zh_CN/landing_featured_item.php @@ -0,0 +1,51 @@ + 'Featured Content', + 'description' => 'Curate specific photos and albums to feature on the landing page.', + + 'mode_automatic' => 'Automatic (latest published public albums)', + 'mode_manual' => 'Manual (curate specific photos/albums)', + + 'field_enabled' => 'Enable featured content', + 'field_mode' => 'Mode', + 'field_count' => 'Number of automatic items', + 'field_count_hint' => 'Between 3 and 12.', + + 'search_placeholder' => 'Search photos/albums by title…', + 'search_no_results' => 'No results.', + 'add' => 'Add', + + 'no_items' => 'No items curated yet.', + 'col_preview' => 'Preview', + 'col_title' => 'Title', + 'col_type' => 'Type', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + 'type_photo' => 'Photo', + 'type_album' => 'Album', + + 'confirm_delete_header' => 'Remove Featured Item', + 'confirm_delete_message' => 'Are you sure you want to remove ":title" from the featured content?', + + 'added' => 'Item added successfully.', + 'deleted' => 'Item removed successfully.', + 'reordered' => 'Featured items reordered successfully.', + 'error_load' => 'Failed to load featured items.', + 'error_search' => 'Failed to search.', + 'error_add' => 'Failed to add item.', + 'error_delete' => 'Failed to remove item.', + 'error_reorder' => 'Failed to reorder featured items.', +]; diff --git a/lang/zh_CN/landing_link.php b/lang/zh_CN/landing_link.php new file mode 100644 index 00000000000..cf37252cb5f --- /dev/null +++ b/lang/zh_CN/landing_link.php @@ -0,0 +1,73 @@ + 'Extra Links', + 'description' => 'Manage an arbitrary, ordered list of extra links shown on the nav and/or footer of the landing page.', + + // Empty state + 'no_links' => 'No extra links configured yet.', + 'create_first' => 'Create your first link', + + // Table columns + 'col_label' => 'Label', + 'col_url' => 'URL', + 'col_placement' => 'Placement', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + + // Placement labels + 'placement_nav' => 'Nav', + 'placement_footer' => 'Footer', + 'placement_both' => 'Nav & Footer', + + // Built-in links (Gallery, Contact) + 'badge_built_in' => 'Built-in', + 'built_in_cannot_delete' => 'This built-in link cannot be deleted. Disable it instead, or reorder it like any other link.', + 'built_in_url_hint' => 'This is a built-in link and its target cannot be changed.', + + // Buttons + 'create' => 'Create Link', + 'edit' => 'Edit', + 'delete' => 'Delete', + 'cancel' => 'Cancel', + 'save' => 'Save', + + // Form fields + 'field_label' => 'Label', + 'field_label_placeholder' => 'e.g. Instagram', + 'field_url' => 'URL', + 'field_url_placeholder' => 'https://example.com', + 'field_placement' => 'Placement', + 'field_open_in_new_tab' => 'Open in new tab', + 'field_enabled' => 'Enabled', + + // Modal titles + 'modal_create_title' => 'Create Link', + 'modal_edit_title' => 'Edit Link', + + // Delete confirmation + 'confirm_delete_header' => 'Delete Link', + 'confirm_delete_message' => 'Are you sure you want to delete the link ":label"? This action cannot be undone.', + + // Toasts + 'created' => 'Link created successfully.', + 'updated' => 'Link updated successfully.', + 'deleted' => 'Link deleted successfully.', + 'reordered' => 'Links reordered successfully.', + 'error_load' => 'Failed to load links.', + 'error_save' => 'Failed to save link.', + 'error_delete' => 'Failed to delete link.', + 'error_reorder' => 'Failed to reorder links.', +]; diff --git a/lang/zh_CN/watermark.php b/lang/zh_CN/watermark.php index 6860c862127..be6175d3e5b 100644 --- a/lang/zh_CN/watermark.php +++ b/lang/zh_CN/watermark.php @@ -57,6 +57,7 @@ 'up' => 'Up', 'down' => 'Down', ], + 'reset_to_zero' => 'Reset to 0', 'save' => 'Save Settings', 'saved' => 'Watermark settings saved.', diff --git a/lang/zh_TW/all_settings.php b/lang/zh_TW/all_settings.php index e84618f1e63..4f4eccf7d32 100644 --- a/lang/zh_TW/all_settings.php +++ b/lang/zh_TW/all_settings.php @@ -216,6 +216,18 @@ 'landing_background_portrait_mode' => 'Mode for portrait background', 'landing_background_landscape' => 'Value for landscape background (URL, photo ID, or album ID)', 'landing_background_portrait' => 'Value for portrait background (URL, photo ID, or album ID)', + 'landing_layout' => 'Landing page layout', + 'landing_intro_screen_enabled' => 'Enable the intro splash screen', + 'landing_hero_text_position' => 'Hero text position', + 'landing_hero_text_color' => 'Hero text color', + 'landing_hero_text_opacity' => 'Hero text opacity (%)', + 'landing_animation_preset' => 'Landing page animation preset', + 'landing_about_enabled' => 'Enable the about section', + 'landing_about_text' => 'About section text', + 'landing_featured_items_enabled' => 'Enable the featured content section', + 'landing_featured_items_mode' => 'Featured content mode', + 'landing_featured_items_count' => 'Number of automatic featured items', + 'landing_cta_text' => 'Call-to-action text override', 'metrics_enabled' => 'Enable statistics on photos & albums', 'metrics_logged_in_users_enabed' => 'Enable statistics for logged-in users', 'metrics_access' => 'Access level for statistics of the album/photo', @@ -613,6 +625,18 @@ 'landing_background_portrait_mode' => 'Options: static (URL), photo_id (specific photo), random (random public photo), latest_album_cover (latest album cover), random_from_album (random from album).', 'landing_background_landscape' => 'Depends on mode: URL for static, photo ID for photo_id, album ID for random_from_album. This image is also used when sharing the gallery link directly.', 'landing_background_portrait' => 'Depends on mode: URL for static, photo ID for photo_id, album ID for random_from_album.', + 'landing_layout' => 'Options: classic (default, free), portfolio/minimal/studio (require Lychee SE). Classic is unchanged and remains free forever.', + 'landing_intro_screen_enabled' => 'Applies to the classic and portfolio layouts. Disable to skip straight to the hero.', + 'landing_hero_text_position' => 'Options: top_left, top_right, bottom_left, bottom_right, center. Applies to the classic and portfolio layouts.', + 'landing_hero_text_color' => 'Leave empty to use the default white. Applies to the headline and subtitle text only, on every layout.', + 'landing_hero_text_opacity' => 'Range 0-100. Applies to the headline and subtitle text only, on every layout.', + 'landing_animation_preset' => 'Options: none/classic_fade (free), zoom_in/parallax_scroll/slide_reveal (require Lychee SE).', + 'landing_about_enabled' => 'Applies to the portfolio and minimal layouts.', + 'landing_about_text' => 'Admin-authored HTML, rendered verbatim (same trust model as the footer additional text).', + 'landing_featured_items_enabled' => 'Applies to the portfolio layout only. Requires Lychee SE to take effect.', + 'landing_featured_items_mode' => 'Automatic: most recently published public albums. Manual: admin-curated photos/albums.', + 'landing_featured_items_count' => 'Range 3-12. Only used in automatic featured content mode.', + 'landing_cta_text' => 'Leave empty to use each layout\'s default label.', 'metrics_enabled' => 'If enabled, anonymours users will be measured.', 'metrics_logged_in_users_enabed' => 'If enabled, logged-in users will be measured as well (admin users are not measured).', 'metrics_access' => '', diff --git a/lang/zh_TW/landing.php b/lang/zh_TW/landing.php index 0e3fe1700cb..614c5f576e4 100644 --- a/lang/zh_TW/landing.php +++ b/lang/zh_TW/landing.php @@ -10,4 +10,20 @@ 'access_gallery' => '存取相集', 'Powered_by_Lychee' => '由 Lychee 提供', 'copyright' => '本網站所有相片版權 %1$s © %2$s', + 'client_login' => 'Client Login', + 'view_public_gallery' => 'View public gallery', + 'contact' => 'Contact', + 'portfolio' => [ + 'about' => 'About', + 'featured' => 'Recent Work', + 'scroll_down' => 'Scroll down', + ], + 'meridian' => [ + 'explore_label' => 'Explore', + 'explore_caption' => 'View the gallery', + 'contact_caption' => 'Get in touch', + ], + 'studio' => [ + 'welcome_back' => 'Welcome back', + ], ]; diff --git a/lang/zh_TW/landing_config.php b/lang/zh_TW/landing_config.php new file mode 100644 index 00000000000..02f332b1335 --- /dev/null +++ b/lang/zh_TW/landing_config.php @@ -0,0 +1,103 @@ + 'Landing Page', + 'tab_settings' => 'Settings', + 'tab_links' => 'Links', + 'tab_featured' => 'Featured', + + 'section_layout' => 'Layout & Structure', + 'section_hero' => 'Hero', + 'section_background_landscape' => 'Background (Landscape)', + 'section_background_portrait' => 'Background (Portrait)', + 'section_cta_position' => 'Call-to-Action Position', + 'section_content' => 'Content', + + 'field_layout' => 'Layout', + 'field_intro_screen_enabled' => 'Intro splash screen', + 'field_hero_text_position' => 'Hero text position', + 'field_hero_text_color' => 'Hero text color', + 'field_hero_text_opacity' => 'Hero text opacity', + 'field_animation_preset' => 'Animation preset', + 'field_cta_text' => 'text', + 'field_cta_text_placeholder' => 'Leave empty for the layout default', + 'field_about_enabled' => 'About section', + 'field_about_text' => 'About text', + + 'field_background_mode' => 'Source', + 'background_mode_options' => [ + 'static' => 'URL', + 'photo_id' => 'Photo ID', + 'random' => 'Random public photo', + 'latest_album_cover' => 'Latest album cover', + 'random_from_album' => 'Random photo from album', + ], + 'field_background_url' => 'Image URL', + 'field_background_url_placeholder' => 'https://…', + 'field_background_photo_id' => 'Photo ID', + 'field_background_photo_id_placeholder' => '24-character photo ID', + 'field_background_photo_id_hint' => 'Photo ID of the image to use. Open a photo and copy the last 24 characters from the URL.', + 'field_background_album_id' => 'Album ID', + 'field_background_album_id_placeholder' => '24-character album ID', + 'background_load_error' => 'Could not load photo. Make sure the ID is correct and you have access to it.', + 'background_mode_hint' => [ + 'random' => 'A random public photo is picked on every page load.', + 'latest_album_cover' => 'The cover of the most recently published public album is used.', + 'random_from_album' => 'A random photo from this album is picked on every page load. The preview only updates after saving.', + ], + + 'preview_orientation_landscape' => 'Preview landscape background', + 'preview_orientation_portrait' => 'Preview portrait background', + + 'field_cta_position' => 'Position', + 'cta_position_options' => [ + 'top-left' => 'Top Left', + 'top' => 'Top Center', + 'top-right' => 'Top Right', + 'left' => 'Middle Left', + 'center' => 'Center', + 'right' => 'Middle Right', + 'bottom-left' => 'Bottom Left', + 'bottom' => 'Bottom Center', + 'bottom-right' => 'Bottom Right', + ], + 'field_cta_shift_type' => 'Shift unit', + 'cta_shift_type_options' => [ + 'relative' => 'Relative (%)', + 'absolute' => 'Absolute (px)', + ], + 'cta_shift_type_hint' => 'Relative shifts are a percentage of the viewport size; absolute shifts are a fixed number of pixels.', + 'field_cta_shift_x' => 'Horizontal Shift (:value)', + 'cta_shift_x_direction_options' => [ + 'left' => 'Left', + 'right' => 'Right', + ], + 'field_cta_shift_y' => 'Vertical Shift (:value)', + 'cta_shift_y_direction_options' => [ + 'up' => 'Up', + 'down' => 'Down', + ], + 'reset_to_zero' => 'Reset to 0', + + 'preview_title' => 'Live Preview', + 'preview_hint' => 'Updates instantly as you edit — nothing is saved until you click Save.', + 'flat_list_hint' => 'These settings are also editable from the flat generic Settings list.', + + 'save' => 'Save', + 'saved' => 'Landing page settings saved.', + 'save_error' => 'Failed to save landing page settings.', + + 'se_required' => 'This layout/preset requires Lychee SE.', +]; diff --git a/lang/zh_TW/landing_featured_item.php b/lang/zh_TW/landing_featured_item.php new file mode 100644 index 00000000000..3ee73cbc4cc --- /dev/null +++ b/lang/zh_TW/landing_featured_item.php @@ -0,0 +1,51 @@ + 'Featured Content', + 'description' => 'Curate specific photos and albums to feature on the landing page.', + + 'mode_automatic' => 'Automatic (latest published public albums)', + 'mode_manual' => 'Manual (curate specific photos/albums)', + + 'field_enabled' => 'Enable featured content', + 'field_mode' => 'Mode', + 'field_count' => 'Number of automatic items', + 'field_count_hint' => 'Between 3 and 12.', + + 'search_placeholder' => 'Search photos/albums by title…', + 'search_no_results' => 'No results.', + 'add' => 'Add', + + 'no_items' => 'No items curated yet.', + 'col_preview' => 'Preview', + 'col_title' => 'Title', + 'col_type' => 'Type', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + 'type_photo' => 'Photo', + 'type_album' => 'Album', + + 'confirm_delete_header' => 'Remove Featured Item', + 'confirm_delete_message' => 'Are you sure you want to remove ":title" from the featured content?', + + 'added' => 'Item added successfully.', + 'deleted' => 'Item removed successfully.', + 'reordered' => 'Featured items reordered successfully.', + 'error_load' => 'Failed to load featured items.', + 'error_search' => 'Failed to search.', + 'error_add' => 'Failed to add item.', + 'error_delete' => 'Failed to remove item.', + 'error_reorder' => 'Failed to reorder featured items.', +]; diff --git a/lang/zh_TW/landing_link.php b/lang/zh_TW/landing_link.php new file mode 100644 index 00000000000..cf37252cb5f --- /dev/null +++ b/lang/zh_TW/landing_link.php @@ -0,0 +1,73 @@ + 'Extra Links', + 'description' => 'Manage an arbitrary, ordered list of extra links shown on the nav and/or footer of the landing page.', + + // Empty state + 'no_links' => 'No extra links configured yet.', + 'create_first' => 'Create your first link', + + // Table columns + 'col_label' => 'Label', + 'col_url' => 'URL', + 'col_placement' => 'Placement', + 'col_enabled' => 'Enabled', + 'col_actions' => 'Actions', + + // Placement labels + 'placement_nav' => 'Nav', + 'placement_footer' => 'Footer', + 'placement_both' => 'Nav & Footer', + + // Built-in links (Gallery, Contact) + 'badge_built_in' => 'Built-in', + 'built_in_cannot_delete' => 'This built-in link cannot be deleted. Disable it instead, or reorder it like any other link.', + 'built_in_url_hint' => 'This is a built-in link and its target cannot be changed.', + + // Buttons + 'create' => 'Create Link', + 'edit' => 'Edit', + 'delete' => 'Delete', + 'cancel' => 'Cancel', + 'save' => 'Save', + + // Form fields + 'field_label' => 'Label', + 'field_label_placeholder' => 'e.g. Instagram', + 'field_url' => 'URL', + 'field_url_placeholder' => 'https://example.com', + 'field_placement' => 'Placement', + 'field_open_in_new_tab' => 'Open in new tab', + 'field_enabled' => 'Enabled', + + // Modal titles + 'modal_create_title' => 'Create Link', + 'modal_edit_title' => 'Edit Link', + + // Delete confirmation + 'confirm_delete_header' => 'Delete Link', + 'confirm_delete_message' => 'Are you sure you want to delete the link ":label"? This action cannot be undone.', + + // Toasts + 'created' => 'Link created successfully.', + 'updated' => 'Link updated successfully.', + 'deleted' => 'Link deleted successfully.', + 'reordered' => 'Links reordered successfully.', + 'error_load' => 'Failed to load links.', + 'error_save' => 'Failed to save link.', + 'error_delete' => 'Failed to delete link.', + 'error_reorder' => 'Failed to reorder links.', +]; diff --git a/lang/zh_TW/watermark.php b/lang/zh_TW/watermark.php index 6860c862127..be6175d3e5b 100644 --- a/lang/zh_TW/watermark.php +++ b/lang/zh_TW/watermark.php @@ -57,6 +57,7 @@ 'up' => 'Up', 'down' => 'Down', ], + 'reset_to_zero' => 'Reset to 0', 'save' => 'Save Settings', 'saved' => 'Watermark settings saved.', diff --git a/package-lock.json b/package-lock.json index 23bc4b63b6e..54f80c34a38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "leaflet.markercluster": "^1.5.3", "pinia": "^4.0.2", "pinia-plugin-persistedstate": "^4.7.1", + "playwright": "^1.62.1", "primeicons": "^8.0.0", "primevue": "^4.0.0", "qrcode": "^1.5.3", @@ -1716,6 +1717,7 @@ "version": "2.6.0", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -1754,6 +1756,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1774,6 +1777,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1794,6 +1798,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1814,6 +1819,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1834,6 +1840,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1854,6 +1861,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1874,6 +1882,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1894,6 +1903,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1914,6 +1924,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1934,6 +1945,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1954,6 +1966,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1974,6 +1987,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2243,9 +2257,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2262,9 +2273,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2281,9 +2289,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2300,9 +2305,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2319,9 +2321,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2338,9 +2337,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7277,6 +7273,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, "license": "MIT", "optional": true }, @@ -7706,6 +7703,50 @@ "pathe": "^2.0.3" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/pngjs": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", @@ -9556,9 +9597,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9579,9 +9617,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9602,9 +9637,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/package.json b/package.json index bc0b36588b3..0ad87adfce5 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "leaflet.markercluster": "^1.5.3", "pinia": "^4.0.2", "pinia-plugin-persistedstate": "^4.7.1", + "playwright": "^1.62.1", "primeicons": "^8.0.0", "primevue": "^4.0.0", "qrcode": "^1.5.3", diff --git a/resources/js/lychee.d.ts b/resources/js/lychee.d.ts index 38826541ae7..365f04c2042 100644 --- a/resources/js/lychee.d.ts +++ b/resources/js/lychee.d.ts @@ -103,7 +103,14 @@ declare namespace App { export type FlowStrategy = "auto" | "opt-in"; export type ImageOverlayType = "none" | "desc" | "date" | "exif"; export type JobStatus = 0 | 1 | 2 | 3; + export type LandingAnimationPreset = "none" | "classic_fade" | "zoom_in" | "parallax_scroll" | "slide_reveal"; export type LandingBackgroundModeType = "static" | "photo_id" | "random" | "latest_album_cover" | "random_from_album"; + export type LandingCtaPosition = "top-left" | "top" | "top-right" | "left" | "center" | "right" | "bottom-left" | "bottom" | "bottom-right"; + export type LandingFeaturedItemType = "photo" | "album"; + export type LandingFeaturedItemsMode = "automatic" | "manual"; + export type LandingLayoutType = "classic" | "portfolio" | "meridian" | "studio"; + export type LandingLinkPlacement = "nav" | "footer" | "both"; + export type LandingTextPosition = "top_left" | "top_right" | "bottom_left" | "bottom_right" | "center"; export type LicenseType = | "none" | "reserved" @@ -288,6 +295,12 @@ declare namespace App { per_page: number; total: number; }; + export type LandingFeaturedItemCollection = { + landing_featured_items: App.Http.Resources.Models.LandingFeaturedItemResource[]; + }; + export type LandingLinkCollection = { + landing_links: App.Http.Resources.Models.LandingLinkResource[]; + }; export type PaginatedAlbumsResource = { data: App.Http.Resources.Models.ThumbAlbumResource[]; current_page: number; @@ -663,6 +676,22 @@ declare namespace App { is_password_flag_enabled: boolean; is_sensitive_flag_enabled: boolean; }; + export type LandingFeaturedContentResource = { + item_type: App.Enum.LandingFeaturedItemType; + id: string; + title: string; + thumb_url: string; + url: string; + num_photos: number | null; + }; + export type LandingLinkEmbedResource = { + id: string; + label: string; + url: string; + placement: App.Enum.LandingLinkPlacement; + open_in_new_tab: boolean; + is_built_in: boolean; + }; export type LandingPageResource = { landing_page_enable: boolean; landing_background_landscape: string; @@ -674,6 +703,25 @@ declare namespace App { landing_logo: string; landing_header_logo: string; footer: App.Http.Resources.GalleryConfigs.FooterConfig; + layout: App.Enum.LandingLayoutType; + intro_screen_enabled: boolean; + hero_text_position: App.Enum.LandingTextPosition; + hero_text_color: string; + hero_text_opacity: number; + animation_preset: App.Enum.LandingAnimationPreset; + about_enabled: boolean; + about_text: string; + featured_items_enabled: boolean; + featured_items_mode: App.Enum.LandingFeaturedItemsMode; + featured_items: App.Http.Resources.GalleryConfigs.LandingFeaturedContentResource[]; + links: App.Http.Resources.GalleryConfigs.LandingLinkEmbedResource[]; + cta_text: string; + cta_position: App.Enum.LandingCtaPosition; + cta_shift_type: App.Enum.ShiftType; + cta_shift_x: number; + cta_shift_x_direction: App.Enum.ShiftX; + cta_shift_y: number; + cta_shift_y_direction: App.Enum.ShiftY; }; export type MapProviderData = { layer: string; @@ -930,6 +978,27 @@ declare namespace App { updated_at: string; job: string; }; + export type LandingFeaturedItemResource = { + id: string; + item_type: App.Enum.LandingFeaturedItemType; + item_id: string; + sort_order: number; + enabled: boolean; + created_at: string; + updated_at: string; + }; + export type LandingLinkResource = { + id: string; + label: string; + url: string; + placement: App.Enum.LandingLinkPlacement; + open_in_new_tab: boolean; + sort_order: number; + enabled: boolean; + is_built_in: boolean; + created_at: string; + updated_at: string; + }; export type LightUserResource = { id: number; username: string; @@ -1300,6 +1369,7 @@ declare namespace App { is_mod_frame_enabled: boolean; is_mod_flow_enabled: boolean; is_watermarker_enabled: boolean; + is_watermarker_available: boolean; is_photo_timeline_enabled: boolean; is_mod_renamer_enabled: boolean; is_mod_webshop_enabled: boolean; diff --git a/resources/js/router/paths.ts b/resources/js/router/paths.ts index c381db590fd..48c3fe3e208 100644 --- a/resources/js/router/paths.ts +++ b/resources/js/router/paths.ts @@ -113,6 +113,10 @@ export const paths: RoutePath[] = [ name: "watermark-preview", path: "/admin/watermark", }, + { + name: "landing-config", + path: "/admin/landing-config", + }, { name: "tree", path: "/fixTree", diff --git a/resources/js/services/landing-featured-item-service.ts b/resources/js/services/landing-featured-item-service.ts new file mode 100644 index 00000000000..4fdee34358f --- /dev/null +++ b/resources/js/services/landing-featured-item-service.ts @@ -0,0 +1,43 @@ +/** + * SPDX-License-Identifier: MIT + * Copyright (c) 2017-2018 Tobias Reich + * Copyright (c) 2018-2026 LycheeOrg. + */ + +import axios, { type AxiosResponse } from "axios"; +import Constants from "./constants"; + +export type CreateLandingFeaturedItemRequest = { + item_type: App.Enum.LandingFeaturedItemType; + item_id: string; + sort_order?: number; + enabled?: boolean; +}; + +export type PatchLandingFeaturedItemRequest = Partial & { + landing_featured_item_id: string; +}; + +const LandingFeaturedItemService = { + list(): Promise> { + return axios.get(`${Constants.getApiUrl()}LandingFeaturedItem`, { data: {} }); + }, + + create(data: CreateLandingFeaturedItemRequest): Promise> { + return axios.post(`${Constants.getApiUrl()}LandingFeaturedItem`, data); + }, + + patch(id: string, data: PatchLandingFeaturedItemRequest): Promise> { + return axios.patch(`${Constants.getApiUrl()}LandingFeaturedItem/${id}`, data); + }, + + delete(id: string): Promise> { + return axios.delete(`${Constants.getApiUrl()}LandingFeaturedItem/${id}`, { data: {} }); + }, + + reorder(ids: string[]): Promise> { + return axios.patch(`${Constants.getApiUrl()}LandingFeaturedItem/Reorder`, { ids }); + }, +}; + +export default LandingFeaturedItemService; diff --git a/resources/js/services/landing-link-service.ts b/resources/js/services/landing-link-service.ts new file mode 100644 index 00000000000..9ef19a52b84 --- /dev/null +++ b/resources/js/services/landing-link-service.ts @@ -0,0 +1,53 @@ +/** + * SPDX-License-Identifier: MIT + * Copyright (c) 2017-2018 Tobias Reich + * Copyright (c) 2018-2026 LycheeOrg. + */ + +import axios, { type AxiosResponse } from "axios"; +import Constants from "./constants"; + +export type CreateLandingLinkRequest = { + label: string; + url: string; + placement: App.Enum.LandingLinkPlacement; + open_in_new_tab: boolean; + sort_order: number; + enabled: boolean; +}; + +export type UpdateLandingLinkRequest = CreateLandingLinkRequest & { + landing_link_id: string; +}; + +export type PatchLandingLinkRequest = Partial & { + landing_link_id: string; +}; + +const LandingLinkService = { + list(): Promise> { + return axios.get(`${Constants.getApiUrl()}LandingLink`, { data: {} }); + }, + + create(data: CreateLandingLinkRequest): Promise> { + return axios.post(`${Constants.getApiUrl()}LandingLink`, data); + }, + + update(id: string, data: UpdateLandingLinkRequest): Promise> { + return axios.put(`${Constants.getApiUrl()}LandingLink/${id}`, data); + }, + + patch(id: string, data: PatchLandingLinkRequest): Promise> { + return axios.patch(`${Constants.getApiUrl()}LandingLink/${id}`, data); + }, + + delete(id: string): Promise> { + return axios.delete(`${Constants.getApiUrl()}LandingLink/${id}`, { data: {} }); + }, + + reorder(ids: string[]): Promise> { + return axios.patch(`${Constants.getApiUrl()}LandingLink/Reorder`, { ids }); + }, +}; + +export default LandingLinkService; diff --git a/resources/js/v8/components/footers/LandingFooter.vue b/resources/js/v8/components/footers/LandingFooter.vue index 7c1c4445572..881a815f70c 100644 --- a/resources/js/v8/components/footers/LandingFooter.vue +++ b/resources/js/v8/components/footers/LandingFooter.vue @@ -1,6 +1,10 @@ diff --git a/resources/js/v8/components/forms/landing/LandingLinkFormDialog.vue b/resources/js/v8/components/forms/landing/LandingLinkFormDialog.vue new file mode 100644 index 00000000000..4a28b85d778 --- /dev/null +++ b/resources/js/v8/components/forms/landing/LandingLinkFormDialog.vue @@ -0,0 +1,152 @@ + + + diff --git a/resources/js/v8/components/landing/LandingBackgroundField.vue b/resources/js/v8/components/landing/LandingBackgroundField.vue new file mode 100644 index 00000000000..98c27c6441b --- /dev/null +++ b/resources/js/v8/components/landing/LandingBackgroundField.vue @@ -0,0 +1,127 @@ + + diff --git a/resources/js/v8/components/landing/LandingIntroScreen.vue b/resources/js/v8/components/landing/LandingIntroScreen.vue new file mode 100644 index 00000000000..fef65c51aba --- /dev/null +++ b/resources/js/v8/components/landing/LandingIntroScreen.vue @@ -0,0 +1,53 @@ + + diff --git a/resources/js/v8/components/settings/ConfigGroup.vue b/resources/js/v8/components/settings/ConfigGroup.vue index df66a18ed86..077cb0dad5a 100644 --- a/resources/js/v8/components/settings/ConfigGroup.vue +++ b/resources/js/v8/components/settings/ConfigGroup.vue @@ -161,6 +161,14 @@ +

{{ config.key }} -- {{ config.value }} -- {{ config.documentation }} -- {{ config.type }} @@ -211,6 +219,14 @@ const emits = defineEmits<{ reset: [key: string]; }>(); +function intRangeMin(type: string): number { + return Number(type.split(":")[1] ?? 0); +} + +function intRangeMax(type: string): number { + return Number(type.split(":")[2] ?? undefined); +} + function reset(configKey: string) { emits("reset", configKey); } diff --git a/resources/js/v8/composables/landing/useLandingAnimation.ts b/resources/js/v8/composables/landing/useLandingAnimation.ts new file mode 100644 index 00000000000..ef99d027dbb --- /dev/null +++ b/resources/js/v8/composables/landing/useLandingAnimation.ts @@ -0,0 +1,29 @@ +/** + * SPDX-License-Identifier: MIT + * Copyright (c) 2017-2018 Tobias Reich + * Copyright (c) 2018-2026 LycheeOrg. + */ + +import { computed, type ComputedRef, type Ref } from "vue"; + +/** + * Single choke point for `prefers-reduced-motion`: whatever preset the + * server resolved, a reduced-motion browser always gets `none` (NFR-054-04 / + * WCAG 2.3.3). Every landing layout must read the animation preset through + * this composable, never the raw prop, so this guarantee cannot be bypassed. + */ +function prefersReducedMotion(): boolean { + return typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; +} + +export function useLandingAnimation(data: Ref): { + effectivePreset: ComputedRef; + isReducedMotion: ComputedRef; + introDelayClass: ComputedRef; +} { + const isReducedMotion = computed(() => prefersReducedMotion()); + const effectivePreset = computed(() => (isReducedMotion.value ? "none" : data.value.animation_preset)); + const introDelayClass = computed(() => (data.value.intro_screen_enabled ? "" : "no-intro-delay")); + + return { effectivePreset, isReducedMotion, introDelayClass }; +} diff --git a/resources/js/v8/composables/landing/useLandingBackgroundOrientation.ts b/resources/js/v8/composables/landing/useLandingBackgroundOrientation.ts new file mode 100644 index 00000000000..e291e5808ff --- /dev/null +++ b/resources/js/v8/composables/landing/useLandingBackgroundOrientation.ts @@ -0,0 +1,39 @@ +/** + * SPDX-License-Identifier: MIT + * Copyright (c) 2017-2018 Tobias Reich + * Copyright (c) 2018-2026 LycheeOrg. + */ + +import { computed, type ComputedRef, type Ref } from "vue"; + +export type LandingPreviewOrientation = "landscape" | "portrait"; + +/** + * On the real public page, which of the landscape/portrait background images + * shows is driven purely by the visitor's actual viewport orientation (the + * `portrait:`/`landscape:` Tailwind variants, i.e. `@media (orientation: …)`). + * The admin settings preview embeds these layouts in a fixed-size box that + * never itself matches `(orientation: portrait)` on a desktop browser, so + * `previewOrientation` lets LandingConfig.vue force one or the other for + * visualization purposes only — real visitors are unaffected since the prop + * is left undefined outside the preview. + */ +export function useLandingBackgroundOrientation(previewOrientation: Ref): { + landscapeImageClass: ComputedRef; + portraitImageClass: ComputedRef; +} { + const landscapeImageClass = computed(() => { + if (!previewOrientation.value) { + return "portrait:hidden"; + } + return previewOrientation.value === "portrait" ? "hidden" : ""; + }); + const portraitImageClass = computed(() => { + if (!previewOrientation.value) { + return "landscape:hidden"; + } + return previewOrientation.value === "landscape" ? "hidden" : ""; + }); + + return { landscapeImageClass, portraitImageClass }; +} diff --git a/resources/js/v8/composables/landing/useLandingCtaPosition.ts b/resources/js/v8/composables/landing/useLandingCtaPosition.ts new file mode 100644 index 00000000000..0798fd94a48 --- /dev/null +++ b/resources/js/v8/composables/landing/useLandingCtaPosition.ts @@ -0,0 +1,52 @@ +/** + * SPDX-License-Identifier: MIT + * Copyright (c) 2017-2018 Tobias Reich + * Copyright (c) 2018-2026 LycheeOrg. + */ + +import { computed, type ComputedRef, type Ref } from "vue"; + +/** + * Anchor + shift positioning for the landing page CTA button, mirroring + * WatermarkPreview.vue's `watermarkStyle` (same clamp/sign conventions, + * same relative-%/absolute-px shift semantics) so the two features stay + * mentally consistent for anyone who's configured a watermark before. + */ +export function useLandingCtaPosition(data: Ref): { + ctaStyle: ComputedRef; +} { + const shiftXCss = computed(() => { + const signed = (data.value.cta_shift_x_direction === "left" ? -1 : 1) * data.value.cta_shift_x; + return data.value.cta_shift_type === "relative" ? `${signed}%` : `${signed}px`; + }); + const shiftYCss = computed(() => { + const signed = (data.value.cta_shift_y_direction === "up" ? -1 : 1) * data.value.cta_shift_y; + return data.value.cta_shift_type === "relative" ? `${signed}%` : `${signed}px`; + }); + + // left/right/top/bottom are additive with the configured shift; right/bottom are inverted + // since increasing "right"/"bottom" moves the element towards the center, not away from it. + // Each is wrapped in clamp(0%, ..., 100%) so a large shift can't push the CTA's anchor edge + // past the viewport. + const ctaStyle = computed(() => { + const pos = data.value.cta_position; + const sx = shiftXCss.value; + const sy = shiftYCss.value; + + const positionMap: Record = { + "top-left": `top: clamp(0%, calc(0% + ${sy}), 100%); left: clamp(0%, calc(0% + ${sx}), 100%);`, + top: `top: clamp(0%, calc(0% + ${sy}), 100%); left: clamp(0%, calc(50% + ${sx}), 100%); transform: translateX(-50%);`, + "top-right": `top: clamp(0%, calc(0% + ${sy}), 100%); right: clamp(0%, calc(0% - ${sx}), 100%);`, + left: `top: clamp(0%, calc(50% + ${sy}), 100%); left: clamp(0%, calc(0% + ${sx}), 100%); transform: translateY(-50%);`, + center: `top: clamp(0%, calc(50% + ${sy}), 100%); left: clamp(0%, calc(50% + ${sx}), 100%); transform: translate(-50%, -50%);`, + right: `top: clamp(0%, calc(50% + ${sy}), 100%); right: clamp(0%, calc(0% - ${sx}), 100%); transform: translateY(-50%);`, + "bottom-left": `bottom: clamp(0%, calc(0% - ${sy}), 100%); left: clamp(0%, calc(0% + ${sx}), 100%);`, + bottom: `bottom: clamp(0%, calc(0% - ${sy}), 100%); left: clamp(0%, calc(50% + ${sx}), 100%); transform: translateX(-50%);`, + "bottom-right": `bottom: clamp(0%, calc(0% - ${sy}), 100%); right: clamp(0%, calc(0% - ${sx}), 100%);`, + }; + + return `position: absolute; ${positionMap[pos]}`; + }); + + return { ctaStyle }; +} diff --git a/resources/js/v8/composables/landing/useLandingTextPosition.ts b/resources/js/v8/composables/landing/useLandingTextPosition.ts new file mode 100644 index 00000000000..8ebdc3b59ad --- /dev/null +++ b/resources/js/v8/composables/landing/useLandingTextPosition.ts @@ -0,0 +1,29 @@ +/** + * SPDX-License-Identifier: MIT + * Copyright (c) 2017-2018 Tobias Reich + * Copyright (c) 2018-2026 LycheeOrg. + */ + +import { computed, type ComputedRef, type Ref } from "vue"; + +/** + * 5-value Tailwind class map for the landing page hero text position, + * landing-scoped (deliberately not shared with AlbumHeaderPanel.vue's + * equivalent map: albums and the landing page are different bounded + * contexts, see Feature 054 spec Design Notes). + */ +const POSITION_CLASSES: Record = { + top_left: "items-start justify-start text-left pt-24 pl-10 md:pl-20", + top_right: "items-start justify-end text-right pt-24 pr-10 md:pr-20", + bottom_left: "items-end justify-start text-left pb-24 pl-10 md:pl-20", + bottom_right: "items-end justify-end text-right pb-24 pr-10 md:pr-20", + center: "items-center justify-center text-center", +}; + +export function useLandingTextPosition(data: Ref): { + positionClasses: ComputedRef; +} { + const positionClasses = computed(() => POSITION_CLASSES[data.value.hero_text_position] ?? POSITION_CLASSES.center); + + return { positionClasses }; +} diff --git a/resources/js/v8/composables/useAdminTiles.ts b/resources/js/v8/composables/useAdminTiles.ts index f65a02f82d5..ea542384514 100644 --- a/resources/js/v8/composables/useAdminTiles.ts +++ b/resources/js/v8/composables/useAdminTiles.ts @@ -146,9 +146,18 @@ export function useAdminTiles(lycheeStore: LycheeStateStore, leftMenuStore: Left () => (initData.value?.settings.can_edit ?? false) && (is_se_enabled.value || is_se_preview_enabled.value) && - (initData.value?.modules.is_watermarker_enabled ?? false), + (initData.value?.modules.is_watermarker_available ?? false), ), }, + { + key: "landing-config", + group: "extensions", + label: "landing_config.title", + icon: "lucide:layout-template", + to: "/admin/landing-config", + isExternal: false, + visible: computed(() => initData.value?.settings.can_edit ?? false), + }, { key: "bulk-album-edit", group: "core", diff --git a/resources/js/v8/composables/useScrollReveal.ts b/resources/js/v8/composables/useScrollReveal.ts new file mode 100644 index 00000000000..f5105df17e4 --- /dev/null +++ b/resources/js/v8/composables/useScrollReveal.ts @@ -0,0 +1,45 @@ +/** + * SPDX-License-Identifier: MIT + * Copyright (c) 2017-2018 Tobias Reich + * Copyright (c) 2018-2026 LycheeOrg. + */ + +import { onBeforeUnmount, onMounted, ref, type Ref } from "vue"; + +/** + * IntersectionObserver-driven section reveal, used by the `parallax_scroll` + * animation preset (the only preset that reveals per-section on scroll + * rather than once on mount - see Feature 054 T-054-35). + */ +export function useScrollReveal(active: Ref): { + el: Ref; + isVisible: Ref; +} { + const el = ref(null); + const isVisible = ref(false); + let observer: IntersectionObserver | undefined; + + onMounted(() => { + if (!active.value || typeof IntersectionObserver === "undefined" || el.value === null) { + isVisible.value = true; + return; + } + + observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + isVisible.value = true; + observer?.disconnect(); + } + }); + }, + { threshold: 0.15 }, + ); + observer.observe(el.value); + }); + + onBeforeUnmount(() => observer?.disconnect()); + + return { el, isVisible }; +} diff --git a/resources/js/v8/router/routes.ts b/resources/js/v8/router/routes.ts index aa064621af2..be4690729cc 100644 --- a/resources/js/v8/router/routes.ts +++ b/resources/js/v8/router/routes.ts @@ -16,6 +16,7 @@ const FaceMaintenance = () => import("@/v8/views/face-recog/FaceMaintenance.vue" const NsfwConfig = () => import("@/v8/views/admin/NsfwConfig.vue"); const DesignSystem = () => import("@/v8/views/admin/DesignSystem.vue"); const WatermarkPreview = () => import("@/v8/views/admin/WatermarkPreview.vue"); +const LandingConfig = () => import("@/v8/views/admin/LandingConfig.vue"); const Settings = () => import("@/v8/views/admin/Settings.vue"); const Sharing = () => import("@/v8/views/Sharing.vue"); const Users = () => import("@/v8/views/admin/Users.vue"); @@ -74,6 +75,7 @@ const componentByName: Record = { "nsfw-config": NsfwConfig, "design-system": DesignSystem, "watermark-preview": WatermarkPreview, + "landing-config": LandingConfig, settings: Settings, sharing: Sharing, users: Users, diff --git a/resources/js/v8/views/Error.vue b/resources/js/v8/views/Error.vue index 27bd97c0cb4..8e63a8ff57a 100644 --- a/resources/js/v8/views/Error.vue +++ b/resources/js/v8/views/Error.vue @@ -6,6 +6,7 @@ color="error" :title="lycheeError.exception ? `${lycheeError.exception} in ${lycheeError.file}:${lycheeError.line}` : lycheeError.message" @click="closeError" + class="rounded-none" />