From 74dd3475e659710f4d23ea42e4d6bf29ec9ab2d3 Mon Sep 17 00:00:00 2001 From: "Sebastian BURGIN-FIX (ext)" Date: Thu, 23 Jul 2026 09:05:07 +0200 Subject: [PATCH 1/5] wip --- resources/views/app/contact/index.blade.php | 2 +- resources/views/app/legal/imprint/index.blade.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/views/app/contact/index.blade.php b/resources/views/app/contact/index.blade.php index 0a14373..93a22fe 100644 --- a/resources/views/app/contact/index.blade.php +++ b/resources/views/app/contact/index.blade.php @@ -1,7 +1,7 @@ - +
diff --git a/resources/views/app/legal/imprint/index.blade.php b/resources/views/app/legal/imprint/index.blade.php index e15eb86..3e5aa15 100644 --- a/resources/views/app/legal/imprint/index.blade.php +++ b/resources/views/app/legal/imprint/index.blade.php @@ -9,7 +9,7 @@ link-label="{{ __('Zefix') }}"/> - +
From faabaca85f3f443eb7ccae8cbd1cd28d3e8a1683 Mon Sep 17 00:00:00 2001 From: "Sebastian BURGIN-FIX (ext)" Date: Tue, 28 Jul 2026 14:41:39 +0200 Subject: [PATCH 2/5] wip --- .env.ci | 4 +- .env.example | 6 +- app/Actions/LlmUsageStatsAction.php | 31 ++ .../AboutUs/AboutUsIndexController.php | 43 ++- .../Ai/AiLlmAnalyticsIndexController.php | 2 + .../Contact/ContactIndexController.php | 21 +- .../Entry/EntryIndexController.php | 12 +- .../Network/NetworkShowController.php | 6 +- .../Controllers/News/NewsIndexController.php | 25 +- .../Controllers/News/NewsShowController.php | 33 +- .../OpenSource/OpenSoruceShowController.php | 34 +- .../OpenSource/OpenSourceIndexController.php | 19 +- .../Controllers/Robots/RobotsController.php | 33 +- .../Services/ServicesIndexController.php | 5 +- .../Controllers/Sitemap/SitemapController.php | 75 ++++- app/Http/Middleware/SecurityHeaders.php | 7 + app/Models/OpenSource.php | 13 + app/Observers/SitemapCacheObserver.php | 31 ++ app/Providers/AppServiceProvider.php | 7 + app/Seo/Company.php | 116 +++++++ app/Seo/SchemaGraph.php | 308 ++++++++++++++++++ app/Seo/SchemaNodes.php | 244 ++++++++++++++ app/Sitemap/SitemapBuilder.php | 46 ++- app/Support/helpers.php | 39 +++ app/View/Components/AppLayout.php | 7 + composer.lock | 86 ++--- config/app.php | 7 + config/company.php | 109 +++++++ database/seeders/DatabaseSeeder.php | 3 + database/seeders/NewsTableSeeder.php | 36 +- database/seeders/PagesTableSeeder.php | 12 +- database/seeders/data/pages.csv | 8 +- lang/de_CH/components.php | 1 + lang/en_CH.json | 2 +- lang/en_CH/components.php | 1 + package-lock.json | 230 ++++++------- resources/views/app/about-us/index.blade.php | 2 +- .../views/app/ai/llm/analytics.blade.php | 11 + resources/views/app/contact/index.blade.php | 31 +- .../views/app/legal/imprint/index.blade.php | 9 +- resources/views/app/news/index.blade.php | 2 +- resources/views/app/news/show.blade.php | 2 +- .../views/app/open-source/index.blade.php | 9 +- .../views/app/open-source/show.blade.php | 14 +- resources/views/app/services/index.blade.php | 2 +- .../layouts/_partials/_navigation.blade.php | 19 +- .../_partials/_navigation_desktop.blade.php | 6 +- .../_partials/_navigation_mobile.blade.php | 32 +- .../views/layouts/_partials/_schema.blade.php | 18 + .../views/layouts/_partials/_seo.blade.php | 17 +- resources/views/layouts/app.blade.php | 1 + routes/web.php | 6 +- .../AiLlmAnalyticsIndexControllerTest.php | 8 + .../Controllers/RobotsControllerTest.php | 24 +- tests/Feature/Controllers/RouteStatusTest.php | 12 +- tests/Feature/Controllers/ShowPagesTest.php | 17 +- .../Controllers/SitemapControllerTest.php | 19 +- tests/Feature/Seo/MetaTagsTest.php | 128 ++++++++ tests/Feature/Seo/StructuredDataTest.php | 237 ++++++++++++++ tests/lighthouse/README.md | 21 +- tests/lighthouse/pages.json | 21 +- todos/refactor.md | 260 ++++++++++++--- 62 files changed, 2147 insertions(+), 443 deletions(-) create mode 100644 app/Observers/SitemapCacheObserver.php create mode 100644 app/Seo/Company.php create mode 100644 app/Seo/SchemaGraph.php create mode 100644 app/Seo/SchemaNodes.php create mode 100644 config/company.php create mode 100644 resources/views/layouts/_partials/_schema.blade.php create mode 100644 tests/Feature/Seo/MetaTagsTest.php create mode 100644 tests/Feature/Seo/StructuredDataTest.php diff --git a/.env.ci b/.env.ci index 1573ca2..cb382e7 100644 --- a/.env.ci +++ b/.env.ci @@ -1,9 +1,9 @@ -APP_NAME="Laravel Template" +APP_NAME="codebar Solutions AG" APP_ENV=local APP_KEY= APP_DEBUG=true APP_TIMEZONE=UTC -APP_URL=https://laravel-template.test +APP_URL=https://web.codebar.test NOVA_LICENSE_KEY= diff --git a/.env.example b/.env.example index a1a445c..e1a867b 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,8 @@ -APP_NAME="Laravel Template" +APP_NAME="codebar Solutions AG" APP_ENV=local APP_KEY=base64:F+mHMDBbavrsp/I3WYA5lDSwDJJI/0wQG4eM3csq/lo= APP_DEBUG=true -APP_URL=https://laravel-template-l11.test +APP_URL=https://web.codebar.test NOVA_LICENSE_KEY= @@ -27,7 +27,7 @@ SESSION_DRIVER=database SESSION_LIFETIME=120 SESSION_ENCRYPT=false SESSION_PATH=/ -SESSION_DOMAIN=laravel-template-l11.test +SESSION_DOMAIN=web.codebar.test BROADCAST_CONNECTION=log FILESYSTEM_DISK=local diff --git a/app/Actions/LlmUsageStatsAction.php b/app/Actions/LlmUsageStatsAction.php index d8303b2..ef72af5 100644 --- a/app/Actions/LlmUsageStatsAction.php +++ b/app/Actions/LlmUsageStatsAction.php @@ -3,8 +3,10 @@ namespace App\Actions; use App\Models\AiModelDailyUsage; +use Carbon\Carbon; use Carbon\CarbonImmutable; use Closure; +use DateTimeInterface; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Support\Collection; @@ -54,6 +56,35 @@ public function years(): Collection }); } + /** + * When the sync last wrote usage rows to the database. + */ + public function lastSyncedAt(): ?Carbon + { + return $this->remember('last_synced_at', function (): ?Carbon { + return $this->toDate(AiModelDailyUsage::query()->max('updated_at')); + }); + } + + /** + * The most recent day usage data is available for. + */ + public function latestDate(): ?Carbon + { + return $this->remember('latest_date', function (): ?Carbon { + return $this->toDate(AiModelDailyUsage::query()->max('date')); + }); + } + + private function toDate(mixed $value): ?Carbon + { + return match (true) { + is_string($value) => Carbon::parse($value), + $value instanceof DateTimeInterface => Carbon::instance($value), + default => null, + }; + } + /** * @return Collection, completion_tokens: int<0, max>, total_tokens: int<0, max>, requests: int<0, max>}> */ diff --git a/app/Http/Controllers/AboutUs/AboutUsIndexController.php b/app/Http/Controllers/AboutUs/AboutUsIndexController.php index 49a3559..ac6aa67 100644 --- a/app/Http/Controllers/AboutUs/AboutUsIndexController.php +++ b/app/Http/Controllers/AboutUs/AboutUsIndexController.php @@ -4,25 +4,46 @@ use App\Actions\PageAction; use App\Actions\ViewDataAction; +use App\DTO\ContactDTO; +use App\DTO\PageDTO; use App\Http\Controllers\Controller; -use Illuminate\Http\RedirectResponse; -use Illuminate\Support\Str; +use App\Seo\SchemaNodes; +use Illuminate\Support\Collection; use Illuminate\View\View; +use stdClass; class AboutUsIndexController extends Controller { + public function __invoke(): View + { + $locale = app()->getLocale(); + + $page = (new PageAction(locale: null, routeName: 'about-us.index'))->default(); + $contacts = (new ViewDataAction)->contacts($locale); + + return view('app.about-us.index')->with([ + 'page' => $page, + 'contacts' => $contacts, + 'schema' => $page instanceof PageDTO + ? SchemaNodes::team($this->flatten($contacts), $page) + : [], + ]); + } + /** - * Display the user's profile form. + * Flattens the section-keyed contact groups into one list — the schema + * cares about the people, not about which block they render in. + * + * @return Collection */ - public function __invoke(): View|RedirectResponse + private function flatten(stdClass $contacts): Collection { - return redirect()->route(Str::slug(app()->getLocale()).'.start.index'); - - /* $locale = app()->getLocale(); + /** @var Collection $flattened */ + $flattened = collect((array) $contacts) + ->flatMap(fn (mixed $group): array => $group instanceof Collection ? $group->all() : []) + ->filter(fn (mixed $contact): bool => $contact instanceof ContactDTO) + ->values(); - return view('app.about-us.index')->with([ - 'page' => (new PageAction(locale: null, routeName: 'about-us.index'))->default(), - 'contacts' => (new ViewDataAction)->contacts($locale), - ]);*/ + return $flattened; } } diff --git a/app/Http/Controllers/Ai/AiLlmAnalyticsIndexController.php b/app/Http/Controllers/Ai/AiLlmAnalyticsIndexController.php index 0919d26..500e6d4 100644 --- a/app/Http/Controllers/Ai/AiLlmAnalyticsIndexController.php +++ b/app/Http/Controllers/Ai/AiLlmAnalyticsIndexController.php @@ -65,6 +65,8 @@ public function __invoke(Request $request, LlmUsageStatsAction $stats): View 'month' => $month, 'model' => $model, 'modelLabel' => $model === LlmUsageStatsAction::OTHER_MODEL ? $otherLabel : $model, + 'lastSyncedAt' => $stats->lastSyncedAt(), + 'latestDate' => $stats->latestDate(), ]); } diff --git a/app/Http/Controllers/Contact/ContactIndexController.php b/app/Http/Controllers/Contact/ContactIndexController.php index eec9ff1..942b6c9 100644 --- a/app/Http/Controllers/Contact/ContactIndexController.php +++ b/app/Http/Controllers/Contact/ContactIndexController.php @@ -4,6 +4,7 @@ use App\Actions\PageAction; use App\Http\Controllers\Controller; +use App\Seo\SchemaNodes; use Illuminate\View\View; class ContactIndexController extends Controller @@ -15,23 +16,9 @@ public function __invoke(): View { return view('app.contact.index')->with([ 'page' => (new PageAction(locale: null, routeName: 'contact.index'))->default(), - 'openingHours' => $this->openingHours(), + 'openingHours' => config('company.opening_hours'), + 'locations' => config('company.locations'), + 'schema' => SchemaNodes::locations(), ]); } - - /** - * @return array - */ - private function openingHours(): array - { - return [ - ['day' => 'Monday', 'open' => '08:00', 'close' => '18:00'], - ['day' => 'Tuesday', 'open' => '08:00', 'close' => '18:00'], - ['day' => 'Wednesday', 'open' => '08:00', 'close' => '18:00'], - ['day' => 'Thursday', 'open' => '08:00', 'close' => '18:00'], - ['day' => 'Friday', 'open' => '08:00', 'close' => '18:00'], - ['day' => 'Saturday', 'open' => '08:00', 'close' => '12:00'], - ['day' => 'Sunday', 'open' => null, 'close' => null], - ]; - } } diff --git a/app/Http/Controllers/Entry/EntryIndexController.php b/app/Http/Controllers/Entry/EntryIndexController.php index 8b9ff1d..0610e34 100644 --- a/app/Http/Controllers/Entry/EntryIndexController.php +++ b/app/Http/Controllers/Entry/EntryIndexController.php @@ -2,19 +2,21 @@ namespace App\Http\Controllers\Entry; +use App\Enums\LocaleEnum; use App\Http\Controllers\Controller; use Illuminate\Http\RedirectResponse; use Illuminate\Support\Str; class EntryIndexController extends Controller { - /** - * Display the user's profile form. - */ public function __invoke(): RedirectResponse { - $locale = Str::slug(app()->getLocale()); + // Always the default locale, never the session's: a redirect whose + // target changes per visitor is one crawlers cannot rely on, and the + // domain root is the URL most external links point at. It matches the + // x-default hreflang, which also points at the German start page. + $locale = Str::slug(LocaleEnum::DE->value); - return redirect()->route("{$locale}.start.index"); + return redirect()->route("{$locale}.start.index", status: 301); } } diff --git a/app/Http/Controllers/Network/NetworkShowController.php b/app/Http/Controllers/Network/NetworkShowController.php index 7d89b00..7e23bed 100644 --- a/app/Http/Controllers/Network/NetworkShowController.php +++ b/app/Http/Controllers/Network/NetworkShowController.php @@ -21,7 +21,11 @@ public function __invoke(string $slug): View abort_unless(view()->exists('app.network.pages.'.$slug), 404); return view('app.network.pages.'.$slug)->with([ - 'page' => (new PageAction(locale: null, routeName: 'network.index'))->default(), + // Built from the Network itself, not from the index page: otherwise + // every partner page inherits the index's title and description and + // its hreflang alternates point back at /netzwerk instead of at the + // partner page's own translation. + 'page' => (new PageAction)->network(network: $network), 'network' => $network, 'users' => $network->publishedUsers()->get(), ]); diff --git a/app/Http/Controllers/News/NewsIndexController.php b/app/Http/Controllers/News/NewsIndexController.php index 10f6440..53f207d 100644 --- a/app/Http/Controllers/News/NewsIndexController.php +++ b/app/Http/Controllers/News/NewsIndexController.php @@ -4,25 +4,26 @@ use App\Actions\PageAction; use App\Actions\ViewDataAction; +use App\DTO\PageDTO; use App\Http\Controllers\Controller; -use Illuminate\Http\RedirectResponse; -use Illuminate\Support\Str; +use App\Seo\SchemaNodes; use Illuminate\View\View; class NewsIndexController extends Controller { - /** - * Display the user's profile form. - */ - public function __invoke(): View|RedirectResponse + public function __invoke(): View { - return redirect()->route(Str::slug(app()->getLocale()).'.start.index'); + $locale = app()->getLocale(); - /* $locale = app()->getLocale(); + $page = (new PageAction(locale: null, routeName: 'news.index'))->default(); + $news = (new ViewDataAction)->news($locale); - return view('app.news.index')->with([ - 'page' => (new PageAction(locale: null, routeName: 'news.index'))->default(), - 'news' => (new ViewDataAction)->news($locale), - ]);*/ + return view('app.news.index')->with([ + 'page' => $page, + 'news' => $news, + 'schema' => $page instanceof PageDTO + ? SchemaNodes::blog($news, $page, $locale) + : [], + ]); } } diff --git a/app/Http/Controllers/News/NewsShowController.php b/app/Http/Controllers/News/NewsShowController.php index 36b1cea..a3c120b 100644 --- a/app/Http/Controllers/News/NewsShowController.php +++ b/app/Http/Controllers/News/NewsShowController.php @@ -5,28 +5,29 @@ use App\Actions\PageAction; use App\Http\Controllers\Controller; use App\Models\News; -use Illuminate\Http\RedirectResponse; +use App\Seo\SchemaNodes; use Illuminate\Support\Str; use Illuminate\View\View; class NewsShowController extends Controller { - /** - * Display the user's profile form. - */ - public function __invoke(string $locale, News $news): View|RedirectResponse + public function __invoke(string $locale, News $news): View { - return redirect()->route(Str::slug(app()->getLocale()).'.start.index'); + // withReferences builds the alternate-locale PageDTOs, so the article + // gets hreflang links pointing at its own translation rather than at + // the news index. + $page = (new PageAction($locale))->news(news: $news, withReferences: true); - /* return view('app.news.show')->with([ - 'page' => (new PageAction($locale))->news(news: $news), - 'published_at' => $news->published_at?->format('d.m.Y'), - 'last_updated_at' => $news->updated_at?->format('d.m.Y'), - 'author' => $news->author, - 'title' => $news->title, - 'teaser' => $news->teaser, - 'tags' => collect($news->tags), - 'content' => Str::of($news->content ?? '')->markdown(), - ]);*/ + return view('app.news.show')->with([ + 'page' => $page, + 'published_at' => $news->published_at?->format('d.m.Y'), + 'last_updated_at' => $news->updated_at?->format('d.m.Y'), + 'author' => $news->author, + 'title' => $news->title, + 'teaser' => $news->teaser, + 'tags' => collect($news->tags), + 'content' => Str::of($news->content ?? '')->markdown(), + 'schema' => SchemaNodes::blogPosting($news, $page, $locale), + ]); } } diff --git a/app/Http/Controllers/OpenSource/OpenSoruceShowController.php b/app/Http/Controllers/OpenSource/OpenSoruceShowController.php index 2d8d56f..9bcbe7b 100644 --- a/app/Http/Controllers/OpenSource/OpenSoruceShowController.php +++ b/app/Http/Controllers/OpenSource/OpenSoruceShowController.php @@ -5,25 +5,33 @@ use App\Actions\PageAction; use App\Http\Controllers\Controller; use App\Models\OpenSource; -use Illuminate\Http\RedirectResponse; +use App\Seo\SchemaNodes; use Illuminate\Support\Str; use Illuminate\View\View; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class OpenSoruceShowController extends Controller { - /** - * Display the user's profile form. - */ - public function __invoke(string $locale, OpenSource $openSource): View|RedirectResponse + public function __invoke(string $locale, OpenSource $openSource): View { - return redirect()->route(Str::slug(app()->getLocale()).'.start.index'); + // `sync:repositories` creates an entry per GitHub repository but only + // fills title and teaser — content is written by hand. Without it there + // is no page here worth having, let alone indexing, so serve a 404 + // rather than a near-empty URL. + if (! $openSource->hasWrittenContent()) { + throw new NotFoundHttpException; + } - /* return view('app.open-source.show')->with([ - 'page' => (new PageAction(locale: $locale))->openSource(openSource: $openSource), - 'name' => $openSource->title, - 'teaser' => $openSource->teaser, - 'content' => Str::of($openSource->content ?? '')->markdown(), - 'tags' => $openSource->tags, - ]);*/ + $page = (new PageAction(locale: $locale))->openSource(openSource: $openSource, withReferences: true); + + return view('app.open-source.show')->with([ + 'page' => $page, + 'name' => $openSource->title, + 'teaser' => $openSource->teaser, + 'content' => Str::of($openSource->content ?? '')->markdown(), + 'tags' => $openSource->tags, + 'link' => $openSource->link, + 'schema' => SchemaNodes::softwareSourceCode($openSource, $page, $locale), + ]); } } diff --git a/app/Http/Controllers/OpenSource/OpenSourceIndexController.php b/app/Http/Controllers/OpenSource/OpenSourceIndexController.php index 5cee2d9..f984898 100644 --- a/app/Http/Controllers/OpenSource/OpenSourceIndexController.php +++ b/app/Http/Controllers/OpenSource/OpenSourceIndexController.php @@ -5,24 +5,17 @@ use App\Actions\PageAction; use App\Actions\ViewDataAction; use App\Http\Controllers\Controller; -use Illuminate\Http\RedirectResponse; -use Illuminate\Support\Str; use Illuminate\View\View; class OpenSourceIndexController extends Controller { - /** - * Display the user's profile form. - */ - public function __invoke(): View|RedirectResponse + public function __invoke(): View { - return redirect()->route(Str::slug(app()->getLocale()).'.start.index'); + $locale = app()->getLocale(); - /* $locale = app()->getLocale(); - - return view('app.open-source.index')->with([ - 'page' => (new PageAction(locale: null, routeName: 'open-source.index'))->default(), - 'openSource' => (new ViewDataAction)->openSource($locale), - ]);*/ + return view('app.open-source.index')->with([ + 'page' => (new PageAction(locale: null, routeName: 'open-source.index'))->default(), + 'openSource' => (new ViewDataAction)->openSource($locale), + ]); } } diff --git a/app/Http/Controllers/Robots/RobotsController.php b/app/Http/Controllers/Robots/RobotsController.php index 43997e3..7e2e522 100644 --- a/app/Http/Controllers/Robots/RobotsController.php +++ b/app/Http/Controllers/Robots/RobotsController.php @@ -9,17 +9,40 @@ class RobotsController extends Controller { public function __invoke(): Response { + return response(content: $this->content(), headers: [ + 'Content-Type' => 'text/plain', + ]); + } + + private function content(): string + { + // Staging and local copies must stay out of the index — an indexed + // duplicate of the whole site competes with production for the same + // queries. Only production invites crawlers in. + if (! app()->isProduction()) { + return implode("\n", [ + 'User-agent: *', + 'Disallow: /', + ]); + } + $sitemapUrl = rtrim(config()->string('app.url'), '/').'/sitemap.xml'; - $content = implode("\n", [ + return implode("\n", [ 'User-agent: *', 'Disallow:', '', + // Signed, personal and form-only URLs. Nothing here belongs in a + // search result, and crawling them just burns crawl budget. + 'Disallow: /network/request', + 'Disallow: /netzwerk/request', + 'Disallow: /network/manage/', + 'Disallow: /netzwerk/verwalten/', + '', + // AI crawlers are deliberately allowed: being cited in generated + // answers requires being crawlable in the first place. + '', 'Sitemap: '.$sitemapUrl, ]); - - return response(content: $content, headers: [ - 'Content-Type' => 'text/plain', - ]); } } diff --git a/app/Http/Controllers/Services/ServicesIndexController.php b/app/Http/Controllers/Services/ServicesIndexController.php index 5925e5a..319bd8f 100644 --- a/app/Http/Controllers/Services/ServicesIndexController.php +++ b/app/Http/Controllers/Services/ServicesIndexController.php @@ -5,6 +5,7 @@ use App\Actions\PageAction; use App\Actions\ViewDataAction; use App\Http\Controllers\Controller; +use App\Seo\SchemaNodes; use Illuminate\View\View; class ServicesIndexController extends Controller @@ -15,10 +16,12 @@ class ServicesIndexController extends Controller public function __invoke(): View { $locale = app()->getLocale(); + $services = (new ViewDataAction)->services($locale); return view('app.services.index')->with([ 'page' => (new PageAction(locale: null, routeName: 'services.index'))->default(), - 'services' => (new ViewDataAction)->services($locale), + 'services' => $services, + 'schema' => SchemaNodes::services($services, $locale), ]); } } diff --git a/app/Http/Controllers/Sitemap/SitemapController.php b/app/Http/Controllers/Sitemap/SitemapController.php index ee054dc..16c8615 100644 --- a/app/Http/Controllers/Sitemap/SitemapController.php +++ b/app/Http/Controllers/Sitemap/SitemapController.php @@ -7,6 +7,8 @@ use App\Enums\LocaleEnum; use App\Http\Controllers\Controller; use App\Models\Network; +use App\Models\News; +use App\Models\OpenSource; use App\Sitemap\SitemapBuilder; use Illuminate\Http\Response; use Illuminate\Support\Facades\Cache; @@ -26,6 +28,9 @@ class SitemapController extends Controller 'ai.llm.index', 'ai.llm.analytics.index', 'network.index', + 'about-us.index', + 'news.index', + 'open-source.index', ]; protected const array DEFAULT_LOCALES = [ @@ -55,31 +60,77 @@ private function builder(SitemapBuilder $sitemap): void { $this->addDefaultRoutesToSitemap($sitemap); $this->addNetworksToSitemap($sitemap); + $this->addNewsToSitemap($sitemap); + $this->addOpenSourceToSitemap($sitemap); } private function addNetworksToSitemap(SitemapBuilder $sitemap): void { Network::query() - ->where('published', true) + ->published() + // Same filters NetworkShowController enforces: without active() and + // the view check, the sitemap advertises URLs that answer 404. + ->active() ->whereNotNull('page_slug') ->get() + ->filter(fn (Network $network): bool => view()->exists('app.network.pages.'.$network->page_slug)) ->each(function (Network $network) use ($sitemap): void { - $action = new PageAction(locale: null, routeName: null); - - $pages = collect(self::DEFAULT_LOCALES) - ->map(fn (string $locale): PageDTO => $action->network(network: $network, locale: $locale)) - ->values(); + $this->addLocalizedSet( + $sitemap, + fn (string $locale): PageDTO => (new PageAction)->network(network: $network, locale: $locale), + ); + }); + } - $pages->each(function (PageDTO $page) use ($pages): void { - $page->referencePages = $pages; - }); + private function addNewsToSitemap(SitemapBuilder $sitemap): void + { + News::query() + ->whereNotNull('published_at') + ->orderByDesc('published_at') + ->get() + ->each(function (News $news) use ($sitemap): void { + $this->addLocalizedSet( + $sitemap, + fn (string $locale): PageDTO => (new PageAction)->news(news: $news, locale: $locale), + ); + }); + } - $pages->each(function (PageDTO $page) use ($sitemap): void { - $sitemap->addItem(page: $page); - }); + private function addOpenSourceToSitemap(SitemapBuilder $sitemap): void + { + OpenSource::query() + ->where('published', true) + ->get() + // Entries without a written body have no detail page — + // OpenSoruceShowController answers 404 for them. + ->filter(fn (OpenSource $openSource): bool => $openSource->hasWrittenContent()) + ->each(function (OpenSource $openSource) use ($sitemap): void { + $this->addLocalizedSet( + $sitemap, + fn (string $locale): PageDTO => (new PageAction)->openSource(openSource: $openSource, locale: $locale), + ); }); } + /** + * Adds one URL per locale and cross-references them all as hreflang + * alternates of each other. + * + * @param callable(string): PageDTO $builder + */ + private function addLocalizedSet(SitemapBuilder $sitemap, callable $builder): void + { + $pages = collect(self::DEFAULT_LOCALES) + ->map(fn (string $locale): PageDTO => $builder($locale)) + ->values(); + + $pages->each(function (PageDTO $page) use ($pages): void { + $page->referencePages = $pages; + }); + + $pages->each(fn (PageDTO $page) => $sitemap->addItem(page: $page)); + } + private function addDefaultRoutesToSitemap(SitemapBuilder $sitemap): void { collect(value: self::DEFAULT_ROUTES)->each(function (string $routeName) use ($sitemap): void { diff --git a/app/Http/Middleware/SecurityHeaders.php b/app/Http/Middleware/SecurityHeaders.php index 4a2beea..dfa3e36 100644 --- a/app/Http/Middleware/SecurityHeaders.php +++ b/app/Http/Middleware/SecurityHeaders.php @@ -22,6 +22,13 @@ public function handle(Request $request, Closure $next): Response ); } + // Belt and braces alongside robots.txt: a crawler that reaches a + // staging URL through a link rather than the root still gets told not + // to index it. robots.txt only prevents crawling, not indexing. + if (! app()->isProduction()) { + $response->headers->set('X-Robots-Tag', 'noindex, nofollow'); + } + $response->headers->set('Cross-Origin-Opener-Policy', 'same-origin'); $response->headers->set('X-Content-Type-Options', 'nosniff'); $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); diff --git a/app/Models/OpenSource.php b/app/Models/OpenSource.php index 9a941b0..9790320 100644 --- a/app/Models/OpenSource.php +++ b/app/Models/OpenSource.php @@ -34,4 +34,17 @@ public function getRouteKeyName(): string { return 'slug'; } + + /** + * True when someone has written a body for this entry. + * + * `sync:repositories` only imports title, teaser and metadata from GitHub; + * content stays null until it is written by hand. Entries without it have + * no detail page — they link straight to the repository instead, so we + * never publish an all-but-empty URL. + */ + public function hasWrittenContent(): bool + { + return filled($this->content); + } } diff --git a/app/Observers/SitemapCacheObserver.php b/app/Observers/SitemapCacheObserver.php new file mode 100644 index 0000000..e182905 --- /dev/null +++ b/app/Observers/SitemapCacheObserver.php @@ -0,0 +1,31 @@ +forget(); + } + + public function deleted(Model $model): void + { + $this->forget(); + } + + private function forget(): void + { + Cache::forget('sitemap_xml'); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 995a370..2e75e1e 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -9,11 +9,13 @@ use App\Models\NetworkUser; use App\Models\News; use App\Models\OpenSource; +use App\Models\Page; use App\Models\Product; use App\Models\Service; use App\Models\Technology; use App\Observers\NetworkObserver; use App\Observers\NetworkUserObserver; +use App\Observers\SitemapCacheObserver; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\ServiceProvider; use Spatie\Health\Checks\Check; @@ -41,6 +43,11 @@ public function boot(): void Network::observe(NetworkObserver::class); NetworkUser::observe(NetworkUserObserver::class); + // Everything the sitemap draws its URLs from. + foreach ([Page::class, Network::class, News::class, OpenSource::class] as $model) { + $model::observe(SitemapCacheObserver::class); + } + Model::unguard(); Model::shouldBeStrict($this->app->isLocal()); diff --git a/app/Seo/Company.php b/app/Seo/Company.php new file mode 100644 index 0000000..8ab009a --- /dev/null +++ b/app/Seo/Company.php @@ -0,0 +1,116 @@ +string('company.legal_name'); + } + + /** + * @return array + */ + public static function alternateNames(): array + { + return self::strings('company.alternate_names'); + } + + public static function uid(): string + { + return config()->string('company.uid'); + } + + public static function email(): string + { + return config()->string('company.email'); + } + + public static function phone(): string + { + return config()->string('company.phone.e164'); + } + + public static function logo(): string + { + return config()->string('company.logo'); + } + + /** + * Verified profiles on other platforms — schema.org sameAs. + * + * @return array + */ + public static function sameAs(): array + { + return self::strings('company.same_as'); + } + + /** + * @return array + */ + public static function locations(): array + { + /** @var array $locations */ + $locations = config()->array('company.locations'); + + return $locations; + } + + /** + * @return array{key: string, primary: bool, city: string, label: string, street: string, postal_code: string, country: string, map_url: string}|null + */ + public static function primaryLocation(): ?array + { + $locations = self::locations(); + + foreach ($locations as $location) { + if ($location['primary']) { + return $location; + } + } + + return $locations[0] ?? null; + } + + /** + * `open`/`close` are null on days the office is closed. + * + * @return array + */ + public static function openingHours(): array + { + /** @var array $hours */ + $hours = config()->array('company.opening_hours'); + + return $hours; + } + + /** + * Site root without a trailing slash — the base for every absolute URL and + * schema.org @id we emit. + */ + public static function baseUrl(): string + { + return rtrim(config()->string('app.url'), '/'); + } + + /** + * @return array + */ + private static function strings(string $key): array + { + return array_values(array_filter( + config()->array($key), + fn (mixed $value): bool => is_string($value) && $value !== '', + )); + } +} diff --git a/app/Seo/SchemaGraph.php b/app/Seo/SchemaGraph.php new file mode 100644 index 0000000..0607a68 --- /dev/null +++ b/app/Seo/SchemaGraph.php @@ -0,0 +1,308 @@ +. + * + * Everything ends up in a single @graph so the nodes can reference each other + * by @id instead of repeating themselves — the Organization is described once + * and every page points at it. That is what lets Google and LLMs treat all our + * pages as belonging to one known entity rather than as unrelated documents. + * + * Page-specific nodes (LocalBusiness, BlogPosting, Person, …) are appended by + * the view through the `schema` prop on . + * + * @phpstan-type SchemaNode array + */ +class SchemaGraph +{ + /** + * Stable @id anchors. Fragments, not real URLs — schema.org identifiers + * only have to be unique and stable, and keeping them tied to the site + * root means they stay valid on every page. + */ + public const string ORGANIZATION_ID = '#organization'; + + public const string WEBSITE_ID = '#website'; + + /** + * @param array> $additionalNodes + */ + public function __construct( + private readonly ?PageDTO $page = null, + private readonly array $additionalNodes = [], + ) {} + + /** + * @return array + */ + public function toArray(): array + { + $nodes = collect([ + $this->organization(), + $this->website(), + ]) + ->push(...$this->webPageNodes()) + ->push(...$this->additionalNodes) + ->filter() + ->values() + ->all(); + + return [ + '@context' => 'https://schema.org', + '@graph' => $nodes, + ]; + } + + public function toJson(): string + { + return (string) json_encode( + $this->toArray(), + JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE + ); + } + + /** + * The company itself. This is the node that feeds the Knowledge Panel on + * branded searches, so it carries every identifier we can verify. + * + * @return array + */ + public function organization(): array + { + return array_filter([ + '@type' => 'Organization', + '@id' => $this->id(self::ORGANIZATION_ID), + 'name' => Company::legalName(), + 'alternateName' => Company::alternateNames(), + 'url' => $this->baseUrl(), + 'logo' => [ + '@type' => 'ImageObject', + 'url' => $this->asset(Company::logo()), + ], + 'image' => $this->asset(config()->string('seo.default_image')), + 'email' => Company::email(), + 'telephone' => Company::phone(), + 'vatID' => Company::uid(), + 'address' => $this->postalAddress(Company::primaryLocation()), + 'sameAs' => Company::sameAs(), + ], fn (mixed $value): bool => $value !== null && $value !== [] && $value !== ''); + } + + /** + * @return array + */ + private function website(): array + { + return array_filter([ + '@type' => 'WebSite', + '@id' => $this->id(self::WEBSITE_ID), + 'name' => Company::legalName(), + 'alternateName' => Company::alternateNames(), + 'url' => $this->baseUrl(), + 'inLanguage' => $this->language(), + 'publisher' => ['@id' => $this->id(self::ORGANIZATION_ID)], + // alternateName is the only value here that can be empty. + ], fn (mixed $value): bool => $value !== []); + } + + /** + * WebPage plus the breadcrumb trail for it. Breadcrumbs replace the raw URL + * line in the search result with a readable path — cheap and visible. + * + * @return array> + */ + private function webPageNodes(): array + { + if (! $this->page instanceof PageDTO) { + return []; + } + + $url = $this->page->url(); + + $webPage = array_filter([ + '@type' => 'WebPage', + '@id' => $url, + 'url' => $url, + 'name' => $this->page->title, + 'description' => $this->page->description, + 'inLanguage' => $this->language(), + 'dateModified' => $this->page->lastModificationDate->toIso8601String(), + 'isPartOf' => ['@id' => $this->id(self::WEBSITE_ID)], + 'about' => ['@id' => $this->id(self::ORGANIZATION_ID)], + 'primaryImageOfPage' => $this->page->image !== null && $this->page->image !== '' + ? ['@type' => 'ImageObject', 'url' => $this->page->image] + : null, + ], fn (mixed $value): bool => $value !== null && $value !== ''); + + $breadcrumb = $this->breadcrumbList($url); + + return $breadcrumb === null ? [$webPage] : [$webPage, $breadcrumb]; + } + + /** + * Builds the trail from the URL path. + * + * Every intermediate crumb is resolved against the router first: paths like + * /rechtliches or /ki/llm-analytics's parent are not always real routes, and + * a breadcrumb item linking to a 404 is worse than a shorter trail. + * + * Returns null on the start page — a single-item breadcrumb is noise. + * + * @return array|null + */ + private function breadcrumbList(string $url): ?array + { + if ($this->page?->routeKey === 'start.index') { + return null; + } + + $segments = collect(explode('/', trim((string) parse_url($url, PHP_URL_PATH), '/'))) + ->filter(fn (string $segment): bool => $segment !== '') + ->values(); + + $items = collect([[ + '@type' => 'ListItem', + 'position' => 1, + 'name' => Company::legalName(), + 'item' => $this->localizedStartUrl(), + ]]); + + $carry = $this->baseUrl(); + $lastIndex = $segments->count() - 1; + + $segments->each(function (string $segment, int $index) use (&$carry, $items, $lastIndex): void { + $carry .= '/'.$segment; + + $name = $index === $lastIndex + ? $this->currentPageName() + : $this->resolvedPageName($carry); + + // An intermediate segment that resolves to no page of its own is + // not a level a user could navigate to — leave it out entirely. + if ($name === null) { + return; + } + + $items->push([ + '@type' => 'ListItem', + 'position' => $items->count() + 1, + 'name' => $name, + 'item' => $carry, + ]); + }); + + if ($items->count() < 2) { + return null; + } + + return [ + '@type' => 'BreadcrumbList', + '@id' => $url.'#breadcrumb', + 'itemListElement' => $items->all(), + ]; + } + + /** + * Title of the page being rendered, stripped of the " – codebar Solutions AG" + * suffix that belongs in but not in a breadcrumb. + */ + private function currentPageName(): ?string + { + if (! $this->page instanceof PageDTO || $this->page->title === '') { + return null; + } + + return $this->shortTitle($this->page->title); + } + + /** + * Looks up the title of an intermediate URL by matching it against the + * router, then reusing the existing PageAction lookup for its route name. + * Returns null when the URL is not a routable page. + */ + private function resolvedPageName(string $url): ?string + { + try { + $route = app('router')->getRoutes()->match(Request::create($url)); + } catch (Throwable) { + return null; + } + + $routeName = $route->getName(); + + if (! is_string($routeName) || $routeName === '') { + return null; + } + + // Route names are locale-prefixed ("de-ch.ai.index"); PageAction keys + // are not, so drop the prefix before looking the page up. + $routeKey = Str::after($routeName, '.'); + + $page = (new PageAction(routeName: $routeKey))->default(); + + return $page instanceof PageDTO && $page->title !== '' + ? $this->shortTitle($page->title) + : null; + } + + private function shortTitle(string $title): string + { + return Str::of($title) + ->before(' – ') + ->before(' | ') + ->trim() + ->toString(); + } + + /** + * @param array{street: string, postal_code: string, city: string, country: string}|null $location + * @return array<string, mixed>|null + */ + public function postalAddress(?array $location): ?array + { + if ($location === null) { + return null; + } + + return [ + '@type' => 'PostalAddress', + 'streetAddress' => $location['street'], + 'postalCode' => $location['postal_code'], + 'addressLocality' => $location['city'], + 'addressCountry' => $location['country'], + ]; + } + + private function localizedStartUrl(): string + { + return route(Str::slug(app()->getLocale()).'.start.index', absolute: true); + } + + private function language(): string + { + return str_replace('_', '-', app()->getLocale()); + } + + private function baseUrl(): string + { + return Company::baseUrl(); + } + + private function id(string $fragment): string + { + return $this->baseUrl().'/'.$fragment; + } + + private function asset(string $path): string + { + return $path === '' ? '' : $this->baseUrl().'/'.ltrim($path, '/'); + } +} diff --git a/app/Seo/SchemaNodes.php b/app/Seo/SchemaNodes.php new file mode 100644 index 0000000..eab5de1 --- /dev/null +++ b/app/Seo/SchemaNodes.php @@ -0,0 +1,244 @@ +<?php + +namespace App\Seo; + +use App\DTO\ContactDTO; +use App\DTO\PageDTO; +use App\Models\News; +use App\Models\OpenSource; +use App\Models\Service; +use Illuminate\Support\Arr; +use Illuminate\Support\Collection; +use Illuminate\Support\Str; + +/** + * Page-specific schema.org nodes, appended to the global graph built by + * SchemaGraph via the `schema` prop on <x-app-layout>. + * + * Built in controllers rather than in Blade so the shapes stay testable and + * the views keep to markup. + * + * @phpstan-type SchemaNode array<string, mixed> + */ +class SchemaNodes +{ + /** + * One LocalBusiness node per physical location. + * + * ProfessionalService is the LocalBusiness subtype that fits a software + * consultancy. Each branch gets its own @id and points back at the + * Organization, so Google can attach the right opening hours and address + * to the right Business Profile. + * + * @return array<int, array<string, mixed>> + */ + public static function locations(): array + { + $organizationId = self::organizationId(); + + return collect(Company::locations())->map(fn (array $location): array => array_filter([ + '@type' => 'ProfessionalService', + '@id' => $organizationId.'-'.$location['key'], + 'name' => Company::legalName().' — '.$location['city'], + 'parentOrganization' => ['@id' => $organizationId], + 'url' => Company::baseUrl(), + 'email' => Company::email(), + 'telephone' => Company::phone(), + 'vatID' => Company::uid(), + 'address' => [ + '@type' => 'PostalAddress', + 'streetAddress' => $location['street'], + 'postalCode' => $location['postal_code'], + 'addressLocality' => $location['city'], + 'addressCountry' => $location['country'], + ], + 'hasMap' => $location['map_url'], + 'openingHoursSpecification' => self::openingHours(), + 'areaServed' => 'CH', + // openingHoursSpecification is empty when every day is closed. + ], fn (mixed $value): bool => $value !== [])) + ->values() + ->all(); + } + + /** + * Closed days are omitted rather than emitted with null times — schema.org + * treats an absent day as closed. + * + * @return array<int, array<string, mixed>> + */ + private static function openingHours(): array + { + return collect(Company::openingHours()) + ->filter(fn (array $entry): bool => $entry['open'] !== null && $entry['close'] !== null) + ->map(fn (array $entry): array => [ + '@type' => 'OpeningHoursSpecification', + 'dayOfWeek' => 'https://schema.org/'.$entry['day'], + 'opens' => $entry['open'], + 'closes' => $entry['close'], + ]) + ->values() + ->all(); + } + + /** + * ProfilePage wrapper plus a Person node per team member. + * + * @param Collection<int, ContactDTO> $contacts + * @return array<int, array<string, mixed>> + */ + public static function team(Collection $contacts, PageDTO $page): array + { + $organizationId = self::organizationId(); + + $people = $contacts + ->unique(fn (ContactDTO $contact): string => $contact->name) + ->map(fn (ContactDTO $contact): array => array_filter([ + '@type' => 'Person', + 'name' => $contact->name, + 'jobTitle' => $contact->role, + 'image' => $contact->image !== '' ? $contact->image : null, + 'worksFor' => ['@id' => $organizationId], + 'sameAs' => self::linkedIn($contact), + ], fn (mixed $value): bool => $value !== null && $value !== [])) + ->values(); + + if ($people->isEmpty()) { + return []; + } + + return [[ + '@type' => 'ProfilePage', + '@id' => $page->url().'#profilepage', + 'url' => $page->url(), + 'name' => $page->title, + 'about' => ['@id' => $organizationId], + 'mainEntity' => $people->all(), + ]]; + } + + /** + * @return array<int, array<string, mixed>> + */ + public static function blogPosting(News $news, PageDTO $page, string $locale): array + { + $organizationId = self::organizationId(); + + $tags = $news->tags; + + return [array_filter([ + '@type' => 'BlogPosting', + '@id' => $page->url().'#article', + 'headline' => $page->title, + 'description' => $page->description, + 'url' => $page->url(), + 'mainEntityOfPage' => ['@id' => $page->url()], + 'inLanguage' => str_replace('_', '-', $locale), + 'datePublished' => $news->published_at?->toIso8601String(), + 'dateModified' => $news->updated_at?->toIso8601String(), + 'author' => filled($news->author) + ? ['@type' => 'Person', 'name' => $news->author] + : ['@id' => $organizationId], + 'publisher' => ['@id' => $organizationId], + 'image' => filled($news->image) ? $news->image : null, + 'keywords' => is_array($tags) ? array_values($tags) : null, + ], fn (mixed $value): bool => $value !== null && $value !== [])]; + } + + /** + * The news index as a Blog with its posts listed. + * + * @param Collection<int, News> $news + * @return array<int, array<string, mixed>> + */ + public static function blog(Collection $news, PageDTO $page, string $locale): array + { + $organizationId = self::organizationId(); + + return [array_filter([ + '@type' => 'Blog', + '@id' => $page->url().'#blog', + 'url' => $page->url(), + 'name' => $page->title, + 'description' => $page->description, + 'inLanguage' => str_replace('_', '-', $locale), + 'publisher' => ['@id' => $organizationId], + 'blogPost' => $news->map(fn (News $item): array => array_filter([ + '@type' => 'BlogPosting', + 'headline' => $item->getTranslation('title', $locale), + 'url' => route( + Str::slug($locale).'.news.show', + ['locale' => $locale, 'news' => $item], + true + ), + 'datePublished' => $item->published_at?->toIso8601String(), + ], fn (mixed $value): bool => $value !== null && $value !== ''))->all(), + ], fn (mixed $value): bool => $value !== null && $value !== [])]; + } + + /** + * @return array<int, array<string, mixed>> + */ + public static function softwareSourceCode(OpenSource $openSource, PageDTO $page, string $locale): array + { + $organizationId = self::organizationId(); + + $tags = $openSource->tags; + + return [array_filter([ + '@type' => 'SoftwareSourceCode', + '@id' => $page->url().'#software', + 'name' => $page->title, + 'description' => $page->description, + 'url' => $page->url(), + 'codeRepository' => filled($openSource->link) ? $openSource->link : null, + 'programmingLanguage' => filled($openSource->primary_language) + ? $openSource->primary_language + : null, + 'author' => ['@id' => $organizationId], + 'inLanguage' => str_replace('_', '-', $locale), + 'keywords' => is_array($tags) ? array_values($tags) : null, + ], fn (mixed $value): bool => $value !== null && $value !== [])]; + } + + /** + * Service nodes for the expertise page. + * + * These produce no rich result on their own — Service is not in Google's + * rich-result gallery. They are here because they tell search engines and + * LLMs what this company actually sells, which is what gets us surfaced in + * generated answers. + * + * @param Collection<int, Service> $services + * @return array<int, array<string, mixed>> + */ + public static function services(Collection $services, string $locale): array + { + $organizationId = self::organizationId(); + + return $services->map(fn (Service $service): array => array_filter([ + '@type' => 'Service', + 'name' => $service->getTranslation('name', $locale), + 'description' => $service->getTranslation('teaser', $locale), + 'provider' => ['@id' => $organizationId], + 'areaServed' => 'CH', + ], fn (mixed $value): bool => $value !== null && $value !== '')) + ->values() + ->all(); + } + + private static function organizationId(): string + { + return Company::baseUrl().'/'.SchemaGraph::ORGANIZATION_ID; + } + + /** + * @return array<int, string> + */ + private static function linkedIn(ContactDTO $contact): array + { + $url = Arr::get($contact->icons, 'linkedin'); + + return is_string($url) && $url !== '' ? [$url] : []; + } +} diff --git a/app/Sitemap/SitemapBuilder.php b/app/Sitemap/SitemapBuilder.php index 75847ba..65ef44d 100644 --- a/app/Sitemap/SitemapBuilder.php +++ b/app/Sitemap/SitemapBuilder.php @@ -15,11 +15,53 @@ public function __construct() $this->sitemap = Sitemap::create(); } + /** + * Relative importance per section. Priority is only a hint to crawlers, but + * emitting 1.0 for every URL — homepage and a numbers table alike — tells + * them nothing at all. + */ + private const array PRIORITIES = [ + 'start.index' => 1.0, + 'services.index' => 0.9, + 'contact.index' => 0.9, + 'about-us.index' => 0.8, + 'news.index' => 0.8, + 'news.show' => 0.7, + 'ai.index' => 0.7, + 'network.index' => 0.7, + 'open-source.index' => 0.6, + 'open-source.show' => 0.5, + 'jobs.index' => 0.6, + 'ai.llm.index' => 0.6, + 'network.show' => 0.5, + 'media.index' => 0.4, + 'ai.llm.analytics.index' => 0.3, + 'legal.imprint.index' => 0.2, + 'legal.privacy.index' => 0.2, + 'legal.terms.index' => 0.2, + ]; + + /** + * Legal texts and media assets change far less often than the news index. + */ + private const array CHANGE_FREQUENCIES = [ + 'news.index' => Url::CHANGE_FREQUENCY_WEEKLY, + 'ai.llm.analytics.index' => Url::CHANGE_FREQUENCY_DAILY, + 'legal.imprint.index' => Url::CHANGE_FREQUENCY_YEARLY, + 'legal.privacy.index' => Url::CHANGE_FREQUENCY_YEARLY, + 'legal.terms.index' => Url::CHANGE_FREQUENCY_YEARLY, + 'media.index' => Url::CHANGE_FREQUENCY_YEARLY, + ]; + + private const float DEFAULT_PRIORITY = 0.5; + public function addItem(PageDTO $page): void { $url = Url::create(url: $page->url()); - $url->setPriority(priority: 1.0); - $url->setChangeFrequency(changeFrequency: Url::CHANGE_FREQUENCY_WEEKLY); + $url->setPriority(priority: self::PRIORITIES[$page->routeKey] ?? self::DEFAULT_PRIORITY); + $url->setChangeFrequency( + changeFrequency: self::CHANGE_FREQUENCIES[$page->routeKey] ?? Url::CHANGE_FREQUENCY_MONTHLY + ); $url->setLastModificationDate(lastModificationDate: $page->lastModificationDate); if (filled($page->image)) { diff --git a/app/Support/helpers.php b/app/Support/helpers.php index b62bb11..e968969 100644 --- a/app/Support/helpers.php +++ b/app/Support/helpers.php @@ -11,3 +11,42 @@ function localized_route(string $name, mixed $parameters = [], bool $absolute = return route($localizedName, $parameters, $absolute); } } + +if (! function_exists('locale_switch_url')) { + /** + * The current page's URL in another locale. + * + * Lets the language switcher be a real <a href> instead of a POST form. + * Crawlers cannot submit forms, so with a form the two language versions + * are only ever connected by the hreflang tags in <head>; a link makes the + * relationship explicit and lets link equity flow between them. + * + * Falls back to that locale's start page when the current route has no + * counterpart (unnamed routes, error pages). + */ + function locale_switch_url(string $locale): string + { + $localeSlug = Str::slug($locale); + $route = request()->route(); + $routeName = (string) $route?->getName(); + + if ($routeName === '') { + return route($localeSlug.'.start.index'); + } + + // Route names are locale-prefixed ("de-ch.services.index"). + $routeKey = Str::after($routeName, '.'); + $parameters = $route?->parameters() ?? []; + + // Detail routes carry the locale in the path as well as the prefix. + if (array_key_exists('locale', $parameters)) { + $parameters['locale'] = $locale; + } + + try { + return route($localeSlug.'.'.$routeKey, $parameters); + } catch (Throwable) { + return route($localeSlug.'.start.index'); + } + } +} diff --git a/app/View/Components/AppLayout.php b/app/View/Components/AppLayout.php index 8bd4dca..3323f40 100644 --- a/app/View/Components/AppLayout.php +++ b/app/View/Components/AppLayout.php @@ -9,9 +9,15 @@ class AppLayout extends Component { + /** + * @param array<int, array<string, mixed>> $schema + * Page-specific schema.org nodes appended to the global graph + * (LocalBusiness, BlogPosting, Person, …). See App\Seo\SchemaGraph. + */ public function __construct( protected mixed $page, public bool $preconnectCloudinary = false, + protected array $schema = [], ) {} public function render(): View @@ -23,6 +29,7 @@ public function render(): View 'locale' => Str::slug($locale), 'page' => $this->page, 'preconnectCloudinary' => $this->preconnectCloudinary, + 'schema' => $this->schema, ]); } } diff --git a/composer.lock b/composer.lock index af10ba5..5e979e1 100644 --- a/composer.lock +++ b/composer.lock @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.388.12", + "version": "3.389.1", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "9eca8f6408501a47efe5dee2ce494b93eac983a6" + "reference": "98506321be0157d78a2c44424a8fce3a4842081d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/9eca8f6408501a47efe5dee2ce494b93eac983a6", - "reference": "9eca8f6408501a47efe5dee2ce494b93eac983a6", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/98506321be0157d78a2c44424a8fce3a4842081d", + "reference": "98506321be0157d78a2c44424a8fce3a4842081d", "shasum": "" }, "require": { @@ -79,9 +79,9 @@ "ext-json": "*", "ext-pcre": "*", "ext-simplexml": "*", - "guzzlehttp/guzzle": "^7.4.5", - "guzzlehttp/promises": "^2.0", - "guzzlehttp/psr7": "^2.4.5", + "guzzlehttp/guzzle": "^7.8.2 || ^8.0", + "guzzlehttp/promises": "^2.0.3 || ^3.0", + "guzzlehttp/psr7": "^2.6.3 || ^3.0", "mtdowling/jmespath.php": "^2.9.1", "php": ">=8.1", "psr/http-message": "^1.0 || ^2.0", @@ -153,9 +153,9 @@ "support": { "forum": "https://github.com/aws/aws-sdk-php/discussions", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.388.12" + "source": "https://github.com/aws/aws-sdk-php/tree/3.389.1" }, - "time": "2026-07-22T18:05:00+00:00" + "time": "2026-07-27T18:09:06+00:00" }, { "name": "brick/math", @@ -1069,16 +1069,16 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.15.1", + "version": "7.15.2", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "61443dfb33c62f308ee8add20f45b4d6e4bf8d2f" + "reference": "744101956d78b7c1384d0cbf379db13e859167bf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/61443dfb33c62f308ee8add20f45b4d6e4bf8d2f", - "reference": "61443dfb33c62f308ee8add20f45b4d6e4bf8d2f", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/744101956d78b7c1384d0cbf379db13e859167bf", + "reference": "744101956d78b7c1384d0cbf379db13e859167bf", "shasum": "" }, "require": { @@ -1177,7 +1177,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.15.1" + "source": "https://github.com/guzzle/guzzle/tree/7.15.2" }, "funding": [ { @@ -1193,7 +1193,7 @@ "type": "tidelift" } ], - "time": "2026-07-18T11:23:11+00:00" + "time": "2026-07-26T23:23:20+00:00" }, { "name": "guzzlehttp/promises", @@ -1486,16 +1486,16 @@ }, { "name": "laravel/framework", - "version": "v13.21.1", + "version": "v13.23.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "303b5f8dc899f89e8c38c350b639b8fbd193ed16" + "reference": "92a707229148e57f08a249211c8a5a194159c619" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/303b5f8dc899f89e8c38c350b639b8fbd193ed16", - "reference": "303b5f8dc899f89e8c38c350b639b8fbd193ed16", + "url": "https://api.github.com/repos/laravel/framework/zipball/92a707229148e57f08a249211c8a5a194159c619", + "reference": "92a707229148e57f08a249211c8a5a194159c619", "shasum": "" }, "require": { @@ -1521,7 +1521,7 @@ "league/flysystem": "^3.25.1", "league/flysystem-local": "^3.25.1", "league/uri": "^7.5.1", - "monolog/monolog": "^3.0", + "monolog/monolog": "^3.10", "nesbot/carbon": "^3.8.4", "nunomaduro/termwind": "^2.0", "php": "^8.3", @@ -1709,7 +1709,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-07-21T14:27:35+00:00" + "time": "2026-07-27T14:48:58+00:00" }, { "name": "laravel/prompts", @@ -6946,16 +6946,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.38.1", + "version": "v1.41.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "e9247d281d694a5120554d9afaf54e070e88a603" + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", - "reference": "e9247d281d694a5120554d9afaf54e070e88a603", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", "shasum": "" }, "require": { @@ -7004,7 +7004,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" }, "funding": [ { @@ -7024,7 +7024,7 @@ "type": "tidelift" } ], - "time": "2026-05-26T05:58:03+00:00" + "time": "2026-07-28T08:25:59+00:00" }, { "name": "symfony/polyfill-intl-idn", @@ -7449,16 +7449,16 @@ }, { "name": "symfony/polyfill-php85", - "version": "v1.38.1", + "version": "v1.41.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", - "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", "shasum": "" }, "require": { @@ -7505,7 +7505,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0" }, "funding": [ { @@ -7525,20 +7525,20 @@ "type": "tidelift" } ], - "time": "2026-05-26T02:25:22+00:00" + "time": "2026-07-01T12:47:55+00:00" }, { "name": "symfony/polyfill-php86", - "version": "v1.38.0", + "version": "v1.41.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php86.git", - "reference": "fcec68d64f46dc84e1f6ffcf2c6dda40ff3143ad" + "reference": "6bc356ed3d8dbfeea8f0de235e34d670704e880e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php86/zipball/fcec68d64f46dc84e1f6ffcf2c6dda40ff3143ad", - "reference": "fcec68d64f46dc84e1f6ffcf2c6dda40ff3143ad", + "url": "https://api.github.com/repos/symfony/polyfill-php86/zipball/6bc356ed3d8dbfeea8f0de235e34d670704e880e", + "reference": "6bc356ed3d8dbfeea8f0de235e34d670704e880e", "shasum": "" }, "require": { @@ -7585,7 +7585,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php86/tree/v1.38.0" + "source": "https://github.com/symfony/polyfill-php86/tree/v1.41.0" }, "funding": [ { @@ -7605,7 +7605,7 @@ "type": "tidelift" } ], - "time": "2026-05-25T11:52:35+00:00" + "time": "2026-07-02T13:42:24+00:00" }, { "name": "symfony/polyfill-uuid", @@ -10925,11 +10925,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.2.5", + "version": "2.2.6", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0", - "reference": "909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/a6e9b5a9420f6109c091e87d82683bd1a80b87ed", + "reference": "a6e9b5a9420f6109c091e87d82683bd1a80b87ed", "shasum": "" }, "require": { @@ -10985,7 +10985,7 @@ "type": "github" } ], - "time": "2026-07-05T06:31:06+00:00" + "time": "2026-07-26T21:22:49+00:00" }, { "name": "phpunit/php-code-coverage", diff --git a/config/app.php b/config/app.php index 1d0e07c..72eb6d3 100644 --- a/config/app.php +++ b/config/app.php @@ -15,6 +15,13 @@ 'name' => env('APP_NAME', 'codebar Solutions AG'), + /* + * Pinned explicitly rather than left to the framework default: robots.txt, + * the sitemap, canonical tags, OG URLs and the JSON-LD @id anchors are all + * built from this. A wrong value here poisons every absolute URL we emit. + */ + 'url' => env('APP_URL', 'https://www.codebar.ch'), + 'timezone' => env('APP_TIMEZONE', 'Europe/Zurich'), 'locale' => env('APP_LOCALE', 'de_CH'), diff --git a/config/company.php b/config/company.php new file mode 100644 index 0000000..d2835a2 --- /dev/null +++ b/config/company.php @@ -0,0 +1,109 @@ +<?php + +/* +|-------------------------------------------------------------------------- +| Company master data +|-------------------------------------------------------------------------- +| +| Single source of truth for name, address and phone (NAP). Both the visible +| pages (contact, imprint) and the JSON-LD schema graph read from here, so the +| two can never drift apart — inconsistent NAP data is exactly what stops +| Google and AI answer engines from resolving us as one entity. +| +| Keep this in sync with the Zefix entry, the Google Business Profiles and the +| LinkedIn company page. If a value changes here, it changes everywhere. +| +*/ + +return [ + 'legal_name' => 'codebar Solutions AG', + + /* + * Names people actually search for. Feeds schema.org alternateName, which + * is how Google learns that "codebar Solutions" and "codebar Solutions AG" + * are the same entity. + */ + 'alternate_names' => [ + 'codebar Solutions', + 'codebar', + ], + + 'legal_form' => 'Aktiengesellschaft (AG)', + + /* Swiss business identification number (UID/CHE), as shown in the imprint. */ + 'uid' => 'CHE-257.955.682', + 'zefix_url' => 'https://zefix.ch/de/search/entity/list/firm/1466584', + + 'email' => 'info@codebar.ch', + + /* + * 'e164' is the machine-readable form for schema.org and tel: links, + * 'display' is what a human sees on the page. + */ + 'phone' => [ + 'e164' => '+41615156090', + 'display' => '+41 61 515 60 90', + ], + + 'logo' => 'images/logos/codebar-logo-colored.png', + + /* + * Verified profiles on other platforms. schema.org sameAs — this is the + * anchor set search engines and LLMs use to tie the website to the company + * as a known entity. + * + * Only ever add URLs that genuinely belong to us and resolve. An invented + * or dead profile is worse than a short list. + */ + 'same_as' => [ + 'https://github.com/codebar-ag', + // TODO: LinkedIn company page — not yet linked anywhere on the site. + // TODO: Swiss Made Software / Swiss Digital Services profile pages. + ], + + /* + * Physical locations, in the order they appear on the contact page. + * 'primary' marks the registered head office. + * + * 'geo' is intentionally absent: we have no surveyed coordinates, and a + * guessed lat/lng is worse than none — Google geocodes the postal address + * on its own. Add it only with values taken from the Business Profile. + */ + 'locations' => [ + [ + 'key' => 'zunzgen', + 'primary' => true, + 'city' => 'Zunzgen', + 'label' => 'Headquarter', + 'street' => 'Hauptstrasse 91', + 'postal_code' => '4455', + 'country' => 'CH', + 'map_url' => 'https://maps.app.goo.gl/d9iK5vCrHHAHUcvx6', + ], + [ + 'key' => 'oberwil', + 'primary' => false, + 'city' => 'Oberwil', + 'label' => 'Office', + 'street' => 'Langegasse 39', + 'postal_code' => '4104', + 'country' => 'CH', + 'map_url' => 'https://maps.app.goo.gl/1ndrUgUvw2pxxekUA', + ], + ], + + /* + * Opening hours. 'open'/'close' null means closed that day. + * Day keys are English weekday names — schema.org expects exactly these, + * and the opening-hours component translates them for display. + */ + 'opening_hours' => [ + ['day' => 'Monday', 'open' => '08:00', 'close' => '18:00'], + ['day' => 'Tuesday', 'open' => '08:00', 'close' => '18:00'], + ['day' => 'Wednesday', 'open' => '08:00', 'close' => '18:00'], + ['day' => 'Thursday', 'open' => '08:00', 'close' => '18:00'], + ['day' => 'Friday', 'open' => '08:00', 'close' => '18:00'], + ['day' => 'Saturday', 'open' => '08:00', 'close' => '12:00'], + ['day' => 'Sunday', 'open' => null, 'close' => null], + ], +]; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 9a78d87..064e5a1 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -36,6 +36,9 @@ public function run(): void $this->call(PagesTableSeeder::class); $this->call(SeoImageCleanupSeeder::class); $this->call(ContactsTableSeeder::class); + $this->call(NewsTableSeeder::class); + // OpenSource content comes from `php artisan sync:repositories`, which + // pulls the live GitHub repositories — the seed data here is stale. // $this->call(OpenSourceTableSeeder::class); $this->call(TechnologiesTableSeeder::class); $this->call(ServicesTableSeeder::class); diff --git a/database/seeders/NewsTableSeeder.php b/database/seeders/NewsTableSeeder.php index 13c6a5d..09ed62f 100644 --- a/database/seeders/NewsTableSeeder.php +++ b/database/seeders/NewsTableSeeder.php @@ -10,49 +10,35 @@ class NewsTableSeeder extends Seeder { /** - * Run the database seeds. + * Only articles that genuinely exist in both languages belong here. + * + * This seeder previously defined two entries that both read the same + * markdown file, and one of them paired a German title with an unrelated + * English one. Published, that would have produced two URLs with identical + * bodies plus an hreflang pair joining two different articles — exactly the + * duplicate-content and wrong-language signals that cost indexing across a + * whole domain. Add the second article back once its text has been written. */ public function run(): void { - $this->seed( slug: 'docuware-7-12-is-here', publishedAt: Carbon::parse('2025-04-06'), author: 'Sebastian Bürgin-Fix', localizedData: [ 'de_CH' => [ - 'title' => 'Hello World! codebar stellt sich vor.', - 'teaser' => 'Computerprogramme werden vielfach in fernen Ländern entwickelt, nicht aber bei codebar. Hier gibt es «Software made in Basel». Wir haben Sebastian Fix, den Geschäftsführer dieses Start-ups, nach der Idee dahinter gefragt.', + 'title' => 'DocuWare 7.12 ist da', + 'teaser' => 'Mehr Automatisierung, mehr Einblick, mehr Effizienz: Das Release verbessert die E-Rechnungsverarbeitung, bringt IDP in die Cloud-Konfiguration und öffnet Workflow-Daten für Analytics.', 'content' => file_get_contents(database_path('files/news/de_CH/20250406_docuware_712.md')), ], 'en_CH' => [ 'title' => 'DocuWare 7.12 is here', - 'teaser' => 'More automation, more insights, more efficiency', + 'teaser' => 'More automation, more insight, more efficiency: this release improves e-invoice processing, brings IDP into the cloud configuration and opens workflow data up to analytics.', 'content' => file_get_contents(database_path('files/news/en_CH/20250406_docuware_712.md')), ], ], tags: ['DMS/ECM', 'DocuWare'], ); - - $this->seed( - slug: 'docu-ware-cloud-partner', - publishedAt: Carbon::parse('2025-04-06'), - author: 'Sebastian Bürgin-Fix', - localizedData: [ - 'de_CH' => [ - 'title' => 'DocuWare und codebar Solutions AG: Zwei Partner, eine Mission', - 'teaser' => 'Die codebar Solutions AG ist seit Februar 2023 offizieller Partner der Dokumenten-Management-Lösung (DMS) DocuWare Cloud. Dadurch haben unsere Kund*innen ab sofort ein Tool an der Hand, welches ihnen helfen wird, die Digitalisierung im eigenen Unternehmen voranzutreiben.', - 'content' => file_get_contents(database_path('files/news/de_CH/20250406_docuware_712.md')), - ], - 'en_CH' => [ - 'title' => 'DocuWare 7.12 is here', - 'teaser' => 'More automation, more insights, more efficiency', - 'content' => file_get_contents(database_path('files/news/en_CH/20250406_docuware_712.md')), - ], - ], - tags: ['DMS/ECM', 'DocuWare'], - ); - } /** diff --git a/database/seeders/PagesTableSeeder.php b/database/seeders/PagesTableSeeder.php index 4a5b6cb..13a8e95 100644 --- a/database/seeders/PagesTableSeeder.php +++ b/database/seeders/PagesTableSeeder.php @@ -52,8 +52,8 @@ private function upcoming(): void $pages = [ 'technologies.index' => [ 'robots' => 'index,follow', - 'de_CH' => ['title' => 'Offene Technologien & Standards', 'description' => 'Die offenen Technologien und Standards, auf denen unsere Software basiert – bewährt, transparent und herstellerunabhängig.'], - 'en_CH' => ['title' => 'Open Technologies & Standards', 'description' => 'The open technologies and standards we build our software on – proven, transparent and vendor-independent.'], + 'de_CH' => ['title' => 'Offene Technologien & Standards – codebar Solutions AG', 'description' => 'Die offenen Technologien und Standards, auf denen unsere Software basiert – bewährt, transparent und herstellerunabhängig.'], + 'en_CH' => ['title' => 'Open Technologies & Standards – codebar Solutions AG', 'description' => 'The open technologies and standards we build our software on – proven, transparent and vendor-independent.'], ], 'co-working.index' => [ 'robots' => 'index,follow', @@ -62,8 +62,8 @@ private function upcoming(): void ], 'open-source.index' => [ 'robots' => 'index,follow', - 'de_CH' => ['title' => 'Open-Source-Beiträge', 'description' => 'Unsere Beiträge an die Open-Source-Community – Packages, Tools und Libraries, entwickelt und gepflegt von codebar.'], - 'en_CH' => ['title' => 'Open Source Contributions', 'description' => 'Our contributions to the open source community – packages, tools and libraries developed and maintained by codebar.'], + 'de_CH' => ['title' => 'Open-Source-Beiträge – codebar Solutions AG', 'description' => 'Unsere Beiträge an die Open-Source-Community – Packages, Tools und Libraries, entwickelt und gepflegt von codebar.'], + 'en_CH' => ['title' => 'Open Source Contributions – codebar Solutions AG', 'description' => 'Our contributions to the open source community – packages, tools and libraries developed and maintained by codebar.'], ], 'jobs.index' => [ 'robots' => 'index,follow', @@ -107,8 +107,8 @@ private function upcoming(): void ], 'network.request.index' => [ 'robots' => 'noindex,nofollow', - 'de_CH' => ['title' => 'Netzwerk-Profil aktualisieren', 'description' => 'Persönlichen Link anfordern, um das eigene Profil im codebar Netzwerk zu aktualisieren.'], - 'en_CH' => ['title' => 'Update network profile', 'description' => 'Request a personal link to update your codebar network profile.'], + 'de_CH' => ['title' => 'Netzwerk-Profil aktualisieren – codebar Solutions AG', 'description' => 'Persönlichen Link anfordern, um das eigene Profil im codebar Netzwerk zu aktualisieren.'], + 'en_CH' => ['title' => 'Update network profile – codebar Solutions AG', 'description' => 'Request a personal link to update your codebar network profile.'], ], ]; diff --git a/database/seeders/data/pages.csv b/database/seeders/data/pages.csv index 3b5945a..72ef42b 100644 --- a/database/seeders/data/pages.csv +++ b/database/seeders/data/pages.csv @@ -1,15 +1,15 @@ id;key;locale;robots;title;description;image;created_at;updated_at 1;start.index;de_CH;index,follow;Innovative Ideen zum Leben erwecken – codebar Solutions AG;Wir hören zu, verstehen dein Problem und begleiten dich von der ersten Idee bis zur Software im täglichen Einsatz. Ein kleines Team aus der Region Basel.;;2026-06-01 19:12:39;2026-07-22 00:00:00 -2;news.index;de_CH;index,follow;Neuigkeiten & Insights;Aktuelle News, Fachbeiträge und Trends rund um Softwareentwicklung, offene Technologien und digitale Innovation von codebar.;;2026-06-01 19:12:39;2026-06-01 19:12:39 +2;news.index;de_CH;index,follow;Neuigkeiten & Insights – codebar Solutions AG;Aktuelle News, Fachbeiträge und Trends rund um Softwareentwicklung, offene Technologien und digitale Innovation von codebar.;;2026-06-01 19:12:39;2026-06-01 19:12:39 3;about-us.index;de_CH;index,follow;Team – die Menschen hinter codebar Solutions AG;Klein aus Überzeugung: kurze Wege, direkte Ansprechpersonen – und Ausbildung als Teil unserer Kultur. Lerne die Menschen hinter codebar kennen.;;2026-06-01 19:12:39;2026-07-22 00:00:00 4;services.index;de_CH;index,follow;Expertise – Konzeption, Software, DMS & ERP | codebar Solutions AG;Vier Bereiche, ein Weg: von der ersten Idee bis zum laufenden Betrieb – Konzeption, Softwareentwicklung, Dokumentenmanagement und ERP.;;2026-06-01 19:12:39;2026-07-22 00:00:00 -5;products.index;de_CH;index,follow;Nutzerzentrierte Softwarelösungen;Nutzerzentrierte Softwarelösungen mit echtem Mehrwert – entwickelt mit offenen Technologien und Standards.;;2026-06-01 19:12:39;2026-06-01 19:12:39 +5;products.index;de_CH;index,follow;Nutzerzentrierte Softwarelösungen – codebar Solutions AG;Nutzerzentrierte Softwarelösungen mit echtem Mehrwert – entwickelt mit offenen Technologien und Standards.;;2026-06-01 19:12:39;2026-06-01 19:12:39 6;legal.imprint.index;de_CH;index,follow;Impressum – codebar Solutions AG;Rechtliche Angaben zur codebar Solutions AG.;;2026-06-01 19:12:39;2026-07-23 00:00:00 7;contact.index;de_CH;index,follow;Kontakt – lass uns sprechen | codebar Solutions AG;Erzähl uns von deiner Idee oder deinem Projekt – wir hören zu. Telefon, E-Mail und unsere zwei Standorte in der Region Basel.;;2026-06-01 19:12:39;2026-07-22 00:00:00 8;start.index;en_CH;index,follow;Bringing Innovative Ideas to Life – codebar Solutions AG;We listen, understand your problem and stay with you from the first idea to software in daily use. A small team based in the Basel region.;;2026-06-01 19:12:39;2026-07-22 00:00:00 -9;news.index;en_CH;index,follow;News & Insights;Latest news and expert insights on software development, open technologies and digital innovation from codebar.;;2026-06-01 19:12:39;2026-06-01 19:12:39 +9;news.index;en_CH;index,follow;News & Insights – codebar Solutions AG;Latest news and expert insights on software development, open technologies and digital innovation from codebar.;;2026-06-01 19:12:39;2026-06-01 19:12:39 10;about-us.index;en_CH;index,follow;Team – the People Behind codebar Solutions AG;Small by conviction: short paths, direct contacts – and training as part of our culture. Meet the people behind codebar.;;2026-06-01 19:12:39;2026-07-22 00:00:00 11;services.index;en_CH;index,follow;Expertise – Concept, Software, DMS & ERP | codebar Solutions AG;Four areas, one path: from the first idea to day-to-day operations – concept design, software development, document management and ERP.;;2026-06-01 19:12:39;2026-07-22 00:00:00 -12;products.index;en_CH;index,follow;User-Centric Software Solutions;Our software solutions are built around the needs of users, delivering real value through open technologies and standards.;;2026-06-01 19:12:39;2026-06-01 19:12:39 +12;products.index;en_CH;index,follow;User-Centric Software Solutions – codebar Solutions AG;Our software solutions are built around the needs of users, delivering real value through open technologies and standards.;;2026-06-01 19:12:39;2026-06-01 19:12:39 13;legal.imprint.index;en_CH;index,follow;Imprint – codebar Solutions AG;Legal details of codebar Solutions AG.;;2026-06-01 19:12:39;2026-07-23 00:00:00 14;contact.index;en_CH;index,follow;Contact – Let's Talk | codebar Solutions AG;Tell us about your idea or your project – we'll listen. Phone, email and our two locations in the Basel region.;;2026-06-01 19:12:39;2026-07-22 00:00:00 diff --git a/lang/de_CH/components.php b/lang/de_CH/components.php index fc87acd..3d7c934 100644 --- a/lang/de_CH/components.php +++ b/lang/de_CH/components.php @@ -80,6 +80,7 @@ 'total' => 'Total', ], 'empty' => 'Noch keine Nutzungsdaten vorhanden.', + 'last_synced' => 'Zuletzt aktualisiert am :datetime, Daten bis und mit :date.', 'back' => 'Zurück zu unseren LLMs', ], 'ai_llm' => [ diff --git a/lang/en_CH.json b/lang/en_CH.json index 54020f4..1ab8328 100644 --- a/lang/en_CH.json +++ b/lang/en_CH.json @@ -303,6 +303,6 @@ "Terms 8 heading": "8. Severability clause", "Terms 8 body": "Should individual provisions of these GTC be invalid, they shall be replaced by provisions that come closest to the economic purpose. The validity of the remaining provisions remains unaffected.", "Terms 9 heading": "9. Place of jurisdiction & applicable law", - "Terms 9 body": "Swiss law applies. The place of jurisdiction is Basel-Landschaft or the registered office of codebar Solutions AG, unless otherwise agreed.", + "Terms 9 body": "Swiss law applies. The place of jurisdiction is the registered office of codebar Solutions AG, unless otherwise agreed.", "Terms language body": "In the event of discrepancies between the German and English versions of these GTC, the German version prevails." } diff --git a/lang/en_CH/components.php b/lang/en_CH/components.php index d662f79..010b9ec 100644 --- a/lang/en_CH/components.php +++ b/lang/en_CH/components.php @@ -80,6 +80,7 @@ 'total' => 'Total', ], 'empty' => 'No usage data available yet.', + 'last_synced' => 'Last updated on :datetime, data up to and including :date.', 'back' => 'Back to our LLMs', ], 'ai_llm' => [ diff --git a/package-lock.json b/package-lock.json index 3cfcda7..9c6822e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -526,9 +526,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", - "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.3.tgz", + "integrity": "sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==", "cpu": [ "arm" ], @@ -539,9 +539,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", - "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.3.tgz", + "integrity": "sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==", "cpu": [ "arm64" ], @@ -552,9 +552,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", - "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.3.tgz", + "integrity": "sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==", "cpu": [ "arm64" ], @@ -565,9 +565,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", - "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.3.tgz", + "integrity": "sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==", "cpu": [ "x64" ], @@ -578,9 +578,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", - "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.3.tgz", + "integrity": "sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==", "cpu": [ "arm64" ], @@ -591,9 +591,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", - "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.3.tgz", + "integrity": "sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==", "cpu": [ "x64" ], @@ -604,9 +604,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", - "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.3.tgz", + "integrity": "sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==", "cpu": [ "arm" ], @@ -617,9 +617,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", - "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.3.tgz", + "integrity": "sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==", "cpu": [ "arm" ], @@ -630,9 +630,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", - "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.3.tgz", + "integrity": "sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==", "cpu": [ "arm64" ], @@ -643,9 +643,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", - "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.3.tgz", + "integrity": "sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==", "cpu": [ "arm64" ], @@ -656,9 +656,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", - "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.3.tgz", + "integrity": "sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==", "cpu": [ "loong64" ], @@ -669,9 +669,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", - "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.3.tgz", + "integrity": "sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==", "cpu": [ "loong64" ], @@ -682,9 +682,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", - "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.3.tgz", + "integrity": "sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==", "cpu": [ "ppc64" ], @@ -695,9 +695,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", - "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.3.tgz", + "integrity": "sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==", "cpu": [ "ppc64" ], @@ -708,9 +708,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", - "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.3.tgz", + "integrity": "sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==", "cpu": [ "riscv64" ], @@ -721,9 +721,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", - "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.3.tgz", + "integrity": "sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==", "cpu": [ "riscv64" ], @@ -734,9 +734,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", - "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.3.tgz", + "integrity": "sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==", "cpu": [ "s390x" ], @@ -747,9 +747,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", - "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.3.tgz", + "integrity": "sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==", "cpu": [ "x64" ], @@ -760,9 +760,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", - "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.3.tgz", + "integrity": "sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==", "cpu": [ "x64" ], @@ -773,9 +773,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", - "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.3.tgz", + "integrity": "sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==", "cpu": [ "x64" ], @@ -786,9 +786,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", - "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.3.tgz", + "integrity": "sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==", "cpu": [ "arm64" ], @@ -799,9 +799,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", - "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.3.tgz", + "integrity": "sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==", "cpu": [ "arm64" ], @@ -812,9 +812,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", - "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.3.tgz", + "integrity": "sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==", "cpu": [ "ia32" ], @@ -825,9 +825,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", - "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.3.tgz", + "integrity": "sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==", "cpu": [ "x64" ], @@ -838,9 +838,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", - "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.3.tgz", + "integrity": "sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==", "cpu": [ "x64" ], @@ -1166,9 +1166,9 @@ "license": "MIT" }, "node_modules/acorn": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", - "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "devOptional": true, "license": "MIT", "bin": { @@ -1269,9 +1269,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.1.tgz", - "integrity": "sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==", + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.5.tgz", + "integrity": "sha512-xJo6a6YZnwZfnyGmQKWMbVOcii7XRibjOskRh+WJ9UHQoX16xrQrcIgAMQOzfvs8XiLMx6ih/fsLPF73iY2D1A==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -1518,9 +1518,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.395", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.395.tgz", - "integrity": "sha512-7zt9Aw+SrmxLWLN0zhaTWZQiCdryLVrYTq5R7iZakLvi2UQPYMMsROYV/2qVCzMeCiSXHwKOU+sZ4zOVVlrtKA==", + "version": "1.5.397", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.397.tgz", + "integrity": "sha512-khGTy9U9x02KEtsKM8vx5A62BsRmcOsIgDpWr1ImE32Ax8GxHGPHZf+Eu9H8zOOyHJnB0jTbseyTHbq2XCT8yw==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -2246,9 +2246,9 @@ } }, "node_modules/postcss": { - "version": "8.5.22", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", - "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==", + "version": "8.5.24", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.24.tgz", + "integrity": "sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw==", "funding": [ { "type": "opencollective", @@ -2311,9 +2311,9 @@ } }, "node_modules/rollup": { - "version": "4.62.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", - "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.3.tgz", + "integrity": "sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==", "license": "MIT", "dependencies": { "@types/estree": "1.0.9" @@ -2326,31 +2326,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.62.2", - "@rollup/rollup-android-arm64": "4.62.2", - "@rollup/rollup-darwin-arm64": "4.62.2", - "@rollup/rollup-darwin-x64": "4.62.2", - "@rollup/rollup-freebsd-arm64": "4.62.2", - "@rollup/rollup-freebsd-x64": "4.62.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", - "@rollup/rollup-linux-arm-musleabihf": "4.62.2", - "@rollup/rollup-linux-arm64-gnu": "4.62.2", - "@rollup/rollup-linux-arm64-musl": "4.62.2", - "@rollup/rollup-linux-loong64-gnu": "4.62.2", - "@rollup/rollup-linux-loong64-musl": "4.62.2", - "@rollup/rollup-linux-ppc64-gnu": "4.62.2", - "@rollup/rollup-linux-ppc64-musl": "4.62.2", - "@rollup/rollup-linux-riscv64-gnu": "4.62.2", - "@rollup/rollup-linux-riscv64-musl": "4.62.2", - "@rollup/rollup-linux-s390x-gnu": "4.62.2", - "@rollup/rollup-linux-x64-gnu": "4.62.2", - "@rollup/rollup-linux-x64-musl": "4.62.2", - "@rollup/rollup-openbsd-x64": "4.62.2", - "@rollup/rollup-openharmony-arm64": "4.62.2", - "@rollup/rollup-win32-arm64-msvc": "4.62.2", - "@rollup/rollup-win32-ia32-msvc": "4.62.2", - "@rollup/rollup-win32-x64-gnu": "4.62.2", - "@rollup/rollup-win32-x64-msvc": "4.62.2", + "@rollup/rollup-android-arm-eabi": "4.62.3", + "@rollup/rollup-android-arm64": "4.62.3", + "@rollup/rollup-darwin-arm64": "4.62.3", + "@rollup/rollup-darwin-x64": "4.62.3", + "@rollup/rollup-freebsd-arm64": "4.62.3", + "@rollup/rollup-freebsd-x64": "4.62.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.3", + "@rollup/rollup-linux-arm-musleabihf": "4.62.3", + "@rollup/rollup-linux-arm64-gnu": "4.62.3", + "@rollup/rollup-linux-arm64-musl": "4.62.3", + "@rollup/rollup-linux-loong64-gnu": "4.62.3", + "@rollup/rollup-linux-loong64-musl": "4.62.3", + "@rollup/rollup-linux-ppc64-gnu": "4.62.3", + "@rollup/rollup-linux-ppc64-musl": "4.62.3", + "@rollup/rollup-linux-riscv64-gnu": "4.62.3", + "@rollup/rollup-linux-riscv64-musl": "4.62.3", + "@rollup/rollup-linux-s390x-gnu": "4.62.3", + "@rollup/rollup-linux-x64-gnu": "4.62.3", + "@rollup/rollup-linux-x64-musl": "4.62.3", + "@rollup/rollup-openbsd-x64": "4.62.3", + "@rollup/rollup-openharmony-arm64": "4.62.3", + "@rollup/rollup-win32-arm64-msvc": "4.62.3", + "@rollup/rollup-win32-ia32-msvc": "4.62.3", + "@rollup/rollup-win32-x64-gnu": "4.62.3", + "@rollup/rollup-win32-x64-msvc": "4.62.3", "fsevents": "~2.3.2" } }, diff --git a/resources/views/app/about-us/index.blade.php b/resources/views/app/about-us/index.blade.php index 46177ef..0a8b761 100644 --- a/resources/views/app/about-us/index.blade.php +++ b/resources/views/app/about-us/index.blade.php @@ -1,4 +1,4 @@ -<x-app-layout :page="$page" :preconnect-cloudinary="true"> +<x-app-layout :page="$page" :preconnect-cloudinary="true" :schema="$schema"> <x-layout.page-header :title="__('Team')" :intro="__('components.team.header')"/> <x-layout.section> diff --git a/resources/views/app/ai/llm/analytics.blade.php b/resources/views/app/ai/llm/analytics.blade.php index 218dca6..f5db7b1 100644 --- a/resources/views/app/ai/llm/analytics.blade.php +++ b/resources/views/app/ai/llm/analytics.blade.php @@ -50,6 +50,17 @@ class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> </x-layout.section> @endif + @if ($lastSyncedAt && $latestDate) + <x-layout.section class="mt-4 text-sm text-muted"> + <p> + {{ __('components.ai_llm_analytics.last_synced', [ + 'datetime' => \App\Helpers\Facades\HelperDate::formatDateTime($lastSyncedAt), + 'date' => \App\Helpers\Facades\HelperDate::formatDate($latestDate), + ]) }} + </p> + </x-layout.section> + @endif + @if ($periods->isEmpty()) <x-layout.section> <p class="text-muted">{{ __('components.ai_llm_analytics.empty') }}</p> diff --git a/resources/views/app/contact/index.blade.php b/resources/views/app/contact/index.blade.php index 93a22fe..e0ca2ef 100644 --- a/resources/views/app/contact/index.blade.php +++ b/resources/views/app/contact/index.blade.php @@ -1,14 +1,15 @@ -<x-app-layout :page="$page"> +<x-app-layout :page="$page" :schema="$schema"> <x-layout.page-header :title="__('Contact')" :intro="__('components.contact.header')"/> <x-layout.section class="flex flex-col gap-6 sm:flex-row sm:flex-wrap sm:gap-12"> <div> <x-h2 :title="__('Phone')"/> - <x-ui.link href="tel:0041615156090" label="{{ __('+41 61 515 60 90') }}" class="block"/> + <x-ui.link href="tel:{{ config('company.phone.e164') }}" label="{{ __('+41 61 515 60 90') }}" class="block"/> </div> <div> <x-h2 :title="__('Email')"/> - <x-ui.link href="mailto:info@codebar.ch?subject=Hello%20World!" label="{{ __('info(at)codebar.ch') }}" + <x-ui.link href="mailto:{{ config('company.email') }}?subject=Hello%20World!" + label="{{ __('info(at)codebar.ch') }}" class="block"/> </div> </x-layout.section> @@ -16,18 +17,18 @@ class="block"/> <x-layout.section> <x-h2 :title="__('Locations')"/> <x-layout.grid :cols="2" gap="gap-6 sm:gap-4"> - <x-card.address-card - :title="__('Zunzgen')" - :label="__('Headquarter')" - :lines="['codebar Solutions AG', 'Hauptstrasse 91', 'CH-4455 Zunzgen']" - link-href="https://maps.app.goo.gl/d9iK5vCrHHAHUcvx6" - link-label="{{ __('Google Maps') }} — {{ __('Zunzgen') }}"/> - <x-card.address-card - :title="__('Oberwil')" - :label="__('Office')" - :lines="['codebar Solutions AG', 'Langegasse 39', 'CH-4104 Oberwil']" - link-href="https://maps.app.goo.gl/1ndrUgUvw2pxxekUA" - link-label="{{ __('Google Maps') }} — {{ __('Oberwil') }}"/> + @foreach($locations as $location) + <x-card.address-card + :title="__($location['city'])" + :label="__($location['label'])" + :lines="[ + config('company.legal_name'), + $location['street'], + $location['country'] . '-' . $location['postal_code'] . ' ' . $location['city'], + ]" + :link-href="$location['map_url']" + link-label="{{ __('Google Maps') }} — {{ __($location['city']) }}"/> + @endforeach </x-layout.grid> </x-layout.section> diff --git a/resources/views/app/legal/imprint/index.blade.php b/resources/views/app/legal/imprint/index.blade.php index 3e5aa15..c58ac73 100644 --- a/resources/views/app/legal/imprint/index.blade.php +++ b/resources/views/app/legal/imprint/index.blade.php @@ -4,19 +4,20 @@ <x-layout.section> <x-h2 :title="__('Company')"/> <x-card.address-card - :lines="['codebar Solutions AG', __('Legal form AG'), 'CHE-257.955.682']" - link-href="https://zefix.ch/de/search/entity/list/firm/1466584" + :lines="[config('company.legal_name'), __('Legal form AG'), config('company.uid')]" + :link-href="config('company.zefix_url')" link-label="{{ __('Zefix') }}"/> </x-layout.section> <x-layout.section class="flex flex-col gap-6 sm:flex-row sm:flex-wrap sm:gap-12"> <div> <x-h2 :title="__('Email')"/> - <x-ui.link href="mailto:info@codebar.ch" label="{{ __('info(at)codebar.ch') }}" class="block"/> + <x-ui.link href="mailto:{{ config('company.email') }}" label="{{ __('info(at)codebar.ch') }}" + class="block"/> </div> <div> <x-h2 :title="__('Phone')"/> - <x-ui.link href="tel:0041615156090" label="{{ __('+41 61 515 60 90') }}" class="block"/> + <x-ui.link href="tel:{{ config('company.phone.e164') }}" label="{{ __('+41 61 515 60 90') }}" class="block"/> </div> </x-layout.section> diff --git a/resources/views/app/news/index.blade.php b/resources/views/app/news/index.blade.php index cb74506..33be203 100644 --- a/resources/views/app/news/index.blade.php +++ b/resources/views/app/news/index.blade.php @@ -1,4 +1,4 @@ -<x-app-layout :page="$page"> +<x-app-layout :page="$page" :schema="$schema"> <x-layout.page-header :title="__('News')" :intro="__('Insights from our day-to-day work: what we are building, what we are learning, and what is happening at codebar.')"/> diff --git a/resources/views/app/news/show.blade.php b/resources/views/app/news/show.blade.php index 6bd7442..358ed76 100644 --- a/resources/views/app/news/show.blade.php +++ b/resources/views/app/news/show.blade.php @@ -1,4 +1,4 @@ -<x-app-layout :page="$page"> +<x-app-layout :page="$page" :schema="$schema"> @if(!empty($tags) && $tags->count()) <x-layout.section> diff --git a/resources/views/app/open-source/index.blade.php b/resources/views/app/open-source/index.blade.php index f695576..8df6288 100644 --- a/resources/views/app/open-source/index.blade.php +++ b/resources/views/app/open-source/index.blade.php @@ -5,12 +5,17 @@ <x-layout.section> <x-layout.list> @foreach($openSource as $entry) + {{-- Entries with a written body get an internal detail page; the + rest link straight to GitHub, because there is nothing here + to show beyond what the repository already says. --}} <x-card.item-card - :url="$entry->link" + :url="$entry->hasWrittenContent() + ? localized_route('open-source.show', ['locale' => app()->getLocale(), 'openSource' => $entry]) + : $entry->link" :title="$entry->title" :teaser="$entry->teaser" :tags="$entry->tags" - target="_blank" + :target="$entry->hasWrittenContent() ? '_self' : '_blank'" :level="2"/> @endforeach </x-layout.list> diff --git a/resources/views/app/open-source/show.blade.php b/resources/views/app/open-source/show.blade.php index a8f7c81..73fbfc5 100644 --- a/resources/views/app/open-source/show.blade.php +++ b/resources/views/app/open-source/show.blade.php @@ -1,12 +1,24 @@ -<x-app-layout :page="$page"> +<x-app-layout :page="$page" :schema="$schema"> <x-h1 :title="$name"/> <x-h1-teaser :teaser="$teaser"/> + @if(filled($tags)) + <x-layout.section> + <x-data.tag-list :tags="$tags"/> + </x-layout.section> + @endif + <x-layout.section> <x-ui.prose> {!! $content !!} </x-ui.prose> </x-layout.section> + @if(filled($link)) + <x-layout.section> + <x-ui.link :href="$link" target="_blank" label="{{ __('View on GitHub') }}"/> + </x-layout.section> + @endif + </x-app-layout> diff --git a/resources/views/app/services/index.blade.php b/resources/views/app/services/index.blade.php index ce7f498..7ba6148 100644 --- a/resources/views/app/services/index.blade.php +++ b/resources/views/app/services/index.blade.php @@ -1,4 +1,4 @@ -<x-app-layout :page="$page"> +<x-app-layout :page="$page" :schema="$schema"> <x-layout.page-header :title="__('Services')" :intro="__('components.services.header')"/> <x-layout.section> diff --git a/resources/views/layouts/_partials/_navigation.blade.php b/resources/views/layouts/_partials/_navigation.blade.php index 20f5248..31c1e9d 100644 --- a/resources/views/layouts/_partials/_navigation.blade.php +++ b/resources/views/layouts/_partials/_navigation.blade.php @@ -14,15 +14,18 @@ class="md:hidden p-2 -mr-2 hover:text-brand transition focus:outline-none focus- @if(!empty($locales)) <div class="hidden md:flex gap-2 text-lg items-center"> + {{-- Real links, not a form: crawlers cannot submit forms, so a + form would leave the two language versions connected only by + the hreflang tags in <head>. SetLanguage reads the locale + from the URL and persists it, so no POST is needed. --}} @foreach($locales as $language) - <form method="POST" action="{{ route('language.update') }}"> - @csrf - <input type="hidden" name="language" value="{{ $language->value }}"> - <button type="submit" class="hover:text-brand hover:font-semibold transition cursor-pointer" - title="{{ __('Update to :lang language', ['lang' => $language->getLabel()]) }}"> - {{ $language->getLabel() }} - </button> - </form> + <a href="{{ locale_switch_url($language->value) }}" + hreflang="{{ str_replace('_', '-', $language->value) }}" + @if($language->value === app()->getLocale()) aria-current="true" @endif + class="hover:text-brand hover:font-semibold transition" + title="{{ __('Update to :lang language', ['lang' => $language->getLabel()]) }}"> + {{ $language->getLabel() }} + </a> @if (!$loop->last) <span class="text-gray-300 font-light" aria-hidden="true">|</span> @endif diff --git a/resources/views/layouts/_partials/_navigation_desktop.blade.php b/resources/views/layouts/_partials/_navigation_desktop.blade.php index 93e2f0b..4f65d21 100644 --- a/resources/views/layouts/_partials/_navigation_desktop.blade.php +++ b/resources/views/layouts/_partials/_navigation_desktop.blade.php @@ -7,12 +7,16 @@ <x-ui.link :href="localized_route('services.index')" label="{{ __('Services') }}" class="text-xl md:text-2xl" /> - {{-- <span class="text-gray-300" aria-hidden="true">|</span> + <span class="text-gray-300" aria-hidden="true">|</span> <x-ui.link :href="localized_route('about-us.index')" label="{{ __('Team') }}" class="text-xl md:text-2xl" /> <span class="text-gray-300" aria-hidden="true">|</span> + <x-ui.link :href="localized_route('news.index')" label="{{ __('News') }}" class="text-xl md:text-2xl" /> + + {{-- <span class="text-gray-300" aria-hidden="true">|</span> + <x-ui.link :href="localized_route('technologies.index')" label="{{ __('Technologies') }}" class="text-xl md:text-2xl" /> --}} <span class="text-gray-300" aria-hidden="true">|</span> diff --git a/resources/views/layouts/_partials/_navigation_mobile.blade.php b/resources/views/layouts/_partials/_navigation_mobile.blade.php index 3c903d3..d9f22b1 100644 --- a/resources/views/layouts/_partials/_navigation_mobile.blade.php +++ b/resources/views/layouts/_partials/_navigation_mobile.blade.php @@ -26,11 +26,17 @@ class="block py-3 text-center bg-gray-50 hover:text-brand hover:font-semibold tr {{ __('Services') }} </a> - {{-- <!-- Team --> + <!-- Team --> <a href="{{ localized_route('about-us.index') }}" title="{{ __('Team') }}" class="block py-3 text-center bg-gray-50/50 hover:text-brand hover:font-semibold transition"> {{ __('Team') }} - </a> --}} + </a> + + <!-- News --> + <a href="{{ localized_route('news.index') }}" title="{{ __('News') }}" + class="block py-3 text-center bg-gray-50 hover:text-brand hover:font-semibold transition"> + {{ __('News') }} + </a> <!-- AI --> <a href="{{ localized_route('ai.index') }}" title="{{ __('AI') }}" @@ -51,11 +57,11 @@ class="block text-center bg-gray-50/50 hover:text-brand hover:font-semibold tran {{ __('Contact') }} </a> <div class="mt-1 text-sm text-gray-600 space-y-1"> - <a href="tel:0041615156090" title="{{ __('Contact Phone number') }}" + <a href="tel:{{ config('company.phone.e164') }}" title="{{ __('Contact Phone number') }}" class="block text-base hover:text-brand hover:font-semibold transition"> - +41 61 515 60 90 + {{ config('company.phone.display') }} </a> - <a href="mailto:info@codebar.ch" title="{{ __('Contact email address') }}" + <a href="mailto:{{ config('company.email') }}" title="{{ __('Contact email address') }}" class="block text-base hover:text-brand hover:font-semibold transition"> info@codebar.ch </a> @@ -68,15 +74,13 @@ class="block text-base hover:text-brand hover:font-semibold transition"> <span>{{ __('Language') }}</span> <div class="mt-1 flex justify-center gap-4 text-sm text-gray-600"> @foreach ($locales as $language) - <form method="POST" action="{{ route('language.update') }}" rel="nofollow"> - @csrf - <input type="hidden" name="language" value="{{ $language->value }}"> - <button type="submit" - class="text-base hover:text-brand hover:font-semibold transition cursor-pointer" - title="{{ __('Update to :lang language', ['lang' => $language->getLabel()]) }}"> - {{ $language->getLabel() }} - </button> - </form> + <a href="{{ locale_switch_url($language->value) }}" + hreflang="{{ str_replace('_', '-', $language->value) }}" + @if($language->value === app()->getLocale()) aria-current="true" @endif + class="text-base hover:text-brand hover:font-semibold transition" + title="{{ __('Update to :lang language', ['lang' => $language->getLabel()]) }}"> + {{ $language->getLabel() }} + </a> @endforeach </div> </div> diff --git a/resources/views/layouts/_partials/_schema.blade.php b/resources/views/layouts/_partials/_schema.blade.php new file mode 100644 index 0000000..d7961e3 --- /dev/null +++ b/resources/views/layouts/_partials/_schema.blade.php @@ -0,0 +1,18 @@ +{{-- + schema.org JSON-LD graph. + + A <script> with a non-JavaScript type is a data block, never executed, so + our strict CSP (script-src 'self') does not apply to it. If a CSP violation + ever shows up here, config/csp.php already has nonce support to switch on. + + json_encode output is escaped for the </script> sequence only — the payload + is built from our own config and DB content, never from request input. +--}} +@php + $schemaJson = (new \App\Seo\SchemaGraph( + page: $page ?? null, + additionalNodes: $schema ?? [], + ))->toJson(); +@endphp + +<script type="application/ld+json">{!! str_replace('</', '<\/', $schemaJson) !!}</script> diff --git a/resources/views/layouts/_partials/_seo.blade.php b/resources/views/layouts/_partials/_seo.blade.php index bcbda00..5aa86d0 100644 --- a/resources/views/layouts/_partials/_seo.blade.php +++ b/resources/views/layouts/_partials/_seo.blade.php @@ -11,8 +11,17 @@ $hreflangAlternates = collect(\App\Enums\LocaleEnum::cases()) ->mapWithKeys(function ($locale) use ($page) { + $parameters = $page->routeParameters; + + // Detail routes carry the locale in the path as well as in the + // route-name prefix (/news/{locale}/{slug}). Without swapping it + // the English alternate points at the German article. + if (is_array($parameters) && array_key_exists('locale', $parameters)) { + $parameters['locale'] = $locale->value; + } + try { - $url = route(\Illuminate\Support\Str::slug($locale->value).'.'.$page->routeKey, $page->routeParameters, true); + $url = route(\Illuminate\Support\Str::slug($locale->value).'.'.$page->routeKey, $parameters, true); } catch (\Throwable) { return []; } @@ -23,8 +32,6 @@ <title>{{ $page->title }} - - @foreach($hreflangAlternates as $hreflang => $url) @@ -50,7 +57,11 @@ @else + {{-- No page metadata: error pages and anything else rendered without a + PageDTO. These must never enter the index — without an explicit robots + tag an error page competes with real content in search results. --}} {{ config('app.name') }} + @endif diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 4bf893d..b664221 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -20,6 +20,7 @@ class="scroll-smooth" @include('layouts._partials._seo') + @include('layouts._partials._schema') @include('layouts._partials._favicons') @vite(['resources/js/app.js']) diff --git a/routes/web.php b/routes/web.php index cc76eb4..4711038 100644 --- a/routes/web.php +++ b/routes/web.php @@ -80,7 +80,8 @@ Route::get('jobs', JobsIndexController::class)->name('jobs.index'); Route::get('media', MediaIndexController::class)->name('media.index'); - Route::get('contact', ContactIndexController::class)->name('contact.index'); + // Not cached: the opening hours box reflects the current day and open/closed state. + Route::get('contact', ContactIndexController::class)->middleware(DoNotCacheResponse::class)->name('contact.index'); }); Route::group(['as' => Str::slug(LocaleEnum::DE->value).'.'], function () { @@ -122,7 +123,8 @@ Route::get('stellen', JobsIndexController::class)->name('jobs.index'); Route::get('medien', MediaIndexController::class)->name('media.index'); - Route::get('kontakt', ContactIndexController::class)->name('contact.index'); + // Not cached: the opening hours box reflects the current day and open/closed state. + Route::get('kontakt', ContactIndexController::class)->middleware(DoNotCacheResponse::class)->name('contact.index'); }); Route::post('language/update', LocaleUpdateController::class)->name('language.update'); diff --git a/tests/Feature/Controllers/AiLlmAnalyticsIndexControllerTest.php b/tests/Feature/Controllers/AiLlmAnalyticsIndexControllerTest.php index 91477b9..5e1bb5e 100644 --- a/tests/Feature/Controllers/AiLlmAnalyticsIndexControllerTest.php +++ b/tests/Feature/Controllers/AiLlmAnalyticsIndexControllerTest.php @@ -119,6 +119,14 @@ ->assertDontSee('Dezember 2026'); })->group('llm-analytics'); +it('shows when the data was last synced and how far it reaches', function () { + AiModelDailyUsage::query()->update(['updated_at' => '2026-06-11 05:03:00']); + + get('/ki/llm-analytics') + ->assertOk() + ->assertSee('Zuletzt aktualisiert am 11.06.2026 05:03 Uhr, Daten bis und mit 10.06.2026.'); +})->group('llm-analytics'); + it('never displays spend', function () { get('/ki/llm-analytics') ->assertOk() diff --git a/tests/Feature/Controllers/RobotsControllerTest.php b/tests/Feature/Controllers/RobotsControllerTest.php index 9a624e4..911966d 100644 --- a/tests/Feature/Controllers/RobotsControllerTest.php +++ b/tests/Feature/Controllers/RobotsControllerTest.php @@ -2,8 +2,9 @@ use function Pest\Laravel\get; -it('serves robots.txt with sitemap reference', function () { +it('serves robots.txt with sitemap reference in production', function () { config(['app.url' => 'https://codebar.ch']); + app()->detectEnvironment(fn (): string => 'production'); $response = get('robots.txt'); @@ -14,4 +15,25 @@ expect($content)->toContain('User-agent: *'); expect($content)->toContain('Sitemap: https://codebar.ch/sitemap.xml'); + expect($content)->not->toContain('Disallow: /'.PHP_EOL); +})->group('robots'); + +it('disallows crawling outside production', function () { + // A staging copy that gets indexed competes with production for the same + // queries — the environment, not the config, decides this. + expect(app()->isProduction())->toBeFalse(); + + $content = get('robots.txt')->assertOk()->getContent(); + + expect($content)->toContain('Disallow: /'); + expect($content)->not->toContain('Sitemap:'); +})->group('robots'); + +it('keeps signed and form-only network routes out of the crawl', function () { + app()->detectEnvironment(fn (): string => 'production'); + + $content = get('robots.txt')->assertOk()->getContent(); + + expect($content)->toContain('Disallow: /netzwerk/request'); + expect($content)->toContain('Disallow: /netzwerk/verwalten/'); })->group('robots'); diff --git a/tests/Feature/Controllers/RouteStatusTest.php b/tests/Feature/Controllers/RouteStatusTest.php index 74749aa..566349b 100644 --- a/tests/Feature/Controllers/RouteStatusTest.php +++ b/tests/Feature/Controllers/RouteStatusTest.php @@ -21,6 +21,9 @@ [LocaleEnum::EN->value, 'network.index'], [LocaleEnum::EN->value, 'network.request.index'], [LocaleEnum::EN->value, 'services.index'], + [LocaleEnum::EN->value, 'about-us.index'], + [LocaleEnum::EN->value, 'news.index'], + [LocaleEnum::EN->value, 'open-source.index'], // DE-CH [LocaleEnum::DE->value, 'start.index'], @@ -36,6 +39,9 @@ [LocaleEnum::DE->value, 'network.index'], [LocaleEnum::DE->value, 'network.request.index'], [LocaleEnum::DE->value, 'services.index'], + [LocaleEnum::DE->value, 'about-us.index'], + [LocaleEnum::DE->value, 'news.index'], + [LocaleEnum::DE->value, 'open-source.index'], ]; }); @@ -47,16 +53,10 @@ dataset('disabled-routes', function () { return [ - [LocaleEnum::EN->value, 'news.index'], [LocaleEnum::EN->value, 'products.index'], [LocaleEnum::EN->value, 'technologies.index'], - [LocaleEnum::EN->value, 'open-source.index'], - [LocaleEnum::EN->value, 'about-us.index'], - [LocaleEnum::DE->value, 'news.index'], [LocaleEnum::DE->value, 'products.index'], [LocaleEnum::DE->value, 'technologies.index'], - [LocaleEnum::DE->value, 'open-source.index'], - [LocaleEnum::DE->value, 'about-us.index'], [LocaleEnum::DE->value, 'co-working.index'], ]; }); diff --git a/tests/Feature/Controllers/ShowPagesTest.php b/tests/Feature/Controllers/ShowPagesTest.php index af79fcd..0f9d747 100644 --- a/tests/Feature/Controllers/ShowPagesTest.php +++ b/tests/Feature/Controllers/ShowPagesTest.php @@ -12,11 +12,11 @@ $startRoute = fn () => route(Str::slug(LocaleEnum::DE->value).'.start.index'); -it('redirects a news article to the start page while news is disabled', function () use ($startRoute) { +it('renders a news article', function () { $news = News::factory()->create(); get(route('de-ch.news.show', ['locale' => 'de_CH', 'news' => $news->slug])) - ->assertRedirect($startRoute()); + ->assertOk(); })->group('news', 'show'); it('redirects a product to the start page while products are disabled', function () use ($startRoute) { @@ -40,9 +40,18 @@ ->assertRedirect($startRoute()); })->group('technologies', 'show'); -it('redirects an open source project to the start page while open source is disabled', function () use ($startRoute) { +it('renders an open source project that has a written body', function () { $openSource = OpenSource::factory()->create(); get(route('de-ch.open-source.show', ['locale' => 'de_CH', 'openSource' => $openSource->slug])) - ->assertRedirect($startRoute()); + ->assertOk(); +})->group('open-source', 'show'); + +it('does not expose an open source project without a written body', function () { + // sync:repositories imports title and teaser from GitHub but leaves the + // body empty — there is no page worth serving, let alone indexing. + $openSource = OpenSource::factory()->create(['content' => null]); + + get(route('de-ch.open-source.show', ['locale' => 'de_CH', 'openSource' => $openSource->slug])) + ->assertNotFound(); })->group('open-source', 'show'); diff --git a/tests/Feature/Controllers/SitemapControllerTest.php b/tests/Feature/Controllers/SitemapControllerTest.php index 7baa984..544ec3a 100644 --- a/tests/Feature/Controllers/SitemapControllerTest.php +++ b/tests/Feature/Controllers/SitemapControllerTest.php @@ -77,13 +77,24 @@ $content = $response->getContent(); - expect($content)->not->toContain('aktuelles'); expect($content)->not->toContain('produkte'); expect($content)->not->toContain('technologien'); - expect($content)->not->toContain('open-source-beitraege'); expect($content)->not->toContain('co-working'); - expect($content)->not->toContain('ueber-uns'); - expect($content)->not->toContain('about-us'); +})->group('sitemap'); + +it('includes the reactivated team, news and open source pages in the sitemap', function () { + seed(PagesTableSeeder::class); + + Cache::flush(); + + $content = get('sitemap.xml')->assertOk()->getContent(); + + expect($content)->toContain('ueber-uns'); + expect($content)->toContain('about-us'); + expect($content)->toContain('aktuelles'); + expect($content)->toContain('news'); + expect($content)->toContain('open-source-beitraege'); + expect($content)->toContain('open-source-contributions'); })->group('sitemap'); it('includes the AI pages in the sitemap', function () { diff --git a/tests/Feature/Seo/MetaTagsTest.php b/tests/Feature/Seo/MetaTagsTest.php new file mode 100644 index 0000000..5648498 --- /dev/null +++ b/tests/Feature/Seo/MetaTagsTest.php @@ -0,0 +1,128 @@ +(.*?)#s', $html, $matches); + + return trim($matches[1] ?? ''); +} + +beforeEach(function () { + seed(PagesTableSeeder::class); +}); + +it('renders the full set of meta tags on a page', function () { + $html = (string) get(route('de-ch.services.index'))->assertOk()->getContent(); + + expect($html) + ->toContain('') + ->toContain('<meta name="description"') + ->toContain('<meta name="robots" content="index,follow">') + ->toContain('<link rel="canonical" href="'.route('de-ch.services.index').'">') + ->toContain('<meta property="og:title"') + ->toContain('<meta property="og:description"') + ->toContain('<meta property="og:image"') + ->toContain('<meta name="twitter:card" content="summary_large_image">'); +})->group('seo'); + +it('pairs both locales with self-referencing hreflang links', function () { + $html = (string) get(route('de-ch.services.index'))->assertOk()->getContent(); + + expect($html) + ->toContain('<link rel="alternate" hreflang="de-CH" href="'.route('de-ch.services.index').'">') + ->toContain('<link rel="alternate" hreflang="en-CH" href="'.route('en-ch.services.index').'">') + ->toContain('<link rel="alternate" hreflang="x-default"'); +})->group('seo'); + +it('swaps the locale route parameter in a detail page hreflang', function () { + // /news/{locale}/{slug} carries the locale twice: in the path prefix and as + // a route parameter. Only swapping the prefix pointed the English alternate + // at the German article — a wrong-language pair Google would ignore. + $news = News::factory()->create(); + + $html = (string) get(route('de-ch.news.show', ['locale' => 'de_CH', 'news' => $news->slug])) + ->assertOk()->getContent(); + + expect($html) + ->toContain('hreflang="en-CH" href="'.route('en-ch.news.show', ['locale' => 'en_CH', 'news' => $news->slug]).'"') + ->toContain('hreflang="de-CH" href="'.route('de-ch.news.show', ['locale' => 'de_CH', 'news' => $news->slug]).'"'); + + // The regression: the English path with the German locale parameter. + expect($html)->not->toContain( + 'hreflang="en-CH" href="'.route('en-ch.news.show', ['locale' => 'de_CH', 'news' => $news->slug]).'"' + ); +})->group('seo'); + +it('canonicalises away query parameters', function () { + // The analytics page has GET filters; every combination must point back at + // the bare URL rather than spawning near-duplicate indexable variants. + $html = (string) get(route('de-ch.ai.llm.analytics.index').'?year=2026&month=3')->assertOk()->getContent(); + + expect($html)->toContain('<link rel="canonical" href="'.route('de-ch.ai.llm.analytics.index').'">'); +})->group('seo'); + +it('gives each locale its own title and description', function () { + $de = (string) get(route('de-ch.contact.index'))->assertOk()->getContent(); + $en = (string) get(route('en-ch.contact.index'))->assertOk()->getContent(); + + expect(titleOf($de))->not->toBe(titleOf($en)); +})->group('seo'); + +it('marks error pages noindex', function () { + // Error pages render without a PageDTO. Without an explicit robots tag they + // would be indexable and compete with real content. + $html = (string) get('/a-page-that-does-not-exist')->assertNotFound()->getContent(); + + expect($html)->toContain('<meta name="robots" content="noindex,follow">'); +})->group('seo'); + +it('keeps the network request page out of the index', function () { + $html = (string) get(route('de-ch.network.request.index'))->assertOk()->getContent(); + + expect($html)->toContain('<meta name="robots" content="noindex,nofollow">'); +})->group('seo'); + +it('gives a news article its own title rather than the index title', function () { + $news = News::factory()->create(); + + $article = (string) get(route('de-ch.news.show', ['locale' => 'de_CH', 'news' => $news->slug])) + ->assertOk()->getContent(); + $index = (string) get(route('de-ch.news.index'))->assertOk()->getContent(); + + expect(titleOf($article))->not->toBe(titleOf($index)); +})->group('seo'); + +it('gives a network partner page its own title and self-referencing hreflang', function () { + // The controller used to build this page's metadata from the network index, + // so every partner page shared the index's title and description and its + // hreflang alternates pointed back at /netzwerk. + Network::factory()->create([ + 'key' => 'baselhack', + 'name' => ['de_CH' => 'BaselHack', 'en_CH' => 'BaselHack'], + 'page_slug' => 'baselhack', + ]); + + $detail = (string) get(route('de-ch.network.show', ['slug' => 'baselhack'])) + ->assertOk()->getContent(); + + expect(titleOf($detail))->toBe('BaselHack') + ->and(titleOf($detail))->not->toBe(titleOf((string) get(route('de-ch.network.index'))->getContent())); + + expect($detail) + ->toContain('hreflang="de-CH" href="'.route('de-ch.network.show', ['slug' => 'baselhack']).'"') + ->toContain('hreflang="en-CH" href="'.route('en-ch.network.show', ['slug' => 'baselhack']).'"'); +})->group('seo'); + +it('links both language versions with crawlable anchors', function () { + // A POST form would leave hreflang as the only connection between locales. + $html = (string) get(route('de-ch.services.index'))->assertOk()->getContent(); + + expect($html)->toContain('href="'.route('en-ch.services.index').'"'); +})->group('seo'); diff --git a/tests/Feature/Seo/StructuredDataTest.php b/tests/Feature/Seo/StructuredDataTest.php new file mode 100644 index 0000000..521369d --- /dev/null +++ b/tests/Feature/Seo/StructuredDataTest.php @@ -0,0 +1,237 @@ +<?php + +use App\Models\News; +use App\Models\Page; +use Database\Seeders\ContactsTableSeeder; +use Database\Seeders\PagesTableSeeder; +use Database\Seeders\ServicesTableSeeder; +use Illuminate\Support\Collection; + +use function Pest\Laravel\get; +use function Pest\Laravel\seed; + +/** + * Extracts and decodes the JSON-LD graph from a rendered page. + * + * Decoding rather than string-matching is the point: a malformed payload is + * silently ignored by Google, so the test has to fail on invalid JSON. + * + * @return array<int, array<string, mixed>> + */ +function schemaGraph(string $url): array +{ + $html = (string) get($url)->assertOk()->getContent(); + + expect($html)->toContain('application/ld+json'); + + preg_match('#<script type="application/ld\+json">(.*?)</script>#s', $html, $matches); + + $decoded = json_decode($matches[1] ?? '', true); + + expect(json_last_error())->toBe(JSON_ERROR_NONE, 'JSON-LD payload is not valid JSON'); + expect($decoded)->toBeArray()->toHaveKey('@context', 'https://schema.org'); + + return nodeList(is_array($decoded) ? ($decoded['@graph'] ?? null) : null)->all(); +} + +/** + * Narrows an untyped JSON value to a list of schema nodes. + * + * @return Collection<int, array<string, mixed>> + */ +function nodeList(mixed $value): Collection +{ + expect($value)->toBeArray(); + + /** @var array<int, array<string, mixed>> $value */ + return collect($value)->values(); +} + +/** + * Finds the single node of a given @type, failing the test when it is absent — + * so the assertions that follow never have to guard against null. + * + * @param array<int, array<string, mixed>> $graph + * @return array<string, mixed> + */ +function schemaNode(array $graph, string $type): array +{ + $node = collect($graph)->firstWhere('@type', $type); + + expect($node)->toBeArray("no {$type} node in the JSON-LD graph"); + + /** @var array<string, mixed> $node */ + return $node; +} + +/** + * All nodes of a given @type. + * + * @param array<int, array<string, mixed>> $graph + * @return Collection<int, array<string, mixed>> + */ +function schemaNodes(array $graph, string $type): Collection +{ + return collect($graph)->where('@type', $type)->values(); +} + +/** + * @param array<string, mixed> $breadcrumb + * @return Collection<int, array<string, mixed>> + */ +function breadcrumbItems(array $breadcrumb): Collection +{ + return nodeList(data_get($breadcrumb, 'itemListElement')); +} + +beforeEach(function () { + seed(PagesTableSeeder::class); +}); + +it('describes the organization on every page', function () { + $organization = schemaNode(schemaGraph(route('de-ch.start.index')), 'Organization'); + + expect(data_get($organization, 'name'))->toBe(config('company.legal_name')) + ->and(data_get($organization, 'vatID'))->toBe(config('company.uid')) + ->and(data_get($organization, 'telephone'))->toBe(config('company.phone.e164')) + ->and(data_get($organization, 'address.streetAddress'))->toBe('Hauptstrasse 91') + ->and(data_get($organization, 'address.addressLocality'))->toBe('Zunzgen') + // sameAs is what ties the site to the company as a known entity. + ->and(data_get($organization, 'sameAs'))->toContain('https://github.com/codebar-ag'); +})->group('seo', 'schema'); + +it('declares both the full legal name and the short brand', function () { + // Both spellings have to be in the graph: "codebar Solutions AG" is what + // Zefix, LinkedIn and the Business Profiles say, "codebar" is what people + // actually type. alternateName is how Google learns they are one entity. + $graph = schemaGraph(route('de-ch.start.index')); + + foreach (['Organization', 'WebSite'] as $type) { + $node = schemaNode($graph, $type); + + expect(data_get($node, 'name'))->toBe('codebar Solutions AG') + ->and(data_get($node, 'alternateName'))->toContain('codebar') + ->and(data_get($node, 'alternateName'))->toContain('codebar Solutions'); + } +})->group('seo', 'schema'); + +it('carries the full company name in every indexable page title', function () { + // A title without the brand wastes the strongest signal a search result has. + // Collected rather than asserted one by one so a failure names every offender. + $missing = []; + + Page::all()->each(function (Page $page) use (&$missing): void { + foreach (['de_CH', 'en_CH'] as $locale) { + $title = $page->getTranslation('title', $locale); + + if (is_string($title) && $title !== '' && ! str_contains($title, 'codebar Solutions AG')) { + $missing[] = "{$page->key} [{$locale}]: {$title}"; + } + } + }); + + expect($missing)->toBe([]); +})->group('seo'); + +it('links WebSite and WebPage back to the organization by id', function () { + $graph = schemaGraph(route('de-ch.services.index')); + + $organizationId = data_get(schemaNode($graph, 'Organization'), '@id'); + + expect(data_get(schemaNode($graph, 'WebSite'), 'publisher.@id'))->toBe($organizationId) + ->and(data_get(schemaNode($graph, 'WebPage'), 'about.@id'))->toBe($organizationId) + ->and(data_get(schemaNode($graph, 'WebPage'), 'url'))->toBe(route('de-ch.services.index')); +})->group('seo', 'schema'); + +it('omits the breadcrumb on the start page', function () { + // A single-item trail carries no information. + expect(schemaNodes(schemaGraph(route('de-ch.start.index')), 'BreadcrumbList'))->toBeEmpty(); +})->group('seo', 'schema'); + +it('builds a breadcrumb from real pages only', function () { + $breadcrumb = schemaNode(schemaGraph(route('de-ch.ai.llm.index')), 'BreadcrumbList'); + + $items = breadcrumbItems($breadcrumb); + + expect($items)->toHaveCount(3) + ->and($items->pluck('item')->all())->toBe([ + route('de-ch.start.index'), + route('de-ch.ai.index'), + route('de-ch.ai.llm.index'), + ]) + ->and($items->pluck('position')->all())->toBe([1, 2, 3]); +})->group('seo', 'schema'); + +it('drops breadcrumb segments that are not routable', function () { + // /rechtliches is a path prefix, not a page — linking to it would send + // users and crawlers to a 404. + $breadcrumb = schemaNode(schemaGraph(route('de-ch.legal.imprint.index')), 'BreadcrumbList'); + + expect(breadcrumbItems($breadcrumb)->pluck('item')->all())->toBe([ + route('de-ch.start.index'), + route('de-ch.legal.imprint.index'), + ]); +})->group('seo', 'schema'); + +it('publishes both locations with opening hours on the contact page', function () { + $graph = schemaGraph(route('de-ch.contact.index')); + + $businesses = schemaNodes($graph, 'ProfessionalService'); + + expect($businesses)->toHaveCount(2) + ->and($businesses->pluck('address.addressLocality')->all())->toBe(['Zunzgen', 'Oberwil']); + + $hours = nodeList(data_get($businesses->first(), 'openingHoursSpecification')); + + // Sunday is closed, so it must be absent rather than present with nulls. + expect($hours)->toHaveCount(6) + ->and($hours->pluck('dayOfWeek')->all())->not->toContain('https://schema.org/Sunday') + ->and(data_get($hours->first(), 'opens'))->toBe('08:00'); +})->group('seo', 'schema'); + +it('describes the team as people on the about page', function () { + seed(ContactsTableSeeder::class); + + $profile = schemaNode(schemaGraph(route('de-ch.about-us.index')), 'ProfilePage'); + + expect(data_get($profile, 'mainEntity'))->not->toBeEmpty() + ->and(data_get($profile, 'mainEntity.0.@type'))->toBe('Person') + ->and(data_get($profile, 'mainEntity.0.name'))->not->toBeEmpty(); +})->group('seo', 'schema'); + +it('describes what the company sells on the expertise page', function () { + seed(ServicesTableSeeder::class); + + $services = schemaNodes(schemaGraph(route('de-ch.services.index')), 'Service'); + + expect($services)->not->toBeEmpty() + ->and($services->first())->toHaveKeys(['name', 'description', 'provider']); +})->group('seo', 'schema'); + +it('marks up a news article as a BlogPosting', function () { + $news = News::factory()->create(); + + $article = schemaNode( + schemaGraph(route('de-ch.news.show', ['locale' => 'de_CH', 'news' => $news->slug])), + 'BlogPosting' + ); + + expect($article)->toHaveKeys(['headline', 'datePublished', 'author', 'publisher']) + ->and(data_get($article, 'inLanguage'))->toBe('de-CH'); +})->group('seo', 'schema'); + +it('escapes a closing script tag in the payload', function () { + // Content is authored by us, but a stray "</script>" in a title would still + // break out of the data block and truncate the page. + $news = News::factory()->create([ + 'title' => ['de_CH' => 'Test </script> Titel', 'en_CH' => 'Test </script> title'], + ]); + + $html = get(route('de-ch.news.show', ['locale' => 'de_CH', 'news' => $news->slug])) + ->assertOk()->getContent(); + + expect($html)->toContain('<\/script>'); + + // The document must still be intact: the closing </head> follows the block. + expect($html)->toContain('</head>'); +})->group('seo', 'schema'); diff --git a/tests/lighthouse/README.md b/tests/lighthouse/README.md index 8e5ed36..5eb6ab7 100644 --- a/tests/lighthouse/README.md +++ b/tests/lighthouse/README.md @@ -25,6 +25,15 @@ entirely fake Cumulative Layout Shift (0.8–1.0 instead of the real ~0.01). Perf/CLS numbers from dev mode are not meaningful; only audit with this script. +**The SEO category caps at 66 locally — that is expected, not a regression.** +Outside `production`, `SecurityHeaders` sends `X-Robots-Tag: noindex, nofollow` +(and `robots.txt` answers `Disallow: /`) so a staging or local copy can never be +indexed alongside the real site. Lighthouse honours that header and fails its +`is-crawlable` audit, which alone costs ~34 points. The page's own +`<meta name="robots">` is still `index,follow`; check the audit's `details.items` +to confirm the source is the header before investigating. Only the SEO score +from a production run is meaningful — perf, a11y and best-practices are unaffected. + ## Output Each run writes to `reports/<timestamp>/`: @@ -36,6 +45,12 @@ Each run writes to `reports/<timestamp>/`: ## Maintaining `pages.json` Some entries are marked with a `note` because the underlying page currently -redirects to the homepage (WIP controllers, or no seeded records for -News/OpenSource). Update `pages.json` as those pages are built out — real -slugs for show pages, once the content and its controllers are back in place. +redirects to the homepage (Products, Technologies and the Services/Products/ +Technologies detail pages are still WIP controllers). Update `pages.json` as +those pages are built out — real slugs for show pages, once the content and its +controllers are back in place. + +Detail-page paths need the full locale (`/news/en_CH/<slug>`, not `/news/en/…`). +`open_source` covers the index only: a repository imported by +`sync:repositories` has no written body, and its detail page answers 404 until +someone writes one. diff --git a/tests/lighthouse/pages.json b/tests/lighthouse/pages.json index 09907c3..27fb711 100644 --- a/tests/lighthouse/pages.json +++ b/tests/lighthouse/pages.json @@ -1,14 +1,14 @@ [ { "name": "home", "path": "/en-ch" }, { "name": "services_index", "path": "/services" }, - { "name": "services_show", "path": "/services/en/konzeption-prototyping", "note": "ServicesShowController currently redirects to start.index (WIP) - re-enable once implemented" }, - { "name": "products_index", "path": "/products" }, - { "name": "products_show", "path": "/products/en/flows", "note": "ProductsShowController currently redirects to start.index (WIP) - re-enable once implemented" }, - { "name": "technologies_index", "path": "/technologies" }, - { "name": "technologies_show", "path": "/technologies/en/laravel-framework", "note": "TechnologiesShowController currently redirects to start.index (WIP) - re-enable once implemented" }, + { "name": "services_show", "path": "/services/en_CH/konzeption-prototyping", "note": "ServicesShowController currently redirects to start.index (WIP) - re-enable once implemented" }, + { "name": "products_index", "path": "/products", "note": "ProductsIndexController currently redirects to start.index (WIP)" }, + { "name": "products_show", "path": "/products/en_CH/flows", "note": "ProductsShowController currently redirects to start.index (WIP) - re-enable once implemented" }, + { "name": "technologies_index", "path": "/technologies", "note": "TechnologiesIndexController currently redirects to start.index (WIP)" }, + { "name": "technologies_show", "path": "/technologies/en_CH/laravel-framework", "note": "TechnologiesShowController currently redirects to start.index (WIP) - re-enable once implemented" }, { "name": "network_index", "path": "/network" }, - { "name": "network_request", "path": "/network/request" }, - { "name": "jobs", "path": "/jobs", "note": "currently redirects to start.index (WIP)" }, + { "name": "network_request", "path": "/network/request", "note": "noindex,nofollow by design - not in the sitemap" }, + { "name": "jobs", "path": "/jobs" }, { "name": "media", "path": "/media" }, { "name": "contact", "path": "/contact" }, { "name": "about_us", "path": "/about-us" }, @@ -17,7 +17,8 @@ { "name": "ai_llm_analytics", "path": "/ai/llm-analytics" }, { "name": "legal_privacy", "path": "/legal/privacy" }, { "name": "legal_imprint", "path": "/legal/imprint" }, - { "name": "legal_terms", "path": "/legal/terms", "note": "currently redirects to start.index (WIP)" }, - { "name": "news", "path": "/news", "note": "currently redirects to start.index (WIP) - no News records seeded" }, - { "name": "open_source", "path": "/open-source-contributions", "note": "currently redirects to start.index (WIP) - no OpenSource records seeded" } + { "name": "legal_terms", "path": "/legal/terms" }, + { "name": "news_index", "path": "/news" }, + { "name": "news_show", "path": "/news/en_CH/docuware-7-12-is-here" }, + { "name": "open_source", "path": "/open-source-contributions", "note": "index only - detail pages 404 until a repository has a written body" } ] diff --git a/todos/refactor.md b/todos/refactor.md index 9bc4eb8..e17cd76 100644 --- a/todos/refactor.md +++ b/todos/refactor.md @@ -1,72 +1,200 @@ # Refactor / Branch-Consolidation TODOs -**Origin:** gap analysis comparing `feature-updates` (master/current) against `main` and `feature-refactor` (PR #48), done ahead of retiring `feature-refactor` / `feature-design` in favor of `feature-updates` → `production`. +**Origin:** gap analysis ahead of retiring the side branches in favour of `feature-updates` → `production`. -Status legend: ✅ done this session · 🟡 partially done / decision needed · 🔴 open +**Scope (updated 2026-07-28):** the original pass only compared `feature-updates` against `main` and `feature-refactor` (PR #48). This pass extends it to **all three local branches** — `feature-design`, `feature-gki`, `feature-refactor` — plus `main`. Two of them had never been assessed, and one of those (`feature-gki`) turned out to hold a finished, unmerged page. + +**Standing decision:** gaps are **documented, not ported.** Nothing from these branches is being merged right now. The branches themselves are left in place — not deleted, not tagged — so nothing is lost while this is under review. + +Status legend: ✅ done / closed · 🟡 partially done, decision needed · 🔴 open · ⛔ rejected, do not port + +--- + +## Branch survey + +| Branch | Tip | Merge-base with `feature-updates` | Unique commits | Verdict | +|---|---|---|---|---| +| `feature-gki` | `1e4dad8` (2026-07-23) | `cd2e0c7` — only 7 behind HEAD | 1 | **Real, unported, cleanly cherry-pickable** | +| `feature-design` | `a3a032e` (2026-05-03) | `cf8f413` — ~64 behind | 1 snapshot | ⛔ Almost entirely superseded; parts would actively break HEAD | +| `feature-refactor` | `f08b3e7` (2026-02-20) | `8d3872e` — ~66 behind | 9 | Mostly already ported or superseded; small hygiene remainder | +| `main` | `478ed3d` (2026-06-30) | — | 52 | Unchanged from the original analysis; still an open architecture question | + +`feature-refactor` also exists on `origin`. `feature-design` and `feature-gki` are **local-only** — deleting them would lose them. + +--- + +## Corrections to the earlier version of this document + +The previous pass was written 2026-07-22. `feature-updates` has moved since, and three items it listed as open are in fact already closed. Recorded here so nobody redoes finished work. + +### ✅ Terms & Conditions (AGB) — done, was listed as 🔴 High +The earlier note said Terms still redirects home with an empty `terms.md`. That is no longer true. +- `app/Http/Controllers/Legal/TermsIndexController.php` renders a real view — no redirect. +- `resources/views/app/legal/terms/index.blade.php` (133 lines) renders all 9 sections plus every subsection (1.1–1.9, 2.1–2.9, 3.1–3.3, 4.1–4.3, 5–9). +- Content lives as ~65 `Terms *` keys in **both** `lang/de_CH.json` and `lang/en_CH.json` — the German AGB was ported into the translation layer, not as a markdown file. +- All six review points from `feature-refactor`'s `terms_feedback.md` are applied: general scope in 1.1, 30-day offer validity + written confirmation in 1.2, explicit no-right-of-withdrawal in 1.3, fixed 01.01–31.12 term with no auto-renewal in 1.5, prices excl. VAT in 1.6, 14-day payment terms in 1.7. That feedback file has served its purpose and does not need porting. +- HEAD additionally has `Terms language body` (German version prevails) and `Terms last updated date` = 23.07.2026, which `feature-refactor` never had. +- `database/files/legal/{de_CH,en_CH}/terms.md` are 0-byte on HEAD, as are `imprint.md` / `privacy.md`. The flat-markdown approach was **abandoned**, not left unfinished — the empty files are leftovers, not a gap. + +### ✅ Jobs page — done, was listed as 🔴 High +- `app/Http/Controllers/Jobs/JobsIndexController.php` returns the view — no redirect. It is byte-identical to `feature-design`'s version; HEAD arrived there independently. +- `resources/views/app/jobs/index.blade.php` is fully translation-key driven: page header, `Jobs intro`, `Jobs training heading/body`, `Jobs open positions heading`, a `Jobs no open positions` panel, and an `@if(false)`-gated spontaneous-application block ready to switch on. +- **`feature-design`'s jobs page is worse and must not be ported** — three invented placeholder roles ("Senior Engineer", "Product Designer", "Project Lead") in hardcoded, untranslated English with `mailto:` apply links. Fabricated content, not real openings. + +### ✅ Services partnerships block — superseded, not lost +The partner-logo partial `resources/views/app/services/_parials/partnerships.blade.php` (note the `_parials` typo) exists only on `feature-design` now. It was deleted in `dec0e5d` — **the same commit that introduced the whole Network feature**, which replaces it properly: `Network` / `NetworkUser` models, `NetworkCategoryEnum`, a real index page at `network.index`, and 9 partner SVGs in `public/images/network/` including `docuware.svg` with the Silver Partner tier badge rendered from `$network->tier_label`. Nothing to recover. + +### ✅ `SyncRepositoriesCommand` — already on HEAD, and better +`app/Console/Commands/SyncRepositoriesCommand.php` is present with `config/services.php` `github.token` and `.env.example` `GITHUB_TOKEN=`. HEAD's version improves on `feature-refactor`'s with a typed `normalizeRepo()`, PHPDoc array shapes and an `is_string($token)` guard. The open questions from the earlier pass still stand (see 🟡 below), but the port itself is done. --- -## Done this session - -### ✅ Co-working page — migrated, left inactive (same pattern as Technologies) -- `app/Http/Controllers/CoWorking/CoWorkingIndexController.php` — new controller, no dependency on `main`'s flat-file `content/` system (that's a separate architecture question, see below). Content is inlined using master's existing `PageAction`/component conventions. -- `resources/views/app/co-working/index.blade.php` — built with master's existing design system (`x-layout.*`, `x-card.address-card`, `x-feature-block`, `x-ui.*`) rather than porting `main`'s bigger, dependency-heavy version (Leaflet map, Alpine lightbox gallery, `x-blocks.pricing-card` — none of which exist on master, and `main`'s own gallery images were never actually added, just a placeholder README). -- Routes added in `routes/web.php` (`co-working` under both locale groups), named `co-working.index` — **registered but not linked in nav**, matching how `technologies.index` is currently dormant (link commented out in `_navigation_desktop.blade.php`). -- SEO metadata added to `PagesTableSeeder::enCH()`/`deCH()` under the existing "pages not yet shipped to production" section, alongside `technologies.index`. -- Content ported from `main`'s `content/co-working/{de_CH,en_CH}/page.md` (location: Langegasse 39, Oberwil — same building as the existing Oberwil branch office on the Contact page; services/amenities list; pricing CHF 750/month; rental conditions). Full interactive gallery/map was intentionally simplified, not ported 1:1 — see note above. -- **To activate later:** uncomment the nav link (same place Technologies' would go) once the page is ready to ship, and consider whether a real photo gallery is wanted before launch. - -### ✅ Opening-hours widget — migrated into the Contact page -- `resources/views/components/opening-hours.blade.php` — ported from `feature-refactor`, adapted to master's design tokens (`brand` color instead of hardcoded blue). -- `ContactIndexController` now builds and passes `openingHours` (Mon–Sat 08:00–18:00, Sun closed — as drafted in `feature-refactor`). -- Wired into `resources/views/app/contact/index.blade.php`, right above the existing "Locations" section. -- Added day-name + widget copy translations to `lang/en_CH.json` / `lang/de_CH.json` (Monday–Sunday, "Opening hours", "Currently open/closed", "Closed", `:open to :close`). -- **Live immediately** — Contact is an active, linked page, so this ships as soon as this branch does. Worth a quick confirmation from whoever owns the business info that Mon–Sat 08:00–18:00 is still accurate (it was a `feature-refactor` draft, never independently verified against real hours). - -### ✅ Sitemap — decision made, item closed -- Master already generates `sitemap.xml` dynamically via `SitemapController`/`SitemapBuilder` on each request. `main`'s static `GenerateSitemapCommand` approach is **explicitly rejected** — the site runs on Laravel Cloud, where writing a static file to disk on a schedule doesn't fit the platform model the way a dynamic route does. No code change needed; this gap is closed as "intentionally not adopting `main`'s approach," not as "ported." - -### 🟡 Open-source repo sync (`SyncRepositoriesCommand`) — schema aligned, command ported, one new bug found and fixed, one bigger issue surfaced -- **Schema aligned:** `github_name`, `stars`, `forks`, `primary_language` added directly to the original `database/migrations/2025_06_24_063710_create_open_sources_table.php` (per explicit instruction: this project always deploys via `migrate:fresh`, no persisted migration history to preserve across environments — so no separate add-column migration, just clean up the original). -- **Command ported:** `app/Console/Commands/SyncRepositoriesCommand.php`, adapted to master's existing `OpenSource` model (master never did the `OpenSource` → `GithubRepository` rename from `feature-refactor`, so this targets the model that actually exists). -- **Config wired:** `config/services.php` gets a `github.token` entry, `.env.example` gets `GITHUB_TOKEN=`. -- **Not scheduled automatically** — deliberately. This command overwrites `title`/`teaser` with the raw GitHub API `description` field on every run. The existing hand-curated `OpenSourceTableSeeder` has real German/English marketing copy per package (e.g. "Nahtlose Integration von Zendesk-Supportfunktionen...") that a scheduled sync would silently clobber with terser English GitHub descriptions. **Needs a decision**: keep hand-written teasers and only sync stats (stars/forks/downloads), or accept GitHub's descriptions as the source of truth. I did not make that call silently. -- **Bigger discovery while checking "seeders still working":** `OpenSourceTableSeeder` is currently **commented out** in `database/seeders/DatabaseSeeder.php` — and it turns out that's not a style choice, it's broken: - - Its private `seed()` method took `string $identifier`, but every one of its 16 call sites passes a named `sharedSlug:` argument — an immediate fatal error. - - It matched rows on a non-existent `identifier` column (the `open_sources` table has no such column) instead of `slug`, and referenced an undefined `$slug` variable. - - **Fixed** the structural bug (renamed the parameter to `sharedSlug` and added the missing `Str::slug()` call, matching the working pattern in `ProductsTableSeeder`/`ServicesTableSeeder`). - - **Not fixed, and not recommended to fix by hand:** every one of the 16 call sites also passes `link:`, `downloads:`, `version:` that the method still doesn't accept — and several of the `sharedSlug` values are corrupted, auto-scraped garbage (e.g. `'packagist-v1220-downloads-212k-laravel,-docuware,-codebar-ag,-docuware,...'` instead of a real package slug). This isn't a typo, it's bad underlying data. I left `OpenSourceTableSeeder` commented out in `DatabaseSeeder` rather than patch ~300 lines of corrupted seed data. - - **Recommendation:** don't repair this seeder — use `sync:repositories` as the actual data source for the open-source listing going forward, since it pulls clean names/slugs straight from the GitHub API. Decide the title/teaser question above, then this item is fully closeable. -- Also worth noting: like Technologies and now Co-Working, the **Open Source page itself is not linked in nav or footer either** — it's in the same "built, routed, but not surfaced" bucket, which is presumably intentional pending real data. - -**Migration note (updated):** initially added this as a separate `add_github_columns_to_open_sources_table` migration out of caution (editing an already-run migration is normally unsafe). Corrected per direct instruction — this project doesn't preserve migration history across deploys, it always runs `migrate:fresh`, so the convention here is to fold schema changes straight into the original create-table migration and keep the migrations folder clean rather than accumulate `add_`/`update_` files. Folded the 4 columns into `2025_06_24_063710_create_open_sources_table.php` and deleted the separate migration. Verified with a full local `php artisan migrate:fresh --seed` — runs clean end-to-end. - -**Note:** two other pre-existing `add_`/`update_` style migrations are still in the list (`2026_06_30_135300_update_codebar_seo_images.php` — a one-off data backfill, not a schema change, so folding doesn't really apply — and `2026_07_14_120000_add_content_sections_to_products_table.php`, a genuine schema addition to `products`). I didn't touch either since they're not mine and weren't part of this ask — flagging in case the same fold-into-original cleanup is wanted there too. +## `feature-gki` — a finished page that was never merged + +**Commit:** `1e4dad8` "Add GKI generative AI consulting page (dormant)" — 8 files, +196/−8, authored 2026-07-23. + +This is the most significant find of this pass. It **supersedes and closes** the "GKI consulting service copy" item that this document previously carried against `feature-refactor`: the same three-tier offering, already rewritten against HEAD's own component kit. + +### What the page is + +A single consulting landing page for codebar's generative-AI offering (GKI = *generative künstliche Intelligenz*), presenting three service tiers as cards in a 3-column grid. German is the source copy; English is a full translation. + +**Intro:** "Our three GKI offerings support companies step by step: from strategic assessment through a working prototype in a matter of days to technical integration into existing systems." + +| Tier | Teaser | Contents | Closing / audience | +|---|---|---|---| +| **GKI Strategy** | Strategic positioning of AI in your organisation. | AI readiness assessment · identification of prioritised use cases · business case & value-creation logic · governance and compliance framework · roadmap (6–12 months) | *Audience:* ideal for executive boards, innovation leads and universities | +| **GKI Sprint** | From problem to working prototype in 2–5 days. | Use-case refinement · prompt architecture · MVP development (e.g. internal copilot, knowledge agent, automation solution) · user testing · scaling decision | *Closing:* "No PowerPoint. Only working systems." | +| **GKI Build** | Technical integration into existing systems. | API integration · CRM / ERP integration · internal knowledge GPTs · automation pipelines · documentation & operating concept | *Closing:* "We build solutions that run in production — not demo environments." | + +The page closes with a single call-to-action button linking to `contact.index`. + +### What it ships + +- `app/Http/Controllers/Gki/GkiIndexController.php` — tiers as a typed private array (`name`, `teaser`, `features[]`, `closing`, `audience`), SEO via the existing `PageAction`. No new model, no migration, no seeder data beyond SEO. +- `resources/views/app/gki/index.blade.php` — built entirely on HEAD's kit: `x-layout.page-header`, `x-layout.section`, `x-layout.grid :cols="3"`, `x-ui.panel`, `x-ui.prose`, `x-ui.button`. Verified: every prop it uses exists on HEAD's components today. +- `routes/web.php` — `gki-consulting` (EN) and `gki-beratung` (DE), both named `gki.index`. +- `database/seeders/PagesTableSeeder.php` — SEO title/description rows for both locales, `robots: index,follow`. +- `lang/de_CH.json` / `lang/en_CH.json` — 26 keys each. +- `tests/Feature/Controllers/RouteStatusTest.php` — 2 cases asserting 200 OK. + +### Dormant by design + +Routes are registered but **not linked in navigation**, the same deliberate pattern as `technologies.index` and `co-working.index`. Activating it later means adding the nav link — the page itself is ready. + +### Relationship to `feature-refactor` + +`feature-refactor` carried the same three-tier content as `app/Data/GkiServiceData.php` plus per-tier **detail pages** at `/ai/{slug}` (`AiShowController`). Those detail pages were dropped in the `feature-gki` rewrite. Reinstating them is not a port — the detail-page bodies would need new copy written, and it would have to coexist with HEAD's `/ai` route, which is now a completely different LLM-usage-statistics hub (`AiIndexController`, `LlmUsageStatsAction`, `ai.llm.*` sub-routes), not a services listing. + +### Merge cost, if it is wanted later + +`git cherry-pick 1e4dad8`. The controller and the view apply clean (both files are untouched on HEAD since the fork). Expect **additive** conflicts, all mechanical, in `routes/web.php`, both lang JSONs and `PagesTableSeeder.php`. Two conflicts need judgement: +- Its `tests/Feature/HttpErrorPagesTest.php` hunk is **already on HEAD** — skip it. +- Take **HEAD's** `RouteStatusTest` `disabled-routes` dataset, which is newer (it has `about-us.index` and `co-working.index` that the branch version lacks). Add only the two `gki.index` entries to the `routes` dataset. --- -## Still open (unchanged from the original gap analysis — see below for full detail) +## ⛔ `feature-design` — do not merge + +A single snapshot commit ("pre-migration state before laravel-start promotion", 2026-05-03) taken from a fork point that predates essentially all of HEAD's work — Network, the AI/LLM hub, co-working, custom error pages, the icon set, the card kit, the seeder rework, `well-known` routes, the sitemap builder. The true two-dot diff against HEAD is ~460 files, +5149/−14094. It is an archive, not a candidate. + +Three findings worth recording, because each looks harmless and is not: + +### Its `ui/*` kit collides destructively with HEAD's +`feature-design` and HEAD independently built component kits that share names but not contracts. +- **`ui/button`** drops the `type` prop and hardcodes `type="button"`. Dropping it in would **silently stop three form submits**: `app/network/manage.blade.php` (Save changes), `app/network/request.blade.php` (Request link), `app/ai/llm/analytics.blade.php` (filter form, which passes `type="submit"`). No error — the buttons just stop working. +- **`ui/button` and `ui/link`** make `$label` required. Every slot-only call site raises `Undefined variable $label`: `components/docuware-showme.blade.php`, `errors/partials/_error-page.blade.php`, `app/network/pages/baselhack.blade.php`, `app/ai/index.blade.php`, `app/ai/llm/index.blade.php`, `app/ai/llm/analytics.blade.php`. +- **`ui/link`** has no `download` prop (breaks `components/card/download-card.blade.php`) and drops `rel="noopener noreferrer"` on `target="_blank"`. +- **`ui/badge`** depends on a `.brand-pill` CSS class that does not exist on HEAD, and forces a redundant `title` equal to the visible text — an a11y regression. + +### Its `resources/css/app.css` has no `@theme` block +HEAD is on Tailwind v4 with a `@theme` block defining `--color-brand`, `--color-brand-strong`, `--color-surface`, `--color-border`, `--color-border-soft`, `--color-muted`, `--radius-pill`, `--radius-panel`. Those generate `bg-brand`, `text-muted`, `border-border`, `rounded-pill`, `rounded-panel` — used site-wide. `feature-design`'s file defines a parallel `:root` variable set (`--brand-color`, `--surface-feature`, `--border-subtle`, `.hero-gradient`, `.surface-card`, `.editorial-divider`, …) with **zero overlap**, and no `@theme` at all. Overwriting the file turns every one of those utility classes into a no-op — a site-wide visual collapse. It also drops HEAD's `.legal-prose` list styling and contains a bogus `@safelist` directive that is not valid Tailwind v4. + +### Its `resources/js/app.js` removes `Alpine.plugin(focus)` +That breaks `x-trap.inert.noscroll` in `_navigation_mobile.blade.php`. It also deletes HEAD's `autoSubmit`, `combobox` (a full a11y combobox with roving `aria-activedescendant`) and `tabs`, breaking `components/form/combobox.blade.php` and the AI analytics page. Its only addition is a richer `navigation` component — whose mobile drawer has `role="dialog"` but *no* focus trap and *no* scroll lock, i.e. an a11y regression against HEAD. + +### Everything else +Nav and footer depend on the `Configuration` model (deleted on HEAD) and hardcode `info@paperflakes.ch`; the paperflakes/codebar brand partials were removed on purpose when the site went single-brand; `config/auth.php` was intentionally deleted on HEAD (Laravel 12 slim config); `config/responsecache.php` is the **older** spatie schema with `enabled => false`, so porting it would silently disable response caching; the `config/{health,nova,permission}.php` and `database/factories/*` "changes" are already byte-identical on HEAD. + +### 🟡 The one idea worth keeping — a styleguide page +`app/Http/Controllers/Styleguide/StyleguideIndexController.php` + `resources/views/app/styleguide/index.blade.php` + a route. HEAD has **no visual index of its own component kit** — `grep -ri styleguide` over HEAD returns nothing. That is a genuine gap. + +Treat it as a **fresh write, not a port** — the view is ~90% `feature-design` kit. The controller is 3 lines but passes `'page' => null`, which HEAD's `AppLayout` does not expect. Mapping if someone builds it: + +| `feature-design` tag | HEAD equivalent | +|---|---| +| `x-blocks.editorial-hero` | `x-layout.page-header` (`teaser` → `intro`, drop `eyebrow`) | +| `x-ui.section` | `x-layout.section` | +| `x-ui.headline level="h1\|h2\|h3"` | `x-h1` / `x-h2` / `x-h3` (all take `:title`) | +| `x-ui.grid columns="3"` | `x-layout.grid :cols="3"` | +| `x-ui.card` | `x-ui.panel` | +| `x-ui.text` | `x-ui.prose` or raw `<p>` | +| `x-ui.feature-card` | `x-card.item-card` / `x-feature-block` | +| `x-ui.cta` | `x-band.cta-band` | +| `x-list-card` | `x-card.item-card` + `x-data.tag-list` | +| `x-blocks.meta-strip` | `x-data.meta-badges` (prop shape differs) | + +No HEAD equivalent exists for `x-ui.eyebrow`, `x-ui.stack`, the `ghost` button variant or the `outline` badge variant — each is either a small new component or a dropped feature. + +--- + +## `feature-refactor` — remainder + +Closed by this pass (evidence above or on HEAD): AGB content, opening-hours widget, `SyncRepositoriesCommand`, Privacy/Terms un-redirect, Paperflakes seeder removal, `PageAction`/`ViewDataAction` changes, `Configuration` removal, nav component extraction, imprint address (HEAD also fixed the "Haupstrasse" typo), footer rewrite, `intro.blade.php`, `tests/Pest.php`. + +Genuinely still missing — **all deliberately not done this pass**: + +| Item | Where | Note | +|---|---|---| +| 🔴 `OpenSoruceShowController` typo | `app/Http/Controllers/OpenSource/OpenSoruceShowController.php` + `routes/web.php:26,64,107` | Misspelled class name, 3 references. `feature-refactor` already fixed it. Cheap hygiene. | +| 🟡 Company social links | `config/site.php` on the branch: `linkedin.com/company/codebarag`, `github.com/orgs/codebar-ag` | `x-icon.linkedin` / `x-icon.github` exist on HEAD but are used **only** on person cards (`card/person-card.blade.php`, `card/network-user-card.blade.php`). The company itself has no social links anywhere; the footer has no social row. | +| 🟢 Empty `routes/console.php` | still wired in `bootstrap/app.php` | Dead file. `feature-refactor` deleted both. | +| 🟢 `.gitignore` | missing `/laravel-start/` | The only entry HEAD lacks — everything else on `feature-design`'s list is already present. | +| 🟡 `ContactSectionEnum` split | `app/Enums/ContactSectionEnum.php` | Branch splits `EMPLOYEES` into Software Engineering / Digital Transformation / Scanning. Only worth it if `about-us.index` is re-enabled (it is currently in `disabled-routes`); needs coordinated edits to `ViewDataAction::contacts()`, `about-us/index.blade.php`, `database/seeders/data/contacts.csv` and lang keys. **The branch's own constant has a typo: `SOFTWARE_ENGINERING`.** | +| 🟢 `repositorySearch` Alpine data + open-source GitHub stat badges | `resources/js/app.js`, `open-source/show.blade.php` | Dead while `OpenSourceIndexController` redirects to start and the open-source routes sit in `disabled-routes`. Only relevant if that listing is re-enabled. | +| ⛔ `OpenSource` → `GithubRepository` rename | model + table + route param | **Rejected as churn.** HEAD already has every GitHub column (`github_name`, `stars`, `forks`, `primary_language`, `version`) folded into the original create-table migration. | +| 🟢 `vite` version | `package.json:20` pins `^6.3.6` | `node_modules` actually resolves **6.4.3**, so the manifest is behind reality. Dependabot PR #41 bumped it to `^6.4.1` on `feature-refactor` only. | +| 🟢 2 extra news entries | `NewsTableSeeder` | DocuWare 7.13 and "Hello World". Low value — news routes are disabled and HEAD's seeder has a different signature. | + +--- + +## Still open — unchanged ### 🔴 High priority -- **Terms & Conditions (AGB) content** — still redirect-home + empty `terms.md` on master. The 188-line German AGB draft + unresolved legal review notes (`terms_feedback.md`) still exist **only** on `feature-refactor`. Not touched this session — this is a legal/business call, not a mechanical port. -- **Jobs page content** — still redirect-home on master. `feature-design` only flips the redirect off; no real listing copy exists anywhere yet. -- **`main`'s architecture rewrite** (flat-file `content/` system, ~10 deleted legacy models, bigger UI kit, CSP/security hardening, custom error pages, `packages/coding-guidelines` + `AGENTS.md`/`boost.json`) — still an open, much bigger question about whether that direction is live intent or an abandoned experiment. Needs an answer from whoever ran that migration on `main` before any further reconciliation work. +- **`main`'s architecture rewrite** — flat-file `content/` system + `MarkdownContentService`, ~10 deleted legacy models, a larger UI kit, CSP/security hardening, custom error pages, `packages/coding-guidelines` + `AGENTS.md`/`boost.json`. Still an open question about whether that direction is live intent or an abandoned experiment. Needs an answer from whoever ran that migration on `main` before any further reconciliation. ### 🟡 Medium / low priority -- GKI consulting service copy (Strategy/Sprint/Build) from `feature-refactor` — confirm with business side whether it's still wanted; no equivalent exists on master. -- Design-system reconciliation — master, `main`, and `feature-design` each built their own incompatible `ui/*` component kit. No action needed unless/until `main`'s direction is decided. -- Custom error pages, CSP/security hardening from `main` — real, isolated wins, portable independent of the bigger content-model question. +- **`SyncRepositoriesCommand` title/teaser policy.** The command is ported but **deliberately not scheduled**: it overwrites `title`/`teaser` with the raw GitHub API `description` on every run, which would clobber the hand-written German/English marketing copy in `OpenSourceTableSeeder`. Decision needed: sync stats only (stars/forks/downloads) and keep hand-written teasers, or accept GitHub descriptions as the source of truth. +- **`OpenSourceTableSeeder` is broken and left commented out** in `DatabaseSeeder`. The structural bug was fixed (parameter renamed to `sharedSlug`, missing `Str::slug()` added), but all 16 call sites still pass `link:` / `downloads:` / `version:` that the method does not accept, and several `sharedSlug` values are corrupted auto-scraped strings (e.g. `'packagist-v1220-downloads-212k-laravel,-docuware,...'`). Recommendation: do not repair by hand — use `sync:repositories` as the data source once the question above is settled. +- **Opening hours need business confirmation.** The Contact page ships Mon–Sat with Sunday closed, drafted on `feature-refactor` and never independently verified. Contact is a live, linked page, so this ships with the branch. +- **Design-system reconciliation** — `feature-updates`, `main` and `feature-design` each built an incompatible `ui/*` kit. No action unless `main`'s direction is decided. See the `feature-design` section for why its kit specifically cannot be merged. +- **Custom error pages and CSP hardening from `main`** — real, isolated wins, portable independently of the content-model question. +- **Pages built but not surfaced.** `technologies.index`, `open-source.index`, `co-working.index`, `about-us.index` and (if merged) `gki.index` are all routed but unlinked. Presumably intentional pending real data, but worth an explicit decision as a group rather than case by case. +- **Pre-existing `add_`/`update_` migrations.** This project deploys via `migrate:fresh`, so the convention is to fold schema changes into the original create-table migration. Two files still break that: `2026_06_30_135300_update_codebar_seo_images.php` (a one-off data backfill — folding does not really apply) and `2026_07_14_120000_add_content_sections_to_products_table.php` (a genuine schema addition to `products`). Neither was touched. + +--- + +## Decisions log + +| Date | Decision | +|---|---| +| 2026-07-28 | **AGB §9 aligned EN → DE.** The two locales diverged: DE named no court, EN said "Basel-Landschaft or the registered office". English now matches German, consistent with the `Terms language body` clause stating the German version prevails. `lang/en_CH.json` — the only code change in this pass. | +| 2026-07-28 | **`feature-gki` documented, not merged.** The page is finished and cherry-pickable; merging is a separate, deliberate decision. | +| 2026-07-28 | **`feature-design` rejected.** Its `ui/*` kit, `app.css` and `app.js` would break HEAD in the specific ways listed above. Only the styleguide-page *idea* is retained, as a fresh write. | +| 2026-07-28 | **Branches left in place.** No deletions, no archive tags. `feature-design` and `feature-gki` are local-only and would be lost if deleted. | +| earlier | **Sitemap: `main`'s static `GenerateSitemapCommand` rejected.** The site runs on Laravel Cloud, where writing a static file on a schedule does not fit the platform model. HEAD's dynamic `SitemapController` / `SitemapBuilder` is the intended approach. | +| earlier | **Migration convention: fold into the original.** No `add_`/`update_` migration files — this project always runs `migrate:fresh`, so schema changes go straight into the create-table migration. Verified end-to-end with `php artisan migrate:fresh --seed`. | --- ## Full original gap tables -The complete file-by-file gap analysis (main ↔ master, `feature-refactor` ↔ master) is preserved below for reference. +The file-by-file gap analysis from the first pass, preserved for reference. Note that several rows are now **out of date** — see "Corrections" above. <details> <summary>Click to expand full original analysis</summary> -### Branch relationship (at time of analysis) +### Branch relationship (at time of the original analysis) | Comparison | Commits only in left | Commits only in right | |---|---|---| @@ -78,15 +206,15 @@ The complete file-by-file gap analysis (main ↔ master, `feature-refactor` ↔ | Area | On `main` | On master | Gap severity | |---|---|---|---| -| Legal pages enabled | Terms/Jobs render real views | Both redirect home | 🔴 High | +| Legal pages enabled | Terms/Jobs render real views | ~~Both redirect home~~ | ✅ Closed — both render on HEAD now | | Content architecture | Flat-file `content/*.md` + `MarkdownContentService` | DB-seeded via Eloquent | 🔴 High | | Legacy model layer | `Configuration/Contact/News/OpenSource/Page/Product/ProductModule/Reference/Role/Service/Technology/User` all deleted | All present, in use | 🔴 High | -| Co-working feature | Full page w/ gallery, map, pricing | ✅ migrated (simplified, inactive) this session | 🟠→✅ | +| Co-working feature | Full page w/ gallery, map, pricing | ✅ migrated (simplified, inactive) | ✅ | | `config/site.php` | Rich config w/ office data + intro copy | Same content hardcoded in blade | 🟢 Low | | UI component kit | Much larger `ui/*` + `blocks/*` | Own smaller, independent kit | 🟡 Low–Med | | Custom error pages | Full 401/403/404/419/429/500/503 set | Laravel defaults | 🟠 Medium | | Security/CSP hardening | `SecurityPolicyBasic`, `CspAllowlist` | Basic `csp.php` only | 🟠 Medium | -| Sitemap generation | `GenerateSitemapCommand` (static) | Dynamic `SitemapController` | ✅ Closed — dynamic approach is the intended one on Laravel Cloud | +| Sitemap generation | `GenerateSitemapCommand` (static) | Dynamic `SitemapController` | ✅ Closed — dynamic approach is intended on Laravel Cloud | | `packages/coding-guidelines` | Full Boost-style skills package + `AGENTS.md` | Doesn't exist | 🟢 Low (dev tooling) | | Observability config | Nightwatch, history, pillars, team, content | Doesn't exist | 🟡 Low–Med | | Honeypot view | Published vendor view | Config only | 🟢 Low | @@ -95,10 +223,34 @@ The complete file-by-file gap analysis (main ↔ master, `feature-refactor` ↔ | Area | On `feature-refactor` | On master | Gap severity | |---|---|---|---| -| Terms & Conditions content | Full AGB draft + review feedback | Empty, disabled | 🔴 High | -| Opening-hours widget | Structured hours + live indicator | ✅ migrated this session | 🟠→✅ | -| GKI consulting service copy | Full marketing copy, 3 tiers | No equivalent | 🟡 Low–Med | -| `SyncRepositoriesCommand` | GitHub API auto-sync | ✅ ported this session (not scheduled) | 🟢→🟡 | +| Terms & Conditions content | Full AGB draft + review feedback | ✅ ported into `lang/*.json`, all feedback applied | ✅ | +| Opening-hours widget | Structured hours + live indicator | ✅ migrated | ✅ | +| GKI consulting service copy | Full marketing copy, 3 tiers | ✅ superseded by `feature-gki` (see above) | ✅ | +| `SyncRepositoriesCommand` | GitHub API auto-sync | ✅ ported (not scheduled) | 🟡 policy decision open | | Everything else (nav extraction, Matrix well-known, model rename, `config/site.php` v1) | — | Already equivalent/superseded | ✅ No gap | +### Gap table: `feature-design` → `feature-updates` (added this pass) + +| Area | On `feature-design` | On master | Verdict | +|---|---|---|---| +| `ui/*` component kit | 15 components, own contracts | 7 components, different contracts | ⛔ Collides — breaks 3 form submits + ~10 slot-only call sites | +| `blocks/*` component kit | 7 editorial blocks | `layout/*` + `card/*` + `band/*` | ⛔ Superseded | +| `resources/css/app.css` | `:root` vars, no `@theme` | Tailwind v4 `@theme` tokens | ⛔ Overwriting collapses site-wide styling | +| `resources/js/app.js` | Richer nav, no focus plugin | `autoSubmit` + `combobox` + `tabs` + focus | ⛔ Removes `Alpine.plugin(focus)`, breaks `x-trap` | +| Styleguide page | Controller + view + route | Doesn't exist | 🟡 Genuine gap — but fresh write, ~90% rewrite | +| Jobs page content | 3 invented English placeholder roles | Translation-key driven, no-openings panel | ⛔ HEAD is better | +| Services partnerships partial | 80-line inline SVG block | Replaced by the Network feature | ✅ Superseded | +| Nav / footer | `$configuration`-driven, paperflakes email | Static, single-brand, focus-trapped | ⛔ Cannot render on HEAD | +| `config/responsecache.php` | Old spatie schema, `enabled => false` | Current spatie v8 schema | ⛔ Would disable response caching | +| `config/auth.php` | Present | Intentionally deleted | ⛔ | +| `.gitignore` | adds `/laravel-start/` | missing that one line | 🟢 Trivial | + +### Gap table: `feature-gki` → `feature-updates` (added this pass) + +| Area | On `feature-gki` | On master | Verdict | +|---|---|---|---| +| GKI consulting page (3 tiers) | Controller + view + routes + SEO + 26 lang keys ×2 + tests | Doesn't exist | 🟡 Real gap — finished, dormant, cherry-pickable as `1e4dad8` | +| `HttpErrorPagesTest` Pest imports | Fixed | ✅ Already fixed | ✅ No gap | +| `RouteStatusTest` disabled-routes | Older list | Newer (has `about-us`, `co-working`) | ✅ HEAD is ahead | + </details> From 503f599ae756cc623180e3de9ec28fd92b9dbbfc Mon Sep 17 00:00:00 2001 From: "Sebastian BURGIN-FIX (ext)" <sebastian.burginfix_ext+sonepar@sonepar.com> Date: Wed, 29 Jul 2026 03:26:49 +0200 Subject: [PATCH 3/5] Improved Website Update --- app/Actions/PageAction.php | 7 +- app/Actions/ViewDataAction.php | 11 +- .../Commands/ImportAiModelsCommand.php | 251 +++++++++ .../Commands/ImportNetworksCommand.php | 242 +++++++++ app/Console/Commands/ImportNewsCommand.php | 481 ++++++++++++++++++ app/Console/Commands/ImportPagesCommand.php | 203 ++++++++ .../Commands/ImportProductsCommand.php | 320 ++++++++++++ .../Commands/ImportServicesCommand.php | 278 ++++++++++ app/Console/Commands/ImportTeamCommand.php | 225 ++++++++ .../Commands/ImportTechnologiesCommand.php | 279 ++++++++++ app/Contracts/HasTranslatedRouteKey.php | 16 + app/DTO/PageDTO.php | 12 + .../Ai/AiLlmAnalyticsIndexController.php | 1 - .../Controllers/News/NewsIndexController.php | 68 ++- .../Controllers/News/NewsShowController.php | 71 ++- .../OpenSource/OpenSoruceShowController.php | 47 +- .../OpenSource/OpenSourceIndexController.php | 24 +- .../Controllers/Sitemap/SitemapController.php | 25 +- .../Start/StartIndexController.php | 4 + app/Markdown/NewsMarkdown.php | 265 ++++++++++ app/Models/News.php | 106 +++- app/Models/NewsSeries.php | 45 ++ app/Models/NewsTag.php | 33 ++ app/Observers/NewsObserver.php | 36 ++ app/Providers/AppServiceProvider.php | 2 + app/Seo/SchemaNodes.php | 32 +- app/Support/LocalizedRouteParameters.php | 50 ++ app/Support/NewsImage.php | 113 ++++ app/Support/PageNavigation.php | 6 + app/Support/helpers.php | 10 +- app/Traits/HasLocalizedRouteBinding.php | 34 +- app/View/Components/AppLayout.php | 6 + composer.lock | 30 +- config/company.php | 2 +- database/factories/ContactFactory.php | 6 +- database/factories/NewsFactory.php | 20 +- database/files/ai_models/deepseek-ocr.yaml | 12 + .../files/ai_models/deepseek-v4-flash.yaml | 12 + database/files/ai_models/gemma4-12b.yaml | 6 + database/files/ai_models/gemma4-31b.yaml | 12 + database/files/ai_models/gemma4-e4b.yaml | 6 + database/files/ai_models/kimi-linear-48b.yaml | 12 + database/files/ai_models/qwen3-5-122b.yaml | 6 + database/files/ai_models/qwen3-5-4b.yaml | 6 + database/files/ai_models/qwen3-5-9b.yaml | 6 + database/files/ai_models/qwen3-6-27b.yaml | 6 + database/files/ai_models/qwen3-6-35b-a3b.yaml | 6 + database/files/ai_models/qwen3-6-35b.yaml | 12 + database/files/ai_models/qwen3-coder-30b.yaml | 12 + .../files/ai_models/qwen3-embedding-4b.yaml | 6 + .../files/ai_models/qwen3-embedding-8b.yaml | 12 + .../files/ai_models/qwen3-reranker-8b.yaml | 5 + database/files/ai_models/qwen3-vl-32b.yaml | 12 + database/files/ai_models/qwen3-vl-8b.yaml | 12 + database/files/legal/de_CH/imprint.md | 0 database/files/legal/de_CH/privacy.md | 0 database/files/legal/de_CH/terms.md | 0 database/files/legal/en_CH/imprint.md | 0 database/files/legal/en_CH/privacy.md | 0 database/files/legal/en_CH/terms.md | 0 database/files/networks/baselhack.yaml | 14 + database/files/networks/docuware.yaml | 11 + database/files/networks/iway.yaml | 11 + database/files/networks/odoo.yaml | 11 + database/files/networks/pst.yaml | 11 + .../networks/swiss-digital-services.yaml | 8 + .../networks/swiss-laravel-association.yaml | 10 + .../files/networks/swiss-made-software.yaml | 8 + .../networks/wieland-business-solutions.yaml | 11 + ...md => 2025-04-06-docuware-7-12-is-here.md} | 25 +- .../de_CH/2025-09-15-docuware-7-13-is-here.md | 60 +++ .../de_CH/2026-05-12-docuware-7-14-is-here.md | 61 +++ .../de_CH/2026-07-28-bausteine-styleguide.md | 139 +++++ ...md => 2025-04-06-docuware-7-12-is-here.md} | 25 +- .../en_CH/2025-09-15-docuware-7-13-is-here.md | 58 +++ .../en_CH/2026-05-12-docuware-7-14-is-here.md | 61 +++ .../en_CH/2026-07-28-bausteine-styleguide.md | 138 +++++ database/files/pages/about-us.index.yaml | 8 + database/files/pages/ai.index.yaml | 8 + .../files/pages/ai.llm.analytics.index.yaml | 8 + database/files/pages/ai.llm.index.yaml | 8 + database/files/pages/co-working.index.yaml | 8 + database/files/pages/contact.index.yaml | 8 + database/files/pages/jobs.index.yaml | 8 + database/files/pages/legal.imprint.index.yaml | 8 + database/files/pages/legal.privacy.index.yaml | 8 + database/files/pages/legal.terms.index.yaml | 8 + database/files/pages/media.index.yaml | 8 + database/files/pages/network.index.yaml | 8 + .../files/pages/network.request.index.yaml | 8 + database/files/pages/news.index.yaml | 8 + database/files/pages/open-source.index.yaml | 8 + database/files/pages/products.index.yaml | 8 + database/files/pages/services.index.yaml | 8 + database/files/pages/start.index.yaml | 8 + database/files/pages/technologies.index.yaml | 8 + database/files/products/de_CH/clouddocs.md | 19 - database/files/products/de_CH/docuhub.md | 21 - database/files/products/de_CH/flows.md | 42 ++ database/files/products/en_CH/clouddocs.md | 19 - database/files/products/en_CH/docuhub.md | 20 - database/files/products/en_CH/flows.md | 42 ++ .../services/de_CH/dms-ecm-consulting.md | 27 + .../de_CH/individuelle-softwareentwicklung.md | 30 ++ .../services/de_CH/konzeption-prototyping.md | 28 + .../files/services/de_CH/open-source-erp.md | 17 + .../services/en_CH/dms-ecm-consulting.md | 23 + .../en_CH/individuelle-softwareentwicklung.md | 26 + .../services/en_CH/konzeption-prototyping.md | 24 + .../files/services/en_CH/open-source-erp.md | 12 + .../files/team/alexander-christoph-boll.yaml | 13 + database/files/team/julian-leipert.yaml | 12 + database/files/team/melanie-buergin-fix.yaml | 10 + .../files/team/sebastian-buergin-fix.yaml | 14 + database/files/team/tobias-brogle.yaml | 13 + .../technologies/de_CH/laravel-framework.md | 10 + .../technologies/en_CH/laravel-framework.md | 10 + .../2025_04_04_181258_create_news_table.php | 35 -- ...025_06_23_130054_create_contacts_table.php | 2 + ..._07_28_150000_create_news_series_table.php | 26 + .../2026_07_28_150010_create_news_table.php | 47 ++ ...26_07_28_150020_create_news_tags_table.php | 25 + ...7_28_150030_create_news_news_tag_table.php | 23 + ..._28_150040_create_news_relations_table.php | 24 + database/seeders/AiModelsTableSeeder.php | 41 +- database/seeders/ContactsTableSeeder.php | 26 +- database/seeders/NetworkUsersTableSeeder.php | 112 ++-- database/seeders/NetworksTableSeeder.php | 153 +----- database/seeders/NewsTableSeeder.php | 63 +-- database/seeders/PagesTableSeeder.php | 132 +---- database/seeders/ProductsTableSeeder.php | 116 +---- database/seeders/SeoImageCleanupSeeder.php | 5 +- database/seeders/ServicesTableSeeder.php | 219 +------- database/seeders/TechnologiesTableSeeder.php | 32 +- .../seeders/data/ai_model_daily_usages.csv | 165 +++--- database/seeders/data/ai_models.csv | 19 - database/seeders/data/contacts.csv | 9 - database/seeders/data/network_users.csv | 7 + database/seeders/data/pages.csv | 15 - database/seeders/data/technologies.csv | 3 - database/seeders/data/users.csv | 2 +- lang/de_CH.json | 301 ++++++----- lang/de_CH/components.php | 9 +- lang/en_CH.json | 301 ++++++----- lang/en_CH/components.php | 9 +- package-lock.json | 18 +- .../poppins-400-normal-latin-ext.woff2 | Bin 0 -> 5640 bytes .../poppins/poppins-400-normal-latin.woff2 | Bin 0 -> 7900 bytes .../poppins-600-normal-latin-ext.woff2 | Bin 0 -> 5552 bytes .../poppins/poppins-600-normal-latin.woff2 | Bin 0 -> 7992 bytes .../poppins-700-normal-latin-ext.woff2 | Bin 0 -> 5448 bytes .../poppins/poppins-700-normal-latin.woff2 | Bin 0 -> 7848 bytes resources/css/app.css | 453 ++++++++++++++++- resources/js/app.js | 64 +++ resources/views/app/about-us/index.blade.php | 8 + resources/views/app/ai/index.blade.php | 27 +- .../views/app/ai/llm/analytics.blade.php | 16 +- resources/views/app/ai/llm/index.blade.php | 20 +- resources/views/app/media/index.blade.php | 8 + resources/views/app/network/index.blade.php | 28 +- resources/views/app/network/manage.blade.php | 210 ++++---- .../app/network/pages/baselhack.blade.php | 4 +- resources/views/app/network/request.blade.php | 26 +- resources/views/app/news/index.blade.php | 91 +++- resources/views/app/news/show.blade.php | 156 +++++- .../views/app/open-source/show.blade.php | 11 +- resources/views/app/products/show.blade.php | 6 +- resources/views/app/services/index.blade.php | 16 +- resources/views/app/services/show.blade.php | 6 +- resources/views/app/start/index.blade.php | 2 + .../views/app/technologies/show.blade.php | 6 +- .../components/ai-llm/model-row.blade.php | 8 +- .../components/ai-llm/usage-summary.blade.php | 17 + .../views/components/breadcrumbs.blade.php | 35 ++ .../components/card/item-card-body.blade.php | 33 ++ .../views/components/card/item-card.blade.php | 37 +- .../views/components/card/linkable.blade.php | 18 + .../views/components/card/nav-card.blade.php | 16 +- .../card/network-user-card.blade.php | 35 +- .../components/card/person-card.blade.php | 70 +-- .../components/data/meta-badges.blade.php | 2 +- .../views/components/data/tag-list.blade.php | 2 +- resources/views/components/demo/bar.blade.php | 15 + resources/views/components/explore.blade.php | 6 +- .../views/components/form/actions.blade.php | 7 + .../views/components/form/combobox.blade.php | 40 +- .../views/components/form/field.blade.php | 21 + .../views/components/form/file.blade.php | 16 + .../views/components/form/input.blade.php | 31 ++ .../views/components/h1-teaser.blade.php | 3 - resources/views/components/h1.blade.php | 4 +- resources/views/components/h2.blade.php | 2 +- resources/views/components/h3.blade.php | 2 +- .../components/layout/page-header.blade.php | 10 +- .../views/components/layout/section.blade.php | 4 +- resources/views/components/nav/link.blade.php | 33 ++ .../components/nav/locale-switch.blade.php | 25 + .../views/components/news/avatar.blade.php | 15 + .../views/components/news/byline.blade.php | 20 + .../views/components/news/card.blade.php | 86 ++++ .../views/components/news/latest.blade.php | 33 ++ .../components/news/series-nav.blade.php | 73 +++ .../news/table-of-contents.blade.php | 19 + .../views/components/next-page.blade.php | 2 +- .../views/components/opening-hours.blade.php | 28 +- resources/views/components/ui/alert.blade.php | 14 + .../views/components/ui/arrow-link.blade.php | 15 + .../views/components/ui/badge-link.blade.php | 17 +- resources/views/components/ui/badge.blade.php | 70 ++- .../views/components/ui/button.blade.php | 26 +- resources/views/components/ui/link.blade.php | 4 +- resources/views/components/ui/prose.blade.php | 4 +- .../components/ui/social-links.blade.php | 48 ++ resources/views/demo/_app_layout.blade.php | 6 +- resources/views/demo/_layout.blade.php | 15 +- .../views/layouts/_partials/_footer.blade.php | 90 ++-- .../_partials/_footer/labels.blade.php | 6 +- .../layouts/_partials/_navigation.blade.php | 32 +- .../_partials/_navigation_desktop.blade.php | 46 +- .../_partials/_navigation_mobile.blade.php | 116 ++--- .../views/layouts/_partials/_seo.blade.php | 58 ++- resources/views/layouts/app.blade.php | 33 +- resources/views/markdown/callout.blade.php | 17 + resources/views/markdown/compare.blade.php | 30 ++ resources/views/markdown/figure.blade.php | 26 + resources/views/markdown/gallery.blade.php | 31 ++ resources/views/markdown/quote.blade.php | 6 + resources/views/markdown/steps.blade.php | 17 + resources/views/markdown/video.blade.php | 40 ++ routes/console.php | 7 +- .../AiLlmAnalyticsIndexControllerTest.php | 5 +- tests/Feature/Controllers/RouteStatusTest.php | 4 +- tests/Feature/Controllers/ShowPagesTest.php | 13 +- .../Controllers/SitemapControllerTest.php | 7 +- tests/Feature/News/ImportNewsCommandTest.php | 183 +++++++ tests/Feature/News/NewsPublicationTest.php | 106 ++++ tests/Feature/Seo/MetaTagsTest.php | 64 ++- .../Services/ImportServicesCommandTest.php | 136 +++++ tests/Feature/Team/ImportTeamCommandTest.php | 154 ++++++ tests/Unit/Markdown/NewsMarkdownTest.php | 115 +++++ tests/Unit/Support/PageNavigationTest.php | 89 ++++ todos/news-konzept.md | 358 +++++++++++++ todos/refactor.md | 2 +- 243 files changed, 8504 insertions(+), 2139 deletions(-) create mode 100644 app/Console/Commands/ImportAiModelsCommand.php create mode 100644 app/Console/Commands/ImportNetworksCommand.php create mode 100644 app/Console/Commands/ImportNewsCommand.php create mode 100644 app/Console/Commands/ImportPagesCommand.php create mode 100644 app/Console/Commands/ImportProductsCommand.php create mode 100644 app/Console/Commands/ImportServicesCommand.php create mode 100644 app/Console/Commands/ImportTeamCommand.php create mode 100644 app/Console/Commands/ImportTechnologiesCommand.php create mode 100644 app/Contracts/HasTranslatedRouteKey.php create mode 100644 app/Markdown/NewsMarkdown.php create mode 100644 app/Models/NewsSeries.php create mode 100644 app/Models/NewsTag.php create mode 100644 app/Observers/NewsObserver.php create mode 100644 app/Support/LocalizedRouteParameters.php create mode 100644 app/Support/NewsImage.php create mode 100644 database/files/ai_models/deepseek-ocr.yaml create mode 100644 database/files/ai_models/deepseek-v4-flash.yaml create mode 100644 database/files/ai_models/gemma4-12b.yaml create mode 100644 database/files/ai_models/gemma4-31b.yaml create mode 100644 database/files/ai_models/gemma4-e4b.yaml create mode 100644 database/files/ai_models/kimi-linear-48b.yaml create mode 100644 database/files/ai_models/qwen3-5-122b.yaml create mode 100644 database/files/ai_models/qwen3-5-4b.yaml create mode 100644 database/files/ai_models/qwen3-5-9b.yaml create mode 100644 database/files/ai_models/qwen3-6-27b.yaml create mode 100644 database/files/ai_models/qwen3-6-35b-a3b.yaml create mode 100644 database/files/ai_models/qwen3-6-35b.yaml create mode 100644 database/files/ai_models/qwen3-coder-30b.yaml create mode 100644 database/files/ai_models/qwen3-embedding-4b.yaml create mode 100644 database/files/ai_models/qwen3-embedding-8b.yaml create mode 100644 database/files/ai_models/qwen3-reranker-8b.yaml create mode 100644 database/files/ai_models/qwen3-vl-32b.yaml create mode 100644 database/files/ai_models/qwen3-vl-8b.yaml delete mode 100644 database/files/legal/de_CH/imprint.md delete mode 100644 database/files/legal/de_CH/privacy.md delete mode 100644 database/files/legal/de_CH/terms.md delete mode 100644 database/files/legal/en_CH/imprint.md delete mode 100644 database/files/legal/en_CH/privacy.md delete mode 100644 database/files/legal/en_CH/terms.md create mode 100644 database/files/networks/baselhack.yaml create mode 100644 database/files/networks/docuware.yaml create mode 100644 database/files/networks/iway.yaml create mode 100644 database/files/networks/odoo.yaml create mode 100644 database/files/networks/pst.yaml create mode 100644 database/files/networks/swiss-digital-services.yaml create mode 100644 database/files/networks/swiss-laravel-association.yaml create mode 100644 database/files/networks/swiss-made-software.yaml create mode 100644 database/files/networks/wieland-business-solutions.yaml rename database/files/news/de_CH/{20250406_docuware_712.md => 2025-04-06-docuware-7-12-is-here.md} (73%) create mode 100644 database/files/news/de_CH/2025-09-15-docuware-7-13-is-here.md create mode 100644 database/files/news/de_CH/2026-05-12-docuware-7-14-is-here.md create mode 100644 database/files/news/de_CH/2026-07-28-bausteine-styleguide.md rename database/files/news/en_CH/{20250406_docuware_712.md => 2025-04-06-docuware-7-12-is-here.md} (72%) create mode 100644 database/files/news/en_CH/2025-09-15-docuware-7-13-is-here.md create mode 100644 database/files/news/en_CH/2026-05-12-docuware-7-14-is-here.md create mode 100644 database/files/news/en_CH/2026-07-28-bausteine-styleguide.md create mode 100644 database/files/pages/about-us.index.yaml create mode 100644 database/files/pages/ai.index.yaml create mode 100644 database/files/pages/ai.llm.analytics.index.yaml create mode 100644 database/files/pages/ai.llm.index.yaml create mode 100644 database/files/pages/co-working.index.yaml create mode 100644 database/files/pages/contact.index.yaml create mode 100644 database/files/pages/jobs.index.yaml create mode 100644 database/files/pages/legal.imprint.index.yaml create mode 100644 database/files/pages/legal.privacy.index.yaml create mode 100644 database/files/pages/legal.terms.index.yaml create mode 100644 database/files/pages/media.index.yaml create mode 100644 database/files/pages/network.index.yaml create mode 100644 database/files/pages/network.request.index.yaml create mode 100644 database/files/pages/news.index.yaml create mode 100644 database/files/pages/open-source.index.yaml create mode 100644 database/files/pages/products.index.yaml create mode 100644 database/files/pages/services.index.yaml create mode 100644 database/files/pages/start.index.yaml create mode 100644 database/files/pages/technologies.index.yaml delete mode 100644 database/files/products/de_CH/clouddocs.md delete mode 100644 database/files/products/de_CH/docuhub.md create mode 100644 database/files/products/de_CH/flows.md delete mode 100644 database/files/products/en_CH/clouddocs.md delete mode 100644 database/files/products/en_CH/docuhub.md create mode 100644 database/files/products/en_CH/flows.md create mode 100644 database/files/services/de_CH/dms-ecm-consulting.md create mode 100644 database/files/services/de_CH/individuelle-softwareentwicklung.md create mode 100644 database/files/services/de_CH/konzeption-prototyping.md create mode 100644 database/files/services/de_CH/open-source-erp.md create mode 100644 database/files/services/en_CH/dms-ecm-consulting.md create mode 100644 database/files/services/en_CH/individuelle-softwareentwicklung.md create mode 100644 database/files/services/en_CH/konzeption-prototyping.md create mode 100644 database/files/services/en_CH/open-source-erp.md create mode 100644 database/files/team/alexander-christoph-boll.yaml create mode 100644 database/files/team/julian-leipert.yaml create mode 100644 database/files/team/melanie-buergin-fix.yaml create mode 100644 database/files/team/sebastian-buergin-fix.yaml create mode 100644 database/files/team/tobias-brogle.yaml create mode 100644 database/files/technologies/de_CH/laravel-framework.md create mode 100644 database/files/technologies/en_CH/laravel-framework.md delete mode 100644 database/migrations/2025_04_04_181258_create_news_table.php create mode 100644 database/migrations/2026_07_28_150000_create_news_series_table.php create mode 100644 database/migrations/2026_07_28_150010_create_news_table.php create mode 100644 database/migrations/2026_07_28_150020_create_news_tags_table.php create mode 100644 database/migrations/2026_07_28_150030_create_news_news_tag_table.php create mode 100644 database/migrations/2026_07_28_150040_create_news_relations_table.php delete mode 100644 database/seeders/data/ai_models.csv delete mode 100644 database/seeders/data/contacts.csv create mode 100644 database/seeders/data/network_users.csv delete mode 100644 database/seeders/data/pages.csv delete mode 100644 database/seeders/data/technologies.csv create mode 100644 public/fonts/poppins/poppins-400-normal-latin-ext.woff2 create mode 100644 public/fonts/poppins/poppins-400-normal-latin.woff2 create mode 100644 public/fonts/poppins/poppins-600-normal-latin-ext.woff2 create mode 100644 public/fonts/poppins/poppins-600-normal-latin.woff2 create mode 100644 public/fonts/poppins/poppins-700-normal-latin-ext.woff2 create mode 100644 public/fonts/poppins/poppins-700-normal-latin.woff2 create mode 100644 resources/views/components/ai-llm/usage-summary.blade.php create mode 100644 resources/views/components/breadcrumbs.blade.php create mode 100644 resources/views/components/card/item-card-body.blade.php create mode 100644 resources/views/components/card/linkable.blade.php create mode 100644 resources/views/components/demo/bar.blade.php create mode 100644 resources/views/components/form/actions.blade.php create mode 100644 resources/views/components/form/field.blade.php create mode 100644 resources/views/components/form/file.blade.php create mode 100644 resources/views/components/form/input.blade.php delete mode 100644 resources/views/components/h1-teaser.blade.php create mode 100644 resources/views/components/nav/link.blade.php create mode 100644 resources/views/components/nav/locale-switch.blade.php create mode 100644 resources/views/components/news/avatar.blade.php create mode 100644 resources/views/components/news/byline.blade.php create mode 100644 resources/views/components/news/card.blade.php create mode 100644 resources/views/components/news/latest.blade.php create mode 100644 resources/views/components/news/series-nav.blade.php create mode 100644 resources/views/components/news/table-of-contents.blade.php create mode 100644 resources/views/components/ui/alert.blade.php create mode 100644 resources/views/components/ui/arrow-link.blade.php create mode 100644 resources/views/components/ui/social-links.blade.php create mode 100644 resources/views/markdown/callout.blade.php create mode 100644 resources/views/markdown/compare.blade.php create mode 100644 resources/views/markdown/figure.blade.php create mode 100644 resources/views/markdown/gallery.blade.php create mode 100644 resources/views/markdown/quote.blade.php create mode 100644 resources/views/markdown/steps.blade.php create mode 100644 resources/views/markdown/video.blade.php create mode 100644 tests/Feature/News/ImportNewsCommandTest.php create mode 100644 tests/Feature/News/NewsPublicationTest.php create mode 100644 tests/Feature/Services/ImportServicesCommandTest.php create mode 100644 tests/Feature/Team/ImportTeamCommandTest.php create mode 100644 tests/Unit/Markdown/NewsMarkdownTest.php create mode 100644 tests/Unit/Support/PageNavigationTest.php create mode 100644 todos/news-konzept.md diff --git a/app/Actions/PageAction.php b/app/Actions/PageAction.php index f4c2651..76cb7cf 100644 --- a/app/Actions/PageAction.php +++ b/app/Actions/PageAction.php @@ -11,6 +11,7 @@ use App\Models\Product; use App\Models\Service; use App\Models\Technology; +use App\Support\LocalizedRouteParameters; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; @@ -64,10 +65,12 @@ public function news(News $news, bool $withReferences = false, ?string $locale = routeName: Str::slug(title: $locale).'.news.show', title: $this->translatedString($news->getTranslation('title', $locale)), description: $this->translatedString($news->getTranslation('teaser', $locale)), - image: $news->image, + image: $news->hero_image, lastModificationDate: Carbon::parse($news->updated_at ?? now()), - routeParameters: ['locale' => $locale, 'news' => $news], + routeParameters: LocalizedRouteParameters::for(['locale' => $locale, 'news' => $news], $locale), referencePages: $withReferences ? $this->alternateLocalePages($news, $locale, fn (News $n, string $l) => $this->news($n, false, $l)) : null, + publishedAt: $news->published_at !== null ? Carbon::parse($news->published_at) : null, + authorName: $news->authorName(), ); } diff --git a/app/Actions/ViewDataAction.php b/app/Actions/ViewDataAction.php index 5f87a58..382c289 100644 --- a/app/Actions/ViewDataAction.php +++ b/app/Actions/ViewDataAction.php @@ -50,7 +50,13 @@ public function news(string $locale): Collection $key = Str::slug("news_published_{$locale}"); return Cache::rememberForever($key, function () { - return News::whereNotNull('published_at')->orderByDesc('published_at')->get(); + // Eager loaded: the cards read the series title and the author's picture, + // the index filters on tags, and Model::shouldBeStrict() turns a lazy load + // into an exception locally. + return News::with(['series', 'authorContact', 'newsTags']) + ->published() + ->orderByDesc('published_at') + ->get(); }); } @@ -122,8 +128,11 @@ public function contacts(string $locale): \stdClass $key = Str::slug("contacts_published_{$locale}"); return Cache::rememberForever($key, function () use ($locale) { + // Ordered by the `sort` field from the YAML files rather than alphabetically: + // the team page has an intended order that a surname does not express. $publishedContacts = Contact::query() ->where('published', true) + ->orderBy('sort') ->orderBy('name') ->get(); diff --git a/app/Console/Commands/ImportAiModelsCommand.php b/app/Console/Commands/ImportAiModelsCommand.php new file mode 100644 index 0000000..f339af8 --- /dev/null +++ b/app/Console/Commands/ImportAiModelsCommand.php @@ -0,0 +1,251 @@ +<?php + +namespace App\Console\Commands; + +use App\Enums\AiModelCategoryEnum; +use App\Models\AiModel; +use Illuminate\Console\Command; +use Symfony\Component\Yaml\Exception\ParseException; +use Symfony\Component\Yaml\Yaml; + +/** + * Reads one YAML file per model from database/files/ai_models/ and writes them to the + * database. The files are the source of truth — running this repeatedly is safe. + * + * `replaced_by` in the front matter references another model by its file key, not a + * database id — ids are not stable across a fresh import, so the link is resolved by + * key in a second pass once every row exists. + */ +class ImportAiModelsCommand extends Command +{ + protected $signature = 'ai-models:import + {--dry-run : Show what would change without writing anything} + {--path= : Read from this directory instead of database/files/ai_models}'; + + protected $description = 'Import AI models from database/files/ai_models/*.yaml'; + + public function handle(): int + { + $files = $this->files(); + + if ($files === []) { + $this->components->warn('No AI model files found under '.$this->basePath().'.'); + + return self::SUCCESS; + } + + $dryRun = (bool) $this->option('dry-run'); + $imported = 0; + $skipped = 0; + $parsedByKey = []; + + foreach ($files as $path) { + $data = $this->parse($path); + + if ($data === null) { + $skipped++; + + continue; + } + + $expected = $data['key'].'.yaml'; + + if (basename($path) !== $expected) { + $this->components->warn(sprintf('%s should be named %s.', basename($path), $expected)); + } + + $parsedByKey[$data['key']] = $data; + + if ($dryRun) { + $exists = AiModel::where('name', $data['name'])->exists(); + $this->components->twoColumnDetail($data['key'], $exists ? '<fg=yellow>would update</>' : '<fg=green>would create</>'); + } + + $imported++; + } + + if ($dryRun) { + $this->newLine(); + $this->components->info(sprintf('%d model(s) pending, %d skipped.', $imported, $skipped)); + + return $skipped > 0 ? self::FAILURE : self::SUCCESS; + } + + $idsByKey = []; + + foreach ($parsedByKey as $key => $data) { + $model = AiModel::updateOrCreate( + ['name' => $data['name']], + [ + 'category' => $data['category']->value, + 'order' => $data['order'], + 'provider' => $data['provider'], + 'ram' => $data['ram'], + 'license' => $data['license'], + 'role' => $data['role'], + 'link_label' => $data['link_label'], + 'link_url' => $data['link_url'], + 'archived_at' => $data['archived_at'], + ] + ); + + $idsByKey[$key] = $model->id; + $this->components->twoColumnDetail($key, '<fg=green>imported</>'); + } + + foreach ($parsedByKey as $key => $data) { + if ($data['replaced_by'] === null) { + continue; + } + + $targetId = $idsByKey[$data['replaced_by']] ?? null; + + if ($targetId === null) { + $this->components->error(sprintf('"%s" replaces an unknown model key "%s".', $key, $data['replaced_by'])); + + continue; + } + + AiModel::whereKey($idsByKey[$key])->update(['replaced_by_id' => $targetId]); + } + + $this->removeOrphans(array_map(fn (array $data): string => $data['name'], $parsedByKey)); + + $this->newLine(); + $this->components->info(sprintf('%d model(s) imported, %d skipped.', $imported, $skipped)); + + return $skipped > 0 ? self::FAILURE : self::SUCCESS; + } + + private function basePath(): string + { + $override = $this->option('path'); + + return is_string($override) && $override !== '' ? rtrim($override, '/') : database_path('files/ai_models'); + } + + /** + * @return array<int, string> + */ + private function files(): array + { + $base = $this->basePath(); + + if (! is_dir($base)) { + return []; + } + + $files = array_merge(glob($base.'/*.yaml') ?: [], glob($base.'/*.yml') ?: []); + sort($files); + + return $files; + } + + /** + * @return array{key: string, name: string, category: AiModelCategoryEnum, order: int, provider: ?string, ram: ?string, license: ?string, role: ?array<string, string>, link_label: ?string, link_url: ?string, archived_at: ?string, replaced_by: ?string}|null + */ + private function parse(string $path): ?array + { + try { + $parsed = Yaml::parseFile($path); + } catch (ParseException $exception) { + $this->components->error(basename($path).': '.$exception->getMessage()); + + return null; + } + + if (! is_array($parsed)) { + $this->components->error(basename($path).' is not a YAML mapping — skipped.'); + + return null; + } + + $key = $this->string($parsed['key'] ?? '') ?: pathinfo($path, PATHINFO_FILENAME); + $name = $this->string($parsed['name'] ?? ''); + + if ($name === '') { + $this->components->error(basename($path).' has no "name" — skipped.'); + + return null; + } + + $category = AiModelCategoryEnum::tryFrom($this->string($parsed['category'] ?? '')); + + if ($category === null) { + $this->components->error(basename($path).' has no valid "category" — skipped.'); + + return null; + } + + return [ + 'key' => $key, + 'name' => $name, + 'category' => $category, + 'order' => is_int($parsed['order'] ?? null) ? $parsed['order'] : 0, + 'provider' => $this->nullableString($parsed['provider'] ?? null), + 'ram' => $this->nullableString($parsed['ram'] ?? null), + 'license' => $this->nullableString($parsed['license'] ?? null), + 'role' => $this->role($parsed['role'] ?? null), + 'link_label' => $this->nullableString($parsed['link_label'] ?? null), + 'link_url' => $this->nullableString($parsed['link_url'] ?? null), + 'archived_at' => $this->nullableString($parsed['archived_at'] ?? null), + 'replaced_by' => $this->nullableString($parsed['replaced_by'] ?? null), + ]; + } + + /** + * @return array<string, string>|null + */ + private function role(mixed $value): ?array + { + if (! is_array($value)) { + return null; + } + + $result = []; + + foreach ($value as $locale => $text) { + if (is_string($locale) && is_string($text) && $text !== '') { + $result[$locale] = $text; + } + } + + return $result === [] ? null : $result; + } + + /** + * A model removed from the repository must disappear from the catalogue too, + * otherwise the files stop being the source of truth. + * + * @param array<int, string> $names + */ + private function removeOrphans(array $names): void + { + $orphans = AiModel::whereNotIn('name', $names)->get(); + + foreach ($orphans as $orphan) { + $this->components->twoColumnDetail($orphan->name, '<fg=red>removed</>'); + $orphan->delete(); + } + } + + private function string(mixed $value): string + { + if (is_string($value)) { + return trim($value); + } + + if (is_int($value) || is_float($value) || is_bool($value)) { + return trim((string) $value); + } + + return ''; + } + + private function nullableString(mixed $value): ?string + { + $string = $this->string($value); + + return $string === '' ? null : $string; + } +} diff --git a/app/Console/Commands/ImportNetworksCommand.php b/app/Console/Commands/ImportNetworksCommand.php new file mode 100644 index 0000000..d01129b --- /dev/null +++ b/app/Console/Commands/ImportNetworksCommand.php @@ -0,0 +1,242 @@ +<?php + +namespace App\Console\Commands; + +use App\Enums\LocaleEnum; +use App\Enums\NetworkCategoryEnum; +use App\Enums\NetworkStatusEnum; +use App\Models\Network; +use Illuminate\Console\Command; +use Symfony\Component\Yaml\Exception\ParseException; +use Symfony\Component\Yaml\Yaml; + +/** + * Reads one YAML file per network partner from database/files/networks/ and writes + * them to the database. The files are the source of truth — running this repeatedly + * is safe. + */ +class ImportNetworksCommand extends Command +{ + protected $signature = 'networks:import + {--dry-run : Show what would change without writing anything} + {--path= : Read from this directory instead of database/files/networks}'; + + protected $description = 'Import network partners from database/files/networks/*.yaml'; + + public function handle(): int + { + $files = $this->files(); + + if ($files === []) { + $this->components->warn('No network files found under '.$this->basePath().'.'); + + return self::SUCCESS; + } + + $dryRun = (bool) $this->option('dry-run'); + $imported = 0; + $skipped = 0; + $keys = []; + + foreach ($files as $path) { + $data = $this->parse($path); + + if ($data === null) { + $skipped++; + + continue; + } + + $key = $data['key']; + $keys[] = $key; + + $expected = $key.'.yaml'; + + if (basename($path) !== $expected) { + $this->components->warn(sprintf('%s should be named %s.', basename($path), $expected)); + } + + if ($dryRun) { + $exists = Network::where('key', $key)->exists(); + $this->components->twoColumnDetail($key, $exists ? '<fg=yellow>would update</>' : '<fg=green>would create</>'); + $imported++; + + continue; + } + + Network::updateOrCreate(['key' => $key], [ + 'name' => $data['name'], + 'category' => $data['category']->value, + 'status' => $data['status']->value, + 'cover_url' => $data['cover_url'], + 'tier_label' => $data['tier_label'], + 'excerpt' => $data['excerpt'], + 'website' => $data['website'], + 'since_year' => $data['since_year'], + 'until_year' => $data['until_year'], + 'page_slug' => $data['page_slug'], + 'published' => $data['published'], + 'sort' => $data['sort'], + ]); + + $this->components->twoColumnDetail($key, '<fg=green>imported</>'); + $imported++; + } + + if (! $dryRun) { + $this->removeOrphans($keys); + } + + $this->newLine(); + $this->components->info(sprintf('%d network(s) %s, %d skipped.', $imported, $dryRun ? 'pending' : 'imported', $skipped)); + + return $skipped > 0 ? self::FAILURE : self::SUCCESS; + } + + private function basePath(): string + { + $override = $this->option('path'); + + return is_string($override) && $override !== '' ? rtrim($override, '/') : database_path('files/networks'); + } + + /** + * @return array<int, string> + */ + private function files(): array + { + $base = $this->basePath(); + + if (! is_dir($base)) { + return []; + } + + $files = array_merge(glob($base.'/*.yaml') ?: [], glob($base.'/*.yml') ?: []); + sort($files); + + return $files; + } + + /** + * @return array{key: string, category: NetworkCategoryEnum, status: NetworkStatusEnum, sort: int, cover_url: ?string, website: ?string, since_year: ?int, until_year: ?int, page_slug: ?string, published: bool, name: array<string, string>, tier_label: array<string, string>, excerpt: array<string, string>}|null + */ + private function parse(string $path): ?array + { + try { + $parsed = Yaml::parseFile($path); + } catch (ParseException $exception) { + $this->components->error(basename($path).': '.$exception->getMessage()); + + return null; + } + + if (! is_array($parsed)) { + $this->components->error(basename($path).' is not a YAML mapping — skipped.'); + + return null; + } + + $key = $this->string($parsed['key'] ?? '') ?: pathinfo($path, PATHINFO_FILENAME); + $names = is_array($parsed['name'] ?? null) ? $parsed['name'] : []; + + $missing = array_diff( + array_map(fn (LocaleEnum $case): string => $case->value, LocaleEnum::cases()), + array_keys($names) + ); + + if ($missing !== []) { + $this->components->error(sprintf('%s is missing a name for %s.', basename($path), implode(', ', $missing))); + + return null; + } + + $category = NetworkCategoryEnum::tryFrom($this->string($parsed['category'] ?? '')); + + if ($category === null) { + $this->components->error(basename($path).' has no valid "category".'); + + return null; + } + + return [ + 'key' => $key, + 'category' => $category, + 'status' => NetworkStatusEnum::tryFrom($this->string($parsed['status'] ?? '')) ?? NetworkStatusEnum::ACTIVE, + 'sort' => is_int($parsed['sort'] ?? null) ? $parsed['sort'] : 0, + 'cover_url' => $this->nullableString($parsed['cover_url'] ?? null), + 'website' => $this->nullableString($parsed['website'] ?? null), + 'since_year' => $this->nullableInt($parsed['since_year'] ?? null), + 'until_year' => $this->nullableInt($parsed['until_year'] ?? null), + 'page_slug' => $this->nullableString($parsed['page_slug'] ?? null), + 'published' => (bool) ($parsed['published'] ?? true), + 'name' => array_map(fn (mixed $value): string => $this->string($value), $names), + 'tier_label' => $this->localizedMap($parsed['tier_label'] ?? null), + 'excerpt' => $this->localizedMap($parsed['excerpt'] ?? null), + ]; + } + + /** + * @return array<string, string> + */ + private function localizedMap(mixed $value): array + { + if (! is_array($value)) { + return []; + } + + $result = []; + + foreach ($value as $locale => $text) { + if (is_string($locale) && is_string($text) && $text !== '') { + $result[$locale] = $text; + } + } + + return $result; + } + + /** + * A network removed from the repository must disappear from the site too, + * otherwise the files stop being the source of truth. + * + * @param array<int, string> $keys + */ + private function removeOrphans(array $keys): void + { + $orphans = Network::whereNotIn('key', $keys)->get(); + + foreach ($orphans as $orphan) { + $this->components->twoColumnDetail($orphan->key, '<fg=red>removed</>'); + $orphan->delete(); + } + } + + private function string(mixed $value): string + { + if (is_string($value)) { + return trim($value); + } + + if (is_int($value) || is_float($value) || is_bool($value)) { + return trim((string) $value); + } + + return ''; + } + + private function nullableInt(mixed $value): ?int + { + if (is_int($value)) { + return $value; + } + + return is_string($value) && ctype_digit($value) ? (int) $value : null; + } + + private function nullableString(mixed $value): ?string + { + $string = $this->string($value); + + return $string === '' ? null : $string; + } +} diff --git a/app/Console/Commands/ImportNewsCommand.php b/app/Console/Commands/ImportNewsCommand.php new file mode 100644 index 0000000..75cb310 --- /dev/null +++ b/app/Console/Commands/ImportNewsCommand.php @@ -0,0 +1,481 @@ +<?php + +namespace App\Console\Commands; + +use App\Enums\LocaleEnum; +use App\Markdown\NewsMarkdown; +use App\Models\Contact; +use App\Models\News; +use App\Models\NewsSeries; +use App\Models\NewsTag; +use App\Observers\NewsObserver; +use Illuminate\Console\Command; +use Illuminate\Support\Carbon; +use Illuminate\Support\Str; +use Symfony\Component\Yaml\Exception\ParseException; +use Symfony\Component\Yaml\Yaml; + +/** + * Reads the article files under database/files/news/{locale}/ and writes them to the + * database. The markdown files are the source of truth — running this repeatedly is safe. + */ +class ImportNewsCommand extends Command +{ + protected $signature = 'news:import + {--dry-run : Show what would change without writing anything} + {--key= : Import a single article by its key} + {--path= : Read from this directory instead of database/files/news}'; + + protected $description = 'Import news articles from database/files/news/{locale}/*.md'; + + public function handle(NewsMarkdown $markdown): int + { + $documents = $this->readDocuments(); + + if ($documents === []) { + $this->components->warn('No article files found under '.$this->basePath().'.'); + + return self::SUCCESS; + } + + $dryRun = (bool) $this->option('dry-run'); + $only = $this->option('key'); + + $imported = 0; + $skipped = 0; + + foreach ($documents as $key => $localeDocuments) { + if (is_string($only) && $only !== '' && $only !== $key) { + continue; + } + + $missing = array_diff( + array_map(fn (LocaleEnum $case): string => $case->value, LocaleEnum::cases()), + array_keys($localeDocuments) + ); + + if ($missing !== []) { + $this->components->error(sprintf( + '"%s" is missing a translation for %s — every article must exist in both languages.', + $key, + implode(', ', $missing) + )); + $skipped++; + + continue; + } + + if ($dryRun) { + $existing = News::where('key', $key)->exists(); + $this->components->twoColumnDetail($key, $existing ? '<fg=yellow>would update</>' : '<fg=green>would create</>'); + $imported++; + + continue; + } + + $this->store($key, $localeDocuments, $markdown); + $this->components->twoColumnDetail($key, '<fg=green>imported</>'); + $imported++; + } + + if (! $dryRun && $imported > 0) { + $this->linkRelatedArticles($documents); + NewsObserver::flush(); + } + + $this->newLine(); + $this->components->info(sprintf('%d article(s) %s, %d skipped.', $imported, $dryRun ? 'pending' : 'imported', $skipped)); + + return $skipped > 0 ? self::FAILURE : self::SUCCESS; + } + + private function basePath(): string + { + $override = $this->option('path'); + + return is_string($override) && $override !== '' ? rtrim($override, '/') : database_path('files/news'); + } + + /** + * @return array<string, array<string, array{front: array<string, mixed>, body: string}>> + */ + private function readDocuments(): array + { + $documents = []; + + foreach (LocaleEnum::cases() as $case) { + $directory = $this->basePath().'/'.$case->value; + + if (! is_dir($directory)) { + continue; + } + + foreach (glob($directory.'/*.md') ?: [] as $path) { + $parsed = $this->parseFile($path); + + if ($parsed === null) { + continue; + } + + $key = $parsed['front']['key'] ?? null; + + if (! is_string($key) || $key === '') { + $this->components->error(basename($path).' has no "key" in its front matter — skipped.'); + + continue; + } + + $this->checkFileName($path, $key, $parsed['front']); + + $documents[$key][$case->value] = $parsed; + } + } + + ksort($documents); + + return $documents; + } + + /** + * Warns when a file breaks the naming convention. Both language files of an article + * carry the same name, so a listing sorts by publication date and the pair sits + * together. A mismatch is reported, not fatal — renaming must never break an import. + * + * @param array<string, mixed> $front + */ + private function checkFileName(string $path, string $key, array $front): void + { + $publishedAt = $this->publishedAt($front['published_at'] ?? null); + + if ($publishedAt === null) { + return; + } + + $expected = $publishedAt->format('Y-m-d').'-'.$key.'.md'; + + if (basename($path) !== $expected) { + $this->components->warn(sprintf('%s should be named %s.', basename($path), $expected)); + } + } + + /** + * @return array{front: array<string, mixed>, body: string}|null + */ + private function parseFile(string $path): ?array + { + $contents = file_get_contents($path); + + if ($contents === false) { + return null; + } + + $contents = str_replace("\r\n", "\n", $contents); + + if (! str_starts_with($contents, '---')) { + $this->components->error(basename($path).' has no YAML front matter — skipped.'); + + return null; + } + + $parts = preg_split('/^---\s*$/m', $contents, 3); + + if ($parts === false || count($parts) < 3) { + $this->components->error(basename($path).' has malformed front matter — skipped.'); + + return null; + } + + try { + // Without PARSE_DATETIME, `published_at: 2025-04-06` arrives as a Unix timestamp. + $front = Yaml::parse($parts[1], Yaml::PARSE_DATETIME); + } catch (ParseException $exception) { + $this->components->error(basename($path).': '.$exception->getMessage()); + + return null; + } + + $normalised = []; + + if (is_array($front)) { + foreach ($front as $field => $value) { + if (is_string($field)) { + $normalised[$field] = $value; + } + } + } + + return [ + 'front' => $normalised, + 'body' => trim($parts[2]), + ]; + } + + /** + * @param array<string, array{front: array<string, mixed>, body: string}> $localeDocuments + */ + private function store(string $key, array $localeDocuments, NewsMarkdown $markdown): void + { + $primary = $localeDocuments[LocaleEnum::DE->value]['front']; + + $translated = ['title' => [], 'teaser' => [], 'content' => [], 'slug' => []]; + $optional = ['hero_caption' => [], 'hero_alt' => []]; + + foreach ($localeDocuments as $locale => $document) { + $translated['title'][$locale] = $this->string($document['front']['title'] ?? ''); + $translated['teaser'][$locale] = $this->string($document['front']['teaser'] ?? ''); + $translated['content'][$locale] = $document['body']; + $translated['slug'][$locale] = Str::slug($this->string( + $document['front']['slug'] ?? $document['front']['title'] ?? $key + )); + $optional['hero_caption'][$locale] = $this->string($document['front']['hero_caption'] ?? ''); + $optional['hero_alt'][$locale] = $this->string($document['front']['hero_alt'] ?? ''); + } + + // Languages without a caption are left out rather than stored as empty strings. + foreach ($optional as $field => $values) { + $translated[$field] = array_filter($values); + } + + $news = $this->existing($key, $translated['slug']) ?? new News(['key' => $key]); + + $news->fill([ + ...$translated, + 'hero_image' => $this->nullableString($primary['hero'] ?? null), + 'published_at' => $this->publishedAt($primary['published_at'] ?? null), + // Defaults to true: an article with a date is live unless it says otherwise. + 'published' => (bool) ($primary['published'] ?? true), + 'author' => $this->nullableString($primary['author_name'] ?? null), + 'contact_id' => $this->resolveContactId($primary['author'] ?? null), + 'series_id' => $this->resolveSeriesId($primary['series'] ?? null, $localeDocuments), + 'series_position' => $this->nullableInt($primary['series_position'] ?? null), + 'featured' => (bool) ($primary['featured'] ?? false), + 'tags' => $this->tagLabels($primary['tags'] ?? []), + 'reading_minutes' => $markdown->readingMinutes($localeDocuments[LocaleEnum::DE->value]['body']), + ]); + + $news->key = $key; + $news->save(); + + $news->newsTags()->sync($this->resolveTagIds($primary['tags'] ?? [], $localeDocuments)); + } + + /** + * Matches on the key first, then on any of the slugs. The fallback lets an article + * be re-keyed without colliding with its own row on the per-locale slug index. + * + * @param array<string, string> $slugs + */ + private function existing(string $key, array $slugs): ?News + { + $news = News::where('key', $key)->first(); + + if ($news !== null) { + return $news; + } + + return News::where(function ($query) use ($slugs): void { + foreach ($slugs as $locale => $slug) { + $query->orWhere('slug->'.$locale, $slug); + } + })->first(); + } + + private function publishedAt(mixed $value): ?Carbon + { + if ($value === null || $value === '') { + return null; + } + + if ($value instanceof \DateTimeInterface) { + return Carbon::instance($value); + } + + if (is_int($value)) { + return Carbon::createFromTimestamp($value); + } + + $date = $this->string($value); + + return $date === '' ? null : Carbon::parse($date); + } + + /** + * The author is a contacts row: either its numeric id or the email in its icons. + */ + private function resolveContactId(mixed $value): ?int + { + if ($value === null || $value === '') { + return null; + } + + $id = $this->nullableInt($value); + + if ($id !== null) { + return Contact::whereKey($id)->exists() ? $id : null; + } + + $needle = mb_strtolower($this->string($value)); + + $match = Contact::all()->first(function (Contact $contact) use ($needle): bool { + $icons = $contact->icons; + $email = is_array($icons) && isset($icons['email']) ? $this->string($icons['email']) : ''; + + return mb_strtolower($email) === $needle || mb_strtolower($contact->name) === $needle; + }); + + if ($match === null) { + $this->components->warn(sprintf( + 'No contact found for author "%s" — the article keeps its plain author name.', + $this->string($value) + )); + + return null; + } + + return $match->id; + } + + /** + * @param array<string, array{front: array<string, mixed>, body: string}> $localeDocuments + */ + private function resolveSeriesId(mixed $value, array $localeDocuments): ?int + { + if (! is_string($value) || $value === '') { + return null; + } + + $titles = []; + $slugs = []; + + foreach ($localeDocuments as $locale => $document) { + $title = $this->string($document['front']['series_title'] ?? '') ?: Str::headline($value); + $titles[$locale] = $title; + $slugs[$locale] = Str::slug($this->string($document['front']['series_slug'] ?? '') ?: $title); + } + + $series = NewsSeries::firstOrNew(['key' => $value]); + + // Only fill the labels on creation — an existing series may have been edited. + if (! $series->exists) { + $series->setTranslations('title', $titles); + $series->setTranslations('slug', $slugs); + $series->published = true; + $series->save(); + } + + return $series->id; + } + + /** + * @return array<int, string> + */ + private function tagLabels(mixed $tags): array + { + if (! is_array($tags)) { + return []; + } + + return array_values(array_filter(array_map( + fn (mixed $tag): string => $this->string($tag), + $tags + ), fn (string $tag): bool => $tag !== '')); + } + + /** + * @param array<string, array{front: array<string, mixed>, body: string}> $localeDocuments + * @return array<int, int> + */ + private function resolveTagIds(mixed $tags, array $localeDocuments): array + { + $ids = []; + + foreach ($this->tagLabels($tags) as $label) { + // "DMS/ECM" must not collapse into "dmsecm". + $key = Str::slug(str_replace(['/', '&'], ' ', $label)); + $tag = NewsTag::firstOrNew(['key' => $key]); + + if (! $tag->exists) { + $titles = []; + $slugs = []; + + foreach (array_keys($localeDocuments) as $locale) { + // A tag written in the German file is used verbatim in both languages + // until someone gives it a translated label in the database. + $titles[$locale] = $label; + $slugs[$locale] = $key; + } + + $tag->setTranslations('title', $titles); + $tag->setTranslations('slug', $slugs); + $tag->save(); + } + + $ids[] = $tag->id; + } + + return $ids; + } + + /** + * @param array<string, array<string, array{front: array<string, mixed>, body: string}>> $documents + */ + private function linkRelatedArticles(array $documents): void + { + foreach ($documents as $key => $localeDocuments) { + $front = $localeDocuments[LocaleEnum::DE->value]['front'] ?? []; + $related = $front['related'] ?? []; + + if (! is_array($related) || $related === []) { + continue; + } + + $news = News::where('key', $key)->first(); + + if ($news === null) { + continue; + } + + $keys = array_values(array_map(fn (mixed $k): string => $this->string($k), $related)); + + /** @var array<int, int> $targets */ + $targets = News::whereIn('key', $keys)->pluck('id')->all(); + + $sync = []; + + foreach (array_values($targets) as $index => $id) { + $sync[$id] = ['sort' => $index]; + } + + $news->relatedArticles()->sync($sync); + } + } + + private function string(mixed $value): string + { + if (is_string($value)) { + return trim($value); + } + + if (is_int($value) || is_float($value) || is_bool($value)) { + return trim((string) $value); + } + + return ''; + } + + private function nullableInt(mixed $value): ?int + { + if (is_int($value)) { + return $value; + } + + return is_string($value) && ctype_digit($value) ? (int) $value : null; + } + + private function nullableString(mixed $value): ?string + { + $string = $this->string($value); + + return $string === '' ? null : $string; + } +} diff --git a/app/Console/Commands/ImportPagesCommand.php b/app/Console/Commands/ImportPagesCommand.php new file mode 100644 index 0000000..920e417 --- /dev/null +++ b/app/Console/Commands/ImportPagesCommand.php @@ -0,0 +1,203 @@ +<?php + +namespace App\Console\Commands; + +use App\Enums\LocaleEnum; +use App\Models\Page; +use Illuminate\Console\Command; +use Symfony\Component\Yaml\Exception\ParseException; +use Symfony\Component\Yaml\Yaml; + +/** + * Reads one YAML file per page from database/files/pages/ and writes them to the + * database. The files are the source of truth — running this repeatedly is safe. + * + * Replaces a CSV that carried JSON inside its cells, which was effectively + * uneditable by hand and produced unreadable diffs. + */ +class ImportPagesCommand extends Command +{ + protected $signature = 'pages:import + {--dry-run : Show what would change without writing anything} + {--path= : Read from this directory instead of database/files/pages}'; + + protected $description = 'Import page SEO metadata from database/files/pages/*.yaml'; + + public function handle(): int + { + $files = $this->files(); + + if ($files === []) { + $this->components->warn('No page files found under '.$this->basePath().'.'); + + return self::SUCCESS; + } + + $dryRun = (bool) $this->option('dry-run'); + $imported = 0; + $skipped = 0; + $keys = []; + + foreach ($files as $path) { + $data = $this->parse($path); + + if ($data === null) { + $skipped++; + + continue; + } + + $key = $data['key']; + $keys[] = $key; + + $expected = $key.'.yaml'; + + if (basename($path) !== $expected) { + $this->components->warn(sprintf('%s should be named %s.', basename($path), $expected)); + } + + if ($dryRun) { + $exists = Page::where('key', $key)->exists(); + $this->components->twoColumnDetail($key, $exists ? '<fg=yellow>would update</>' : '<fg=green>would create</>'); + $imported++; + + continue; + } + + Page::updateOrCreate(['key' => $key], [ + 'robots' => $data['robots'], + 'title' => $data['title'], + 'description' => $data['description'], + 'image' => $data['image'], + ]); + + $this->components->twoColumnDetail($key, '<fg=green>imported</>'); + $imported++; + } + + if (! $dryRun) { + $this->removeOrphans($keys); + } + + $this->newLine(); + $this->components->info(sprintf('%d page(s) %s, %d skipped.', $imported, $dryRun ? 'pending' : 'imported', $skipped)); + + return $skipped > 0 ? self::FAILURE : self::SUCCESS; + } + + private function basePath(): string + { + $override = $this->option('path'); + + return is_string($override) && $override !== '' ? rtrim($override, '/') : database_path('files/pages'); + } + + /** + * @return array<int, string> + */ + private function files(): array + { + $base = $this->basePath(); + + if (! is_dir($base)) { + return []; + } + + $files = array_merge(glob($base.'/*.yaml') ?: [], glob($base.'/*.yml') ?: []); + sort($files); + + return $files; + } + + /** + * @return array{key: string, robots: string, title: array<string, string>, description: array<string, string>, image: ?string}|null + */ + private function parse(string $path): ?array + { + try { + $parsed = Yaml::parseFile($path); + } catch (ParseException $exception) { + $this->components->error(basename($path).': '.$exception->getMessage()); + + return null; + } + + if (! is_array($parsed)) { + $this->components->error(basename($path).' is not a YAML mapping — skipped.'); + + return null; + } + + $key = $this->string($parsed['key'] ?? '') ?: pathinfo($path, PATHINFO_FILENAME); + $robots = $this->string($parsed['robots'] ?? ''); + + if ($robots === '') { + $this->components->error(basename($path).' has no "robots" — skipped.'); + + return null; + } + + $titles = is_array($parsed['title'] ?? null) ? $parsed['title'] : []; + $descriptions = is_array($parsed['description'] ?? null) ? $parsed['description'] : []; + + $missing = array_diff( + array_map(fn (LocaleEnum $case): string => $case->value, LocaleEnum::cases()), + array_keys($titles), + array_keys($descriptions) + ); + + if ($missing !== []) { + $this->components->error(sprintf( + '%s is missing a title or description for %s.', + basename($path), + implode(', ', $missing) + )); + + return null; + } + + return [ + 'key' => $key, + 'robots' => $robots, + 'title' => array_map(fn (mixed $value): string => $this->string($value), $titles), + 'description' => array_map(fn (mixed $value): string => $this->string($value), $descriptions), + 'image' => $this->nullableString($parsed['image'] ?? null), + ]; + } + + /** + * A page removed from the repository must disappear from the site too, otherwise + * the files stop being the source of truth. + * + * @param array<int, string> $keys + */ + private function removeOrphans(array $keys): void + { + $orphans = Page::whereNotIn('key', $keys)->get(); + + foreach ($orphans as $orphan) { + $this->components->twoColumnDetail($orphan->key, '<fg=red>removed</>'); + $orphan->delete(); + } + } + + private function string(mixed $value): string + { + if (is_string($value)) { + return trim($value); + } + + if (is_int($value) || is_float($value) || is_bool($value)) { + return trim((string) $value); + } + + return ''; + } + + private function nullableString(mixed $value): ?string + { + $string = $this->string($value); + + return $string === '' ? null : $string; + } +} diff --git a/app/Console/Commands/ImportProductsCommand.php b/app/Console/Commands/ImportProductsCommand.php new file mode 100644 index 0000000..2cad8f5 --- /dev/null +++ b/app/Console/Commands/ImportProductsCommand.php @@ -0,0 +1,320 @@ +<?php + +namespace App\Console\Commands; + +use App\Enums\LocaleEnum; +use App\Models\Product; +use Illuminate\Console\Command; +use Symfony\Component\Yaml\Exception\ParseException; +use Symfony\Component\Yaml\Yaml; + +/** + * Reads the product files under database/files/products/{locale}/ and writes them to + * the database. The markdown files are the source of truth — running this repeatedly + * is safe. + */ +class ImportProductsCommand extends Command +{ + protected $signature = 'products:import + {--dry-run : Show what would change without writing anything} + {--path= : Read from this directory instead of database/files/products}'; + + protected $description = 'Import products from database/files/products/{locale}/*.md'; + + public function handle(): int + { + $documents = $this->readDocuments(); + + if ($documents === []) { + $this->components->warn('No product files found under '.$this->basePath().'.'); + + return self::SUCCESS; + } + + $dryRun = (bool) $this->option('dry-run'); + $imported = 0; + $skipped = 0; + $keys = []; + + foreach ($documents as $key => $localeDocuments) { + $missing = array_diff( + array_map(fn (LocaleEnum $case): string => $case->value, LocaleEnum::cases()), + array_keys($localeDocuments) + ); + + if ($missing !== []) { + $this->components->error(sprintf( + '"%s" is missing a translation for %s — every product must exist in both languages.', + $key, + implode(', ', $missing) + )); + $skipped++; + + continue; + } + + $keys[] = $key; + + if ($dryRun) { + $exists = Product::where('slug', $key)->exists(); + $this->components->twoColumnDetail($key, $exists ? '<fg=yellow>would update</>' : '<fg=green>would create</>'); + $imported++; + + continue; + } + + $this->store($key, $localeDocuments); + $this->components->twoColumnDetail($key, '<fg=green>imported</>'); + $imported++; + } + + if (! $dryRun) { + $this->removeOrphans($keys); + } + + $this->newLine(); + $this->components->info(sprintf('%d product(s) %s, %d skipped.', $imported, $dryRun ? 'pending' : 'imported', $skipped)); + + return $skipped > 0 ? self::FAILURE : self::SUCCESS; + } + + private function basePath(): string + { + $override = $this->option('path'); + + return is_string($override) && $override !== '' ? rtrim($override, '/') : database_path('files/products'); + } + + /** + * @return array<string, array<string, array{front: array<string, mixed>, body: string}>> + */ + private function readDocuments(): array + { + $documents = []; + + foreach (LocaleEnum::cases() as $case) { + $directory = $this->basePath().'/'.$case->value; + + if (! is_dir($directory)) { + continue; + } + + foreach (glob($directory.'/*.md') ?: [] as $path) { + $parsed = $this->parseFile($path); + + if ($parsed === null) { + continue; + } + + $key = $parsed['front']['key'] ?? null; + + if (! is_string($key) || $key === '') { + $this->components->error(basename($path).' has no "key" in its front matter — skipped.'); + + continue; + } + + $expected = $key.'.md'; + + if (basename($path) !== $expected) { + $this->components->warn(sprintf('%s should be named %s.', basename($path), $expected)); + } + + $documents[$key][$case->value] = $parsed; + } + } + + ksort($documents); + + return $documents; + } + + /** + * @return array{front: array<string, mixed>, body: string}|null + */ + private function parseFile(string $path): ?array + { + $contents = file_get_contents($path); + + if ($contents === false) { + return null; + } + + $contents = str_replace("\r\n", "\n", $contents); + + if (! str_starts_with($contents, '---')) { + $this->components->error(basename($path).' has no YAML front matter — skipped.'); + + return null; + } + + $parts = preg_split('/^---\s*$/m', $contents, 3); + + if ($parts === false || count($parts) < 3) { + $this->components->error(basename($path).' has malformed front matter — skipped.'); + + return null; + } + + try { + $front = Yaml::parse($parts[1]); + } catch (ParseException $exception) { + $this->components->error(basename($path).': '.$exception->getMessage()); + + return null; + } + + $normalised = []; + + if (is_array($front)) { + foreach ($front as $field => $value) { + if (is_string($field)) { + $normalised[$field] = $value; + } + } + } + + return [ + 'front' => $normalised, + 'body' => trim($parts[2]), + ]; + } + + /** + * Shared fields (order, image, url, tags) are read from the German file — a + * product is one thing with one set of metadata, translated into two bodies of + * copy, not two independent records that happen to share a slug. + * + * @param array<string, array{front: array<string, mixed>, body: string}> $localeDocuments + */ + private function store(string $key, array $localeDocuments): void + { + $primary = $localeDocuments[LocaleEnum::DE->value]['front']; + + $translated = ['name' => [], 'headline' => [], 'teaser' => [], 'content' => []]; + $translated += ['features_heading' => [], 'features_intro' => [], 'features' => []]; + $translated += ['deployment_heading' => [], 'deployment_intro' => [], 'deployment_options' => []]; + $translated += ['cta_heading' => [], 'cta_body' => []]; + + foreach ($localeDocuments as $locale => $document) { + $front = $document['front']; + + $translated['name'][$locale] = $this->string($front['name'] ?? ''); + $translated['headline'][$locale] = $this->string($front['headline'] ?? ''); + $translated['teaser'][$locale] = $this->string($front['teaser'] ?? ''); + $translated['content'][$locale] = $document['body']; + $translated['features_heading'][$locale] = $this->string($front['features_heading'] ?? ''); + $translated['features_intro'][$locale] = $this->string($front['features_intro'] ?? ''); + $translated['features'][$locale] = $this->items($front['features'] ?? []); + $translated['deployment_heading'][$locale] = $this->string($front['deployment_heading'] ?? ''); + $translated['deployment_intro'][$locale] = $this->string($front['deployment_intro'] ?? ''); + $translated['deployment_options'][$locale] = $this->items($front['deployment_options'] ?? []); + $translated['cta_heading'][$locale] = $this->string($front['cta_heading'] ?? ''); + $translated['cta_body'][$locale] = $this->string($front['cta_body'] ?? ''); + } + + Product::updateOrCreate( + ['slug' => $key], + [ + 'published' => (bool) ($primary['published'] ?? true), + 'order' => $this->nullableInt($primary['order'] ?? null) ?? 0, + 'image' => $this->string($primary['image'] ?? ''), + 'url' => $this->nullableString($primary['url'] ?? null), + 'tags' => $this->tagLabels($primary['tags'] ?? []), + ...$translated, + ] + ); + } + + /** + * A product removed from the repository must disappear from the site too, + * otherwise the files stop being the source of truth. + * + * @param array<int, string> $keys + */ + private function removeOrphans(array $keys): void + { + $orphans = Product::whereNotIn('slug', $keys)->get(); + + foreach ($orphans as $orphan) { + $this->components->twoColumnDetail($orphan->slug, '<fg=red>removed</>'); + $orphan->delete(); + } + } + + /** + * Normalises a `features`/`deployment_options` front-matter list into + * {title, description} pairs, dropping anything malformed. + * + * @return array<int, array{title: string, description: string}> + */ + private function items(mixed $items): array + { + if (! is_array($items)) { + return []; + } + + $result = []; + + foreach ($items as $item) { + if (! is_array($item)) { + continue; + } + + $title = $this->string($item['title'] ?? ''); + $description = $this->string($item['description'] ?? ''); + + if ($title === '' && $description === '') { + continue; + } + + $result[] = ['title' => $title, 'description' => $description]; + } + + return $result; + } + + /** + * @return array<int, string> + */ + private function tagLabels(mixed $tags): array + { + if (! is_array($tags)) { + return []; + } + + return array_values(array_filter(array_map( + fn (mixed $tag): string => $this->string($tag), + $tags + ), fn (string $tag): bool => $tag !== '')); + } + + private function string(mixed $value): string + { + if (is_string($value)) { + return trim($value); + } + + if (is_int($value) || is_float($value) || is_bool($value)) { + return trim((string) $value); + } + + return ''; + } + + private function nullableInt(mixed $value): ?int + { + if (is_int($value)) { + return $value; + } + + return is_string($value) && ctype_digit($value) ? (int) $value : null; + } + + private function nullableString(mixed $value): ?string + { + $string = $this->string($value); + + return $string === '' ? null : $string; + } +} diff --git a/app/Console/Commands/ImportServicesCommand.php b/app/Console/Commands/ImportServicesCommand.php new file mode 100644 index 0000000..59bf922 --- /dev/null +++ b/app/Console/Commands/ImportServicesCommand.php @@ -0,0 +1,278 @@ +<?php + +namespace App\Console\Commands; + +use App\Enums\LocaleEnum; +use App\Models\Service; +use Illuminate\Console\Command; +use Symfony\Component\Yaml\Exception\ParseException; +use Symfony\Component\Yaml\Yaml; + +/** + * Reads the service files under database/files/services/{locale}/ and writes them to the + * database. The markdown files are the source of truth — running this repeatedly is safe. + */ +class ImportServicesCommand extends Command +{ + protected $signature = 'services:import + {--dry-run : Show what would change without writing anything} + {--path= : Read from this directory instead of database/files/services}'; + + protected $description = 'Import services from database/files/services/{locale}/*.md'; + + public function handle(): int + { + $documents = $this->readDocuments(); + + if ($documents === []) { + $this->components->warn('No service files found under '.$this->basePath().'.'); + + return self::SUCCESS; + } + + $dryRun = (bool) $this->option('dry-run'); + $imported = 0; + $skipped = 0; + $keys = []; + + foreach ($documents as $key => $localeDocuments) { + $missing = array_diff( + array_map(fn (LocaleEnum $case): string => $case->value, LocaleEnum::cases()), + array_keys($localeDocuments) + ); + + if ($missing !== []) { + $this->components->error(sprintf( + '"%s" is missing a translation for %s — every service must exist in both languages.', + $key, + implode(', ', $missing) + )); + $skipped++; + + continue; + } + + $keys[] = $key; + + if ($dryRun) { + $exists = Service::where('slug', $key)->exists(); + $this->components->twoColumnDetail($key, $exists ? '<fg=yellow>would update</>' : '<fg=green>would create</>'); + $imported++; + + continue; + } + + $this->store($key, $localeDocuments); + $this->components->twoColumnDetail($key, '<fg=green>imported</>'); + $imported++; + } + + if (! $dryRun) { + $this->removeOrphans($keys); + } + + $this->newLine(); + $this->components->info(sprintf('%d service(s) %s, %d skipped.', $imported, $dryRun ? 'pending' : 'imported', $skipped)); + + return $skipped > 0 ? self::FAILURE : self::SUCCESS; + } + + private function basePath(): string + { + $override = $this->option('path'); + + return is_string($override) && $override !== '' ? rtrim($override, '/') : database_path('files/services'); + } + + /** + * @return array<string, array<string, array{front: array<string, mixed>, body: string}>> + */ + private function readDocuments(): array + { + $documents = []; + + foreach (LocaleEnum::cases() as $case) { + $directory = $this->basePath().'/'.$case->value; + + if (! is_dir($directory)) { + continue; + } + + foreach (glob($directory.'/*.md') ?: [] as $path) { + $parsed = $this->parseFile($path); + + if ($parsed === null) { + continue; + } + + $key = $parsed['front']['key'] ?? null; + + if (! is_string($key) || $key === '') { + $this->components->error(basename($path).' has no "key" in its front matter — skipped.'); + + continue; + } + + $expected = $key.'.md'; + + if (basename($path) !== $expected) { + $this->components->warn(sprintf('%s should be named %s.', basename($path), $expected)); + } + + $documents[$key][$case->value] = $parsed; + } + } + + ksort($documents); + + return $documents; + } + + /** + * @return array{front: array<string, mixed>, body: string}|null + */ + private function parseFile(string $path): ?array + { + $contents = file_get_contents($path); + + if ($contents === false) { + return null; + } + + $contents = str_replace("\r\n", "\n", $contents); + + if (! str_starts_with($contents, '---')) { + $this->components->error(basename($path).' has no YAML front matter — skipped.'); + + return null; + } + + $parts = preg_split('/^---\s*$/m', $contents, 3); + + if ($parts === false || count($parts) < 3) { + $this->components->error(basename($path).' has malformed front matter — skipped.'); + + return null; + } + + try { + $front = Yaml::parse($parts[1]); + } catch (ParseException $exception) { + $this->components->error(basename($path).': '.$exception->getMessage()); + + return null; + } + + $normalised = []; + + if (is_array($front)) { + foreach ($front as $field => $value) { + if (is_string($field)) { + $normalised[$field] = $value; + } + } + } + + return [ + 'front' => $normalised, + 'body' => trim($parts[2]), + ]; + } + + /** + * Shared fields (order, group, published, image, url, tags) are read from the + * German file — a service is one thing with one set of metadata, translated + * into two bodies of copy, not two independent records that happen to share a slug. + * + * @param array<string, array{front: array<string, mixed>, body: string}> $localeDocuments + */ + private function store(string $key, array $localeDocuments): void + { + $primary = $localeDocuments[LocaleEnum::DE->value]['front']; + + $name = []; + $teaser = []; + $content = []; + + foreach ($localeDocuments as $locale => $document) { + $name[$locale] = $this->string($document['front']['name'] ?? ''); + $teaser[$locale] = $this->string($document['front']['teaser'] ?? ''); + $content[$locale] = $document['body']; + } + + Service::updateOrCreate( + ['slug' => $key], + [ + 'published' => (bool) ($primary['published'] ?? true), + 'group' => $this->string($primary['group'] ?? '') ?: 'services', + 'order' => $this->nullableInt($primary['order'] ?? null) ?? 0, + 'name' => $name, + 'teaser' => $teaser, + 'content' => $content, + 'image' => $this->string($primary['image'] ?? ''), + 'url' => $this->nullableString($primary['url'] ?? null), + 'tags' => $this->tagLabels($primary['tags'] ?? []), + ] + ); + } + + /** + * A service removed from the repository must disappear from the site too, otherwise + * the files stop being the source of truth. + * + * @param array<int, string> $keys + */ + private function removeOrphans(array $keys): void + { + $orphans = Service::whereNotIn('slug', $keys)->get(); + + foreach ($orphans as $orphan) { + $this->components->twoColumnDetail($orphan->slug, '<fg=red>removed</>'); + $orphan->delete(); + } + } + + /** + * @return array<int, string> + */ + private function tagLabels(mixed $tags): array + { + if (! is_array($tags)) { + return []; + } + + return array_values(array_filter(array_map( + fn (mixed $tag): string => $this->string($tag), + $tags + ), fn (string $tag): bool => $tag !== '')); + } + + private function string(mixed $value): string + { + if (is_string($value)) { + return trim($value); + } + + if (is_int($value) || is_float($value) || is_bool($value)) { + return trim((string) $value); + } + + return ''; + } + + private function nullableInt(mixed $value): ?int + { + if (is_int($value)) { + return $value; + } + + return is_string($value) && ctype_digit($value) ? (int) $value : null; + } + + private function nullableString(mixed $value): ?string + { + $string = $this->string($value); + + return $string === '' ? null : $string; + } +} diff --git a/app/Console/Commands/ImportTeamCommand.php b/app/Console/Commands/ImportTeamCommand.php new file mode 100644 index 0000000..33a9a3e --- /dev/null +++ b/app/Console/Commands/ImportTeamCommand.php @@ -0,0 +1,225 @@ +<?php + +namespace App\Console\Commands; + +use App\Models\Contact; +use Illuminate\Console\Command; +use Illuminate\Support\Facades\File; +use Symfony\Component\Yaml\Exception\ParseException; +use Symfony\Component\Yaml\Yaml; + +/** + * Reads one YAML file per person from database/files/team/ and writes them to the + * contacts table. The files are the source of truth — running this repeatedly is safe. + * + * Replaces a semicolon-separated CSV that carried JSON inside its cells, which was + * effectively uneditable by hand and produced unreadable diffs. + */ +class ImportTeamCommand extends Command +{ + protected $signature = 'team:import + {--dry-run : Show what would change without writing anything} + {--path= : Read from this directory instead of database/files/team}'; + + protected $description = 'Import team members from database/files/team/*.yaml'; + + public function handle(): int + { + $files = $this->files(); + + if ($files === []) { + $this->components->warn('No team files found under '.$this->basePath().'.'); + + return self::SUCCESS; + } + + $dryRun = (bool) $this->option('dry-run'); + $imported = 0; + $skipped = 0; + $keys = []; + + foreach ($files as $path) { + $data = $this->parse($path); + + if ($data === null) { + $skipped++; + + continue; + } + + $key = $data['key']; + $keys[] = $key; + + // The file name is the key, so the directory listing reads as the team list. + $expected = $key.'.'.pathinfo($path, PATHINFO_EXTENSION); + + if (basename($path) !== $expected) { + $this->components->warn(sprintf('%s should be named %s.', basename($path), $expected)); + } + + if ($dryRun) { + $exists = Contact::where('key', $key)->exists(); + $this->components->twoColumnDetail($key, $exists ? '<fg=yellow>would update</>' : '<fg=green>would create</>'); + $imported++; + + continue; + } + + Contact::updateOrCreate(['key' => $key], [ + 'name' => $data['name'], + 'published' => $data['published'], + 'sort' => $data['sort'], + 'image' => $data['image'], + 'sections' => $data['sections'], + 'icons' => $data['icons'], + ]); + + $this->components->twoColumnDetail($key, '<fg=green>imported</>'); + $imported++; + } + + if (! $dryRun) { + $this->removeOrphans($keys); + Contact::clearPublishedCache(); + } + + $this->newLine(); + $this->components->info(sprintf('%d person(s) %s, %d skipped.', $imported, $dryRun ? 'pending' : 'imported', $skipped)); + + return $skipped > 0 ? self::FAILURE : self::SUCCESS; + } + + private function basePath(): string + { + $override = $this->option('path'); + + return is_string($override) && $override !== '' ? rtrim($override, '/') : database_path('files/team'); + } + + /** + * @return array<int, string> + */ + private function files(): array + { + $base = $this->basePath(); + + if (! is_dir($base)) { + return []; + } + + $files = array_merge(glob($base.'/*.yaml') ?: [], glob($base.'/*.yml') ?: []); + sort($files); + + return $files; + } + + /** + * @return array{key: string, name: string, published: bool, sort: int, image: string, sections: array<string, mixed>, icons: array<string, mixed>}|null + */ + private function parse(string $path): ?array + { + try { + $parsed = Yaml::parseFile($path); + } catch (ParseException $exception) { + $this->components->error(basename($path).': '.$exception->getMessage()); + + return null; + } + + if (! is_array($parsed)) { + $this->components->error(basename($path).' is not a YAML mapping — skipped.'); + + return null; + } + + $key = $this->string($parsed['key'] ?? '') ?: pathinfo($path, PATHINFO_FILENAME); + $name = $this->string($parsed['name'] ?? ''); + + if ($name === '') { + $this->components->error(basename($path).' has no "name" — skipped.'); + + return null; + } + + return [ + 'key' => $key, + 'name' => $name, + 'published' => (bool) ($parsed['published'] ?? false), + 'sort' => is_int($parsed['sort'] ?? null) ? $parsed['sort'] : 0, + 'image' => $this->string($parsed['image'] ?? ''), + // Each section carries its own key, which the DTO reads back — filling it + // here keeps the YAML free of a value that only repeats the mapping key. + 'sections' => $this->sections($parsed['sections'] ?? []), + 'icons' => $this->icons($parsed['icons'] ?? null), + ]; + } + + /** + * @return array<string, string> + */ + private function icons(mixed $icons): array + { + if (! is_array($icons)) { + return []; + } + + $result = []; + + foreach ($icons as $name => $value) { + if (is_string($name) && is_string($value) && $value !== '') { + $result[$name] = $value; + } + } + + return $result; + } + + /** + * @return array<string, mixed> + */ + private function sections(mixed $sections): array + { + if (! is_array($sections)) { + return []; + } + + $result = []; + + foreach ($sections as $section => $definition) { + if (! is_string($section)) { + continue; + } + + $entry = ['key' => $section]; + + if (is_array($definition) && isset($definition['role']) && is_array($definition['role'])) { + $entry['role'] = $definition['role']; + } + + $result[$section] = $entry; + } + + return $result; + } + + /** + * A person removed from the repository must disappear from the site too, otherwise + * the files stop being the source of truth. + * + * @param array<int, string> $keys + */ + private function removeOrphans(array $keys): void + { + $orphans = Contact::whereNotIn('key', $keys)->get(); + + foreach ($orphans as $orphan) { + $this->components->twoColumnDetail($orphan->key, '<fg=red>removed</>'); + $orphan->delete(); + } + } + + private function string(mixed $value): string + { + return is_string($value) ? trim($value) : ''; + } +} diff --git a/app/Console/Commands/ImportTechnologiesCommand.php b/app/Console/Commands/ImportTechnologiesCommand.php new file mode 100644 index 0000000..6cdae5b --- /dev/null +++ b/app/Console/Commands/ImportTechnologiesCommand.php @@ -0,0 +1,279 @@ +<?php + +namespace App\Console\Commands; + +use App\Enums\LocaleEnum; +use App\Models\Technology; +use Illuminate\Console\Command; +use Symfony\Component\Yaml\Exception\ParseException; +use Symfony\Component\Yaml\Yaml; + +/** + * Reads the technology files under database/files/technologies/{locale}/ and writes + * them to the database. The markdown files are the source of truth — running this + * repeatedly is safe. + */ +class ImportTechnologiesCommand extends Command +{ + protected $signature = 'technologies:import + {--dry-run : Show what would change without writing anything} + {--path= : Read from this directory instead of database/files/technologies}'; + + protected $description = 'Import technologies from database/files/technologies/{locale}/*.md'; + + public function handle(): int + { + $documents = $this->readDocuments(); + + if ($documents === []) { + $this->components->warn('No technology files found under '.$this->basePath().'.'); + + return self::SUCCESS; + } + + $dryRun = (bool) $this->option('dry-run'); + $imported = 0; + $skipped = 0; + $keys = []; + + foreach ($documents as $key => $localeDocuments) { + $missing = array_diff( + array_map(fn (LocaleEnum $case): string => $case->value, LocaleEnum::cases()), + array_keys($localeDocuments) + ); + + if ($missing !== []) { + $this->components->error(sprintf( + '"%s" is missing a translation for %s — every technology must exist in both languages.', + $key, + implode(', ', $missing) + )); + $skipped++; + + continue; + } + + $keys[] = $key; + + if ($dryRun) { + $exists = Technology::where('slug', $key)->exists(); + $this->components->twoColumnDetail($key, $exists ? '<fg=yellow>would update</>' : '<fg=green>would create</>'); + $imported++; + + continue; + } + + $this->store($key, $localeDocuments); + $this->components->twoColumnDetail($key, '<fg=green>imported</>'); + $imported++; + } + + if (! $dryRun) { + $this->removeOrphans($keys); + } + + $this->newLine(); + $this->components->info(sprintf('%d technology/ies %s, %d skipped.', $imported, $dryRun ? 'pending' : 'imported', $skipped)); + + return $skipped > 0 ? self::FAILURE : self::SUCCESS; + } + + private function basePath(): string + { + $override = $this->option('path'); + + return is_string($override) && $override !== '' ? rtrim($override, '/') : database_path('files/technologies'); + } + + /** + * @return array<string, array<string, array{front: array<string, mixed>, body: string}>> + */ + private function readDocuments(): array + { + $documents = []; + + foreach (LocaleEnum::cases() as $case) { + $directory = $this->basePath().'/'.$case->value; + + if (! is_dir($directory)) { + continue; + } + + foreach (glob($directory.'/*.md') ?: [] as $path) { + $parsed = $this->parseFile($path); + + if ($parsed === null) { + continue; + } + + $key = $parsed['front']['key'] ?? null; + + if (! is_string($key) || $key === '') { + $this->components->error(basename($path).' has no "key" in its front matter — skipped.'); + + continue; + } + + $expected = $key.'.md'; + + if (basename($path) !== $expected) { + $this->components->warn(sprintf('%s should be named %s.', basename($path), $expected)); + } + + $documents[$key][$case->value] = $parsed; + } + } + + ksort($documents); + + return $documents; + } + + /** + * @return array{front: array<string, mixed>, body: string}|null + */ + private function parseFile(string $path): ?array + { + $contents = file_get_contents($path); + + if ($contents === false) { + return null; + } + + $contents = str_replace("\r\n", "\n", $contents); + + if (! str_starts_with($contents, '---')) { + $this->components->error(basename($path).' has no YAML front matter — skipped.'); + + return null; + } + + $parts = preg_split('/^---\s*$/m', $contents, 3); + + if ($parts === false || count($parts) < 3) { + $this->components->error(basename($path).' has malformed front matter — skipped.'); + + return null; + } + + try { + $front = Yaml::parse($parts[1]); + } catch (ParseException $exception) { + $this->components->error(basename($path).': '.$exception->getMessage()); + + return null; + } + + $normalised = []; + + if (is_array($front)) { + foreach ($front as $field => $value) { + if (is_string($field)) { + $normalised[$field] = $value; + } + } + } + + return [ + 'front' => $normalised, + 'body' => trim($parts[2]), + ]; + } + + /** + * Shared fields (group, order, published, image, link, tags) are read from the + * German file — a technology is one thing translated into two bodies of copy, + * not two independent records that happen to share a slug. + * + * @param array<string, array{front: array<string, mixed>, body: string}> $localeDocuments + */ + private function store(string $key, array $localeDocuments): void + { + $primary = $localeDocuments[LocaleEnum::DE->value]['front']; + + $title = []; + $teaser = []; + $content = []; + + foreach ($localeDocuments as $locale => $document) { + $title[$locale] = $this->string($document['front']['title'] ?? ''); + $teaser[$locale] = $this->string($document['front']['teaser'] ?? ''); + $content[$locale] = $document['body']; + } + + Technology::updateOrCreate( + ['slug' => $key], + [ + 'published' => (bool) ($primary['published'] ?? true), + 'group' => $this->string($primary['group'] ?? ''), + 'order' => $this->nullableInt($primary['order'] ?? null) ?? 0, + 'title' => $title, + 'teaser' => $teaser, + 'content' => $content, + 'image' => $this->string($primary['image'] ?? ''), + 'link' => $this->nullableString($primary['link'] ?? null), + 'tags' => $this->tagLabels($primary['tags'] ?? []), + ] + ); + } + + /** + * A technology removed from the repository must disappear from the site too, + * otherwise the files stop being the source of truth. + * + * @param array<int, string> $keys + */ + private function removeOrphans(array $keys): void + { + $orphans = Technology::whereNotIn('slug', $keys)->get(); + + foreach ($orphans as $orphan) { + $this->components->twoColumnDetail($orphan->slug, '<fg=red>removed</>'); + $orphan->delete(); + } + } + + /** + * @return array<int, string> + */ + private function tagLabels(mixed $tags): array + { + if (! is_array($tags)) { + return []; + } + + return array_values(array_filter(array_map( + fn (mixed $tag): string => $this->string($tag), + $tags + ), fn (string $tag): bool => $tag !== '')); + } + + private function string(mixed $value): string + { + if (is_string($value)) { + return trim($value); + } + + if (is_int($value) || is_float($value) || is_bool($value)) { + return trim((string) $value); + } + + return ''; + } + + private function nullableInt(mixed $value): ?int + { + if (is_int($value)) { + return $value; + } + + return is_string($value) && ctype_digit($value) ? (int) $value : null; + } + + private function nullableString(mixed $value): ?string + { + $string = $this->string($value); + + return $string === '' ? null : $string; + } +} diff --git a/app/Contracts/HasTranslatedRouteKey.php b/app/Contracts/HasTranslatedRouteKey.php new file mode 100644 index 0000000..08b8935 --- /dev/null +++ b/app/Contracts/HasTranslatedRouteKey.php @@ -0,0 +1,16 @@ +<?php + +namespace App\Contracts; + +/** + * A model whose URL slug exists once per language, so route parameters have to be + * rebuilt when linking to another locale instead of reusing the current slug. + * + * Only the translation lookup is declared here — getRouteKey() and getRouteKeyName() + * come from Eloquent\Model, which declares them without return types and would clash + * with a typed signature. + */ +interface HasTranslatedRouteKey +{ + public function getTranslation(string $key, string $locale, bool $useFallbackLocale = true): mixed; +} diff --git a/app/DTO/PageDTO.php b/app/DTO/PageDTO.php index 27b0cd5..c03ba33 100644 --- a/app/DTO/PageDTO.php +++ b/app/DTO/PageDTO.php @@ -21,8 +21,20 @@ public function __construct( public ?string $description = null, public ?string $image = null, public ?Collection $referencePages = null, + /** + * Set only on editorial pages. Their presence is what switches the + * Open Graph type from "website" to "article" — a shared article card + * carries a byline and a date, a page card does not. + */ + public ?Carbon $publishedAt = null, + public ?string $authorName = null, ) {} + public function isArticle(): bool + { + return $this->publishedAt instanceof Carbon; + } + public function url(): string { return route($this->routeName, $this->routeParameters, true); diff --git a/app/Http/Controllers/Ai/AiLlmAnalyticsIndexController.php b/app/Http/Controllers/Ai/AiLlmAnalyticsIndexController.php index 500e6d4..f3d5d1c 100644 --- a/app/Http/Controllers/Ai/AiLlmAnalyticsIndexController.php +++ b/app/Http/Controllers/Ai/AiLlmAnalyticsIndexController.php @@ -66,7 +66,6 @@ public function __invoke(Request $request, LlmUsageStatsAction $stats): View 'model' => $model, 'modelLabel' => $model === LlmUsageStatsAction::OTHER_MODEL ? $otherLabel : $model, 'lastSyncedAt' => $stats->lastSyncedAt(), - 'latestDate' => $stats->latestDate(), ]); } diff --git a/app/Http/Controllers/News/NewsIndexController.php b/app/Http/Controllers/News/NewsIndexController.php index 53f207d..9c5d2f8 100644 --- a/app/Http/Controllers/News/NewsIndexController.php +++ b/app/Http/Controllers/News/NewsIndexController.php @@ -6,7 +6,10 @@ use App\Actions\ViewDataAction; use App\DTO\PageDTO; use App\Http\Controllers\Controller; +use App\Models\News; +use App\Models\NewsTag; use App\Seo\SchemaNodes; +use Illuminate\Support\Collection; use Illuminate\View\View; class NewsIndexController extends Controller @@ -16,14 +19,73 @@ public function __invoke(): View $locale = app()->getLocale(); $page = (new PageAction(locale: null, routeName: 'news.index'))->default(); - $news = (new ViewDataAction)->news($locale); + $all = (new ViewDataAction)->news($locale); + + // Filtering happens by topic slug in the query string rather than on its own + // route: the canonical URL stays /aktuelles, so filtered views never compete + // with the index in search results. + $topics = $this->topics($all, $locale); + $active = $this->activeTopic($topics); + + $news = $active === null + ? $all + : $all->filter(fn (News $entry): bool => $entry->newsTags->contains('id', $active->id))->values(); + + // The lead article is the one flagged in its front matter, otherwise the newest. + $lead = $news->firstWhere('featured', true) ?? $news->first(); + $rest = $lead === null ? $news : $news->reject(fn (News $entry): bool => $entry->is($lead)); return view('app.news.index')->with([ 'page' => $page, - 'news' => $news, + 'lead' => $lead, + 'news' => $rest->values(), + 'topics' => $topics, + 'activeTopic' => $active, + 'total' => $news->count(), 'schema' => $page instanceof PageDTO - ? SchemaNodes::blog($news, $page, $locale) + ? SchemaNodes::blog($all, $page, $locale) : [], ]); } + + /** + * Topics that actually have a published article behind them, in the order they + * first appear. A filter that leads to an empty page is worse than no filter. + * + * @param Collection<int, News> $news + * @return Collection<int, NewsTag> + */ + private function topics(Collection $news, string $locale): Collection + { + $used = $news->flatMap(fn (News $entry): array => $entry->newsTags->modelKeys())->unique(); + + if ($used->isEmpty()) { + return new Collection; + } + + /** @var Collection<int, NewsTag> */ + return NewsTag::query() + ->whereIn('id', $used->all()) + ->get() + ->sortBy(function (NewsTag $tag) use ($locale): string { + $title = $tag->getTranslation('title', $locale); + + return is_string($title) ? $title : ''; + }) + ->values(); + } + + /** + * @param Collection<int, NewsTag> $topics + */ + private function activeTopic(Collection $topics): ?NewsTag + { + $slug = request()->query('thema'); + + if (! is_string($slug) || $slug === '') { + return null; + } + + return $topics->first(fn (NewsTag $tag): bool => $tag->slug === $slug); + } } diff --git a/app/Http/Controllers/News/NewsShowController.php b/app/Http/Controllers/News/NewsShowController.php index a3c120b..cb24139 100644 --- a/app/Http/Controllers/News/NewsShowController.php +++ b/app/Http/Controllers/News/NewsShowController.php @@ -3,31 +3,90 @@ namespace App\Http\Controllers\News; use App\Actions\PageAction; +use App\DTO\ContactDTO; use App\Http\Controllers\Controller; +use App\Markdown\NewsMarkdown; use App\Models\News; use App\Seo\SchemaNodes; -use Illuminate\Support\Str; +use Illuminate\Database\Eloquent\Collection; use Illuminate\View\View; class NewsShowController extends Controller { - public function __invoke(string $locale, News $news): View + public function __invoke(string $locale, News $news, NewsMarkdown $markdown): View { + // Drafts must not be reachable by guessing the URL — the route binding + // resolves by slug alone and does not know about publication state. + abort_if(! $news->isPublished(), 404); + // withReferences builds the alternate-locale PageDTOs, so the article // gets hreflang links pointing at its own translation rather than at // the news index. $page = (new PageAction($locale))->news(news: $news, withReferences: true); + $body = is_string($news->content) ? $news->content : ''; + $author = $news->authorContact; + return view('app.news.show')->with([ 'page' => $page, - 'published_at' => $news->published_at?->format('d.m.Y'), - 'last_updated_at' => $news->updated_at?->format('d.m.Y'), - 'author' => $news->author, + 'news' => $news, 'title' => $news->title, 'teaser' => $news->teaser, 'tags' => collect($news->tags), - 'content' => Str::of($news->content ?? '')->markdown(), + 'content' => $markdown->toHtml($body), + 'headings' => $markdown->headings($body), + 'authorName' => $news->authorName(), + 'authorRole' => $author !== null ? ContactDTO::fromModel($author, 'employees', $locale)->role : null, + 'authorImage' => $author?->image, + 'authorLinkedin' => $author !== null && is_array($author->icons) ? ($author->icons['linkedin'] ?? null) : null, + 'series' => $news->series, + 'seriesParts' => $news->seriesParts(), + 'related' => $this->relatedArticles($news), 'schema' => SchemaNodes::blogPosting($news, $page, $locale), ]); } + + /** + * Curated cross-links first, topped up with articles sharing a tag so the + * footer is never empty on an article nobody has linked yet. + * + * @return Collection<int, News> + */ + private function relatedArticles(News $news): Collection + { + $curated = $news->relatedArticles()->published()->get(); + + if ($curated->count() >= 3) { + return $curated->take(3); + } + + $tags = $this->tagStrings($news); + + $byTag = News::query() + ->published() + ->whereKeyNot($news->getKey()) + ->whereNotIn('id', $curated->modelKeys()) + ->when($news->series_id !== null, fn ($query) => $query->where(function ($inner) use ($news) { + // Other parts of the same series already have their own navigation. + $inner->whereNull('series_id')->orWhere('series_id', '!=', $news->series_id); + })) + ->orderByDesc('published_at') + ->get() + ->sortByDesc(fn (News $candidate): int => count(array_intersect($tags, $this->tagStrings($candidate)))) + ->take(3 - $curated->count()); + + return $curated->merge($byTag)->values(); + } + + /** + * @return array<int, string> + */ + private function tagStrings(News $news): array + { + if (! is_array($news->tags)) { + return []; + } + + return array_values(array_filter($news->tags, fn (mixed $tag): bool => is_string($tag))); + } } diff --git a/app/Http/Controllers/OpenSource/OpenSoruceShowController.php b/app/Http/Controllers/OpenSource/OpenSoruceShowController.php index 9bcbe7b..8ce7fe1 100644 --- a/app/Http/Controllers/OpenSource/OpenSoruceShowController.php +++ b/app/Http/Controllers/OpenSource/OpenSoruceShowController.php @@ -2,36 +2,41 @@ namespace App\Http\Controllers\OpenSource; -use App\Actions\PageAction; use App\Http\Controllers\Controller; use App\Models\OpenSource; -use App\Seo\SchemaNodes; +use Illuminate\Http\RedirectResponse; use Illuminate\Support\Str; use Illuminate\View\View; -use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class OpenSoruceShowController extends Controller { - public function __invoke(string $locale, OpenSource $openSource): View + /** + * Disabled alongside the index. With no entry point left, a detail page + * would only be reachable from a stale external link, so send those to the + * start page instead of serving an orphan. + */ + public function __invoke(string $locale, OpenSource $openSource): View|RedirectResponse { - // `sync:repositories` creates an entry per GitHub repository but only - // fills title and teaser — content is written by hand. Without it there - // is no page here worth having, let alone indexing, so serve a 404 - // rather than a near-empty URL. - if (! $openSource->hasWrittenContent()) { - throw new NotFoundHttpException; - } + return redirect()->route(Str::slug(app()->getLocale()).'.start.index'); - $page = (new PageAction(locale: $locale))->openSource(openSource: $openSource, withReferences: true); + /* // `sync:repositories` creates an entry per GitHub repository but only + // fills title and teaser — content is written by hand. Without it there + // is no page here worth having, let alone indexing, so serve a 404 + // rather than a near-empty URL. + if (! $openSource->hasWrittenContent()) { + throw new NotFoundHttpException; + } - return view('app.open-source.show')->with([ - 'page' => $page, - 'name' => $openSource->title, - 'teaser' => $openSource->teaser, - 'content' => Str::of($openSource->content ?? '')->markdown(), - 'tags' => $openSource->tags, - 'link' => $openSource->link, - 'schema' => SchemaNodes::softwareSourceCode($openSource, $page, $locale), - ]); + $page = (new PageAction(locale: $locale))->openSource(openSource: $openSource, withReferences: true); + + return view('app.open-source.show')->with([ + 'page' => $page, + 'name' => $openSource->title, + 'teaser' => $openSource->teaser, + 'content' => Str::of($openSource->content ?? '')->markdown(), + 'tags' => $openSource->tags, + 'link' => $openSource->link, + 'schema' => SchemaNodes::softwareSourceCode($openSource, $page, $locale), + ]);*/ } } diff --git a/app/Http/Controllers/OpenSource/OpenSourceIndexController.php b/app/Http/Controllers/OpenSource/OpenSourceIndexController.php index f984898..266e793 100644 --- a/app/Http/Controllers/OpenSource/OpenSourceIndexController.php +++ b/app/Http/Controllers/OpenSource/OpenSourceIndexController.php @@ -2,20 +2,28 @@ namespace App\Http\Controllers\OpenSource; -use App\Actions\PageAction; -use App\Actions\ViewDataAction; use App\Http\Controllers\Controller; +use Illuminate\Http\RedirectResponse; +use Illuminate\Support\Str; use Illuminate\View\View; class OpenSourceIndexController extends Controller { - public function __invoke(): View + /** + * Disabled until the listing actually has entries. `sync:repositories` is + * not scheduled, so this page rendered its intro and nothing else — an + * empty URL that search engines read as thin content. Restore the body + * below once repositories are synced and written up. + */ + public function __invoke(): View|RedirectResponse { - $locale = app()->getLocale(); + return redirect()->route(Str::slug(app()->getLocale()).'.start.index'); - return view('app.open-source.index')->with([ - 'page' => (new PageAction(locale: null, routeName: 'open-source.index'))->default(), - 'openSource' => (new ViewDataAction)->openSource($locale), - ]); + /* $locale = app()->getLocale(); + + return view('app.open-source.index')->with([ + 'page' => (new PageAction(locale: null, routeName: 'open-source.index'))->default(), + 'openSource' => (new ViewDataAction)->openSource($locale), + ]);*/ } } diff --git a/app/Http/Controllers/Sitemap/SitemapController.php b/app/Http/Controllers/Sitemap/SitemapController.php index 16c8615..d249ed6 100644 --- a/app/Http/Controllers/Sitemap/SitemapController.php +++ b/app/Http/Controllers/Sitemap/SitemapController.php @@ -8,7 +8,6 @@ use App\Http\Controllers\Controller; use App\Models\Network; use App\Models\News; -use App\Models\OpenSource; use App\Sitemap\SitemapBuilder; use Illuminate\Http\Response; use Illuminate\Support\Facades\Cache; @@ -30,7 +29,7 @@ class SitemapController extends Controller 'network.index', 'about-us.index', 'news.index', - 'open-source.index', + // 'open-source.index' — disabled controller, see OpenSourceIndexController. ]; protected const array DEFAULT_LOCALES = [ @@ -61,7 +60,6 @@ private function builder(SitemapBuilder $sitemap): void $this->addDefaultRoutesToSitemap($sitemap); $this->addNetworksToSitemap($sitemap); $this->addNewsToSitemap($sitemap); - $this->addOpenSourceToSitemap($sitemap); } private function addNetworksToSitemap(SitemapBuilder $sitemap): void @@ -85,7 +83,10 @@ private function addNetworksToSitemap(SitemapBuilder $sitemap): void private function addNewsToSitemap(SitemapBuilder $sitemap): void { News::query() - ->whereNotNull('published_at') + ->published() + // PageDTO resolves the author name for the article metadata, which + // reads this relation — without it that is one query per article. + ->with('authorContact') ->orderByDesc('published_at') ->get() ->each(function (News $news) use ($sitemap): void { @@ -96,22 +97,6 @@ private function addNewsToSitemap(SitemapBuilder $sitemap): void }); } - private function addOpenSourceToSitemap(SitemapBuilder $sitemap): void - { - OpenSource::query() - ->where('published', true) - ->get() - // Entries without a written body have no detail page — - // OpenSoruceShowController answers 404 for them. - ->filter(fn (OpenSource $openSource): bool => $openSource->hasWrittenContent()) - ->each(function (OpenSource $openSource) use ($sitemap): void { - $this->addLocalizedSet( - $sitemap, - fn (string $locale): PageDTO => (new PageAction)->openSource(openSource: $openSource, locale: $locale), - ); - }); - } - /** * Adds one URL per locale and cross-references them all as hreflang * alternates of each other. diff --git a/app/Http/Controllers/Start/StartIndexController.php b/app/Http/Controllers/Start/StartIndexController.php index 27cd7c6..bbfb5e0 100644 --- a/app/Http/Controllers/Start/StartIndexController.php +++ b/app/Http/Controllers/Start/StartIndexController.php @@ -4,6 +4,7 @@ use App\Actions\LocaleAction; use App\Actions\PageAction; +use App\Actions\ViewDataAction; use App\Enums\LocaleEnum; use App\Http\Controllers\Controller; use Illuminate\Support\Str; @@ -24,6 +25,9 @@ public function __invoke(): View return view('app.start.index')->with([ 'page' => (new PageAction(locale: null, routeName: 'start.index'))->default(), + // Two entries only: the start page teases the latest news, it does not + // list them — the next-page card below leads to the full overview. + 'latestNews' => (new ViewDataAction)->news($locale)->take(2), ]); } } diff --git a/app/Markdown/NewsMarkdown.php b/app/Markdown/NewsMarkdown.php new file mode 100644 index 0000000..401b633 --- /dev/null +++ b/app/Markdown/NewsMarkdown.php @@ -0,0 +1,265 @@ +<?php + +namespace App\Markdown; + +use Illuminate\Support\Facades\View; +use Illuminate\Support\Str; +use League\CommonMark\Environment\Environment; +use League\CommonMark\Exception\CommonMarkException; +use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension; +use League\CommonMark\Extension\GithubFlavoredMarkdownExtension; +use League\CommonMark\MarkdownConverter; +use Symfony\Component\Yaml\Exception\ParseException; +use Symfony\Component\Yaml\Yaml; + +/** + * Renders an article body: GitHub-flavoured Markdown plus fenced block directives. + * + * :::figure{src="…" width="wide" alt="…"} + * Bildlegende + * ::: + * + * The directives exist so the look of a figure, a callout or a step list lives in the + * design system rather than in hand-written HTML inside every article. Raw HTML in the + * source is escaped — an article file is content, not a template. + */ +class NewsMarkdown +{ + /** Directives whose body is a YAML list rather than prose. */ + private const array YAML_BODY = ['gallery', 'compare', 'steps']; + + private const array KNOWN = ['figure', 'gallery', 'compare', 'quote', 'callout', 'steps', 'video']; + + public function toHtml(string $markdown): string + { + $segments = $this->split($markdown); + + $html = ''; + + foreach ($segments as $segment) { + $html .= $segment['type'] === 'markdown' + ? $this->convert($segment['content']) + : $this->renderDirective($segment['name'], $segment['attributes'], $segment['content']); + } + + return $this->addHeadingIds($html); + } + + /** + * Estimated reading time. Directive bodies count too, but their fence lines do not. + */ + public function readingMinutes(string $markdown): int + { + $text = preg_replace('/^:::\w*(\{[^}]*\})?[ \t]*$/m', '', $markdown) ?? $markdown; + $words = preg_match_all('/[\p{L}\p{N}]+/u', strip_tags($this->convert($text))); + + return max(1, (int) ceil(($words ?: 0) / 200)); + } + + /** + * Gives every h2/h3 a stable anchor so the table of contents can link to it. + * Runs on the assembled document so duplicate headings across segments still differ. + */ + private function addHeadingIds(string $html): string + { + $seen = []; + + return (string) preg_replace_callback( + '/<h([23])>(.*?)<\/h\1>/s', + function (array $match) use (&$seen): string { + $id = $this->anchor(trim(strip_tags($match[2])), $seen); + + return '<h'.$match[1].' id="'.e($id).'">'.$match[2].'</h'.$match[1].'>'; + }, + $html + ); + } + + /** + * @param array<string, int> $seen + */ + private function anchor(string $title, array &$seen): string + { + $id = Str::slug($title); + + if ($id === '') { + $id = 'abschnitt'; + } + + $seen[$id] = ($seen[$id] ?? 0) + 1; + + return $seen[$id] > 1 ? $id.'-'.$seen[$id] : $id; + } + + /** + * Headings of level 2 and 3, for the table of contents. Anchors match toHtml(). + * + * @return array<int, array{id: string, level: int, title: string}> + */ + public function headings(string $markdown): array + { + $withoutDirectives = preg_replace('/^:::.*?^:::\s*$/ms', '', $markdown) ?? $markdown; + + preg_match_all('/^(#{2,3})\s+(.+?)\s*$/m', $withoutDirectives, $matches, PREG_SET_ORDER); + + $headings = []; + $seen = []; + + foreach ($matches as $match) { + $title = trim(strip_tags($this->convert($match[2]))); + + $headings[] = [ + 'id' => $this->anchor($title, $seen), + 'level' => strlen($match[1]), + 'title' => $title, + ]; + } + + return $headings; + } + + /** + * @return array<int, array{type: string, name: string, attributes: array<string, string>, content: string}> + */ + private function split(string $markdown): array + { + $pattern = '/^:::([a-z]+)[ \t]*(\{[^}]*\})?[ \t]*\R(.*?)^:::[ \t]*$/ms'; + + $segments = []; + $offset = 0; + + if (preg_match_all($pattern, $markdown, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE) === false) { + return [['type' => 'markdown', 'name' => '', 'attributes' => [], 'content' => $markdown]]; + } + + foreach ($matches as $match) { + [$whole, $start] = $match[0]; + + $before = substr($markdown, $offset, $start - $offset); + + if (trim($before) !== '') { + $segments[] = ['type' => 'markdown', 'name' => '', 'attributes' => [], 'content' => $before]; + } + + $name = $match[1][0]; + + $segments[] = in_array($name, self::KNOWN, true) + ? [ + 'type' => 'directive', + 'name' => $name, + 'attributes' => $this->parseAttributes($match[2][0]), + 'content' => $match[3][0], + ] + // An unknown directive is a typo in the article, not a reason to lose the text. + : ['type' => 'markdown', 'name' => '', 'attributes' => [], 'content' => $match[3][0]]; + + $offset = $start + strlen($whole); + } + + $rest = substr($markdown, $offset); + + if (trim($rest) !== '') { + $segments[] = ['type' => 'markdown', 'name' => '', 'attributes' => [], 'content' => $rest]; + } + + return $segments; + } + + /** + * @return array<string, string> + */ + private function parseAttributes(string $raw): array + { + if ($raw === '') { + return []; + } + + preg_match_all('/([a-z_]+)\s*=\s*"([^"]*)"/i', $raw, $matches, PREG_SET_ORDER); + + $attributes = []; + + foreach ($matches as $match) { + $attributes[strtolower($match[1])] = $match[2]; + } + + return $attributes; + } + + /** + * @param array<string, string> $attributes + */ + private function renderDirective(string $name, array $attributes, string $body): string + { + $data = [ + 'attributes' => $attributes, + 'width' => $attributes['width'] ?? 'text', + ]; + + if (in_array($name, self::YAML_BODY, true)) { + $data['items'] = $this->parseYamlList($body); + } else { + $data['body'] = $this->convert($body); + $data['text'] = trim(strip_tags($this->convert($body))); + } + + return View::make('markdown.'.$name, $data)->render(); + } + + /** + * @return array<int, array<string, string>> + */ + private function parseYamlList(string $body): array + { + try { + $parsed = Yaml::parse($body); + } catch (ParseException) { + return []; + } + + if (! is_array($parsed)) { + return []; + } + + $items = []; + + foreach ($parsed as $entry) { + if (! is_array($entry)) { + continue; + } + + $item = []; + + foreach ($entry as $key => $value) { + if (is_string($key) && (is_string($value) || is_int($value))) { + $item[$key] = (string) $value; + } + } + + if ($item !== []) { + $items[] = $item; + } + } + + return $items; + } + + private function convert(string $markdown): string + { + $environment = (new Environment([ + // Article files are content. Anything that looks like markup in them is + // text, not a template — this is what keeps a typo from becoming an XSS. + 'html_input' => 'escape', + 'allow_unsafe_links' => false, + ])) + ->addExtension(new CommonMarkCoreExtension) + ->addExtension(new GithubFlavoredMarkdownExtension); + + $converter = new MarkdownConverter($environment); + + try { + return $converter->convert($markdown)->getContent(); + } catch (CommonMarkException) { + return ''; + } + } +} diff --git a/app/Models/News.php b/app/Models/News.php index 743f4ec..dbf260c 100644 --- a/app/Models/News.php +++ b/app/Models/News.php @@ -2,13 +2,18 @@ namespace App\Models; +use App\Contracts\HasTranslatedRouteKey; use App\Traits\HasLocalizedRouteBinding; use Database\Factories\NewsFactory; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Spatie\Translatable\HasTranslations; -class News extends Model +class News extends Model implements HasTranslatedRouteKey { /** @use HasFactory<NewsFactory> */ use HasFactory; @@ -17,13 +22,21 @@ class News extends Model use HasTranslations; /** @var array<int, string> */ - protected array $translatable = ['title', 'teaser', 'content']; + protected array $translatable = ['title', 'teaser', 'content', 'slug', 'hero_caption', 'hero_alt']; protected $casts = [ 'tags' => 'json', 'published_at' => 'datetime', + 'featured' => 'boolean', + 'published' => 'boolean', ]; + /** The slug is translated, so route binding resolves it per locale. */ + public static function routeBindingIsTranslated(): bool + { + return true; + } + public function getLocale(): string { return app()->getLocale(); @@ -33,4 +46,93 @@ public function getRouteKeyName(): string { return 'slug'; } + + /** + * Deliberately not named author() — the legacy `author` string column would shadow + * the relation on attribute access and silently return a name instead of a Contact. + * + * @return BelongsTo<Contact, $this> + */ + public function authorContact(): BelongsTo + { + return $this->belongsTo(Contact::class, 'contact_id'); + } + + /** The display name of the author, from the linked contact or the legacy column. */ + public function authorName(): ?string + { + $contact = $this->authorContact; + + if ($contact !== null) { + return $contact->name; + } + + return is_string($this->author) && $this->author !== '' ? $this->author : null; + } + + /** + * @return BelongsTo<NewsSeries, $this> + */ + public function series(): BelongsTo + { + return $this->belongsTo(NewsSeries::class, 'series_id'); + } + + /** + * @return BelongsToMany<NewsTag, $this> + */ + public function newsTags(): BelongsToMany + { + return $this->belongsToMany(NewsTag::class, 'news_news_tag'); + } + + /** + * Manually curated cross-links, kept unidirectional in the database but read both ways. + * + * @return BelongsToMany<News, $this> + */ + public function relatedArticles(): BelongsToMany + { + return $this->belongsToMany(News::class, 'news_relations', 'news_id', 'related_news_id') + ->withPivot('sort') + ->orderBy('news_relations.sort'); + } + + /** + * @param Builder<News> $query + * @return Builder<News> + */ + public function scopePublished(Builder $query): Builder + { + return $query->whereNotNull('published_at')->where('published', true); + } + + /** + * Live means both: a publication date and the `published` flag from the front + * matter. Clearing the date would take an article offline too, but it would also + * throw away the date the article is sorted and shown by. + */ + public function isPublished(): bool + { + return $this->published_at !== null && $this->published; + } + + /** + * The other parts of this article's series, ordered, published only. + * + * @return Collection<int, News> + */ + public function seriesParts(): Collection + { + if ($this->series_id === null) { + return new Collection; + } + + /** @var Collection<int, News> */ + return self::query() + ->published() + ->where('series_id', $this->series_id) + ->orderBy('series_position') + ->get(); + } } diff --git a/app/Models/NewsSeries.php b/app/Models/NewsSeries.php new file mode 100644 index 0000000..e78c296 --- /dev/null +++ b/app/Models/NewsSeries.php @@ -0,0 +1,45 @@ +<?php + +namespace App\Models; + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Spatie\Translatable\HasTranslations; + +/** + * An ordered, named sequence of articles — "DMS-Migration, Teil 2 von 5". + */ +class NewsSeries extends Model +{ + use HasTranslations; + + protected $table = 'news_series'; + + /** @var array<int, string> */ + protected array $translatable = ['title', 'slug', 'description']; + + protected $casts = [ + 'published' => 'boolean', + ]; + + /** + * @return HasMany<News, $this> + */ + public function articles(): HasMany + { + return $this->hasMany(News::class, 'series_id')->orderBy('series_position'); + } + + /** + * @return HasMany<News, $this> + */ + public function publishedArticles(): HasMany + { + return $this->articles()->published(); + } + + public function getRouteKeyName(): string + { + return 'slug'; + } +} diff --git a/app/Models/NewsTag.php b/app/Models/NewsTag.php new file mode 100644 index 0000000..d0c679e --- /dev/null +++ b/app/Models/NewsTag.php @@ -0,0 +1,33 @@ +<?php + +namespace App\Models; + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Spatie\Translatable\HasTranslations; + +/** + * A browsable topic. The denormalised `news.tags` JSON column stays the display and + * schema.org keyword source; this table exists so a topic can carry its own title, + * description and URL. + */ +class NewsTag extends Model +{ + use HasTranslations; + + /** @var array<int, string> */ + protected array $translatable = ['title', 'slug', 'description']; + + /** + * @return BelongsToMany<News, $this> + */ + public function articles(): BelongsToMany + { + return $this->belongsToMany(News::class, 'news_news_tag'); + } + + public function getRouteKeyName(): string + { + return 'slug'; + } +} diff --git a/app/Observers/NewsObserver.php b/app/Observers/NewsObserver.php new file mode 100644 index 0000000..a877a54 --- /dev/null +++ b/app/Observers/NewsObserver.php @@ -0,0 +1,36 @@ +<?php + +namespace App\Observers; + +use App\Enums\LocaleEnum; +use App\Models\News; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Str; +use Spatie\ResponseCache\Facades\ResponseCache; + +/** + * The published-news list is cached forever per locale and the rendered HTML is cached + * on top of that. Without this, a freshly imported article appeared in the sitemap + * immediately but stayed invisible on /aktuelles until someone cleared the cache by hand. + */ +class NewsObserver +{ + public function saved(News $news): void + { + self::flush(); + } + + public function deleted(News $news): void + { + self::flush(); + } + + public static function flush(): void + { + foreach (LocaleEnum::cases() as $locale) { + Cache::forget(Str::slug("news_published_{$locale->value}")); + } + + ResponseCache::clear(); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 2e75e1e..3e9307a 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -15,6 +15,7 @@ use App\Models\Technology; use App\Observers\NetworkObserver; use App\Observers\NetworkUserObserver; +use App\Observers\NewsObserver; use App\Observers\SitemapCacheObserver; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\ServiceProvider; @@ -42,6 +43,7 @@ public function boot(): void Network::observe(NetworkObserver::class); NetworkUser::observe(NetworkUserObserver::class); + News::observe(NewsObserver::class); // Everything the sitemap draws its URLs from. foreach ([Page::class, Network::class, News::class, OpenSource::class] as $model) { diff --git a/app/Seo/SchemaNodes.php b/app/Seo/SchemaNodes.php index eab5de1..b5652d6 100644 --- a/app/Seo/SchemaNodes.php +++ b/app/Seo/SchemaNodes.php @@ -136,15 +136,39 @@ public static function blogPosting(News $news, PageDTO $page, string $locale): a 'inLanguage' => str_replace('_', '-', $locale), 'datePublished' => $news->published_at?->toIso8601String(), 'dateModified' => $news->updated_at?->toIso8601String(), - 'author' => filled($news->author) - ? ['@type' => 'Person', 'name' => $news->author] - : ['@id' => $organizationId], + 'author' => self::articleAuthor($news, $organizationId), 'publisher' => ['@id' => $organizationId], - 'image' => filled($news->image) ? $news->image : null, + 'image' => filled($news->hero_image) ? $news->hero_image : null, 'keywords' => is_array($tags) ? array_values($tags) : null, ], fn (mixed $value): bool => $value !== null && $value !== [])]; } + /** + * A linked contact becomes a real Person node with picture and LinkedIn profile, + * which is what Google reads for author authority. Without one the organisation + * stays the author, as before. + * + * @return array<string, mixed> + */ + private static function articleAuthor(News $news, string $organizationId): array + { + $contact = $news->authorContact; + + if ($contact !== null) { + return array_filter([ + '@type' => 'Person', + 'name' => $contact->name, + 'image' => $contact->image !== '' ? $contact->image : null, + 'worksFor' => ['@id' => $organizationId], + 'sameAs' => is_array($contact->icons) ? Arr::get($contact->icons, 'linkedin') : null, + ], fn (mixed $value): bool => $value !== null); + } + + return filled($news->author) + ? ['@type' => 'Person', 'name' => $news->author] + : ['@id' => $organizationId]; + } + /** * The news index as a Blog with its posts listed. * diff --git a/app/Support/LocalizedRouteParameters.php b/app/Support/LocalizedRouteParameters.php new file mode 100644 index 0000000..1e7aa3e --- /dev/null +++ b/app/Support/LocalizedRouteParameters.php @@ -0,0 +1,50 @@ +<?php + +namespace App\Support; + +use App\Contracts\HasTranslatedRouteKey; +use Illuminate\Database\Eloquent\Model; + +/** + * Rewrites route parameters for another language. + * + * Detail routes carry the locale twice — once in the route-name prefix and once as a path + * segment (/aktuelles/{locale}/{slug}). Since slugs are translated, swapping only the + * locale segment would leave the German URL pointing at the English slug. + */ +class LocalizedRouteParameters +{ + /** + * @param array<string, mixed> $parameters + * @return array<string, mixed> + */ + public static function for(array $parameters, string $locale): array + { + foreach ($parameters as $name => $value) { + if ($name === 'locale') { + $parameters[$name] = $locale; + + continue; + } + + if ($value instanceof Model) { + $parameters[$name] = self::routeKeyFor($value, $locale); + } + } + + return $parameters; + } + + private static function routeKeyFor(Model $model, string $locale): mixed + { + if (! $model instanceof HasTranslatedRouteKey) { + return $model->getRouteKey(); + } + + $slug = $model->getTranslation($model->getRouteKeyName(), $locale); + + // An article without a translation for this language keeps its default key + // rather than producing a URL with an empty segment. + return is_string($slug) && $slug !== '' ? $slug : $model->getRouteKey(); + } +} diff --git a/app/Support/NewsImage.php b/app/Support/NewsImage.php new file mode 100644 index 0000000..c90e8ef --- /dev/null +++ b/app/Support/NewsImage.php @@ -0,0 +1,113 @@ +<?php + +namespace App\Support; + +/** + * Resolves an image reference from an article's front matter or a :::figure directive. + * + * Three forms are accepted, so local placeholders keep working before assets are uploaded: + * - a full https:// URL (Cloudinary URLs get their transformation rewritten) + * - a Cloudinary public ID ("www-codebar-ch/news/foo/hero") + * - a path inside public/ ("images/news/foo.jpg" or "/images/news/foo.jpg") + * + * Editorial images are width-constrained only (c_limit) — unlike avatars, they must keep + * their own aspect ratio rather than being cropped square. + */ +class NewsImage +{ + private const string CLOUDINARY_HOST = 'res.cloudinary.com'; + + /** @var array<int, int> */ + public const array WIDTHS = [640, 960, 1280, 1920]; + + public static function src(?string $reference, int $width): ?string + { + if ($reference === null || trim($reference) === '') { + return null; + } + + $reference = trim($reference); + + if (self::isLocalPath($reference)) { + return asset(ltrim($reference, '/')); + } + + if (str_starts_with($reference, 'http://') || str_starts_with($reference, 'https://')) { + return str_contains($reference, self::CLOUDINARY_HOST) + ? self::rewriteCloudinaryUrl($reference, $width) + : $reference; + } + + return self::fromPublicId($reference, $width); + } + + public static function srcset(?string $reference, int $maxWidth): ?string + { + if (self::src($reference, $maxWidth) === null) { + return null; + } + + $widths = array_values(array_filter(self::WIDTHS, fn (int $w): bool => $w <= $maxWidth)); + + if ($widths === []) { + $widths = [$maxWidth]; + } + + $entries = []; + + foreach ($widths as $width) { + $url = self::src($reference, $width); + + if ($url !== null) { + $entries[] = $url.' '.$width.'w'; + } + } + + return $entries === [] ? null : implode(', ', $entries); + } + + private static function isLocalPath(string $reference): bool + { + return str_starts_with($reference, '/') + || str_starts_with($reference, 'images/') + || str_starts_with($reference, 'storage/'); + } + + private static function transformation(int $width): string + { + return "w_{$width},c_limit,f_auto,q_auto"; + } + + private static function fromPublicId(string $publicId, int $width): ?string + { + $cloud = config('filesystems.disks.cloudinary.cloud_name'); + + if (! is_string($cloud) || $cloud === '') { + return null; + } + + return 'https://'.self::CLOUDINARY_HOST.'/'.$cloud.'/image/upload/' + .self::transformation($width).'/'.ltrim($publicId, '/'); + } + + private static function rewriteCloudinaryUrl(string $url, int $width): string + { + $marker = '/image/upload/'; + $position = strpos($url, $marker); + + if ($position === false) { + return $url; + } + + $base = substr($url, 0, $position + strlen($marker)); + $rest = substr($url, $position + strlen($marker)); + $segments = explode('/', $rest); + + // Drop an existing transformation segment so ours does not stack on top of it. + if (preg_match('/^[a-z0-9_,.:-]+$/', $segments[0]) === 1 && preg_match('/[whcfqe]_/', $segments[0]) === 1) { + array_shift($segments); + } + + return $base.self::transformation($width).'/'.implode('/', $segments); + } +} diff --git a/app/Support/PageNavigation.php b/app/Support/PageNavigation.php index 3ce178f..2af330c 100644 --- a/app/Support/PageNavigation.php +++ b/app/Support/PageNavigation.php @@ -14,6 +14,12 @@ public static function pages(): array return [ ['route' => 'start.index', 'label' => __('Home'), 'teaser' => __('components.explore.home')], ['route' => 'services.index', 'label' => __('Services'), 'teaser' => __('components.explore.services')], + // Position matches the header navigation. The `components.explore.team` + // translation existed all along; only this entry was missing, so the team + // page appeared in the header but in neither the explore grid nor the + // next-page chain. + ['route' => 'about-us.index', 'label' => __('Team'), 'teaser' => __('components.explore.team')], + ['route' => 'news.index', 'label' => __('News'), 'teaser' => __('components.explore.news')], ['route' => 'ai.index', 'label' => __('AI'), 'teaser' => __('components.explore.ai')], ['route' => 'network.index', 'label' => __('Network'), 'teaser' => __('components.explore.network')], ['route' => 'contact.index', 'label' => __('Contact'), 'teaser' => __('components.explore.contact')], diff --git a/app/Support/helpers.php b/app/Support/helpers.php index e968969..ae807bf 100644 --- a/app/Support/helpers.php +++ b/app/Support/helpers.php @@ -1,5 +1,6 @@ <?php +use App\Support\LocalizedRouteParameters; use Illuminate\Support\Str; if (! function_exists('localized_route')) { @@ -36,12 +37,13 @@ function locale_switch_url(string $locale): string // Route names are locale-prefixed ("de-ch.services.index"). $routeKey = Str::after($routeName, '.'); + + /** @var array<string, mixed> $parameters */ $parameters = $route?->parameters() ?? []; - // Detail routes carry the locale in the path as well as the prefix. - if (array_key_exists('locale', $parameters)) { - $parameters['locale'] = $locale; - } + // Detail routes carry the locale in the path as well as the prefix, and their + // slugs are translated — both have to be swapped together. + $parameters = LocalizedRouteParameters::for($parameters, $locale); try { return route($localeSlug.'.'.$routeKey, $parameters); diff --git a/app/Traits/HasLocalizedRouteBinding.php b/app/Traits/HasLocalizedRouteBinding.php index bf60d6c..af1e170 100644 --- a/app/Traits/HasLocalizedRouteBinding.php +++ b/app/Traits/HasLocalizedRouteBinding.php @@ -2,20 +2,48 @@ namespace App\Traits; +use App\Enums\LocaleEnum; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Route; trait HasLocalizedRouteBinding { + /** + * Models that store one slug per language override this to true. The binder then + * matches against the requested locale's slug instead of a plain column comparison. + */ + public static function routeBindingIsTranslated(): bool + { + return false; + } + public static function registerLocalizedBinding(string $parameter): void { /** @var class-string<static> $modelClass */ $modelClass = static::class; Route::bind(key: $parameter, binder: function (string $value) use ($modelClass): Model { - return $modelClass::query() - ->where('slug', $value) - ->firstOrFail(); + if (! $modelClass::routeBindingIsTranslated()) { + return $modelClass::query()->where('slug', $value)->firstOrFail(); + } + + $query = $modelClass::query(); + + // The {locale} segment names the translation being requested; try it first so + // /aktuelles/de_CH/… resolves the German slug even when an English one matches too. + $requested = request()->route('locale'); + $locales = array_map(fn (LocaleEnum $case): string => $case->value, LocaleEnum::cases()); + + if (is_string($requested) && in_array($requested, $locales, true)) { + $locales = array_merge([$requested], array_diff($locales, [$requested])); + } + + foreach ($locales as $index => $locale) { + $method = $index === 0 ? 'where' : 'orWhere'; + $query->{$method}('slug->'.$locale, $value); + } + + return $query->firstOrFail(); }); } } diff --git a/app/View/Components/AppLayout.php b/app/View/Components/AppLayout.php index 3323f40..b066987 100644 --- a/app/View/Components/AppLayout.php +++ b/app/View/Components/AppLayout.php @@ -18,6 +18,11 @@ public function __construct( protected mixed $page, public bool $preconnectCloudinary = false, protected array $schema = [], + /** + * Editorial pages manage their own width so figures can break out of the + * reading column. Everything else stays inside the shared max-w-4xl frame. + */ + public bool $wide = false, ) {} public function render(): View @@ -30,6 +35,7 @@ public function render(): View 'page' => $this->page, 'preconnectCloudinary' => $this->preconnectCloudinary, 'schema' => $this->schema, + 'wide' => $this->wide, ]); } } diff --git a/composer.lock b/composer.lock index 5e979e1..a2ed68a 100644 --- a/composer.lock +++ b/composer.lock @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.389.1", + "version": "3.389.2", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "98506321be0157d78a2c44424a8fce3a4842081d" + "reference": "784e0fb95e752e55c4654b5800b900c78f6d3990" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/98506321be0157d78a2c44424a8fce3a4842081d", - "reference": "98506321be0157d78a2c44424a8fce3a4842081d", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/784e0fb95e752e55c4654b5800b900c78f6d3990", + "reference": "784e0fb95e752e55c4654b5800b900c78f6d3990", "shasum": "" }, "require": { @@ -153,9 +153,9 @@ "support": { "forum": "https://github.com/aws/aws-sdk-php/discussions", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.389.1" + "source": "https://github.com/aws/aws-sdk-php/tree/3.389.2" }, - "time": "2026-07-27T18:09:06+00:00" + "time": "2026-07-28T18:10:25+00:00" }, { "name": "brick/math", @@ -9360,16 +9360,16 @@ }, { "name": "laravel/pint", - "version": "v1.29.3", + "version": "v1.30.0", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "da1d1111a6aa2e082d2a388b194afe1ba0a05d14" + "reference": "72a0540d1aa10b6c146bda2a22f3ae003123c0ea" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/da1d1111a6aa2e082d2a388b194afe1ba0a05d14", - "reference": "da1d1111a6aa2e082d2a388b194afe1ba0a05d14", + "url": "https://api.github.com/repos/laravel/pint/zipball/72a0540d1aa10b6c146bda2a22f3ae003123c0ea", + "reference": "72a0540d1aa10b6c146bda2a22f3ae003123c0ea", "shasum": "" }, "require": { @@ -9380,14 +9380,16 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.95.8", - "illuminate/view": "^12.62.0", + "composer/semver": "^3.4.4", + "friendsofphp/php-cs-fixer": "^3.95.17", + "illuminate/view": "^12.64.0", "larastan/larastan": "^3.10.0", "laravel-zero/framework": "^12.1.0", "laravel/agent-detector": "^2.0.2", + "laravel/prompts": "^0.3.21", "mockery/mockery": "^1.6.12", "nunomaduro/termwind": "^2.4.0", - "pestphp/pest": "^3.8.6" + "pestphp/pest": "^3.8.7" }, "bin": [ "builds/pint" @@ -9424,7 +9426,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2026-06-16T15:34:04+00:00" + "time": "2026-07-28T20:48:56+00:00" }, { "name": "laravel/sail", diff --git a/config/company.php b/config/company.php index d2835a2..2d312f0 100644 --- a/config/company.php +++ b/config/company.php @@ -56,8 +56,8 @@ * or dead profile is worse than a short list. */ 'same_as' => [ + 'https://www.linkedin.com/company/codebar-solutions-ag', 'https://github.com/codebar-ag', - // TODO: LinkedIn company page — not yet linked anywhere on the site. // TODO: Swiss Made Software / Swiss Digital Services profile pages. ], diff --git a/database/factories/ContactFactory.php b/database/factories/ContactFactory.php index 04e45bf..d071deb 100644 --- a/database/factories/ContactFactory.php +++ b/database/factories/ContactFactory.php @@ -17,9 +17,13 @@ class ContactFactory extends Factory */ public function definition(): array { + $name = fake()->unique()->name(); + return [ + 'key' => str($name)->slug()->toString(), 'published' => true, - 'name' => fake()->name(), + 'sort' => fake()->numberBetween(1, 99), + 'name' => $name, 'sections' => [], 'image' => fake()->imageUrl(), 'icons' => [], diff --git a/database/factories/NewsFactory.php b/database/factories/NewsFactory.php index c42cf87..38f2988 100644 --- a/database/factories/NewsFactory.php +++ b/database/factories/NewsFactory.php @@ -21,14 +21,30 @@ public function definition(): array $titleEn = fake()->unique()->sentence(); return [ + 'key' => str($titleDe)->slug()->toString(), 'title' => ['de_CH' => $titleDe, 'en_CH' => $titleEn], - 'slug' => str($titleDe)->slug(), + 'slug' => [ + 'de_CH' => str($titleDe)->slug()->toString(), + 'en_CH' => str($titleEn)->slug()->toString(), + ], 'teaser' => ['de_CH' => fake()->sentence(), 'en_CH' => fake()->sentence()], 'content' => ['de_CH' => fake()->paragraphs(3, true), 'en_CH' => fake()->paragraphs(3, true)], - 'image' => fake()->imageUrl(), + 'hero_image' => null, 'published_at' => fake()->dateTime(), + 'published' => true, 'author' => fake()->name(), 'tags' => fake()->words(2), + 'reading_minutes' => fake()->numberBetween(2, 12), ]; } + + public function unpublished(): static + { + return $this->state(fn (): array => ['published' => false]); + } + + public function featured(): static + { + return $this->state(fn (): array => ['featured' => true]); + } } diff --git a/database/files/ai_models/deepseek-ocr.yaml b/database/files/ai_models/deepseek-ocr.yaml new file mode 100644 index 0000000..5048dfd --- /dev/null +++ b/database/files/ai_models/deepseek-ocr.yaml @@ -0,0 +1,12 @@ +key: deepseek-ocr +name: deepseek-ocr +category: vision_documents +order: 4 +provider: 'DeepSeek AI (CN)' +ram: '6,7 GB RAM' +license: MIT +role: + de: 'PDF/Scan → Markdown' + en: 'PDF/scan → Markdown' +link_label: Ollama +link_url: 'https://ollama.com/library/deepseek-ocr' diff --git a/database/files/ai_models/deepseek-v4-flash.yaml b/database/files/ai_models/deepseek-v4-flash.yaml new file mode 100644 index 0000000..ea7602f --- /dev/null +++ b/database/files/ai_models/deepseek-v4-flash.yaml @@ -0,0 +1,12 @@ +key: deepseek-v4-flash +name: 'deepseek-v4:flash' +category: reasoning_coding +order: 1 +provider: 'DeepSeek AI (CN)' +ram: '~102 GB RAM' +license: MIT +role: + de: 'Flaggschiff: Analysen, Buchungslogik, komplexes Coding' + en: 'Flagship: analysis, booking logic, complex coding' +link_label: 'Hugging Face' +link_url: 'https://huggingface.co/unsloth' diff --git a/database/files/ai_models/gemma4-12b.yaml b/database/files/ai_models/gemma4-12b.yaml new file mode 100644 index 0000000..12fac76 --- /dev/null +++ b/database/files/ai_models/gemma4-12b.yaml @@ -0,0 +1,6 @@ +key: gemma4-12b +name: 'gemma4:12b' +category: vision_documents +order: 1 +archived_at: '2026-07-01' +replaced_by: qwen3-vl-8b diff --git a/database/files/ai_models/gemma4-31b.yaml b/database/files/ai_models/gemma4-31b.yaml new file mode 100644 index 0000000..0fa0015 --- /dev/null +++ b/database/files/ai_models/gemma4-31b.yaml @@ -0,0 +1,12 @@ +key: gemma4-31b +name: 'gemma4:31b' +category: vision_documents +order: 3 +provider: Google +ram: '19 GB RAM' +license: Gemma +role: + de: 'Bild-Input + stilsichere Texte' + en: 'Image input + polished writing' +link_label: Ollama +link_url: 'https://ollama.com/library/gemma4' diff --git a/database/files/ai_models/gemma4-e4b.yaml b/database/files/ai_models/gemma4-e4b.yaml new file mode 100644 index 0000000..0a64c71 --- /dev/null +++ b/database/files/ai_models/gemma4-e4b.yaml @@ -0,0 +1,6 @@ +key: gemma4-e4b +name: 'gemma4:e4b' +category: vision_documents +order: 2 +archived_at: '2026-07-01' +replaced_by: qwen3-vl-8b diff --git a/database/files/ai_models/kimi-linear-48b.yaml b/database/files/ai_models/kimi-linear-48b.yaml new file mode 100644 index 0000000..2bbe701 --- /dev/null +++ b/database/files/ai_models/kimi-linear-48b.yaml @@ -0,0 +1,12 @@ +key: kimi-linear-48b +name: 'kimi-linear:48b' +category: reasoning_coding +order: 2 +provider: 'Moonshot AI (CN)' +ram: '30 GB RAM' +license: MIT +role: + de: 'Alltags-Sprinter, lange Dokumente & Kontextverständnis' + en: 'Everyday sprinter, long documents & context understanding' +link_label: 'Hugging Face' +link_url: 'https://huggingface.co/moonshotai' diff --git a/database/files/ai_models/qwen3-5-122b.yaml b/database/files/ai_models/qwen3-5-122b.yaml new file mode 100644 index 0000000..605e95d --- /dev/null +++ b/database/files/ai_models/qwen3-5-122b.yaml @@ -0,0 +1,6 @@ +key: qwen3-5-122b +name: 'qwen3.5:122b' +category: reasoning_coding +order: 1 +archived_at: '2026-07-01' +replaced_by: deepseek-v4-flash diff --git a/database/files/ai_models/qwen3-5-4b.yaml b/database/files/ai_models/qwen3-5-4b.yaml new file mode 100644 index 0000000..f1dde5e --- /dev/null +++ b/database/files/ai_models/qwen3-5-4b.yaml @@ -0,0 +1,6 @@ +key: qwen3-5-4b +name: 'qwen3.5:4b' +category: reasoning_coding +order: 5 +archived_at: '2026-07-01' +replaced_by: kimi-linear-48b diff --git a/database/files/ai_models/qwen3-5-9b.yaml b/database/files/ai_models/qwen3-5-9b.yaml new file mode 100644 index 0000000..0817119 --- /dev/null +++ b/database/files/ai_models/qwen3-5-9b.yaml @@ -0,0 +1,6 @@ +key: qwen3-5-9b +name: 'qwen3.5:9b' +category: reasoning_coding +order: 4 +archived_at: '2026-07-01' +replaced_by: kimi-linear-48b diff --git a/database/files/ai_models/qwen3-6-27b.yaml b/database/files/ai_models/qwen3-6-27b.yaml new file mode 100644 index 0000000..2b33ad4 --- /dev/null +++ b/database/files/ai_models/qwen3-6-27b.yaml @@ -0,0 +1,6 @@ +key: qwen3-6-27b +name: 'qwen3.6:27b' +category: reasoning_coding +order: 3 +archived_at: '2026-07-01' +replaced_by: qwen3-6-35b diff --git a/database/files/ai_models/qwen3-6-35b-a3b.yaml b/database/files/ai_models/qwen3-6-35b-a3b.yaml new file mode 100644 index 0000000..0396db5 --- /dev/null +++ b/database/files/ai_models/qwen3-6-35b-a3b.yaml @@ -0,0 +1,6 @@ +key: qwen3-6-35b-a3b +name: 'qwen3.6:35b-a3b' +category: reasoning_coding +order: 2 +archived_at: '2026-07-01' +replaced_by: kimi-linear-48b diff --git a/database/files/ai_models/qwen3-6-35b.yaml b/database/files/ai_models/qwen3-6-35b.yaml new file mode 100644 index 0000000..e1f048b --- /dev/null +++ b/database/files/ai_models/qwen3-6-35b.yaml @@ -0,0 +1,12 @@ +key: qwen3-6-35b +name: 'qwen3.6:35b' +category: reasoning_coding +order: 4 +provider: 'Alibaba / Qwen (CN)' +ram: '23 GB RAM' +license: Apache-2.0 +role: + de: Reserve-Mittelklasse + en: Mid-range reserve +link_label: Ollama +link_url: 'https://ollama.com/library/qwen3.6' diff --git a/database/files/ai_models/qwen3-coder-30b.yaml b/database/files/ai_models/qwen3-coder-30b.yaml new file mode 100644 index 0000000..2310574 --- /dev/null +++ b/database/files/ai_models/qwen3-coder-30b.yaml @@ -0,0 +1,12 @@ +key: qwen3-coder-30b +name: 'qwen3-coder:30b' +category: reasoning_coding +order: 3 +provider: 'Alibaba / Qwen (CN)' +ram: '18 GB RAM' +license: Apache-2.0 +role: + de: 'Coding-Spezialist für schnelle Iterationen & Agenten-Tasks' + en: 'Coding specialist for fast iterations & agent tasks' +link_label: Ollama +link_url: 'https://ollama.com/library/qwen3-coder' diff --git a/database/files/ai_models/qwen3-embedding-4b.yaml b/database/files/ai_models/qwen3-embedding-4b.yaml new file mode 100644 index 0000000..8f4b57b --- /dev/null +++ b/database/files/ai_models/qwen3-embedding-4b.yaml @@ -0,0 +1,6 @@ +key: qwen3-embedding-4b +name: 'qwen3-embedding:4b' +category: retrieval_search +order: 1 +archived_at: '2026-07-01' +replaced_by: qwen3-embedding-8b diff --git a/database/files/ai_models/qwen3-embedding-8b.yaml b/database/files/ai_models/qwen3-embedding-8b.yaml new file mode 100644 index 0000000..e22da08 --- /dev/null +++ b/database/files/ai_models/qwen3-embedding-8b.yaml @@ -0,0 +1,12 @@ +key: qwen3-embedding-8b +name: 'qwen3-embedding:8b' +category: retrieval_search +order: 1 +provider: 'Alibaba / Qwen (CN)' +ram: '4,7 GB RAM' +license: Apache-2.0 +role: + de: 'Vektoren für Ähnlichkeitssuche' + en: 'Vectors for similarity search' +link_label: Ollama +link_url: 'https://ollama.com/library/qwen3-embedding' diff --git a/database/files/ai_models/qwen3-reranker-8b.yaml b/database/files/ai_models/qwen3-reranker-8b.yaml new file mode 100644 index 0000000..b631cb3 --- /dev/null +++ b/database/files/ai_models/qwen3-reranker-8b.yaml @@ -0,0 +1,5 @@ +key: qwen3-reranker-8b +name: 'qwen3-reranker:8b' +category: retrieval_search +order: 2 +archived_at: '2026-07-01' diff --git a/database/files/ai_models/qwen3-vl-32b.yaml b/database/files/ai_models/qwen3-vl-32b.yaml new file mode 100644 index 0000000..6d042c2 --- /dev/null +++ b/database/files/ai_models/qwen3-vl-32b.yaml @@ -0,0 +1,12 @@ +key: qwen3-vl-32b +name: 'qwen3-vl:32b' +category: vision_documents +order: 1 +provider: 'Alibaba / Qwen (CN)' +ram: '20 GB RAM' +license: Apache-2.0 +role: + de: 'Detailliertes Bildverständnis: Screenshots, Diagramme, Belege' + en: 'Detailed image understanding: screenshots, diagrams, receipts' +link_label: Ollama +link_url: 'https://ollama.com/library/qwen3-vl' diff --git a/database/files/ai_models/qwen3-vl-8b.yaml b/database/files/ai_models/qwen3-vl-8b.yaml new file mode 100644 index 0000000..9d0a87c --- /dev/null +++ b/database/files/ai_models/qwen3-vl-8b.yaml @@ -0,0 +1,12 @@ +key: qwen3-vl-8b +name: 'qwen3-vl:8b' +category: vision_documents +order: 2 +provider: 'Alibaba / Qwen (CN)' +ram: '6,1 GB RAM' +license: Apache-2.0 +role: + de: 'Schnelle Vision-Variante für einfache Bildaufgaben' + en: 'Fast vision variant for simple image tasks' +link_label: Ollama +link_url: 'https://ollama.com/library/qwen3-vl' diff --git a/database/files/legal/de_CH/imprint.md b/database/files/legal/de_CH/imprint.md deleted file mode 100644 index e69de29..0000000 diff --git a/database/files/legal/de_CH/privacy.md b/database/files/legal/de_CH/privacy.md deleted file mode 100644 index e69de29..0000000 diff --git a/database/files/legal/de_CH/terms.md b/database/files/legal/de_CH/terms.md deleted file mode 100644 index e69de29..0000000 diff --git a/database/files/legal/en_CH/imprint.md b/database/files/legal/en_CH/imprint.md deleted file mode 100644 index e69de29..0000000 diff --git a/database/files/legal/en_CH/privacy.md b/database/files/legal/en_CH/privacy.md deleted file mode 100644 index e69de29..0000000 diff --git a/database/files/legal/en_CH/terms.md b/database/files/legal/en_CH/terms.md deleted file mode 100644 index e69de29..0000000 diff --git a/database/files/networks/baselhack.yaml b/database/files/networks/baselhack.yaml new file mode 100644 index 0000000..39521d9 --- /dev/null +++ b/database/files/networks/baselhack.yaml @@ -0,0 +1,14 @@ +key: baselhack +category: sponsoring +sort: 60 +cover_url: 'https://res.cloudinary.com/codebar/image/upload/w_900,h_300,c_fill,f_auto,q_auto/www-codebar-ch/network/baselhack-3x1-black.svg' +website: 'https://www.baselhack.ch' +name: + de_CH: BaselHack + en_CH: BaselHack +tier_label: + de_CH: 'Silver Sponsor' + en_CH: 'Silver Sponsor' +excerpt: + de_CH: 'Hackathon Nordwestschweiz' + en_CH: 'Hackathon in the Basel region' diff --git a/database/files/networks/docuware.yaml b/database/files/networks/docuware.yaml new file mode 100644 index 0000000..2cb3322 --- /dev/null +++ b/database/files/networks/docuware.yaml @@ -0,0 +1,11 @@ +key: docuware +category: software +sort: 30 +cover_url: 'https://res.cloudinary.com/codebar/image/upload/w_900,h_300,c_fill,f_auto,q_auto/www-codebar-ch/network/docuware-3x1-black.svg' +website: 'https://start.docuware.com' +name: + de_CH: DocuWare + en_CH: DocuWare +excerpt: + de_CH: DMS/ECM + en_CH: DMS/ECM diff --git a/database/files/networks/iway.yaml b/database/files/networks/iway.yaml new file mode 100644 index 0000000..0b76e70 --- /dev/null +++ b/database/files/networks/iway.yaml @@ -0,0 +1,11 @@ +key: iway +category: infrastructure +sort: 50 +cover_url: 'https://res.cloudinary.com/codebar/image/upload/w_900,h_300,c_fill,f_auto,q_auto/www-codebar-ch/network/iway-3x1-black.svg' +website: 'https://www.iway.ch' +name: + de_CH: iWay + en_CH: iWay +excerpt: + de_CH: Internet & Telefonie + en_CH: Internet & telephony diff --git a/database/files/networks/odoo.yaml b/database/files/networks/odoo.yaml new file mode 100644 index 0000000..584bbf5 --- /dev/null +++ b/database/files/networks/odoo.yaml @@ -0,0 +1,11 @@ +key: odoo +category: software +sort: 40 +cover_url: 'https://res.cloudinary.com/codebar/image/upload/w_900,h_300,c_fill,f_auto,q_auto/www-codebar-ch/network/odoo-3x1-black.svg' +website: 'https://www.odoo.com' +name: + de_CH: Odoo + en_CH: Odoo +excerpt: + de_CH: Open Source ERP + en_CH: Open-source ERP diff --git a/database/files/networks/pst.yaml b/database/files/networks/pst.yaml new file mode 100644 index 0000000..be665ac --- /dev/null +++ b/database/files/networks/pst.yaml @@ -0,0 +1,11 @@ +key: pst +category: collaboration +sort: 20 +cover_url: 'https://res.cloudinary.com/codebar/image/upload/w_900,h_300,c_fill,f_auto,q_auto/www-codebar-ch/network/pst-3x1-black.svg' +website: 'https://www.pstgmbh.ch' +name: + de_CH: 'PST GmbH' + en_CH: 'PST GmbH' +excerpt: + de_CH: 'Finanzen & Buchhaltung' + en_CH: 'Finance & accounting' diff --git a/database/files/networks/swiss-digital-services.yaml b/database/files/networks/swiss-digital-services.yaml new file mode 100644 index 0000000..0e7d72d --- /dev/null +++ b/database/files/networks/swiss-digital-services.yaml @@ -0,0 +1,8 @@ +key: swiss-digital-services +category: certification +sort: 90 +cover_url: 'https://res.cloudinary.com/codebar/image/upload/w_900,h_300,c_fill,f_auto,q_auto/www-codebar-ch/network/swiss-digital-services-3x1-black.svg' +website: 'https://www.swissmadesoftware.org/en/about/swiss-digital-services.html' +name: + de_CH: 'Swiss Digital Services' + en_CH: 'Swiss Digital Services' diff --git a/database/files/networks/swiss-laravel-association.yaml b/database/files/networks/swiss-laravel-association.yaml new file mode 100644 index 0000000..64a7aaf --- /dev/null +++ b/database/files/networks/swiss-laravel-association.yaml @@ -0,0 +1,10 @@ +key: swiss-laravel-association +category: sponsoring +sort: 70 +website: 'https://laravel.swiss' +name: + de_CH: 'Swiss Laravel Association' + en_CH: 'Swiss Laravel Association' +excerpt: + de_CH: 'Laravel-Community' + en_CH: 'Laravel community' diff --git a/database/files/networks/swiss-made-software.yaml b/database/files/networks/swiss-made-software.yaml new file mode 100644 index 0000000..1961287 --- /dev/null +++ b/database/files/networks/swiss-made-software.yaml @@ -0,0 +1,8 @@ +key: swiss-made-software +category: certification +sort: 80 +cover_url: 'https://res.cloudinary.com/codebar/image/upload/w_900,h_300,c_fill,f_auto,q_auto/www-codebar-ch/network/swiss-made-software-3x1-black.svg' +website: 'https://www.swissmadesoftware.org/en/about/swiss-made-software.html' +name: + de_CH: 'Swiss Made Software' + en_CH: 'Swiss Made Software' diff --git a/database/files/networks/wieland-business-solutions.yaml b/database/files/networks/wieland-business-solutions.yaml new file mode 100644 index 0000000..86234f0 --- /dev/null +++ b/database/files/networks/wieland-business-solutions.yaml @@ -0,0 +1,11 @@ +key: wieland-business-solutions +category: collaboration +sort: 10 +cover_url: 'https://res.cloudinary.com/codebar/image/upload/w_900,h_300,c_fill,f_auto,q_auto/www-codebar-ch/network/wieland-3x1-black.svg' +website: 'https://www.business-solutions.gmbh' +name: + de_CH: 'Wieland Business Solutions AG' + en_CH: 'Wieland Business Solutions AG' +excerpt: + de_CH: 'Sparringspartner DMS/ECM' + en_CH: 'DMS/ECM sparring partner' diff --git a/database/files/news/de_CH/20250406_docuware_712.md b/database/files/news/de_CH/2025-04-06-docuware-7-12-is-here.md similarity index 73% rename from database/files/news/de_CH/20250406_docuware_712.md rename to database/files/news/de_CH/2025-04-06-docuware-7-12-is-here.md index a378374..cfa7f2a 100644 --- a/database/files/news/de_CH/20250406_docuware_712.md +++ b/database/files/news/de_CH/2025-04-06-docuware-7-12-is-here.md @@ -1,3 +1,14 @@ +--- +key: docuware-7-12-is-here +slug: docuware-7-12-ist-da +title: DocuWare 7.12 ist da +teaser: >- + Mehr Automatisierung und Einblick: Das Release verbessert die E-Rechnungsverarbeitung, bringt IDP in die Cloud und öffnet Workflow-Daten für Analytics. +published_at: 2025-04-06 +author: sebastian.buergin@codebar.ch +tags: [DMS/ECM] +--- + Das aktuelle Release bringt zahlreiche Verbesserungen in den Bereichen E-Rechnungsverarbeitung, IDP-Integration, Analytics sowie bei Sicherheit und Konfiguration. @@ -35,15 +46,5 @@ Mit der neuen **Workflow Analytics API** lassen sich DocuWare-Daten direkt in g Mehr zu den Neuerungen in DocuWare 7.12 finden Sie im Knowledge Center – inklusive Anwendungsbeispielen und konkretem Nutzen für Ihre Organisation: -<ul> - <li> - <a href="https://knowledgecenter.docuware.com/docs/de/neuheiten-docuware-version-712" target="_blank" title="Knowledge Center – Neues in DocuWare Version 7.12"> - Knowledge Center – Neues in DocuWare Version 7.12 - </a> - </li> - <li> - <a href="https://knowledgecenter.docuware.com/docs/news-712-technical-release-notes" target="_blank" title="Technische Release Notes 7.12 (EN)"> - Technische Release Notes 7.12 (EN) - </a> - </li> -</ul> \ No newline at end of file +- [Knowledge Center – Neues in DocuWare Version 7.12](https://knowledgecenter.docuware.com/docs/de/neuheiten-docuware-version-712) +- [Technische Release Notes 7.12 (EN)](https://knowledgecenter.docuware.com/docs/news-712-technical-release-notes) diff --git a/database/files/news/de_CH/2025-09-15-docuware-7-13-is-here.md b/database/files/news/de_CH/2025-09-15-docuware-7-13-is-here.md new file mode 100644 index 0000000..96573f5 --- /dev/null +++ b/database/files/news/de_CH/2025-09-15-docuware-7-13-is-here.md @@ -0,0 +1,60 @@ +--- +key: docuware-7-13-is-here +slug: docuware-7-13-ist-da +title: DocuWare 7.13 ist da +teaser: >- + Workflows entstehen neu im Browser, der Rechnungseingang kommt mit ausländischen E-Rechnungsformaten zurecht, und die Anmeldung schützt ein zweiter Faktor. +published_at: 2025-09-15 +author: sebastian.buergin@codebar.ch +tags: [DMS/ECM] +--- + +Vier Neuerungen prägen dieses Release aus Sicht der täglichen Arbeit: ein Workflow Designer ohne Installation, +ein Rechnungseingang für internationale Formate, ein zweiter Faktor bei der Anmeldung und Formulare, die sich +zwischendurch weglegen lassen. + +## Workflows im Browser gestalten + +Der Workflow Designer ist keine eigene Anwendung mehr. Prozesse entstehen direkt in der DocuWare-Konfiguration, im +Browser, ohne dass auf dem Arbeitsplatz etwas installiert sein muss. Im Alltag fällt das an mehreren Stellen auf: + +- Zuständigkeiten werden pro Aufgabe hinterlegt statt einmal für den ganzen Prozess. +- Änderungen sichern sich von selbst; ein Schritt zurück ist jederzeit möglich. +- Mehrere Schritte lassen sich gemeinsam markieren, kopieren oder entfernen. +- Einzelne Workflows lassen sich exportieren, nicht mehr nur der gesamte Bestand. +- Für unerwartete Abbrüche kann eine eigene Reaktion hinterlegt werden. + +Eine Übersicht zeigt alle Archive samt den Prozessen, die darauf laufen. Ältere Workflows aus der Desktop-Anwendung +werden mit wenigen Klicks übernommen. Die übernommene Fassung wird erst mit dem Publizieren aktiv – laufende Vorgänge +arbeiten bis dahin unverändert weiter. + +## E-Rechnungen aus dem Ausland + +Bisher deckte eine Konfiguration die Formate auf UBL- und CII-Basis ab. Neu erkennt DocuWare das Format selbst und +wählt die passende Verarbeitung, auch bei Standards, die auf keiner dieser beiden Grundlagen aufbauen. + +Für den Rechnungseingang bedeutet das: ein Postfach, ein Ordner. Wer Lieferanten in Italien und Polen hat, hinterlegt +FatturaPA und KSeF je einmal; XRechnung und FacturaE laufen weiterhin über dieselbe Konfiguration. Dieselbe Erkennung +greift auch bei anderen XML-Dokumenten, etwa bei Lieferscheinen. + +## Anmeldung mit zweitem Faktor + +Der Zugang lässt sich zusätzlich mit einem Einmalcode aus einer Authenticator-App schützen. Die Organisation gibt die +Funktion frei, jede Person aktiviert sie im eigenen Profil. In der Benutzerverwaltung ist ersichtlich, wer bereits +umgestellt hat – und bei einem verlorenen Telefon lässt sich der zweite Faktor gezielt für eine Person zurücksetzen. + +## Formulare zwischendurch weglegen + +Ein angefangenes Formular geht nicht mehr verloren: Der Stand lässt sich über den Formularlink sichern und später +fertigstellen oder an jemanden weiterreichen, der die fehlenden Angaben kennt. Aus einem Workflow heraus können Felder +mit bereits erfassten Daten vorbelegt werden. + +Auswahllisten filtern auf Wunsch nur noch nach dem Anfang der Eingabe. Bei Nummern führt das zu deutlich kürzeren +Trefferlisten als eine Suche über den gesamten Eintrag. + +## Weitere Informationen + +Alle Neuerungen mit Screenshots und Anwendungsbeispielen finden Sie im Knowledge Center: + +- [Knowledge Center – Neues in DocuWare Version 7.13](https://knowledgecenter.docuware.com/docs/de/neuheiten-docuware-version-713) +- [Technische Release Notes 7.13](https://knowledgecenter.docuware.com/docs/de/neuheiten-713-technical-release-notes) diff --git a/database/files/news/de_CH/2026-05-12-docuware-7-14-is-here.md b/database/files/news/de_CH/2026-05-12-docuware-7-14-is-here.md new file mode 100644 index 0000000..85fbfd4 --- /dev/null +++ b/database/files/news/de_CH/2026-05-12-docuware-7-14-is-here.md @@ -0,0 +1,61 @@ +--- +key: docuware-7-14-is-here +slug: docuware-7-14-ist-da +title: DocuWare 7.14 ist da +teaser: >- + Die letzten Einstellungen wandern in den Browser, eine neu gebaute App bringt Aufgaben aufs Telefon, und Archive ziehen zwischen Cloud-Organisationen um. +published_at: 2026-05-12 +author: sebastian.buergin@codebar.ch +tags: [DMS/ECM] +--- + +Dieses Release räumt auf. Die letzte Desktop-Anwendung für Einstellungen verschwindet, die Mobile App wurde von Grund +auf neu gebaut, und zwei Module werden abgekündigt. + +## Alles im Browser + +Die DocuWare Administration wird eingestellt. Was dort noch übrig war, findet sich neu in der DocuWare-Konfiguration: + +- **Dateiverbindungen** unter «Integrationen», mit Vorschau und direktem Upload der CSV-Datei. Der Umweg über FTP + entfällt. +- **Effektive Archivrechte** in der Benutzerverwaltung – eine Ansicht darüber, was eine Person tatsächlich darf. +- **Lizenzübersicht** in den Organisationseinstellungen, mit Lizenztyp und genutzten Anwendungen pro Person. + +## Die neue App + +Die Mobile App wurde neu entwickelt. Sie heisst schlicht «DocuWare», die bisherige läuft als «DocuWare Classic» +weiter. Der Zuschnitt folgt dem, was unterwegs wirklich gebraucht wird: + +- Dokumente suchen und direkt aus der App heraus weitergeben – der Fall für Aussendienst und Servicetechnik. +- Aufgaben nach Prozess gruppiert abarbeiten. +- Push-Benachrichtigungen bei neuen Aufgaben. Erst damit bleiben Freigaben nicht mehr tagelang liegen, weil niemand + ins Postfach geschaut hat. +- Dateien aus anderen Apps nach DocuWare übergeben. +- Register für den Wechsel zwischen einer Aufgabe und dem zugehörigen Dokument. + +## Sicherheit und Automatisierung + +- **Zwei-Faktor-Authentifizierung** war bisher freiwillig. Neu lässt sie sich für die gesamte Organisation + vorschreiben, mit Ausnahmen für einzelne Personen oder ganze Rollen. +- **Cloud-zu-Cloud-Transfer** verschiebt oder kopiert Dokumente und ganze Archive von einer DocuWare-Cloud-Organisation + in eine andere. Gedacht für die Zusammenführung nach einer Übernahme oder die Trennung nach einer Ausgliederung. +- **Anmerkungen bleiben erhalten:** Ein Workflow kann ein Dokument samt Stempeln und Notizen als PDF an eine + Schnittstelle übergeben. Bisher ging nur das Original oder ein PDF ohne diese Ergänzungen. +- **Zwei neue Aufrufe der Platform API** legen Benutzergruppen an und trennen geheftete Dokumente wieder auf. +- **IDP-Schlüssel** werden beim Anlegen automatisch gesetzt; das Kopieren von Hand entfällt. + +## Was verschwindet + +- **DocuWare Request** wird eingestellt und ist standardmässig ausgeblendet. Wer «Export als Backup (mit + elektronischer Signatur)» in einer bestehenden Konfiguration nutzt, ist davon nicht betroffen. +- **Connect to Outlook** läuft aus, weil Microsoft das klassische Outlook bis 2029 abkündigt. Nachfolger ist das + Add-in DocuWare for Outlook – es übernimmt neu die bestehenden Ablagekonfigurationen mitsamt Ablageort, Indexierung + und Behandlung von Anhängen. Umbauen muss man dafür nichts. + +## Weitere Informationen + +Alle Neuerungen mit Screenshots und Anwendungsbeispielen finden Sie im Knowledge Center: + +- [Knowledge Center – Neues in DocuWare Version 7.14](https://knowledgecenter.docuware.com/docs/de/2026-04-docuware-714) +- [Technische Release Notes 7.14](https://knowledgecenter.docuware.com/docs/de/news-714-technical-release-notes) +- [Die neue DocuWare Mobile App](https://knowledgecenter.docuware.com/docs/de/april-26-news-new-mobile-app) diff --git a/database/files/news/de_CH/2026-07-28-bausteine-styleguide.md b/database/files/news/de_CH/2026-07-28-bausteine-styleguide.md new file mode 100644 index 0000000..b420d00 --- /dev/null +++ b/database/files/news/de_CH/2026-07-28-bausteine-styleguide.md @@ -0,0 +1,139 @@ +--- +key: bausteine-styleguide +slug: bausteine-im-ueberblick +title: Alle Bausteine im Überblick +teaser: >- + Dieser Artikel existiert, um jeden verfügbaren Inhaltsbaustein einmal in echt zu zeigen. + Er dient als Referenz beim Schreiben — und als Kontrolle, dass das Design überall trägt. +published_at: 2026-07-28 +published: false +author: sebastian.buergin@codebar.ch +hero: images/templates/cover-template.jpg +hero_alt: Platzhaltergrafik im Seitenverhältnis 3:1 +hero_caption: Das Titelbild steht in derselben Breite wie alles andere auf der Seite. +tags: [Styleguide, Redaktion] +featured: false +--- + +Der erste Absatz ist der Einstieg. Die ganze Website nutzt eine einzige Schrift — +Poppins —, im Artikel nur etwas grösser und luftiger gesetzt. **Fetter Text** und *kursiver Text* funktionieren wie gewohnt, +ebenso [Links](https://www.codebar.ch) und `Inline-Code`. + +## Überschrift der zweiten Ebene + +Zwischentitel unterscheiden sich vom Fliesstext über Grösse und Gewicht, nicht über eine +zweite Schriftart. + +### Überschrift der dritten Ebene + +Unterabschnitte erscheinen im Inhaltsverzeichnis eingerückt und ohne Nummer. + +- Ein Listenpunkt. +- Ein zweiter Listenpunkt, der lang genug ist, um über mehr als eine Zeile zu laufen und + damit den Zeilenabstand innerhalb eines Punktes zu zeigen. + - Ein verschachtelter Punkt. + - Noch einer. + +1. Nummeriert geht auch. +2. Zweiter Punkt. + +## Bilder + +Alle Elemente teilen sich eine Breite — Bilder beginnen und enden dort, wo der +Fliesstext beginnt und endet: + +:::figure{src="images/templates/cover-template.jpg" width="text" alt="Platzhalter in Textbreite"} +Eine Bildlegende steht zentriert unter dem Bild. +::: + +`width="wide"` bleibt als Angabe gültig, ändert aber nichts mehr an der Breite: + +:::figure{src="images/templates/cover-template.jpg" width="wide" alt="Platzhalter in Ausbruchsbreite"} +Dieselbe Breite wie das Bild darüber. +::: + +## Galerie und Vergleich + +:::gallery{cols="3" caption="Eine Galerie aus drei Bildern."} +- src: images/templates/cover-template.jpg + alt: Erstes Bild + caption: Erstes Bild +- src: images/templates/cover-template.jpg + alt: Zweites Bild + caption: Zweites Bild +- src: images/templates/cover-template.jpg + alt: Drittes Bild + caption: Drittes Bild +::: + +:::compare{caption="Zwei Zustände direkt nebeneinander."} +- src: images/templates/cover-template.jpg + alt: Zustand vorher + caption: Vorher · 412 000 Dokumente +- src: images/templates/cover-template.jpg + alt: Zustand nachher + caption: Nachher · 268 000 Dokumente +::: + +## Zitat + +:::quote{cite="Sebastian Bürgin-Fix"} +Die Migration ist nie das Problem. Die Daten sind es. +::: + +## Hinweisboxen + +:::callout{type="info"} +Ein neutraler Hinweis für Zusatzinformationen, die den Lesefluss nicht unterbrechen sollen. +::: + +:::callout{type="tip" title="Aus der Praxis"} +Ein Tipp aus einem echten Projekt. +::: + +:::callout{type="warning" title="Vor dem ersten Lauf"} +Ein vollständiges, wiederherstellbares Backup — und ein dokumentierter Test der +Wiederherstellung. Ein Backup, das nie zurückgespielt wurde, ist eine Annahme. +::: + +:::callout{type="summary"} +Eine Zusammenfassung am Ende eines längeren Abschnitts. +::: + +## Schritt für Schritt + +:::steps +- title: Inventar erstellen + body: Alle Ablagen erfassen, Volumen und Dateitypen zählen, Zugriffe der letzten 24 Monate auswerten. +- title: Regeln festlegen + body: Pro Dokumentart entscheiden — übernehmen, archivieren oder verwerfen. Schriftlich, mit einer verantwortlichen Person. +- title: Probelauf fahren + body: Zehn Prozent des Bestandes migrieren und gegen die Regeln prüfen, bevor der Rest folgt. +- title: Abgleich dokumentieren + body: Zählstände vorher und nachher gegenüberstellen und die Differenz erklären können. +::: + +## Code + +```sql +SELECT checksum, COUNT(*) AS copies +FROM documents +WHERE archived_at IS NOT NULL +GROUP BY checksum +HAVING COUNT(*) > 1 +ORDER BY copies DESC; +``` + +## Tabelle + +| Kategorie | Anteil | Behandlung | +| --- | --- | --- | +| Aktiv genutzt (24 Monate) | 12 % | Direkt übernehmen | +| Aufbewahrungspflichtig | 41 % | Mit Frist übernehmen | +| Dubletten | 19 % | Zusammenführen | +| Ohne Rechtsgrund | 28 % | Nach Freigabe verwerfen | + +## Schluss + +Damit sind alle Bausteine einmal durch. Wer einen neuen braucht, ergänzt ihn in +`app/Markdown/NewsMarkdown.php` und legt das Template unter `resources/views/markdown/` ab. diff --git a/database/files/news/en_CH/20250406_docuware_712.md b/database/files/news/en_CH/2025-04-06-docuware-7-12-is-here.md similarity index 72% rename from database/files/news/en_CH/20250406_docuware_712.md rename to database/files/news/en_CH/2025-04-06-docuware-7-12-is-here.md index 3f37e7a..88a5799 100644 --- a/database/files/news/en_CH/20250406_docuware_712.md +++ b/database/files/news/en_CH/2025-04-06-docuware-7-12-is-here.md @@ -1,3 +1,14 @@ +--- +key: docuware-7-12-is-here +slug: docuware-7-12-is-here +title: DocuWare 7.12 is here +teaser: >- + More automation and insight: this release improves e-invoice processing, brings IDP into the cloud configuration and opens workflow data up to analytics. +published_at: 2025-04-06 +author: sebastian.buergin@codebar.ch +tags: [DMS/ECM] +--- + The latest release delivers numerous enhancements in the areas of e-invoice processing, IDP integration, analytics, as well as security and configuration. @@ -35,15 +46,5 @@ With the new **Workflow Analytics API**, DocuWare data can be seamlessly transfe Learn more about the new features in DocuWare 7.12 in the Knowledge Center – including use cases and real-world benefits for your organization: -<ul> - <li> - <a href="https://knowledgecenter.docuware.com/docs/en/news-docuware-version-712" target="_blank" title="Knowledge Center – What’s New in DocuWare Version 7.12"> - Knowledge Center – What’s New in DocuWare Version 7.12 - </a> - </li> - <li> - <a href="https://knowledgecenter.docuware.com/docs/news-712-technical-release-notes" target="_blank" title="Technical Release Notes 7.12)"> - Technical Release Notes 7.12 - </a> - </li> -</ul> +- [Knowledge Center – What's New in DocuWare Version 7.12](https://knowledgecenter.docuware.com/docs/en/news-docuware-version-712) +- [Technical Release Notes 7.12](https://knowledgecenter.docuware.com/docs/news-712-technical-release-notes) diff --git a/database/files/news/en_CH/2025-09-15-docuware-7-13-is-here.md b/database/files/news/en_CH/2025-09-15-docuware-7-13-is-here.md new file mode 100644 index 0000000..c38ffd4 --- /dev/null +++ b/database/files/news/en_CH/2025-09-15-docuware-7-13-is-here.md @@ -0,0 +1,58 @@ +--- +key: docuware-7-13-is-here +slug: docuware-7-13-is-here +title: DocuWare 7.13 is here +teaser: >- + Workflows are now built in the browser, invoice intake copes with foreign e-invoice formats, and logging in can be secured with a second factor. +published_at: 2025-09-15 +author: sebastian.buergin@codebar.ch +tags: [DMS/ECM] +--- + +Four changes shape this release from the perspective of everyday work: a Workflow Designer without an installation, +invoice intake for international formats, a second factor at login, and forms you can put aside halfway through. + +## Building workflows in the browser + +The Workflow Designer is no longer a separate application. Processes are built directly in the DocuWare Configuration, +in the browser, with nothing to install on the workstation. Several things stand out in daily use: + +- Responsibilities are set per task rather than once for the entire process. +- Changes save themselves, and a step backwards is always available. +- Several steps can be selected, copied or removed together. +- Individual workflows can be exported instead of only the entire set. +- A custom response can be defined for unexpected interruptions. + +An overview lists every file cabinet along with the processes running on it. Older workflows from the desktop +application are taken over in a few clicks. The transferred version only goes live once published – until then, +processes already under way continue unchanged. + +## E-invoices from abroad + +Until now, a single configuration covered the formats based on UBL and CII. DocuWare now identifies the format itself +and picks the matching processing path, including standards built on neither of those foundations. + +For invoice intake, that means one mailbox and one folder. Anyone with suppliers in Italy and Poland sets up FatturaPA +and KSeF once each, while XRechnung and FacturaE continue to share a configuration. The same identification also works +for other XML documents, delivery notes among them. + +## Logging in with a second factor + +Access can be protected with an additional one-time code from an authenticator app. The organisation releases the +feature, and each person activates it in their own profile. User Management shows who has already switched over – and +if a phone goes missing, the second factor can be reset for that individual. + +## Putting forms aside + +A half-finished form is no longer lost: its state can be saved through the form link and completed later, or handed to +someone who knows the missing details. From within a workflow, fields can be pre-populated with data already on file. + +Select lists can be filtered by the beginning of what you type. For numbers, that produces far shorter result lists +than searching across the whole entry. + +## More information + +All new features, with screenshots and use cases, are documented in the Knowledge Center: + +- [Knowledge Center – What's New in DocuWare Version 7.13](https://knowledgecenter.docuware.com/docs/news-docuware-version-713) +- [Technical Release Notes 7.13](https://knowledgecenter.docuware.com/docs/news-713-technical-release-notes) diff --git a/database/files/news/en_CH/2026-05-12-docuware-7-14-is-here.md b/database/files/news/en_CH/2026-05-12-docuware-7-14-is-here.md new file mode 100644 index 0000000..c996282 --- /dev/null +++ b/database/files/news/en_CH/2026-05-12-docuware-7-14-is-here.md @@ -0,0 +1,61 @@ +--- +key: docuware-7-14-is-here +slug: docuware-7-14-is-here +title: DocuWare 7.14 is here +teaser: >- + The last settings move into the browser, a rebuilt app puts tasks on your phone, and file cabinets can be moved between two cloud organisations. +published_at: 2026-05-12 +author: sebastian.buergin@codebar.ch +tags: [DMS/ECM] +--- + +This release tidies up. The last desktop application for settings disappears, the mobile app has been rebuilt from +scratch, and two modules are being retired. + +## Everything in the browser + +DocuWare Administration is discontinued. Whatever was left in it now lives in the DocuWare Configuration: + +- **File connections** under “Integrations”, with a preview and a direct upload of the CSV file. The detour via FTP is + gone. +- **Effective file cabinet rights** in User Management – a single view of what a person is actually allowed to do. +- **License overview** in the Organization Settings, showing license type and applications in use per person. + +## The new app + +The mobile app has been rebuilt. It is simply called “DocuWare”, while the previous one continues as “DocuWare +Classic”. Its scope follows what is genuinely needed away from the desk: + +- Find documents and pass them on straight from the app – the case for field sales and service technicians. +- Work through tasks grouped by process. +- Push notifications for new tasks. Only with these do approvals stop sitting for days because nobody checked their + inbox. +- Hand files over to DocuWare from other apps. +- Tabs for switching between a task and the document it belongs to. + +## Security and automation + +- **Two-factor authentication** used to be voluntary. It can now be made mandatory across the organisation, with + exceptions for individual people or entire roles. +- **Cloud-to-cloud transfer** moves or copies documents and whole file cabinets from one DocuWare Cloud organisation to + another. Intended for consolidation after an acquisition or separation after a spin-off. +- **Annotations stay with the document:** a workflow can hand a document over to an interface as a PDF including stamps + and notes. Previously only the original or a PDF without those additions was possible. +- **Two new Platform API calls** create user groups and separate clipped documents again. +- **IDP keys** are set automatically on creation, so there is nothing left to copy by hand. + +## What is going away + +- **DocuWare Request** is discontinued and hidden by default. Anyone using “Export as backup (with electronic + signing)” in an existing configuration is unaffected. +- **Connect to Outlook** is being phased out, as Microsoft retires Classic Outlook by 2029. Its successor is the + DocuWare for Outlook add-in – which now takes over the existing storage configurations, including storage location, + indexing and how attachments are handled. Nothing needs rebuilding. + +## More information + +All new features, with screenshots and use cases, are documented in the Knowledge Center: + +- [Knowledge Center – What's New in DocuWare Version 7.14](https://knowledgecenter.docuware.com/docs/news-docuware-version-714) +- [Technical Release Notes 7.14](https://knowledgecenter.docuware.com/docs/news-714-technical-release-notes) +- [The new DocuWare mobile app](https://knowledgecenter.docuware.com/docs/april-26-news-new-mobile-app) diff --git a/database/files/news/en_CH/2026-07-28-bausteine-styleguide.md b/database/files/news/en_CH/2026-07-28-bausteine-styleguide.md new file mode 100644 index 0000000..b61bc88 --- /dev/null +++ b/database/files/news/en_CH/2026-07-28-bausteine-styleguide.md @@ -0,0 +1,138 @@ +--- +key: bausteine-styleguide +slug: content-blocks-at-a-glance +title: Every content block at a glance +teaser: >- + This article exists to show each available content block once, for real. It doubles as a + reference while writing and as a check that the design holds up everywhere. +published_at: 2026-07-28 +published: false +author: sebastian.buergin@codebar.ch +hero: images/templates/cover-template.jpg +hero_alt: Placeholder graphic in a 3:1 aspect ratio +hero_caption: The lead image sits at the same width as everything else on the page. +tags: [Styleguide, Redaktion] +featured: false +--- + +The opening paragraph sets the scene. The whole site uses a single typeface — Poppins — +set a little larger and more open inside an article. **Bold text** and *italics* work as expected, as do +[links](https://www.codebar.ch) and `inline code`. + +## A second-level heading + +Section headings differ from the body copy by size and weight, not by a second typeface. + +### A third-level heading + +Sub-sections appear indented and unnumbered in the table of contents. + +- A list item. +- A second item, long enough to run past a single line and show the leading inside one + item. + - A nested item. + - And another. + +1. Numbered works too. +2. Second item. + +## Images + +Every element shares one width — images start and end where the body copy starts +and ends: + +:::figure{src="images/templates/cover-template.jpg" width="text" alt="Placeholder at text width"} +A caption sits centred beneath the image. +::: + +`width="wide"` remains valid, but no longer changes the width: + +:::figure{src="images/templates/cover-template.jpg" width="wide" alt="Placeholder at breakout width"} +The same width as the image above. +::: + +## Gallery and comparison + +:::gallery{cols="3" caption="A gallery of three images."} +- src: images/templates/cover-template.jpg + alt: First image + caption: First image +- src: images/templates/cover-template.jpg + alt: Second image + caption: Second image +- src: images/templates/cover-template.jpg + alt: Third image + caption: Third image +::: + +:::compare{caption="Two states side by side."} +- src: images/templates/cover-template.jpg + alt: State before + caption: Before · 412,000 documents +- src: images/templates/cover-template.jpg + alt: State after + caption: After · 268,000 documents +::: + +## Quote + +:::quote{cite="Sebastian Bürgin-Fix"} +The migration is never the problem. The data is. +::: + +## Callouts + +:::callout{type="info"} +A neutral note for background that should not interrupt the reading flow. +::: + +:::callout{type="tip" title="From practice"} +A tip taken from a real project. +::: + +:::callout{type="warning" title="Before the first run"} +A complete, restorable backup — and a documented test of the restore. A backup that has +never been restored is an assumption. +::: + +:::callout{type="summary"} +A summary closing a longer section. +::: + +## Step by step + +:::steps +- title: Take inventory + body: Record every repository, count volume and file types, review access over the last 24 months. +- title: Agree the rules + body: Decide per document type — migrate, archive or discard. In writing, with a named owner. +- title: Run a pilot + body: Migrate ten percent of the stock and check it against the rules before the rest follows. +- title: Document the reconciliation + body: Put the counts before and after side by side, and be able to explain the difference. +::: + +## Code + +```sql +SELECT checksum, COUNT(*) AS copies +FROM documents +WHERE archived_at IS NOT NULL +GROUP BY checksum +HAVING COUNT(*) > 1 +ORDER BY copies DESC; +``` + +## Table + +| Category | Share | Treatment | +| --- | --- | --- | +| Actively used (24 months) | 12 % | Migrate directly | +| Subject to retention | 41 % | Migrate with a retention period | +| Duplicates | 19 % | Merge | +| No legal basis | 28 % | Discard once approved | + +## Closing + +That is every block once through. Anyone who needs a new one adds it to +`app/Markdown/NewsMarkdown.php` and drops the template into `resources/views/markdown/`. diff --git a/database/files/pages/about-us.index.yaml b/database/files/pages/about-us.index.yaml new file mode 100644 index 0000000..0e6c849 --- /dev/null +++ b/database/files/pages/about-us.index.yaml @@ -0,0 +1,8 @@ +key: about-us.index +robots: index,follow +title: + de_CH: 'Team – die Menschen hinter codebar Solutions AG' + en_CH: 'Team – the People Behind codebar Solutions AG' +description: + de_CH: 'Klein aus Überzeugung: kurze Wege, direkte Ansprechpersonen – und Ausbildung als Teil unserer Kultur. Lerne die Menschen hinter codebar kennen.' + en_CH: 'Small by conviction: short paths, direct contacts – and training as part of our culture. Meet the people behind codebar.' diff --git a/database/files/pages/ai.index.yaml b/database/files/pages/ai.index.yaml new file mode 100644 index 0000000..c6e6c82 --- /dev/null +++ b/database/files/pages/ai.index.yaml @@ -0,0 +1,8 @@ +key: ai.index +robots: index,follow +title: + de_CH: 'Lokale KI auf eigener Infrastruktur | codebar Solutions AG' + en_CH: 'Local AI on Our Own Infrastructure | codebar Solutions AG' +description: + de_CH: 'Transparenz statt Buzzwords: Wir zeigen offen, wie wir KI in der eigenen Arbeit einsetzen – mit lokalen Open-Source-Modellen und echten Nutzungszahlen.' + en_CH: 'Transparency over buzzwords: an open look at how we use AI in our own work – with local open-source models and real usage figures.' diff --git a/database/files/pages/ai.llm.analytics.index.yaml b/database/files/pages/ai.llm.analytics.index.yaml new file mode 100644 index 0000000..05ef29b --- /dev/null +++ b/database/files/pages/ai.llm.analytics.index.yaml @@ -0,0 +1,8 @@ +key: ai.llm.analytics.index +robots: index,follow +title: + de_CH: 'LLM-Nutzungsstatistik – codebar Solutions AG' + en_CH: 'LLM Usage Analytics – codebar Solutions AG' +description: + de_CH: 'Token-Verbrauch und Anfragen unserer lokal betriebenen Modelle – transparent aufgeschlüsselt pro Monat und Modell.' + en_CH: 'Token consumption and requests of our locally run models – transparently broken down per month and model.' diff --git a/database/files/pages/ai.llm.index.yaml b/database/files/pages/ai.llm.index.yaml new file mode 100644 index 0000000..a7110dd --- /dev/null +++ b/database/files/pages/ai.llm.index.yaml @@ -0,0 +1,8 @@ +key: ai.llm.index +robots: index,follow +title: + de_CH: 'Unsere lokalen LLMs im Einsatz – codebar Solutions AG' + en_CH: 'Our Local LLMs in Action – codebar Solutions AG' +description: + de_CH: 'Diese lokalen Open-Source-Modelle betreiben wir aktuell – auf eigener Hardware im hauseigenen Bürokeller. Modelle, Infrastruktur und Nutzung.' + en_CH: 'The local open-source models we currently run – on our own hardware, in our own office basement. Models, infrastructure and usage.' diff --git a/database/files/pages/co-working.index.yaml b/database/files/pages/co-working.index.yaml new file mode 100644 index 0000000..33c38ed --- /dev/null +++ b/database/files/pages/co-working.index.yaml @@ -0,0 +1,8 @@ +key: co-working.index +robots: index,follow +title: + de_CH: 'Co-Working bei codebar Solutions AG' + en_CH: 'Co-Working at codebar Solutions AG' +description: + de_CH: 'Ein professioneller Arbeitsplatz in Oberwil — ruhig, gut ausgestattet und Teil eines Tech-Teams.' + en_CH: 'A professional workspace in Oberwil — quiet, well-equipped, and embedded in a tech team.' diff --git a/database/files/pages/contact.index.yaml b/database/files/pages/contact.index.yaml new file mode 100644 index 0000000..2a64c82 --- /dev/null +++ b/database/files/pages/contact.index.yaml @@ -0,0 +1,8 @@ +key: contact.index +robots: index,follow +title: + de_CH: "Kontakt – lass uns sprechen | codebar Solutions AG" + en_CH: "Contact – Let's Talk | codebar Solutions AG" +description: + de_CH: 'Erzähl uns von deiner Idee oder deinem Projekt – wir hören zu. Telefon, E-Mail und unsere zwei Standorte in der Region Basel.' + en_CH: "Tell us about your idea or your project – we'll listen. Phone, email and our two locations in the Basel region." diff --git a/database/files/pages/jobs.index.yaml b/database/files/pages/jobs.index.yaml new file mode 100644 index 0000000..a7bd93e --- /dev/null +++ b/database/files/pages/jobs.index.yaml @@ -0,0 +1,8 @@ +key: jobs.index +robots: index,follow +title: + de_CH: 'Stellen – arbeiten bei codebar Solutions AG' + en_CH: 'Jobs – Working at codebar Solutions AG' +description: + de_CH: 'Klein aus Überzeugung: Verantwortung ab Tag eins, offene Technologien und Praktika mitten im Projektalltag. So arbeitest du bei codebar.' + en_CH: 'Small by conviction: responsibility from day one, open technologies and internships inside real projects. This is what working at codebar is like.' diff --git a/database/files/pages/legal.imprint.index.yaml b/database/files/pages/legal.imprint.index.yaml new file mode 100644 index 0000000..c420d05 --- /dev/null +++ b/database/files/pages/legal.imprint.index.yaml @@ -0,0 +1,8 @@ +key: legal.imprint.index +robots: index,follow +title: + de_CH: 'Impressum – codebar Solutions AG' + en_CH: 'Imprint – codebar Solutions AG' +description: + de_CH: 'Rechtliche Angaben zur codebar Solutions AG: Rechtsform, UID-Nummer, Handelsregistereintrag, Zeichnungsberechtigte sowie Kontaktdaten und Haftungsausschluss.' + en_CH: 'Legal details of codebar Solutions AG: legal form, UID number, commercial register entry, authorised representatives, contact details and disclaimer.' diff --git a/database/files/pages/legal.privacy.index.yaml b/database/files/pages/legal.privacy.index.yaml new file mode 100644 index 0000000..93e41c9 --- /dev/null +++ b/database/files/pages/legal.privacy.index.yaml @@ -0,0 +1,8 @@ +key: legal.privacy.index +robots: index,follow +title: + de_CH: 'Datenschutz – codebar Solutions AG' + en_CH: 'Privacy – codebar Solutions AG' +description: + de_CH: 'Wie wir Personendaten auf codebar.ch bearbeiten: welche Daten anfallen, wofür wir sie nutzen, wie lange wir sie aufbewahren – und welche Rechte du hast.' + en_CH: 'How we process personal data on codebar.ch: what we collect, why, how long we keep it – and the rights you have.' diff --git a/database/files/pages/legal.terms.index.yaml b/database/files/pages/legal.terms.index.yaml new file mode 100644 index 0000000..140b609 --- /dev/null +++ b/database/files/pages/legal.terms.index.yaml @@ -0,0 +1,8 @@ +key: legal.terms.index +robots: index,follow +title: + de_CH: 'AGB – codebar Solutions AG' + en_CH: 'Terms & Conditions – codebar Solutions AG' +description: + de_CH: 'Die Allgemeinen Geschäftsbedingungen der codebar Solutions AG: Angebote, Projektabwicklung, Nutzungsrechte, Haftung und Support.' + en_CH: 'The general terms and conditions of codebar Solutions AG: offers, project delivery, rights of use, liability and support.' diff --git a/database/files/pages/media.index.yaml b/database/files/pages/media.index.yaml new file mode 100644 index 0000000..49422bd --- /dev/null +++ b/database/files/pages/media.index.yaml @@ -0,0 +1,8 @@ +key: media.index +robots: index,follow +title: + de_CH: 'Medien & Markenassets – codebar Solutions AG' + en_CH: 'Media & Brand Assets – codebar Solutions AG' +description: + de_CH: 'Offizielle codebar-Logos für Presse und Partner – farbig, invertiert, schwarz-weiss, als PNG und SVG.' + en_CH: 'Official codebar logos for press and partners – colour, inverted, black and white, as PNG and SVG.' diff --git a/database/files/pages/network.index.yaml b/database/files/pages/network.index.yaml new file mode 100644 index 0000000..d444807 --- /dev/null +++ b/database/files/pages/network.index.yaml @@ -0,0 +1,8 @@ +key: network.index +robots: index,follow +title: + de_CH: 'Netzwerk – Partner & Community | codebar Solutions AG' + en_CH: 'Network – Partners & Community | codebar Solutions AG' +description: + de_CH: 'Mit wem wir arbeiten: Projekt-, Software- und Infrastrukturpartner, unser Engagement in der Community – und die Labels, die dahinterstehen.' + en_CH: 'Who we work with: project, software and infrastructure partners, our community engagement – and the labels behind it.' diff --git a/database/files/pages/network.request.index.yaml b/database/files/pages/network.request.index.yaml new file mode 100644 index 0000000..8ea676b --- /dev/null +++ b/database/files/pages/network.request.index.yaml @@ -0,0 +1,8 @@ +key: network.request.index +robots: noindex,nofollow +title: + de_CH: 'Netzwerk-Profil aktualisieren – codebar Solutions AG' + en_CH: 'Update network profile – codebar Solutions AG' +description: + de_CH: 'Persönlichen Link anfordern, um das eigene Profil im codebar Netzwerk zu aktualisieren.' + en_CH: 'Request a personal link to update your codebar network profile.' diff --git a/database/files/pages/news.index.yaml b/database/files/pages/news.index.yaml new file mode 100644 index 0000000..f316053 --- /dev/null +++ b/database/files/pages/news.index.yaml @@ -0,0 +1,8 @@ +key: news.index +robots: index,follow +title: + de_CH: 'Neuigkeiten & Insights – codebar Solutions AG' + en_CH: 'News & Insights – codebar Solutions AG' +description: + de_CH: 'Aktuelle News, Fachbeiträge und Trends rund um Softwareentwicklung, offene Technologien und digitale Innovation von codebar.' + en_CH: 'Latest news and expert insights on software development, open technologies and digital innovation from codebar.' diff --git a/database/files/pages/open-source.index.yaml b/database/files/pages/open-source.index.yaml new file mode 100644 index 0000000..8189b47 --- /dev/null +++ b/database/files/pages/open-source.index.yaml @@ -0,0 +1,8 @@ +key: open-source.index +robots: index,follow +title: + de_CH: 'Open-Source-Beiträge – codebar Solutions AG' + en_CH: 'Open Source Contributions – codebar Solutions AG' +description: + de_CH: 'Unsere Beiträge an die Open-Source-Community – Packages, Tools und Libraries, entwickelt und gepflegt von codebar.' + en_CH: 'Our contributions to the open source community – packages, tools and libraries developed and maintained by codebar.' diff --git a/database/files/pages/products.index.yaml b/database/files/pages/products.index.yaml new file mode 100644 index 0000000..a40a9de --- /dev/null +++ b/database/files/pages/products.index.yaml @@ -0,0 +1,8 @@ +key: products.index +robots: index,follow +title: + de_CH: 'Nutzerzentrierte Softwarelösungen – codebar Solutions AG' + en_CH: 'User-Centric Software Solutions – codebar Solutions AG' +description: + de_CH: 'Nutzerzentrierte Softwarelösungen mit echtem Mehrwert – entwickelt mit offenen Technologien und Standards.' + en_CH: 'Our software solutions are built around the needs of users, delivering real value through open technologies and standards.' diff --git a/database/files/pages/services.index.yaml b/database/files/pages/services.index.yaml new file mode 100644 index 0000000..687ba18 --- /dev/null +++ b/database/files/pages/services.index.yaml @@ -0,0 +1,8 @@ +key: services.index +robots: index,follow +title: + de_CH: 'Konzeption, Software, DMS & ERP | codebar Solutions AG' + en_CH: 'Concept, Software, DMS & ERP | codebar Solutions AG' +description: + de_CH: 'Vier Bereiche, ein Weg: von der ersten Idee bis zum laufenden Betrieb – Konzeption, Softwareentwicklung, Dokumentenmanagement und ERP.' + en_CH: 'Four areas, one path: from the first idea to day-to-day operations – concept design, software development, document management and ERP.' diff --git a/database/files/pages/start.index.yaml b/database/files/pages/start.index.yaml new file mode 100644 index 0000000..68d6c8c --- /dev/null +++ b/database/files/pages/start.index.yaml @@ -0,0 +1,8 @@ +key: start.index +robots: index,follow +title: + de_CH: 'Innovative Ideen zum Leben erwecken – codebar Solutions AG' + en_CH: 'Bringing Innovative Ideas to Life – codebar Solutions AG' +description: + de_CH: 'Wir hören zu, verstehen dein Problem und begleiten dich von der ersten Idee bis zur Software im täglichen Einsatz. Ein kleines Team aus der Region Basel.' + en_CH: 'We listen, understand your problem and stay with you from the first idea to software in daily use. A small team based in the Basel region.' diff --git a/database/files/pages/technologies.index.yaml b/database/files/pages/technologies.index.yaml new file mode 100644 index 0000000..483cf4e --- /dev/null +++ b/database/files/pages/technologies.index.yaml @@ -0,0 +1,8 @@ +key: technologies.index +robots: index,follow +title: + de_CH: 'Offene Technologien & Standards – codebar Solutions AG' + en_CH: 'Open Technologies & Standards – codebar Solutions AG' +description: + de_CH: 'Die offenen Technologien und Standards, auf denen unsere Software basiert – bewährt, transparent und herstellerunabhängig.' + en_CH: 'The open technologies and standards we build our software on – proven, transparent and vendor-independent.' diff --git a/database/files/products/de_CH/clouddocs.md b/database/files/products/de_CH/clouddocs.md deleted file mode 100644 index 79c1fe5..0000000 --- a/database/files/products/de_CH/clouddocs.md +++ /dev/null @@ -1,19 +0,0 @@ -**CloudDocs** ist ein modernes, mandantenfähiges Portal, mit dem du ganz einfach Dokumente aus deinem DMS/ECM mit deinen Kund:innen teilen kannst – sicher, effizient und ohne Aufwand. - -Mit **CloudDocs** können deine Kund:innen Dokumente über eine einfache Online-Oberfläche ansehen, herunterladen und hochladen. Alles bleibt mit deinem internen Dokumentensystem synchronisiert – du behältst die Kontrolle und bietest gleichzeitig den Zugriff, den deine Kund:innen erwarten. - -## Mehrere Probleme auf einmal lösen -- Kein Hin- und Her-Senden von Dokumenten per E-Mail mehr -- Keine verlorenen Dateien oder veralteten Versionen -- Weniger manueller Aufwand und schnellere Reaktionszeiten - -## Was du mit CloudDocs machen kannst -- Dokumente direkt aus deinem DMS mit deinen Kund:innen teilen -- Kund:innen können Dateien hochladen, die direkt in deinem System landen -- Mit mehreren Mandanten nutzen – Daten bleiben sauber getrennt und sicher -- Ein professionelles, intuitives Portal anbieten – ohne zusätzlichen Schulungsaufwand - -**CloudDocs** hilft dir, besseren Service zu bieten und die Kommunikation zu vereinfachen – indem du deinen Kund:innen genau das gibst, was sie brauchen: sicheren, selbstbedienbaren Dokumentenzugriff. - -## Möchtest du die Zusammenarbeit mit deinen Kund:innen verbessern? -Wenn du die Dokumentenverwaltung vereinfachen oder die Interaktion mit deinen Kund:innen optimieren möchtest – sprich mit uns! \ No newline at end of file diff --git a/database/files/products/de_CH/docuhub.md b/database/files/products/de_CH/docuhub.md deleted file mode 100644 index 2cc4306..0000000 --- a/database/files/products/de_CH/docuhub.md +++ /dev/null @@ -1,21 +0,0 @@ -**DocuHub** ist dein Schweizer Taschenmesser für das Dokumentenmanagement. Es erweitert und verbessert die Funktionen deines bestehenden **DMS-/ECM**-Systems – wie DocuWare, SharePoint oder M-Files – durch die Anbindung leistungsstarker Drittanbieter-Dienste und die Optimierung deiner Dokumentenprozesse. - -Egal ob du Exporte automatisieren, externe Services anbinden oder deine Dokumentenverarbeitung mit KI anreichern möchtest – **DocuHub** hilft dir, mehr aus deinen Dokumenten herauszuholen. - -## Unterstützte Plattformen -- **DMS/ECM**: DocuWare, SharePoint, M-Files -- **Speicherlösungen**: FTP/SFTP, Amazon S3, Azure Blob Storage - -## Modulare Funktionen -- **Backup & Export**: Automatischer Export von Dokumenten inkl. Indexdaten und Metadaten wie OCR-Ergebnisse -- **PostFinance-Integration**: Empfange Yellowbill-Rechnungen über das PostFinance-Portal und integriere sie direkt in deinen Rechnungsprozess -- **Azure Document Intelligence**: Klassifiziere und extrahiere Daten mithilfe vortrainierter oder benutzerdefinierter KI-Modelle von Azure -- **Externe Aktionen**: Binde externe Nutzer:innen sicher in deine Dokumentenprozesse ein -- **Bexio Sync**: Verwalte deine Buchhaltungsprozesse direkt im DMS und synchronisiere finale Daten mit Bexio -- **Microsoft Dynamics 365 FO**: Prüfe Rechnungen gegen Bestellungen und übertrage abgeglichene Daten an Dynamics FO -- **Sproof**: Integriere digitale Signaturen nahtlos in deinen Dokumentenfluss -- **Postmark**: Versende professionelle E-Mail-Benachrichtigungen mit anpassbaren Templates - -## Eigene Module? Lass uns sprechen! -Du brauchst eine individuelle Lösung oder möchtest deine Prozesse weiter ausbauen? Wir entwickeln massgeschneiderte Module nach deinen Anforderungen. - diff --git a/database/files/products/de_CH/flows.md b/database/files/products/de_CH/flows.md new file mode 100644 index 0000000..721d17c --- /dev/null +++ b/database/files/products/de_CH/flows.md @@ -0,0 +1,42 @@ +--- +key: flows +order: 1 +image: 'https://res.cloudinary.com/codebar/image/upload/c_scale,dpr_2.0,f_auto,q_auto,w_1200/www-paperflakes-ch/seo/seo_paperflakes.webp' +tags: [] +name: Flows +headline: 'Dokumentenprozesse, gesteuert von Agenten. Auf Infrastruktur, die du kontrollierst.' +teaser: 'Flows ist eine Orchestrierungsplattform für dokumentenbasierte Prozesse — verbinde dein DMS und lass KI-Agenten Dokumente automatisch extrahieren, validieren und verarbeiten.' +features_heading: 'Orchestrierte Agenten-Workflows – extrahieren, verstehen, validieren, zurückschreiben.' +features_intro: 'Flows verbindet sich mit deinem bestehenden DMS. Keine Migration; dein Archiv bleibt, wo es ist. Darauf konfigurierst du Workflows, die auslösen, sobald ein Dokument eintrifft.' +features: + - title: 'Agentische Extraktion mit Validierung' + description: 'Definiere das Schema, das du brauchst. Agenten extrahieren die Daten, validieren sie gegen deine eigenen Systeme und Daten und schreiben geprüfte Ergebnisse zurück – strukturiert und passend zu deinen Feldnamen.' + - title: 'Multi-Agenten-Workflow-Orchestrierung' + description: 'Für komplexe Prozesse verkettest du spezialisierte Agenten – Klassifizierung, Validierung, Anreicherung, Routing. Die Orchestrierung basiert auf dem Microsoft Agent Framework, und MCP-Tool-Integrationen verbinden deine Workflows direkt mit deinen Geschäftssystemen und Daten.' + - title: 'Prompt- und Agenten-Engineering' + description: 'Verbessere Prompts anhand echter Produktionsdaten, generiere sie aus dem Wissen in deinem Datenpool oder DMS und verwalte Prompts und Schemas in einer versionierten, wiederverwendbaren Knowledge-Bibliothek.' + - title: 'Modell- und Anbieterfreiheit' + description: 'Führe Workflows bei dem Anbieter aus, der zu deinen Anforderungen an Genauigkeit, Kosten und Datenresidenz passt – und vergleiche denselben Workflow über mehrere Modelle hinweg, um zu sehen, welches bei deinen Dokumenten am besten abschneidet. Ein Wechsel bedeutet keinen Neuaufbau. Eigene lokale Modelle (Bring-your-own) folgen als Nächstes.' + - title: 'Vollständige Nachvollziehbarkeit' + description: 'Jeder Lauf wird lückenlos protokolliert: Dokument, Modell, Tokens, Dauer, Ergebnis.' +deployment_heading: 'Drei Wege, Flows zu betreiben. Alle isoliert.' +deployment_intro: 'Jede Bereitstellung erhält ihre eigene Datenbank, Schlüsselverwaltung, Speicher und Endpunkte – automatisch bereitgestellt, mit Firewalling und automatischer Rotation der Secrets. Du entscheidest, wo sie läuft, und wählst bei jeder Option die Azure-Region deiner Bereitstellung, sofern diese die benötigten Dienste unterstützt.' +deployment_options: + - title: 'Auf deiner eigenen Azure-Infrastruktur' + description: 'Flows wird in deine bestehende Azure-Umgebung integriert. Dein Abonnement, deine Governance, deine Kontrolle.' + - title: 'Dediziertes Azure-Abonnement' + description: 'Ein vollständig isoliertes Abonnement, das für dich betrieben wird – maximale Trennung, ohne dass du es selbst betreiben musst.' + - title: 'Gemeinsames Abonnement, dedizierte Ressourcengruppe' + description: 'Deine eigene Ressourcengruppe und Bereitstellungen innerhalb eines gemeinsamen Abonnements – isolierte Ressourcen bei geringerem Fussabdruck.' +cta_heading: 'Interessiert?' +cta_body: 'Wir zeigen dir Flows anhand deiner eigenen Dokumente.' +published: true +--- + +## Extraktionstools lesen Dokumente. Sie verstehen dein Geschäft nicht. + +Zwei Probleme halten Dokumentenprozesse manuell. + +Das erste ist der Aufwand: Dein DMS speichert Dokumente zuverlässig, aber die Arbeit drumherum – Lesen, Indexieren, Validieren, Übertragen von Daten in andere Systeme – hängt weiterhin von Personen ab, die Dateien öffnen und Daten eintippen. Das skaliert nur durch zusätzliches Personal. + +Das zweite Problem liegt tiefer: Klassische Dokumentenextraktion liefert Felder, nicht mehr. Sie versteht den Inhalt nicht und kann das Ergebnis nicht gegen deine eigenen Daten prüfen – existiert dieser Lieferant, stimmt der Betrag mit der Bestellung überein, ist das die richtige Kostenstelle? Selbst mit einem Extraktionstool muss also weiterhin eine Person jedes Ergebnis prüfen, bevor es vertrauenswürdig ist. diff --git a/database/files/products/en_CH/clouddocs.md b/database/files/products/en_CH/clouddocs.md deleted file mode 100644 index 163f226..0000000 --- a/database/files/products/en_CH/clouddocs.md +++ /dev/null @@ -1,19 +0,0 @@ -**CloudDocs** is a modern, multitenant portal that lets you easily share documents from your DMS/ECM with your own customers—securely, efficiently, and without hassle. - -With **CloudDocs**, your customers can view, download, and upload documents through a simple online interface. Everything stays in sync with your internal document system, so you stay in control while giving your customers the access they expect. - -## Solve key problems at once -- Stop emailing documents back and forth -- Avoid lost files or outdated versions -- Reduce manual effort and response times - -## What CloudDocs lets you do -- Share documents directly from your DMS with your customers -- Let customers upload files that go straight into your system -- Use it with multiple clients—data stays separated and secure -- Offer a professional, intuitive portal with no extra training needed - -**CloudDocs** helps you deliver better service and streamline communication—by giving your customers exactly what they need: secure, self-service document access. - -## Want to Improve How You Work with Your Customers? -If you're looking to simplify document handling or enhance how your customers interact with you—let’s talk. \ No newline at end of file diff --git a/database/files/products/en_CH/docuhub.md b/database/files/products/en_CH/docuhub.md deleted file mode 100644 index a20882d..0000000 --- a/database/files/products/en_CH/docuhub.md +++ /dev/null @@ -1,20 +0,0 @@ -**DocuHub** is your Swiss army knife for document management. It enhances and extends the functionality of your existing **DMS/ECM** system—like DocuWare, SharePoint, or M-Files—by connecting it with powerful third-party services and streamlining your document workflows. - -Whether you need to automate exports, connect external services, or enrich your document processing with AI **DocuHub** helps you do more with your documents. - -## Supported Platforms -- **DMS/ECM**: DocuWare, SharePoint, M-Files -- **Storage**: FTP/SFTP, Amazon S3, Azure Blob Storage - -## Modular Features -- **Backup & Export**: Automatically export documents along with index fields and metadata like OCR results -- **PostFinance Integration**: Receive Yellowbill invoices via PostFinance Portal and map them directly into your invoice process -- **Azure Document Intelligence**: Classify and extract data from documents using Azure’s prebuilt or custom AI models -- **External Actions**: Invite external users into your document workflows for secure collaboration -- **Bexio Sync**: Build and manage your accounting process within the DMS and sync final data to Bexio -- **Microsoft Dynamics 365 FO**: Validate invoices against purchase orders and transfer matched data to Dynamics FO -- **Sproof**: Integrate digital signing into your document flow -- **Postmark**: Send professional email notifications with customizable templates - -## Custom Modules? Let's Talk! -Need a tailored solution or want to extend your document process even further? We build custom modules to match your exact needs. \ No newline at end of file diff --git a/database/files/products/en_CH/flows.md b/database/files/products/en_CH/flows.md new file mode 100644 index 0000000..d24a4f7 --- /dev/null +++ b/database/files/products/en_CH/flows.md @@ -0,0 +1,42 @@ +--- +key: flows +order: 1 +image: 'https://res.cloudinary.com/codebar/image/upload/c_scale,dpr_2.0,f_auto,q_auto,w_1200/www-paperflakes-ch/seo/seo_paperflakes.webp' +tags: [] +name: Flows +headline: 'Document workflows, run by agents. On infrastructure you control.' +teaser: 'Flows is an orchestration platform for document-based processes — connect your DMS and let AI agents extract, validate, and process documents automatically.' +features_heading: 'Orchestrated agent workflows — extract, understand, validate, write back.' +features_intro: 'Flows connects to your existing DMS. No migration; your archive stays where it is. On top of it, you configure workflows that trigger the moment a document arrives.' +features: + - title: 'Agentic extraction with validation' + description: 'Define the schema you need. Agents extract the data, validate it against your own systems and data, and write verified results back — structured and keyed to your field names.' + - title: 'Multi-agent workflow orchestration' + description: 'For complex processes, chain specialized agents — classification, validation, enrichment, routing. Orchestration is built on the Microsoft Agent Framework, and MCP tool integrations connect your workflows directly to your business systems and data.' + - title: 'Prompt & agent engineering' + description: 'Improve prompts from real production data, generate them from the knowledge in your data pool or DMS, and manage prompts and schemas in a versioned, reusable Knowledge library.' + - title: 'Model and provider freedom' + description: "Run workflows on the provider that fits your accuracy, cost, and data-residency requirements — and benchmark the same workflow against multiple models to see which performs best on your documents. Switching doesn't mean rebuilding. Bring-your-own local models is next." + - title: 'Full traceability' + description: 'Every run is logged end to end: document, model, tokens, duration, result.' +deployment_heading: 'Three ways to run Flows. All isolated.' +deployment_intro: 'Every deployment gets its own database, key management, storage, and endpoints — provisioned automatically, with firewalling and automatic secret rotation. You choose where it runs, and with each option, you decide the Azure region of your deployment, provided it supports the services the platform requires.' +deployment_options: + - title: 'On your own Azure infrastructure' + description: 'Flows is deployed into your existing Azure environment. Your subscription, your governance, your controls.' + - title: 'Dedicated Azure subscription' + description: 'A fully isolated subscription operated for you — maximum separation without running it yourself.' + - title: 'Shared subscription, dedicated resource group' + description: 'Your own resource group and deployments within a shared subscription — isolated resources at a lighter footprint.' +cta_heading: 'Interested?' +cta_body: "We'll walk you through Flows on your own documents." +published: true +--- + +## Extraction tools read documents. They don't understand your business. + +Two problems keep document processes manual. + +The first is effort: your DMS stores documents reliably, but the work around it — reading, indexing, validating, moving data into other systems — still depends on people opening files and typing. It only scales by hiring. + +The second is deeper: conventional document extraction returns fields, nothing more. It doesn't understand the content, and it can't check the result against your own data — does this supplier exist, does the amount match the order, is this the right cost center? So even with an extraction tool, a person still has to verify every result before it can be trusted. diff --git a/database/files/services/de_CH/dms-ecm-consulting.md b/database/files/services/de_CH/dms-ecm-consulting.md new file mode 100644 index 0000000..ed83a6e --- /dev/null +++ b/database/files/services/de_CH/dms-ecm-consulting.md @@ -0,0 +1,27 @@ +--- +key: dms-ecm-consulting +name: 'DMS/ECM' +teaser: >- + Vom Papier zum papierlosen Büro – und weiter zur Automatisierung: Als DocuWare Silver & + Cloud Partner digitalisieren wir deine Dokumente und bilden Prozesse in Workflows ab. Vom + Einzelunternehmen bis zum Konzern mit über 200 Nutzer:innen. +order: 3 +published: true +group: services +tags: [DocuWare, Dokumentenmanagement, Workflows] +--- + +## Dokumentenmanagement, das im Alltag funktioniert + +Verträge, Rechnungen, Personaldossiers: Dokumente sind das Rückgrat vieler Geschäftsprozesse. Wir beraten dich herstellerneutral und begleiten dich von der Analyse bis zum laufenden Betrieb deines DMS/ECM. + +### Unsere Leistungen + +- **Analyse & Beratung** — Wir nehmen deine Dokumentenprozesse auf und zeigen, wo Digitalisierung den grössten Nutzen bringt. +- **Konzeption** — Ablagestruktur, Indexierung, Berechtigungen und Aufbewahrung, sauber durchdacht. +- **Implementierung** — Einführung und Konfiguration deines DMS — unter anderem mit DocuWare, wo wir langjährige Erfahrung mitbringen. +- **Integration & Automatisierung** — Anbindung an ERP, CRM und Fachanwendungen sowie automatisierte Dokumentenworkflows, bis hin zu KI-gestützter Verarbeitung. + +### Nach dem Go-live + +Wir lassen dich nicht mit dem System allein: Schulung, Support und die kontinuierliche Weiterentwicklung deiner Dokumentenprozesse gehören dazu. diff --git a/database/files/services/de_CH/individuelle-softwareentwicklung.md b/database/files/services/de_CH/individuelle-softwareentwicklung.md new file mode 100644 index 0000000..7a831f9 --- /dev/null +++ b/database/files/services/de_CH/individuelle-softwareentwicklung.md @@ -0,0 +1,30 @@ +--- +key: individuelle-softwareentwicklung +name: 'Individuelle Softwareentwicklung' +teaser: >- + Portale, Schnittstellen, Automatisierungen: Software, die im Hintergrund zuverlässig ihre + Arbeit macht. Wir entwickeln mit offenen Technologien wie Laravel – und betreuen deine + Lösung auch nach dem Go-live, über Jahre. +order: 2 +published: true +group: services +tags: ['Backend & Schnittstellen', Laravel, Open Source] +--- + +## Software, die zu deinen Prozessen passt + +Standardsoftware endet dort, wo dein Geschäft besonders ist. Wir entwickeln individuelle Lösungen, die sich nach deinen Prozessen richten — nicht umgekehrt. + +### Was wir bauen + +- **Portallösungen** — Kunden-, Partner- und Mitarbeiterportale mit klarer Benutzerführung. +- **Schnittstellen & APIs** — Saubere, dokumentierte Schnittstellen, die Systeme zuverlässig verbinden. +- **Integrationen** — Wir verbinden bestehende Systeme wie ERP, CRM oder DMS zu durchgängigen Abläufen. + +### Open Source im Fokus + +Wir setzen auf offene Technologien und Standards. Das bedeutet: keine Lizenzabhängigkeiten, eine aktive Community hinter den eingesetzten Werkzeugen und Software, die dir gehört und die du weiterentwickeln kannst — mit uns oder ohne uns. + +### Nachhaltig entwickelt + +Automatisierte Tests, Code-Reviews und kontinuierliche Deployments gehören für uns zum Handwerk. So bleibt deine Lösung auch nach Jahren wartbar und erweiterbar. diff --git a/database/files/services/de_CH/konzeption-prototyping.md b/database/files/services/de_CH/konzeption-prototyping.md new file mode 100644 index 0000000..ec7d724 --- /dev/null +++ b/database/files/services/de_CH/konzeption-prototyping.md @@ -0,0 +1,28 @@ +--- +key: konzeption-prototyping +name: 'Konzeption & Prototyping' +teaser: >- + Wir bringen deine Idee aufs Papier – visuell. Mit Mockups und klickbaren Prototypen entsteht + früh ein gemeinsames Bild, noch bevor die erste Zeile Code geschrieben ist. Am Ende hältst + du ein technisches Konzept in der Hand, inklusive Technologiewahl – als Basis für die + Umsetzung, bei uns oder bei anderen. +order: 1 +published: true +group: services +tags: [Requirements Engineering, Prototyping, UX] +--- + +## Von der Idee zum greifbaren Konzept + +Am Anfang steht Zuhören: Wir wollen verstehen, was dein Vorhaben antreibt, wer es nutzen wird und was es leisten muss. Daraus entwickeln wir gemeinsam ein Konzept, das die künftigen Nutzer:innen ins Zentrum stellt — und machen es als klickbaren Prototyp erlebbar, bevor eine Zeile produktiver Code entsteht. + +### Unser Vorgehen + +- **Verstehen** — In Workshops und Gesprächen klären wir Ziele, Nutzer:innen und Rahmenbedingungen. +- **Konzipieren** — Wir erarbeiten Informationsarchitektur, Abläufe und Datenmodell, verständlich dokumentiert. +- **Prototyping** — Klickbare Prototypen machen Ideen früh greifbar und diskutierbar. +- **Validieren** — Feedback von echten Nutzer:innen fliesst direkt in die nächste Iteration ein. + +### Warum sich das lohnt + +Ein Prototyp zeigt früh, ob eine Idee trägt. Entscheidungen fallen anhand von etwas Greifbarem statt auf Papier — das reduziert Risiken und spart in der Umsetzung Zeit und Kosten. Und wenn es weitergeht, bildet das Konzept die solide Grundlage für die Entwicklung. diff --git a/database/files/services/de_CH/open-source-erp.md b/database/files/services/de_CH/open-source-erp.md new file mode 100644 index 0000000..7de535b --- /dev/null +++ b/database/files/services/de_CH/open-source-erp.md @@ -0,0 +1,17 @@ +--- +key: open-source-erp +name: 'Open Source ERP' +teaser: >- + Unser jüngster Bereich – und einer, hinter dem wir stehen: Odoo setzen wir seit Kurzem + selbst als ERP ein. Als Odoo-Partner bieten wir dir Schritt für Schritt an, was sich bei + uns bewährt – heute die Einführung von Projektmanagement und Zeiterfassung, ohne + Lizenz-Lock-in. +order: 4 +published: true +group: services +tags: [Odoo, 'ERP-Einführung', Open Source] +--- + +## Odoo – das offene ERP + +Unser jüngster Bereich – und einer, hinter dem wir stehen: Odoo setzen wir seit Kurzem selbst als ERP ein. Als Odoo-Partner bieten wir dir Schritt für Schritt an, was sich bei uns bewährt – heute die Einführung von Projektmanagement und Zeiterfassung, ohne Lizenz-Lock-in. diff --git a/database/files/services/en_CH/dms-ecm-consulting.md b/database/files/services/en_CH/dms-ecm-consulting.md new file mode 100644 index 0000000..47e8f43 --- /dev/null +++ b/database/files/services/en_CH/dms-ecm-consulting.md @@ -0,0 +1,23 @@ +--- +key: dms-ecm-consulting +name: 'DMS/ECM' +teaser: >- + From paper to a paperless office – and on to automation: as a DocuWare Silver & Cloud + Partner, we digitise your documents and turn processes into workflows. From one-person + businesses to corporations with more than 200 users. +--- + +## Document management that works in everyday business + +Contracts, invoices, HR files: documents are the backbone of many business processes. We advise you independently of any vendor and support you from the first analysis through to the day-to-day operation of your DMS/ECM. + +### What we offer + +- **Analysis & consulting** — We map your document processes and show where digitalisation delivers the greatest value. +- **Concept design** — Filing structure, indexing, permissions and retention, thought through properly. +- **Implementation** — Rollout and configuration of your DMS — including DocuWare, where we bring years of experience. +- **Integration & automation** — Connections to ERP, CRM and line-of-business applications, plus automated document workflows up to AI-assisted processing. + +### After go-live + +We don't leave you alone with the system: training, support and the continuous improvement of your document processes are part of the package. diff --git a/database/files/services/en_CH/individuelle-softwareentwicklung.md b/database/files/services/en_CH/individuelle-softwareentwicklung.md new file mode 100644 index 0000000..bad09fe --- /dev/null +++ b/database/files/services/en_CH/individuelle-softwareentwicklung.md @@ -0,0 +1,26 @@ +--- +key: individuelle-softwareentwicklung +name: 'Individual software development' +teaser: >- + Portals, interfaces, automation: software that quietly does its job in the background. We + build with open technologies such as Laravel – and look after your solution long after + go-live. +--- + +## Software that fits your processes + +Off-the-shelf software ends where your business is unique. We build individual solutions that adapt to your processes — not the other way around. + +### What we build + +- **Portal solutions** — Customer, partner and employee portals with clear, intuitive user journeys. +- **Interfaces & APIs** — Clean, documented interfaces that connect systems reliably. +- **Integrations** — We connect existing systems such as ERP, CRM or DMS into seamless end-to-end processes. + +### A focus on open source + +We rely on open technologies and standards. That means no licence lock-in, an active community behind the tools we use, and software that belongs to you and that you can keep evolving — with us or without us. + +### Built to last + +Automated tests, code reviews and continuous deployments are part of our craft. That keeps your solution maintainable and extensible, even years down the road. diff --git a/database/files/services/en_CH/konzeption-prototyping.md b/database/files/services/en_CH/konzeption-prototyping.md new file mode 100644 index 0000000..5f897fc --- /dev/null +++ b/database/files/services/en_CH/konzeption-prototyping.md @@ -0,0 +1,24 @@ +--- +key: konzeption-prototyping +name: 'Concept design & prototyping' +teaser: >- + We put your idea on paper – visually. Mockups and clickable prototypes create a shared + picture early on, before a single line of code is written. In the end, you hold a technical + concept in your hands, including the technology stack – as a basis for implementation, by + us or by others. +--- + +## From idea to tangible concept + +It starts with listening: we want to understand what drives your project, who will use it and what it needs to deliver. From there, we develop a concept together that puts future users at the centre — and bring it to life as a clickable prototype before a single line of production code is written. + +### How we work + +- **Understand** — In workshops and conversations, we clarify goals, users and constraints. +- **Design** — We work out information architecture, workflows and data model, documented in plain language. +- **Prototype** — Clickable prototypes make ideas tangible and easy to discuss early on. +- **Validate** — Feedback from real users flows straight into the next iteration. + +### Why it pays off + +A prototype shows early whether an idea holds up. Decisions are made on something tangible instead of on paper — reducing risk and saving time and cost during implementation. And when the project moves ahead, the concept forms a solid foundation for development. diff --git a/database/files/services/en_CH/open-source-erp.md b/database/files/services/en_CH/open-source-erp.md new file mode 100644 index 0000000..2e7731b --- /dev/null +++ b/database/files/services/en_CH/open-source-erp.md @@ -0,0 +1,12 @@ +--- +key: open-source-erp +name: 'Open-source ERP' +teaser: >- + Our newest area – and one we stand behind: we recently started running Odoo as our own + ERP. As an Odoo partner, we offer you step by step what proves itself in our daily work – + today, the rollout of project management and time tracking, free of licence lock-in. +--- + +## Odoo – the open ERP + +Our newest area – and one we stand behind: we recently started running Odoo as our own ERP. As an Odoo partner, we offer you step by step what proves itself in our daily work – today, the rollout of project management and time tracking, free of licence lock-in. diff --git a/database/files/team/alexander-christoph-boll.yaml b/database/files/team/alexander-christoph-boll.yaml new file mode 100644 index 0000000..78d6c6a --- /dev/null +++ b/database/files/team/alexander-christoph-boll.yaml @@ -0,0 +1,13 @@ +key: alexander-christoph-boll +name: 'Alexander Christoph Boll' +published: true +sort: 4 +image: 'https://res.cloudinary.com/codebar/image/upload/e_background_removal/c_thumb,g_face,z_0.7,w_500,h_500,b_rgb:E4E6E9/f_auto,q_auto/www-codebar-ch/team/Alex_v3.webp' +sections: + employees: + role: + de_CH: 'Innovationsmanagement & Projektleitung' + en_CH: 'Innovation Management & Project Lead' +icons: + email: alexander.boll@codebar.ch + linkedin: 'https://www.linkedin.com/in/alexanderboll' diff --git a/database/files/team/julian-leipert.yaml b/database/files/team/julian-leipert.yaml new file mode 100644 index 0000000..7f6f3f2 --- /dev/null +++ b/database/files/team/julian-leipert.yaml @@ -0,0 +1,12 @@ +key: julian-leipert +name: 'Julian Leipert' +published: true +sort: 7 +image: 'https://res.cloudinary.com/codebar/image/upload/e_background_removal/c_thumb,g_face,z_0.7,w_500,h_500,b_rgb:E4E6E9/f_auto,q_auto/www-codebar-ch/team/Julian_V3.webp' +sections: + employees: + role: + de_CH: Applikationsentwickler + en_CH: 'Application Developer' +icons: + email: julian.leipert@codebar.ch diff --git a/database/files/team/melanie-buergin-fix.yaml b/database/files/team/melanie-buergin-fix.yaml new file mode 100644 index 0000000..9d735b0 --- /dev/null +++ b/database/files/team/melanie-buergin-fix.yaml @@ -0,0 +1,10 @@ +key: melanie-buergin-fix +name: 'Melanie Bürgin-Fix' +published: true +sort: 2 +image: 'https://res.cloudinary.com/codebar/image/upload/e_background_removal/c_thumb,g_face,z_0.7,w_500,h_500,b_rgb:E4E6E9/f_auto,q_auto/www-codebar-ch/team/Melanive_v3.webp' +sections: + board_members: {} +icons: + email: melanie.buergin@codebar.ch + linkedin: 'https://www.linkedin.com/in/melanie-buergin' diff --git a/database/files/team/sebastian-buergin-fix.yaml b/database/files/team/sebastian-buergin-fix.yaml new file mode 100644 index 0000000..62d626c --- /dev/null +++ b/database/files/team/sebastian-buergin-fix.yaml @@ -0,0 +1,14 @@ +key: sebastian-buergin-fix +name: 'Sebastian Bürgin-Fix' +published: true +sort: 1 +image: 'https://res.cloudinary.com/codebar/image/upload/e_background_removal/c_thumb,g_face,z_0.7,w_500,h_500,b_rgb:E4E6E9/f_auto,q_auto/www-codebar-ch/team/Sebastian_V3.webp' +sections: + employees: + role: + de_CH: 'Gründer & Software-Architekt' + en_CH: 'Founder & Software Architect' + board_members: {} +icons: + email: sebastian.buergin@codebar.ch + linkedin: 'https://www.linkedin.com/in/sebastian-buergin/' diff --git a/database/files/team/tobias-brogle.yaml b/database/files/team/tobias-brogle.yaml new file mode 100644 index 0000000..9f44dda --- /dev/null +++ b/database/files/team/tobias-brogle.yaml @@ -0,0 +1,13 @@ +key: tobias-brogle +name: 'Tobias Brogle' +published: true +sort: 3 +image: 'https://res.cloudinary.com/codebar/image/upload/e_background_removal/c_thumb,g_face,z_0.7,w_500,h_500,b_rgb:E4E6E9/f_auto,q_auto/www-codebar-ch/team/Tobias_v3.webp' +sections: + employees: + role: + de_CH: Applikationsentwickler + en_CH: 'Application Developer' +icons: + email: tobias.brogle@codebar.ch + linkedin: 'https://www.linkedin.com/in/tobias-brogle-21010b315' diff --git a/database/files/technologies/de_CH/laravel-framework.md b/database/files/technologies/de_CH/laravel-framework.md new file mode 100644 index 0000000..3ec883f --- /dev/null +++ b/database/files/technologies/de_CH/laravel-framework.md @@ -0,0 +1,10 @@ +--- +key: laravel-framework +title: Laravel +teaser: '' +group: Backend +order: 1 +tags: [PHP] +link: 'https://laravel.com/' +published: true +--- diff --git a/database/files/technologies/en_CH/laravel-framework.md b/database/files/technologies/en_CH/laravel-framework.md new file mode 100644 index 0000000..3ec883f --- /dev/null +++ b/database/files/technologies/en_CH/laravel-framework.md @@ -0,0 +1,10 @@ +--- +key: laravel-framework +title: Laravel +teaser: '' +group: Backend +order: 1 +tags: [PHP] +link: 'https://laravel.com/' +published: true +--- diff --git a/database/migrations/2025_04_04_181258_create_news_table.php b/database/migrations/2025_04_04_181258_create_news_table.php deleted file mode 100644 index af86164..0000000 --- a/database/migrations/2025_04_04_181258_create_news_table.php +++ /dev/null @@ -1,35 +0,0 @@ -<?php - -use Illuminate\Database\Migrations\Migration; -use Illuminate\Database\Schema\Blueprint; -use Illuminate\Support\Facades\Schema; - -return new class extends Migration -{ - /** - * Run the migrations. - */ - public function up(): void - { - Schema::create('news', function (Blueprint $table) { - $table->id(); - $table->json('title'); - $table->string('slug')->unique(); - $table->json('teaser'); - $table->json('content')->nullable(); - $table->string('image'); - $table->dateTime('published_at')->nullable(); - $table->string('author')->nullable(); - $table->json('tags')->nullable(); - $table->timestamps(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('news'); - } -}; diff --git a/database/migrations/2025_06_23_130054_create_contacts_table.php b/database/migrations/2025_06_23_130054_create_contacts_table.php index 8f95a85..30a6373 100644 --- a/database/migrations/2025_06_23_130054_create_contacts_table.php +++ b/database/migrations/2025_06_23_130054_create_contacts_table.php @@ -13,7 +13,9 @@ public function up(): void { Schema::create('contacts', function (Blueprint $table) { $table->id(); + $table->string('key')->unique(); $table->boolean('published')->default(false); + $table->unsignedSmallInteger('sort')->default(0); $table->string('name'); $table->json('sections')->nullable(); $table->string('image'); diff --git a/database/migrations/2026_07_28_150000_create_news_series_table.php b/database/migrations/2026_07_28_150000_create_news_series_table.php new file mode 100644 index 0000000..e310692 --- /dev/null +++ b/database/migrations/2026_07_28_150000_create_news_series_table.php @@ -0,0 +1,26 @@ +<?php + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::create('news_series', function (Blueprint $table) { + $table->id(); + $table->string('key')->unique(); + $table->json('title'); + $table->json('slug'); + $table->json('description')->nullable(); + $table->boolean('published')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('news_series'); + } +}; diff --git a/database/migrations/2026_07_28_150010_create_news_table.php b/database/migrations/2026_07_28_150010_create_news_table.php new file mode 100644 index 0000000..e07d2d1 --- /dev/null +++ b/database/migrations/2026_07_28_150010_create_news_table.php @@ -0,0 +1,47 @@ +<?php + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::create('news', function (Blueprint $table) { + $table->id(); + // Cross-language anchor: the de_CH and en_CH markdown files of one article share it. + $table->string('key')->unique(); + $table->json('slug'); + $table->json('title'); + $table->json('teaser'); + $table->json('content')->nullable(); + $table->string('hero_image')->nullable(); + $table->json('hero_caption')->nullable(); + $table->json('hero_alt')->nullable(); + $table->dateTime('published_at')->nullable(); + $table->boolean('published')->default(true); + $table->string('author')->nullable(); + $table->foreignId('contact_id')->nullable()->constrained('contacts')->nullOnDelete(); + $table->foreignId('series_id')->nullable()->constrained('news_series')->nullOnDelete(); + $table->unsignedSmallInteger('series_position')->nullable(); + $table->boolean('featured')->default(false); + $table->unsignedSmallInteger('reading_minutes')->nullable(); + $table->json('tags')->nullable(); + $table->timestamps(); + }); + + // A JSON column cannot carry a plain unique constraint — index the extracted + // per-locale value instead, so each language keeps its own unique slug. + foreach (['de_CH', 'en_CH'] as $locale) { + $index = 'news_slug_'.strtolower(str_replace('_', '', $locale)).'_unique'; + DB::statement("CREATE UNIQUE INDEX {$index} ON news (( slug->>'{$locale}' ))"); + } + } + + public function down(): void + { + Schema::dropIfExists('news'); + } +}; diff --git a/database/migrations/2026_07_28_150020_create_news_tags_table.php b/database/migrations/2026_07_28_150020_create_news_tags_table.php new file mode 100644 index 0000000..c6235c8 --- /dev/null +++ b/database/migrations/2026_07_28_150020_create_news_tags_table.php @@ -0,0 +1,25 @@ +<?php + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::create('news_tags', function (Blueprint $table) { + $table->id(); + $table->string('key')->unique(); + $table->json('title'); + $table->json('slug'); + $table->json('description')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('news_tags'); + } +}; diff --git a/database/migrations/2026_07_28_150030_create_news_news_tag_table.php b/database/migrations/2026_07_28_150030_create_news_news_tag_table.php new file mode 100644 index 0000000..b3b10e1 --- /dev/null +++ b/database/migrations/2026_07_28_150030_create_news_news_tag_table.php @@ -0,0 +1,23 @@ +<?php + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::create('news_news_tag', function (Blueprint $table) { + $table->id(); + $table->foreignId('news_id')->constrained('news')->cascadeOnDelete(); + $table->foreignId('news_tag_id')->constrained('news_tags')->cascadeOnDelete(); + $table->unique(['news_id', 'news_tag_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('news_news_tag'); + } +}; diff --git a/database/migrations/2026_07_28_150040_create_news_relations_table.php b/database/migrations/2026_07_28_150040_create_news_relations_table.php new file mode 100644 index 0000000..3761999 --- /dev/null +++ b/database/migrations/2026_07_28_150040_create_news_relations_table.php @@ -0,0 +1,24 @@ +<?php + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::create('news_relations', function (Blueprint $table) { + $table->id(); + $table->foreignId('news_id')->constrained('news')->cascadeOnDelete(); + $table->foreignId('related_news_id')->constrained('news')->cascadeOnDelete(); + $table->unsignedSmallInteger('sort')->default(0); + $table->unique(['news_id', 'related_news_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('news_relations'); + } +}; diff --git a/database/seeders/AiModelsTableSeeder.php b/database/seeders/AiModelsTableSeeder.php index f86f539..b0bef7a 100644 --- a/database/seeders/AiModelsTableSeeder.php +++ b/database/seeders/AiModelsTableSeeder.php @@ -2,45 +2,18 @@ namespace Database\Seeders; -use App\Enums\AiModelCategoryEnum; -use App\Models\AiModel; -use Database\Seeders\Concerns\ReadsCsv; use Illuminate\Database\Seeder; +use Illuminate\Support\Facades\Artisan; +/** + * The AI model catalogue lives as one YAML file per model under + * database/files/ai_models/. Adding or changing one means editing that file and + * running `php artisan ai-models:import`; no code change is needed. + */ class AiModelsTableSeeder extends Seeder { - use ReadsCsv; - public function run(): void { - $rows = collect($this->readCsv('ai_models.csv')); - - $models = $rows->map(function (array $row) { - return AiModel::updateOrCreate( - ['name' => $row['name']], - [ - 'category' => AiModelCategoryEnum::from($row['category']), - 'order' => (int) $row['order'], - 'provider' => $row['provider'] !== '' ? $row['provider'] : null, - 'ram' => $row['ram'] !== '' ? $row['ram'] : null, - 'license' => $row['license'] !== '' ? $row['license'] : null, - 'role' => $this->decodeJson($row['role']), - 'link_label' => $row['link_label'] !== '' ? $row['link_label'] : null, - 'link_url' => $row['link_url'] !== '' ? $row['link_url'] : null, - 'archived_at' => $row['archived_at'] !== '' ? $row['archived_at'] : null, - ] - ); - }); - - $idsByCsvId = $rows->pluck('id')->combine($models->pluck('id')); - - $rows->each(function (array $row) use ($idsByCsvId, $models) { - if ($row['replaced_by_id'] === '') { - return; - } - - $models->firstWhere('id', $idsByCsvId->get($row['id'])) - ?->update(['replaced_by_id' => $idsByCsvId->get($row['replaced_by_id'])]); - }); + Artisan::call('ai-models:import', [], $this->command->getOutput()); } } diff --git a/database/seeders/ContactsTableSeeder.php b/database/seeders/ContactsTableSeeder.php index c6b8c50..301c7d0 100644 --- a/database/seeders/ContactsTableSeeder.php +++ b/database/seeders/ContactsTableSeeder.php @@ -2,27 +2,21 @@ namespace Database\Seeders; -use App\Models\Contact; -use Database\Seeders\Concerns\ReadsCsv; use Illuminate\Database\Seeder; +use Illuminate\Support\Facades\Artisan; +/** + * Team members live as one YAML file per person under database/files/team/. + * Adding or changing someone means editing that file and running + * `php artisan team:import`; no code change is needed. + * + * This replaced a semicolon-separated CSV that carried JSON inside its cells — + * unreadable in a diff and effectively uneditable by hand. + */ class ContactsTableSeeder extends Seeder { - use ReadsCsv; - public function run(): void { - foreach ($this->readCsv('contacts.csv') as $row) { - Contact::updateOrCreate( - ['id' => $row['id']], - [ - 'name' => $row['name'], - 'published' => filter_var($row['published'], FILTER_VALIDATE_BOOLEAN), - 'sections' => $this->decodeJson($row['sections']), - 'icons' => $this->decodeJson($row['icons']), - 'image' => $row['image'], - ] - ); - } + Artisan::call('team:import', [], $this->command->getOutput()); } } diff --git a/database/seeders/NetworkUsersTableSeeder.php b/database/seeders/NetworkUsersTableSeeder.php index 975320c..f74a3fb 100644 --- a/database/seeders/NetworkUsersTableSeeder.php +++ b/database/seeders/NetworkUsersTableSeeder.php @@ -3,92 +3,42 @@ namespace Database\Seeders; use App\Models\NetworkUser; +use Database\Seeders\Concerns\ReadsCsv; use Illuminate\Database\Seeder; +/** + * Contact channels are real where known, otherwise placeholder (*@example.com). + * All rows seed as unpublished — a contact only appears on the network page once + * explicitly published. + * + * Sourced from network_users.csv — the current data export — not hand-authored, + * so this stays CSV rather than moving to per-item files like networks/services/news. + */ class NetworkUsersTableSeeder extends Seeder { - /** - * Run the database seeds. - * - * Contact channels are real where known, otherwise placeholder - * - * (*@example.com). All rows seed as unpublished — a contact only - * appears on the network page once explicitly published. - */ + use ReadsCsv; + public function run(): void { - $this->seed( - networkKey: 'wieland-business-solutions', - name: 'Dario Wieland', - sort: 10, - email: 'dario.wieland@business-solutions.gmbh', - ); - - $this->seed( - networkKey: 'pst', - name: 'Sarah Fässler', - sort: 10, - email: 'sarah.faessler@pstgmbh.ch', - ); - - $this->seed( - networkKey: 'docuware', - name: 'Vincenzo Carbone', - sort: 10, - role: 'DocuWare Schweiz', - email: 'vincenzo.carbone@docuware.com', - avatarUrl: '/images/placeholders/avatar-sample.svg', - ); - - $this->seed( - networkKey: 'odoo', - name: 'Domenik Friedrich', - sort: 10, - email: 'domf@odoo.com', - ); - - $this->seed( - networkKey: 'baselhack', - name: 'BaselHack', - sort: 10, - email: 'info@baselhack.ch', - ); - - $this->seed( - networkKey: 'iway', - name: 'Patrick Baumeler', - sort: 10, - email: 'patrick.baumeler@example.com', - linkedin: 'https://www.linkedin.com/in/example-patrick', - phone: '+41 61 000 00 03', - ); - } - - private function seed( - string $networkKey, - string $name, - int $sort, - ?string $role = null, - ?string $email = null, - ?string $linkedin = null, - ?string $phone = null, - ?string $avatarUrl = null, - ): void { - NetworkUser::updateOrCreate( - [ - 'network_key' => $networkKey, - 'name' => $name, - ], - [ - 'role' => $role, - 'email' => $email, - 'public_email' => $email, - 'linkedin' => $linkedin, - 'phone' => $phone, - 'avatar_url' => $avatarUrl, - 'published' => false, - 'sort' => $sort, - ] - ); + collect($this->readCsv('network_users.csv'))->each(function (array $row): void { + NetworkUser::updateOrCreate( + [ + 'network_key' => $row['network_key'], + 'name' => $row['name'], + ], + [ + 'role' => $row['role'] !== '' ? $row['role'] : null, + 'avatar_disk' => $row['avatar_disk'] !== '' ? $row['avatar_disk'] : null, + 'avatar_path' => $row['avatar_path'] !== '' ? $row['avatar_path'] : null, + 'avatar_url' => $row['avatar_url'] !== '' ? $row['avatar_url'] : null, + 'email' => $row['email'] !== '' ? $row['email'] : null, + 'public_email' => $row['public_email'] !== '' ? $row['public_email'] : null, + 'linkedin' => $row['linkedin'] !== '' ? $row['linkedin'] : null, + 'phone' => $row['phone'] !== '' ? $row['phone'] : null, + 'published' => filter_var($row['published'], FILTER_VALIDATE_BOOLEAN), + 'sort' => (int) $row['sort'], + ] + ); + }); } } diff --git a/database/seeders/NetworksTableSeeder.php b/database/seeders/NetworksTableSeeder.php index a612a8e..9f008e2 100644 --- a/database/seeders/NetworksTableSeeder.php +++ b/database/seeders/NetworksTableSeeder.php @@ -2,157 +2,18 @@ namespace Database\Seeders; -use App\Enums\NetworkCategoryEnum; -use App\Enums\NetworkStatusEnum; -use App\Models\Network; use Illuminate\Database\Seeder; +use Illuminate\Support\Facades\Artisan; +/** + * Network partners live as one YAML file per company under database/files/networks/. + * Adding or changing one means editing that file and running + * `php artisan networks:import`; no code change is needed. + */ class NetworksTableSeeder extends Seeder { - /** - * Run the database seeds. - */ public function run(): void { - $this->seed( - key: 'wieland-business-solutions', - category: NetworkCategoryEnum::COLLABORATION, - sort: 10, - coverUrl: 'https://res.cloudinary.com/codebar/image/upload/w_900,h_300,c_fill,f_auto,q_auto/www-codebar-ch/network/wieland-3x1-black.svg', - website: 'https://www.business-solutions.gmbh', - localizedData: [ - 'de_CH' => ['name' => 'Wieland Business Solutions AG', 'excerpt' => 'Sparringspartner DMS/ECM'], - 'en_CH' => ['name' => 'Wieland Business Solutions AG', 'excerpt' => 'DMS/ECM sparring partner'], - ], - ); - - $this->seed( - key: 'pst', - category: NetworkCategoryEnum::COLLABORATION, - sort: 20, - coverUrl: 'https://res.cloudinary.com/codebar/image/upload/w_900,h_300,c_fill,f_auto,q_auto/www-codebar-ch/network/pst-3x1-black.svg', - website: 'https://www.pstgmbh.ch', - localizedData: [ - 'de_CH' => ['name' => 'PST GmbH', 'excerpt' => 'Finanzen & Buchhaltung'], - 'en_CH' => ['name' => 'PST GmbH', 'excerpt' => 'Finance & accounting'], - ], - ); - - $this->seed( - key: 'docuware', - category: NetworkCategoryEnum::SOFTWARE, - sort: 30, - coverUrl: 'https://res.cloudinary.com/codebar/image/upload/w_900,h_300,c_fill,f_auto,q_auto/www-codebar-ch/network/docuware-3x1-black.svg', - website: 'https://start.docuware.com', - localizedData: [ - 'de_CH' => ['name' => 'DocuWare', 'excerpt' => 'DMS/ECM'], - 'en_CH' => ['name' => 'DocuWare', 'excerpt' => 'DMS/ECM'], - ], - ); - - $this->seed( - key: 'odoo', - category: NetworkCategoryEnum::SOFTWARE, - sort: 40, - coverUrl: 'https://res.cloudinary.com/codebar/image/upload/w_900,h_300,c_fill,f_auto,q_auto/www-codebar-ch/network/odoo-3x1-black.svg', - website: 'https://www.odoo.com', - localizedData: [ - 'de_CH' => ['name' => 'Odoo', 'excerpt' => 'Open Source ERP'], - 'en_CH' => ['name' => 'Odoo', 'excerpt' => 'Open-source ERP'], - ], - ); - - $this->seed( - key: 'iway', - category: NetworkCategoryEnum::INFRASTRUCTURE, - sort: 50, - coverUrl: 'https://res.cloudinary.com/codebar/image/upload/w_900,h_300,c_fill,f_auto,q_auto/www-codebar-ch/network/iway-3x1-black.svg', - website: 'https://www.iway.ch', - localizedData: [ - 'de_CH' => ['name' => 'iWay', 'excerpt' => 'Internet & Telefonie'], - 'en_CH' => ['name' => 'iWay', 'excerpt' => 'Internet & telephony'], - ], - ); - - $this->seed( - key: 'baselhack', - category: NetworkCategoryEnum::SPONSORING, - sort: 60, - coverUrl: 'https://res.cloudinary.com/codebar/image/upload/w_900,h_300,c_fill,f_auto,q_auto/www-codebar-ch/network/baselhack-3x1-black.svg', - website: 'https://www.baselhack.ch', - localizedData: [ - 'de_CH' => ['name' => 'BaselHack', 'tier_label' => 'Silver Sponsor', 'excerpt' => 'Hackathon Nordwestschweiz'], - 'en_CH' => ['name' => 'BaselHack', 'tier_label' => 'Silver Sponsor', 'excerpt' => 'Hackathon in the Basel region'], - ], - ); - - $this->seed( - key: 'swiss-laravel-association', - category: NetworkCategoryEnum::SPONSORING, - sort: 70, - website: 'https://laravel.swiss', - localizedData: [ - 'de_CH' => ['name' => 'Swiss Laravel Association', 'excerpt' => 'Laravel-Community'], - 'en_CH' => ['name' => 'Swiss Laravel Association', 'excerpt' => 'Laravel community'], - ], - ); - - $this->seed( - key: 'swiss-made-software', - category: NetworkCategoryEnum::CERTIFICATION, - sort: 80, - coverUrl: 'https://res.cloudinary.com/codebar/image/upload/w_900,h_300,c_fill,f_auto,q_auto/www-codebar-ch/network/swiss-made-software-3x1-black.svg', - website: 'https://www.swissmadesoftware.org/en/about/swiss-made-software.html', - localizedData: [ - 'de_CH' => ['name' => 'Swiss Made Software'], - 'en_CH' => ['name' => 'Swiss Made Software'], - ], - ); - - $this->seed( - key: 'swiss-digital-services', - category: NetworkCategoryEnum::CERTIFICATION, - sort: 90, - coverUrl: 'https://res.cloudinary.com/codebar/image/upload/w_900,h_300,c_fill,f_auto,q_auto/www-codebar-ch/network/swiss-digital-services-3x1-black.svg', - website: 'https://www.swissmadesoftware.org/en/about/swiss-digital-services.html', - localizedData: [ - 'de_CH' => ['name' => 'Swiss Digital Services'], - 'en_CH' => ['name' => 'Swiss Digital Services'], - ], - ); - } - - /** - * @param array<string, array<string, string|null>> $localizedData - */ - private function seed( - string $key, - NetworkCategoryEnum $category, - int $sort, - array $localizedData, - ?string $coverUrl = null, - ?string $website = null, - ?string $pageSlug = null, - NetworkStatusEnum $status = NetworkStatusEnum::ACTIVE, - ?int $sinceYear = null, - ?int $untilYear = null, - ): void { - Network::updateOrCreate( - ['key' => $key], - [ - 'name' => collect($localizedData)->map(fn (array $data) => $data['name'])->all(), - 'category' => $category->value, - 'status' => $status->value, - 'cover_url' => collect($localizedData)->first()['cover_url'] ?? $coverUrl, - 'tier_label' => collect($localizedData)->map(fn (array $data) => $data['tier_label'] ?? null)->all(), - 'excerpt' => collect($localizedData)->map(fn (array $data) => $data['excerpt'] ?? null)->all(), - 'website' => $website, - 'since_year' => $sinceYear, - 'until_year' => $untilYear, - 'page_slug' => $pageSlug, - 'published' => true, - 'sort' => $sort, - ] - ); + Artisan::call('networks:import', [], $this->command->getOutput()); } } diff --git a/database/seeders/NewsTableSeeder.php b/database/seeders/NewsTableSeeder.php index 09ed62f..945b787 100644 --- a/database/seeders/NewsTableSeeder.php +++ b/database/seeders/NewsTableSeeder.php @@ -2,62 +2,23 @@ namespace Database\Seeders; -use App\Models\News; use Illuminate\Database\Seeder; -use Illuminate\Support\Arr; -use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Artisan; +/** + * Articles live as markdown files with YAML front matter under + * database/files/news/{locale}/. Adding one means adding two files — one per language — + * and running `php artisan news:import`; no code change is needed any more. + * + * Only articles that genuinely exist in both languages are imported. A pair where one + * side is missing is reported and skipped: publishing it would produce an hreflang pair + * joining two different articles, which is exactly the wrong-language signal that costs + * indexing across a whole domain. + */ class NewsTableSeeder extends Seeder { - /** - * Only articles that genuinely exist in both languages belong here. - * - * This seeder previously defined two entries that both read the same - * markdown file, and one of them paired a German title with an unrelated - * English one. Published, that would have produced two URLs with identical - * bodies plus an hreflang pair joining two different articles — exactly the - * duplicate-content and wrong-language signals that cost indexing across a - * whole domain. Add the second article back once its text has been written. - */ public function run(): void { - $this->seed( - slug: 'docuware-7-12-is-here', - publishedAt: Carbon::parse('2025-04-06'), - author: 'Sebastian Bürgin-Fix', - localizedData: [ - 'de_CH' => [ - 'title' => 'DocuWare 7.12 ist da', - 'teaser' => 'Mehr Automatisierung, mehr Einblick, mehr Effizienz: Das Release verbessert die E-Rechnungsverarbeitung, bringt IDP in die Cloud-Konfiguration und öffnet Workflow-Daten für Analytics.', - 'content' => file_get_contents(database_path('files/news/de_CH/20250406_docuware_712.md')), - ], - 'en_CH' => [ - 'title' => 'DocuWare 7.12 is here', - 'teaser' => 'More automation, more insight, more efficiency: this release improves e-invoice processing, brings IDP into the cloud configuration and opens workflow data up to analytics.', - 'content' => file_get_contents(database_path('files/news/en_CH/20250406_docuware_712.md')), - ], - ], - tags: ['DMS/ECM', 'DocuWare'], - ); - } - - /** - * @param array<string, array<string, mixed>> $localizedData - * @param array<int, string> $tags - */ - private function seed(string $slug, Carbon $publishedAt, string $author, array $localizedData, array $tags = []): void - { - News::updateOrCreate( - ['slug' => $slug], - [ - 'author' => $author, - 'published_at' => $publishedAt, - 'title' => collect($localizedData)->map(fn (array $data) => Arr::get($data, 'title'))->all(), - 'teaser' => collect($localizedData)->map(fn (array $data) => Arr::get($data, 'teaser'))->all(), - 'content' => collect($localizedData)->map(fn (array $data) => Arr::get($data, 'content'))->all(), - 'image' => Arr::get(collect($localizedData)->first() ?? [], 'image', ''), - 'tags' => $tags, - ] - ); + Artisan::call('news:import', [], $this->command->getOutput()); } } diff --git a/database/seeders/PagesTableSeeder.php b/database/seeders/PagesTableSeeder.php index 13a8e95..81a34cd 100644 --- a/database/seeders/PagesTableSeeder.php +++ b/database/seeders/PagesTableSeeder.php @@ -2,131 +2,21 @@ namespace Database\Seeders; -use App\Enums\LocaleEnum; -use App\Models\Page; -use Database\Seeders\Concerns\ReadsCsv; use Illuminate\Database\Seeder; - +use Illuminate\Support\Facades\Artisan; + +/** + * Page SEO metadata lives as one YAML file per page under database/files/pages/. + * Adding or changing a page means editing that file and running + * `php artisan pages:import`; no code change is needed. + * + * This replaced a CSV that carried JSON inside its cells, plus a second, + * duplicate list of "upcoming" pages hardcoded here — both unreadable in a diff. + */ class PagesTableSeeder extends Seeder { - use ReadsCsv; - - /** - * Run the database seeds. - */ public function run(): void { - $this->live(); - $this->upcoming(); - } - - /** - * Pages already published in production — sourced from the current data export. - */ - private function live(): void - { - collect($this->readCsv('pages.csv')) - ->groupBy('key') - ->each(function ($rows, string $key) { - $byLocale = $rows->keyBy('locale'); - $first = $rows->first() ?? []; - - Page::updateOrCreate( - ['key' => $key], - [ - 'robots' => $first['robots'] ?? '', - 'title' => $byLocale->map(fn (array $row) => $row['title'])->all(), - 'description' => $byLocale->map(fn (array $row) => $row['description'])->all(), - 'created_at' => $first['created_at'] ?? now(), - 'updated_at' => $first['updated_at'] ?? now(), - ] - ); - }); - } - - /** - * Pages for sections not yet shipped to production — no export to seed from yet. - */ - private function upcoming(): void - { - $pages = [ - 'technologies.index' => [ - 'robots' => 'index,follow', - 'de_CH' => ['title' => 'Offene Technologien & Standards – codebar Solutions AG', 'description' => 'Die offenen Technologien und Standards, auf denen unsere Software basiert – bewährt, transparent und herstellerunabhängig.'], - 'en_CH' => ['title' => 'Open Technologies & Standards – codebar Solutions AG', 'description' => 'The open technologies and standards we build our software on – proven, transparent and vendor-independent.'], - ], - 'co-working.index' => [ - 'robots' => 'index,follow', - 'de_CH' => ['title' => 'Co-Working bei codebar Solutions AG', 'description' => 'Ein professioneller Arbeitsplatz in Oberwil — ruhig, gut ausgestattet und Teil eines Tech-Teams.'], - 'en_CH' => ['title' => 'Co-Working at codebar Solutions AG', 'description' => 'A professional workspace in Oberwil — quiet, well-equipped, and embedded in a tech team.'], - ], - 'open-source.index' => [ - 'robots' => 'index,follow', - 'de_CH' => ['title' => 'Open-Source-Beiträge – codebar Solutions AG', 'description' => 'Unsere Beiträge an die Open-Source-Community – Packages, Tools und Libraries, entwickelt und gepflegt von codebar.'], - 'en_CH' => ['title' => 'Open Source Contributions – codebar Solutions AG', 'description' => 'Our contributions to the open source community – packages, tools and libraries developed and maintained by codebar.'], - ], - 'jobs.index' => [ - 'robots' => 'index,follow', - 'de_CH' => ['title' => 'Stellen – arbeiten bei codebar Solutions AG', 'description' => 'Klein aus Überzeugung: Verantwortung ab Tag eins, offene Technologien und Praktika mitten im Projektalltag. So arbeitest du bei codebar.'], - 'en_CH' => ['title' => 'Jobs – Working at codebar Solutions AG', 'description' => 'Small by conviction: responsibility from day one, open technologies and internships inside real projects. This is what working at codebar is like.'], - ], - 'legal.terms.index' => [ - 'robots' => 'index,follow', - 'de_CH' => ['title' => 'AGB – codebar Solutions AG', 'description' => 'Die Allgemeinen Geschäftsbedingungen der codebar Solutions AG: Angebote, Projektabwicklung, Nutzungsrechte, Haftung und Support.'], - 'en_CH' => ['title' => 'Terms & Conditions – codebar Solutions AG', 'description' => 'The general terms and conditions of codebar Solutions AG: offers, project delivery, rights of use, liability and support.'], - ], - 'legal.privacy.index' => [ - 'robots' => 'index,follow', - 'de_CH' => ['title' => 'Datenschutz – codebar Solutions AG', 'description' => 'Wie wir Personendaten auf codebar.ch bearbeiten: welche Daten anfallen, wofür wir sie nutzen, wie lange wir sie aufbewahren – und welche Rechte du hast.'], - 'en_CH' => ['title' => 'Privacy – codebar Solutions AG', 'description' => 'How we process personal data on codebar.ch: what we collect, why, how long we keep it – and the rights you have.'], - ], - 'media.index' => [ - 'robots' => 'index,follow', - 'de_CH' => ['title' => 'Medien & Markenassets – codebar Solutions AG', 'description' => 'Offizielle codebar-Logos für Presse und Partner – farbig, invertiert, schwarz-weiss, als PNG und SVG.'], - 'en_CH' => ['title' => 'Media & Brand Assets – codebar Solutions AG', 'description' => 'Official codebar logos for press and partners – colour, inverted, black and white, as PNG and SVG.'], - ], - 'ai.index' => [ - 'robots' => 'index,follow', - 'de_CH' => ['title' => 'KI bei codebar Solutions AG – lokale Modelle, eigene Infrastruktur', 'description' => 'Transparenz statt Buzzwords: Wir zeigen offen, wie wir KI in der eigenen Arbeit einsetzen – mit lokalen Open-Source-Modellen und echten Nutzungszahlen.'], - 'en_CH' => ['title' => 'AI at codebar Solutions AG – Local Models, Own Infrastructure', 'description' => 'Transparency over buzzwords: an open look at how we use AI in our own work – with local open-source models and real usage figures.'], - ], - 'ai.llm.index' => [ - 'robots' => 'index,follow', - 'de_CH' => ['title' => 'Unsere lokalen LLMs im Einsatz – codebar Solutions AG', 'description' => 'Diese lokalen Open-Source-Modelle betreiben wir aktuell – auf eigener Hardware im hauseigenen Bürokeller. Modelle, Infrastruktur und Nutzung.'], - 'en_CH' => ['title' => 'Our Local LLMs in Action – codebar Solutions AG', 'description' => 'The local open-source models we currently run – on our own hardware, in our own office basement. Models, infrastructure and usage.'], - ], - 'ai.llm.analytics.index' => [ - 'robots' => 'index,follow', - 'de_CH' => ['title' => 'LLM-Nutzungsstatistik – codebar Solutions AG', 'description' => 'Token-Verbrauch und Anfragen unserer lokal betriebenen Modelle – transparent aufgeschlüsselt pro Monat und Modell.'], - 'en_CH' => ['title' => 'LLM Usage Analytics – codebar Solutions AG', 'description' => 'Token consumption and requests of our locally run models – transparently broken down per month and model.'], - ], - 'network.index' => [ - 'robots' => 'index,follow', - 'de_CH' => ['title' => 'Netzwerk – Partner & Community | codebar Solutions AG', 'description' => 'Mit wem wir arbeiten: Projekt-, Software- und Infrastrukturpartner, unser Engagement in der Community – und die Labels, die dahinterstehen.'], - 'en_CH' => ['title' => 'Network – Partners & Community | codebar Solutions AG', 'description' => 'Who we work with: project, software and infrastructure partners, our community engagement – and the labels behind it.'], - ], - 'network.request.index' => [ - 'robots' => 'noindex,nofollow', - 'de_CH' => ['title' => 'Netzwerk-Profil aktualisieren – codebar Solutions AG', 'description' => 'Persönlichen Link anfordern, um das eigene Profil im codebar Netzwerk zu aktualisieren.'], - 'en_CH' => ['title' => 'Update network profile – codebar Solutions AG', 'description' => 'Request a personal link to update your codebar network profile.'], - ], - ]; - - foreach ($pages as $key => $data) { - Page::updateOrCreate( - ['key' => $key], - [ - 'robots' => $data['robots'], - 'title' => [ - LocaleEnum::DE->value => $data['de_CH']['title'], - LocaleEnum::EN->value => $data['en_CH']['title'], - ], - 'description' => [ - LocaleEnum::DE->value => $data['de_CH']['description'], - LocaleEnum::EN->value => $data['en_CH']['description'], - ], - ] - ); - } + Artisan::call('pages:import', [], $this->command->getOutput()); } } diff --git a/database/seeders/ProductsTableSeeder.php b/database/seeders/ProductsTableSeeder.php index a6cba64..e487b6b 100644 --- a/database/seeders/ProductsTableSeeder.php +++ b/database/seeders/ProductsTableSeeder.php @@ -2,120 +2,18 @@ namespace Database\Seeders; -use App\Enums\LocaleEnum; -use App\Models\Product; use Illuminate\Database\Seeder; -use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Artisan; +/** + * Products live as markdown files with YAML front matter under + * database/files/products/{locale}/. Adding one means adding two files — one per + * language — and running `php artisan products:import`; no code change is needed. + */ class ProductsTableSeeder extends Seeder { public function run(): void { - $this->seed( - order: 1, - sharedSlug: 'flows', - localizedData: [ - LocaleEnum::DE->value => [ - 'name' => 'Flows', - 'headline' => 'Dokumentenprozesse, gesteuert von Agenten. Auf Infrastruktur, die du kontrollierst.', - 'teaser' => 'Flows ist eine Orchestrierungsplattform für dokumentenbasierte Prozesse — verbinde dein DMS und lass KI-Agenten Dokumente automatisch extrahieren, validieren und verarbeiten.', - 'tags' => [], - 'content' => <<<'MD' - ## Extraktionstools lesen Dokumente. Sie verstehen dein Geschäft nicht. - - Zwei Probleme halten Dokumentenprozesse manuell. - - Das erste ist der Aufwand: Dein DMS speichert Dokumente zuverlässig, aber die Arbeit drumherum – Lesen, Indexieren, Validieren, Übertragen von Daten in andere Systeme – hängt weiterhin von Personen ab, die Dateien öffnen und Daten eintippen. Das skaliert nur durch zusätzliches Personal. - - Das zweite Problem liegt tiefer: Klassische Dokumentenextraktion liefert Felder, nicht mehr. Sie versteht den Inhalt nicht und kann das Ergebnis nicht gegen deine eigenen Daten prüfen – existiert dieser Lieferant, stimmt der Betrag mit der Bestellung überein, ist das die richtige Kostenstelle? Selbst mit einem Extraktionstool muss also weiterhin eine Person jedes Ergebnis prüfen, bevor es vertrauenswürdig ist. - MD, - 'features_heading' => 'Orchestrierte Agenten-Workflows – extrahieren, verstehen, validieren, zurückschreiben.', - 'features_intro' => 'Flows verbindet sich mit deinem bestehenden DMS. Keine Migration; dein Archiv bleibt, wo es ist. Darauf konfigurierst du Workflows, die auslösen, sobald ein Dokument eintrifft.', - 'features' => [ - ['title' => 'Agentische Extraktion mit Validierung', 'description' => 'Definiere das Schema, das du brauchst. Agenten extrahieren die Daten, validieren sie gegen deine eigenen Systeme und Daten und schreiben geprüfte Ergebnisse zurück – strukturiert und passend zu deinen Feldnamen.'], - ['title' => 'Multi-Agenten-Workflow-Orchestrierung', 'description' => 'Für komplexe Prozesse verkettest du spezialisierte Agenten – Klassifizierung, Validierung, Anreicherung, Routing. Die Orchestrierung basiert auf dem Microsoft Agent Framework, und MCP-Tool-Integrationen verbinden deine Workflows direkt mit deinen Geschäftssystemen und Daten.'], - ['title' => 'Prompt- und Agenten-Engineering', 'description' => 'Verbessere Prompts anhand echter Produktionsdaten, generiere sie aus dem Wissen in deinem Datenpool oder DMS und verwalte Prompts und Schemas in einer versionierten, wiederverwendbaren Knowledge-Bibliothek.'], - ['title' => 'Modell- und Anbieterfreiheit', 'description' => 'Führe Workflows bei dem Anbieter aus, der zu deinen Anforderungen an Genauigkeit, Kosten und Datenresidenz passt – und vergleiche denselben Workflow über mehrere Modelle hinweg, um zu sehen, welches bei deinen Dokumenten am besten abschneidet. Ein Wechsel bedeutet keinen Neuaufbau. Eigene lokale Modelle (Bring-your-own) folgen als Nächstes.'], - ['title' => 'Vollständige Nachvollziehbarkeit', 'description' => 'Jeder Lauf wird lückenlos protokolliert: Dokument, Modell, Tokens, Dauer, Ergebnis.'], - ], - 'deployment_heading' => 'Drei Wege, Flows zu betreiben. Alle isoliert.', - 'deployment_intro' => 'Jede Bereitstellung erhält ihre eigene Datenbank, Schlüsselverwaltung, Speicher und Endpunkte – automatisch bereitgestellt, mit Firewalling und automatischer Rotation der Secrets. Du entscheidest, wo sie läuft, und wählst bei jeder Option die Azure-Region deiner Bereitstellung, sofern diese die benötigten Dienste unterstützt.', - 'deployment_options' => [ - ['title' => 'Auf deiner eigenen Azure-Infrastruktur', 'description' => 'Flows wird in deine bestehende Azure-Umgebung integriert. Dein Abonnement, deine Governance, deine Kontrolle.'], - ['title' => 'Dediziertes Azure-Abonnement', 'description' => 'Ein vollständig isoliertes Abonnement, das für dich betrieben wird – maximale Trennung, ohne dass du es selbst betreiben musst.'], - ['title' => 'Gemeinsames Abonnement, dedizierte Ressourcengruppe', 'description' => 'Deine eigene Ressourcengruppe und Bereitstellungen innerhalb eines gemeinsamen Abonnements – isolierte Ressourcen bei geringerem Fussabdruck.'], - ], - 'cta_heading' => 'Interessiert?', - 'cta_body' => 'Wir zeigen dir Flows anhand deiner eigenen Dokumente.', - ], - LocaleEnum::EN->value => [ - 'name' => 'Flows', - 'headline' => 'Document workflows, run by agents. On infrastructure you control.', - 'teaser' => 'Flows is an orchestration platform for document-based processes — connect your DMS and let AI agents extract, validate, and process documents automatically.', - 'tags' => [], - 'content' => <<<'MD' - ## Extraction tools read documents. They don't understand your business. - - Two problems keep document processes manual. - - The first is effort: your DMS stores documents reliably, but the work around it — reading, indexing, validating, moving data into other systems — still depends on people opening files and typing. It only scales by hiring. - - The second is deeper: conventional document extraction returns fields, nothing more. It doesn't understand the content, and it can't check the result against your own data — does this supplier exist, does the amount match the order, is this the right cost center? So even with an extraction tool, a person still has to verify every result before it can be trusted. - MD, - 'features_heading' => 'Orchestrated agent workflows — extract, understand, validate, write back.', - 'features_intro' => 'Flows connects to your existing DMS. No migration; your archive stays where it is. On top of it, you configure workflows that trigger the moment a document arrives.', - 'features' => [ - ['title' => 'Agentic extraction with validation', 'description' => 'Define the schema you need. Agents extract the data, validate it against your own systems and data, and write verified results back — structured and keyed to your field names.'], - ['title' => 'Multi-agent workflow orchestration', 'description' => 'For complex processes, chain specialized agents — classification, validation, enrichment, routing. Orchestration is built on the Microsoft Agent Framework, and MCP tool integrations connect your workflows directly to your business systems and data.'], - ['title' => 'Prompt & agent engineering', 'description' => 'Improve prompts from real production data, generate them from the knowledge in your data pool or DMS, and manage prompts and schemas in a versioned, reusable Knowledge library.'], - ['title' => 'Model and provider freedom', 'description' => "Run workflows on the provider that fits your accuracy, cost, and data-residency requirements — and benchmark the same workflow against multiple models to see which performs best on your documents. Switching doesn't mean rebuilding. Bring-your-own local models is next."], - ['title' => 'Full traceability', 'description' => 'Every run is logged end to end: document, model, tokens, duration, result.'], - ], - 'deployment_heading' => 'Three ways to run Flows. All isolated.', - 'deployment_intro' => 'Every deployment gets its own database, key management, storage, and endpoints — provisioned automatically, with firewalling and automatic secret rotation. You choose where it runs, and with each option, you decide the Azure region of your deployment, provided it supports the services the platform requires.', - 'deployment_options' => [ - ['title' => 'On your own Azure infrastructure', 'description' => 'Flows is deployed into your existing Azure environment. Your subscription, your governance, your controls.'], - ['title' => 'Dedicated Azure subscription', 'description' => 'A fully isolated subscription operated for you — maximum separation without running it yourself.'], - ['title' => 'Shared subscription, dedicated resource group', 'description' => 'Your own resource group and deployments within a shared subscription — isolated resources at a lighter footprint.'], - ], - 'cta_heading' => 'Interested?', - 'cta_body' => "We'll walk you through Flows on your own documents.", - ], - ] - ); - } - - /** - * @param array<string, array<string, mixed>> $localizedData - */ - private function seed(int $order, string $sharedSlug, array $localizedData): void - { - $translated = fn (string $field, mixed $default = null) => collect($localizedData) - ->map(fn (array $data) => Arr::get($data, $field, $default)) - ->all(); - - $first = collect($localizedData)->first() ?? []; - - Product::updateOrCreate( - ['slug' => $sharedSlug], - [ - 'published' => true, - 'order' => $order, - 'name' => $translated('name'), - 'headline' => $translated('headline'), - 'teaser' => $translated('teaser'), - 'tags' => Arr::get($first, 'tags', []), - 'image' => Arr::get($first, 'image', 'https://res.cloudinary.com/codebar/image/upload/c_scale,dpr_2.0,f_auto,q_auto,w_1200/www-paperflakes-ch/seo/seo_paperflakes.webp'), - 'content' => $translated('content'), - 'features_heading' => $translated('features_heading'), - 'features_intro' => $translated('features_intro'), - 'features' => $translated('features', []), - 'deployment_heading' => $translated('deployment_heading'), - 'deployment_intro' => $translated('deployment_intro'), - 'deployment_options' => $translated('deployment_options', []), - 'cta_heading' => $translated('cta_heading'), - 'cta_body' => $translated('cta_body'), - ] - ); + Artisan::call('products:import', [], $this->command->getOutput()); } } diff --git a/database/seeders/SeoImageCleanupSeeder.php b/database/seeders/SeoImageCleanupSeeder.php index 50c38f7..50a30db 100644 --- a/database/seeders/SeoImageCleanupSeeder.php +++ b/database/seeders/SeoImageCleanupSeeder.php @@ -27,7 +27,10 @@ public function run(): void }) ->update(['image' => null]); - foreach (['news', 'technologies', 'open_sources'] as $table) { + // `news` is no longer listed here: it dropped `image` in favour of a + // nullable `hero_image`, which is written by news:import from the article + // front matter and never carried either of these placeholders. + foreach (['technologies', 'open_sources'] as $table) { DB::table($table) ->where(function (Builder $query) { $query->where('image', 'like', self::BROKEN_IMAGE_PATTERN) diff --git a/database/seeders/ServicesTableSeeder.php b/database/seeders/ServicesTableSeeder.php index 5529cf5..c5a8865 100644 --- a/database/seeders/ServicesTableSeeder.php +++ b/database/seeders/ServicesTableSeeder.php @@ -2,219 +2,20 @@ namespace Database\Seeders; -use App\Enums\LocaleEnum; -use App\Models\Service; use Illuminate\Database\Seeder; -use Illuminate\Support\Arr; - +use Illuminate\Support\Facades\Artisan; + +/** + * Services live as markdown files with YAML front matter under + * database/files/services/{locale}/. Adding one means adding two files — one per + * language — and running `php artisan services:import`; no code change is needed. + * + * Only services that genuinely exist in both languages are imported. + */ class ServicesTableSeeder extends Seeder { public function run(): void { - $this->seed( - order: 1, - sharedSlug: 'konzeption-prototyping', - localizedData: [ - LocaleEnum::DE->value => [ - 'name' => 'Konzeption & Prototyping', - 'teaser' => 'Wir bringen deine Idee aufs Papier – visuell. Mit Mockups und klickbaren Prototypen entsteht früh ein gemeinsames Bild, noch bevor die erste Zeile Code geschrieben ist. Am Ende hältst du ein technisches Konzept in der Hand, inklusive Technologiewahl – als Basis für die Umsetzung, bei uns oder bei anderen.', - 'tags' => ['Requirements Engineering', 'Prototyping', 'UX'], - 'content' => <<<'MD' - ## Von der Idee zum greifbaren Konzept - - Am Anfang steht Zuhören: Wir wollen verstehen, was dein Vorhaben antreibt, wer es nutzen wird und was es leisten muss. Daraus entwickeln wir gemeinsam ein Konzept, das die künftigen Nutzer:innen ins Zentrum stellt — und machen es als klickbaren Prototyp erlebbar, bevor eine Zeile produktiver Code entsteht. - - ### Unser Vorgehen - - - **Verstehen** — In Workshops und Gesprächen klären wir Ziele, Nutzer:innen und Rahmenbedingungen. - - **Konzipieren** — Wir erarbeiten Informationsarchitektur, Abläufe und Datenmodell, verständlich dokumentiert. - - **Prototyping** — Klickbare Prototypen machen Ideen früh greifbar und diskutierbar. - - **Validieren** — Feedback von echten Nutzer:innen fliesst direkt in die nächste Iteration ein. - - ### Warum sich das lohnt - - Ein Prototyp zeigt früh, ob eine Idee trägt. Entscheidungen fallen anhand von etwas Greifbarem statt auf Papier — das reduziert Risiken und spart in der Umsetzung Zeit und Kosten. Und wenn es weitergeht, bildet das Konzept die solide Grundlage für die Entwicklung. - MD, - ], - LocaleEnum::EN->value => [ - 'name' => 'Concept design & prototyping', - 'teaser' => 'We put your idea on paper – visually. Mockups and clickable prototypes create a shared picture early on, before a single line of code is written. In the end, you hold a technical concept in your hands, including the technology stack – as a basis for implementation, by us or by others.', - 'tags' => ['Requirements engineering', 'Prototyping', 'UX'], - 'content' => <<<'MD' - ## From idea to tangible concept - - It starts with listening: we want to understand what drives your project, who will use it and what it needs to deliver. From there, we develop a concept together that puts future users at the centre — and bring it to life as a clickable prototype before a single line of production code is written. - - ### How we work - - - **Understand** — In workshops and conversations, we clarify goals, users and constraints. - - **Design** — We work out information architecture, workflows and data model, documented in plain language. - - **Prototype** — Clickable prototypes make ideas tangible and easy to discuss early on. - - **Validate** — Feedback from real users flows straight into the next iteration. - - ### Why it pays off - - A prototype shows early whether an idea holds up. Decisions are made on something tangible instead of on paper — reducing risk and saving time and cost during implementation. And when the project moves ahead, the concept forms a solid foundation for development. - MD, - ], - ] - ); - - $this->seed( - order: 2, - sharedSlug: 'individuelle-softwareentwicklung', - localizedData: [ - LocaleEnum::DE->value => [ - 'name' => 'Individuelle Softwareentwicklung', - 'teaser' => 'Portale, Schnittstellen, Automatisierungen: Software, die im Hintergrund zuverlässig ihre Arbeit macht. Wir entwickeln mit offenen Technologien wie Laravel – und betreuen deine Lösung auch nach dem Go-live, über Jahre.', - 'tags' => ['Backend & Schnittstellen', 'Laravel', 'Open Source'], - 'content' => <<<'MD' - ## Software, die zu deinen Prozessen passt - - Standardsoftware endet dort, wo dein Geschäft besonders ist. Wir entwickeln individuelle Lösungen, die sich nach deinen Prozessen richten — nicht umgekehrt. - - ### Was wir bauen - - - **Portallösungen** — Kunden-, Partner- und Mitarbeiterportale mit klarer Benutzerführung. - - **Schnittstellen & APIs** — Saubere, dokumentierte Schnittstellen, die Systeme zuverlässig verbinden. - - **Integrationen** — Wir verbinden bestehende Systeme wie ERP, CRM oder DMS zu durchgängigen Abläufen. - - ### Open Source im Fokus - - Wir setzen auf offene Technologien und Standards. Das bedeutet: keine Lizenzabhängigkeiten, eine aktive Community hinter den eingesetzten Werkzeugen und Software, die dir gehört und die du weiterentwickeln kannst — mit uns oder ohne uns. - - ### Nachhaltig entwickelt - - Automatisierte Tests, Code-Reviews und kontinuierliche Deployments gehören für uns zum Handwerk. So bleibt deine Lösung auch nach Jahren wartbar und erweiterbar. - MD, - ], - LocaleEnum::EN->value => [ - 'name' => 'Individual software development', - 'teaser' => 'Portals, interfaces, automation: software that quietly does its job in the background. We build with open technologies such as Laravel – and look after your solution long after go-live.', - 'tags' => ['Backend & interfaces', 'Laravel', 'Open source'], - 'content' => <<<'MD' - ## Software that fits your processes - - Off-the-shelf software ends where your business is unique. We build individual solutions that adapt to your processes — not the other way around. - - ### What we build - - - **Portal solutions** — Customer, partner and employee portals with clear, intuitive user journeys. - - **Interfaces & APIs** — Clean, documented interfaces that connect systems reliably. - - **Integrations** — We connect existing systems such as ERP, CRM or DMS into seamless end-to-end processes. - - ### A focus on open source - - We rely on open technologies and standards. That means no licence lock-in, an active community behind the tools we use, and software that belongs to you and that you can keep evolving — with us or without us. - - ### Built to last - - Automated tests, code reviews and continuous deployments are part of our craft. That keeps your solution maintainable and extensible, even years down the road. - MD, - ], - ] - ); - - $this->seed( - order: 3, - sharedSlug: 'dms-ecm-consulting', - localizedData: [ - LocaleEnum::DE->value => [ - 'name' => 'DMS/ECM', - 'teaser' => 'Vom Papier zum papierlosen Büro – und weiter zur Automatisierung: Als DocuWare Silver & Cloud Partner digitalisieren wir deine Dokumente und bilden Prozesse in Workflows ab. Vom Einzelunternehmen bis zum Konzern mit über 200 Nutzer:innen.', - 'tags' => ['DocuWare', 'Dokumentenmanagement', 'Workflows'], - 'content' => <<<'MD' - ## Dokumentenmanagement, das im Alltag funktioniert - - Verträge, Rechnungen, Personaldossiers: Dokumente sind das Rückgrat vieler Geschäftsprozesse. Wir beraten dich herstellerneutral und begleiten dich von der Analyse bis zum laufenden Betrieb deines DMS/ECM. - - ### Unsere Leistungen - - - **Analyse & Beratung** — Wir nehmen deine Dokumentenprozesse auf und zeigen, wo Digitalisierung den grössten Nutzen bringt. - - **Konzeption** — Ablagestruktur, Indexierung, Berechtigungen und Aufbewahrung, sauber durchdacht. - - **Implementierung** — Einführung und Konfiguration deines DMS — unter anderem mit DocuWare, wo wir langjährige Erfahrung mitbringen. - - **Integration & Automatisierung** — Anbindung an ERP, CRM und Fachanwendungen sowie automatisierte Dokumentenworkflows, bis hin zu KI-gestützter Verarbeitung. - - ### Nach dem Go-live - - Wir lassen dich nicht mit dem System allein: Schulung, Support und die kontinuierliche Weiterentwicklung deiner Dokumentenprozesse gehören dazu. - MD, - ], - LocaleEnum::EN->value => [ - 'name' => 'DMS/ECM', - 'teaser' => 'From paper to a paperless office – and on to automation: as a DocuWare Silver & Cloud Partner, we digitise your documents and turn processes into workflows. From one-person businesses to corporations with more than 200 users.', - 'tags' => ['DocuWare', 'Document management', 'Workflows'], - 'content' => <<<'MD' - ## Document management that works in everyday business - - Contracts, invoices, HR files: documents are the backbone of many business processes. We advise you independently of any vendor and support you from the first analysis through to the day-to-day operation of your DMS/ECM. - - ### What we offer - - - **Analysis & consulting** — We map your document processes and show where digitalisation delivers the greatest value. - - **Concept design** — Filing structure, indexing, permissions and retention, thought through properly. - - **Implementation** — Rollout and configuration of your DMS — including DocuWare, where we bring years of experience. - - **Integration & automation** — Connections to ERP, CRM and line-of-business applications, plus automated document workflows up to AI-assisted processing. - - ### After go-live - - We don't leave you alone with the system: training, support and the continuous improvement of your document processes are part of the package. - MD, - ], - ] - ); - $this->seed( - order: 4, - sharedSlug: 'open-source-erp', - localizedData: [ - LocaleEnum::DE->value => [ - 'name' => 'Open Source ERP', - 'teaser' => 'Unser jüngster Bereich – und einer, hinter dem wir stehen: Odoo setzen wir seit Kurzem selbst als ERP ein. Als Odoo-Partner bieten wir dir Schritt für Schritt an, was sich bei uns bewährt – heute die Einführung von Projektmanagement und Zeiterfassung, ohne Lizenz-Lock-in.', - 'tags' => ['Odoo', 'ERP-Einführung', 'Open Source'], - 'content' => <<<'MD' - ## Odoo – das offene ERP - - Unser jüngster Bereich – und einer, hinter dem wir stehen: Odoo setzen wir seit Kurzem selbst als ERP ein. Als Odoo-Partner bieten wir dir Schritt für Schritt an, was sich bei uns bewährt – heute die Einführung von Projektmanagement und Zeiterfassung, ohne Lizenz-Lock-in. - MD, - ], - LocaleEnum::EN->value => [ - 'name' => 'Open-source ERP', - 'teaser' => 'Our newest area – and one we stand behind: we recently started running Odoo as our own ERP. As an Odoo partner, we offer you step by step what proves itself in our daily work – today, the rollout of project management and time tracking, free of licence lock-in.', - 'tags' => ['Odoo', 'ERP rollout', 'Open source'], - 'content' => <<<'MD' - ## Odoo – the open ERP - - Our newest area – and one we stand behind: we recently started running Odoo as our own ERP. As an Odoo partner, we offer you step by step what proves itself in our daily work – today, the rollout of project management and time tracking, free of licence lock-in. - MD, - ], - ] - ); - } - - /** - * @param array<string, array<string, mixed>> $localizedData - */ - private function seed(int $order, string $sharedSlug, array $localizedData): void - { - $translated = fn (string $field, mixed $default = null) => collect($localizedData) - ->map(fn (array $data) => Arr::get($data, $field, $default)) - ->all(); - - $first = collect($localizedData)->first() ?? []; - - Service::updateOrCreate( - ['slug' => $sharedSlug], - [ - 'published' => true, - 'order' => $order, - 'group' => 'services', - 'name' => $translated('name'), - 'teaser' => $translated('teaser'), - 'tags' => Arr::get($first, 'tags', []), - 'content' => $translated('content'), - 'url' => Arr::get($first, 'url'), - 'image' => Arr::get($first, 'image', ''), - ] - ); + Artisan::call('services:import', [], $this->command->getOutput()); } } diff --git a/database/seeders/TechnologiesTableSeeder.php b/database/seeders/TechnologiesTableSeeder.php index 84a3b8c..90ee515 100644 --- a/database/seeders/TechnologiesTableSeeder.php +++ b/database/seeders/TechnologiesTableSeeder.php @@ -2,36 +2,18 @@ namespace Database\Seeders; -use App\Models\Technology; -use Database\Seeders\Concerns\ReadsCsv; use Illuminate\Database\Seeder; +use Illuminate\Support\Facades\Artisan; +/** + * Technologies live as markdown files with YAML front matter under + * database/files/technologies/{locale}/. Adding one means adding two files — one per + * language — and running `php artisan technologies:import`; no code change is needed. + */ class TechnologiesTableSeeder extends Seeder { - use ReadsCsv; - public function run(): void { - collect($this->readCsv('technologies.csv')) - ->groupBy('slug') - ->each(function ($rows, string $slug) { - $byLocale = $rows->keyBy('locale'); - $first = $rows->first() ?? []; - - Technology::updateOrCreate( - ['slug' => $slug], - [ - 'published' => filter_var($first['published'] ?? false, FILTER_VALIDATE_BOOLEAN), - 'group' => $first['group'] ?? '', - 'order' => (int) ($first['order'] ?? 0), - 'title' => $byLocale->map(fn (array $row) => $row['title'])->all(), - 'teaser' => $byLocale->map(fn (array $row) => $row['teaser'])->all(), - 'content' => $byLocale->map(fn (array $row) => $row['content'] !== '' ? $row['content'] : null)->all(), - 'image' => $first['image'] ?? '', - 'tags' => $this->decodeJson($first['tags'] ?? ''), - 'link' => $first['link'] ?? null, - ] - ); - }); + Artisan::call('technologies:import', [], $this->command->getOutput()); } } diff --git a/database/seeders/data/ai_model_daily_usages.csv b/database/seeders/data/ai_model_daily_usages.csv index cf924fd..0a6dfa7 100644 --- a/database/seeders/data/ai_model_daily_usages.csv +++ b/database/seeders/data/ai_model_daily_usages.csv @@ -1,73 +1,94 @@ id;date;model;ai_model_id;prompt_tokens;completion_tokens;total_tokens;requests;spend;created_at;updated_at -1;2026-05-15;qwen3:30b-a3b;;9934;702;10636;1;0.000000;2026-07-21 17:16:37;2026-07-21 17:16:37 -2;2026-05-15;llama3.3:70b-instruct-q4_K_M;;0;0;0;6;0.000000;2026-07-21 17:16:37;2026-07-21 17:16:37 -3;2026-05-15;qwen2.5:32b-instruct-q4_K_M;;35244;1538;36782;12;0.000000;2026-07-21 17:16:37;2026-07-21 17:16:37 -4;2026-05-15;qwen2.5:7b-instruct;;244033;7374;251407;19;0.000000;2026-07-21 17:16:37;2026-07-21 17:16:37 -5;2026-05-15;invoice-extractor;;0;0;0;3;0.000000;2026-07-21 17:16:37;2026-07-21 17:16:37 -6;2026-05-18;qwen2.5:7b-instruct;;100553;522;101075;5;0.000000;2026-07-21 17:16:37;2026-07-21 17:16:37 -7;2026-05-18;qwen3:30b-a3b;;50585;228;50813;2;0.000000;2026-07-21 17:16:37;2026-07-21 17:16:37 -8;2026-05-18;llama3.3:70b-instruct-q4_K_M;;71431;410;71841;5;0.000000;2026-07-21 17:16:37;2026-07-21 17:16:37 -9;2026-05-19;qwen3.6:35b;4;91607;2010;93617;6;0.000000;2026-07-21 17:16:37;2026-07-21 17:16:37 -10;2026-05-19;gpt-oss:20b;;126604;1227;127831;8;0.000000;2026-07-21 17:16:37;2026-07-21 17:16:37 -11;2026-05-19;qwen3.5:4b;14;103928;863;104791;5;0.000000;2026-07-21 17:16:37;2026-07-21 17:16:37 -12;2026-05-28;qwen2.5:32b-instruct-q4_K_M;;7934;830;8764;9;0.000000;2026-07-21 17:16:38;2026-07-21 17:16:38 -13;2026-05-28;qwen2.5-coder:32b;;138;194;332;4;0.000000;2026-07-21 17:16:38;2026-07-21 17:16:38 -14;2026-05-29;qwen3.5:9b;13;110387;18601;128988;43;0.000000;2026-07-21 17:16:39;2026-07-21 17:16:39 -15;2026-05-29;qwen2.5:32b-instruct-q4_K_M;;95403;12185;107588;37;0.000000;2026-07-21 17:16:39;2026-07-21 17:16:39 -16;2026-06-24;gemma4:31b;7;1475;1281;2756;2;0.000000;2026-07-21 17:16:42;2026-07-21 17:16:42 -17;2026-06-24;qwen3.6:27b;12;2836;1403;4239;2;0.000000;2026-07-21 17:16:42;2026-07-21 17:16:42 -18;2026-06-24;qwen3.5:9b;13;6069;2102;8171;5;0.000000;2026-07-21 17:16:42;2026-07-21 17:16:42 -19;2026-06-25;qwen3.6:35b;4;546474;7016;553490;28;0.000000;2026-07-21 17:16:42;2026-07-21 17:16:42 -20;2026-06-25;gemma4:31b;7;75;64;139;1;0.000000;2026-07-21 17:16:42;2026-07-21 17:16:42 -21;2026-06-25;qwen3.6:27b;12;604;22;626;2;0.000000;2026-07-21 17:16:42;2026-07-21 17:16:42 -22;2026-06-25;gemma4:e4b;16;2121098;20002;2141100;87;0.000000;2026-07-21 17:16:42;2026-07-21 17:16:42 -23;2026-06-26;qwen3.5:122b;10;14170796;534286;14705082;2217;0.000000;2026-07-21 17:16:42;2026-07-21 17:16:42 -24;2026-06-26;qwen3.6:35b;4;8099213;234974;8334187;1217;0.000000;2026-07-21 17:16:42;2026-07-21 17:16:42 -25;2026-06-27;qwen3.6:27b;12;482580;9371;491951;81;0.000000;2026-07-21 17:16:43;2026-07-21 17:16:43 -26;2026-06-27;gemma4:31b;7;4011;2109;6120;5;0.000000;2026-07-21 17:16:43;2026-07-21 17:16:43 -27;2026-06-27;qwen3.6:35b-a3b;11;303190;6572;309762;53;0.000000;2026-07-21 17:16:43;2026-07-21 17:16:43 -28;2026-06-27;qwen3.6:35b;4;603564;13588;617152;102;0.000000;2026-07-21 17:16:43;2026-07-21 17:16:43 -29;2026-06-27;qwen3.5:122b;10;1768511;45646;1814157;275;0.000000;2026-07-21 17:16:43;2026-07-21 17:16:43 -30;2026-06-30;qwen3.5:9b;13;1288520;38257;1326777;217;0.000000;2026-07-21 17:16:43;2026-07-21 17:16:43 -31;2026-07-01;gemma4:31b;7;7156;1107;8263;14;0.000000;2026-07-21 17:16:43;2026-07-21 17:16:43 -32;2026-07-01;qwen3.6:27b;12;4943;1243;6186;14;0.000000;2026-07-21 17:16:43;2026-07-21 17:16:43 -33;2026-07-01;qwen3.6:35b-a3b;11;807;150;957;1;0.000000;2026-07-21 17:16:43;2026-07-21 17:16:43 -34;2026-07-02;qwen3.6:27b;12;688132;8708;696840;103;0.000000;2026-07-21 17:16:43;2026-07-21 17:16:43 -35;2026-07-02;qwen3.5:9b;13;14577;1232;15809;10;0.000000;2026-07-21 17:16:43;2026-07-21 17:16:43 -36;2026-07-02;qwen3.5:4b;14;39;97;136;3;0.000000;2026-07-21 17:16:43;2026-07-21 17:16:43 -37;2026-07-02;gemma4:31b;7;2366;182;2548;3;0.000000;2026-07-21 17:16:43;2026-07-21 17:16:43 -38;2026-07-03;qwen3.5:122b;10;441748;3713;445461;62;0.000000;2026-07-21 17:16:44;2026-07-21 17:16:44 -39;2026-07-03;qwen3.6:35b-a3b;11;4934769;50037;4984806;511;0.000000;2026-07-21 17:16:44;2026-07-21 17:16:44 -40;2026-07-06;qwen3.6:27b;12;1485;120;1605;4;0.000000;2026-07-21 17:16:44;2026-07-21 17:16:44 -41;2026-07-06;gemma4:31b;7;340;18;358;1;0.000000;2026-07-21 17:16:44;2026-07-21 17:16:44 -42;2026-07-06;qwen3.6:35b-a3b;11;32715414;65791;32781205;503;0.000000;2026-07-21 17:16:44;2026-07-21 17:16:44 -43;2026-07-06;qwen3.5:9b;13;7101183;28968;7130151;278;0.000000;2026-07-21 17:16:44;2026-07-21 17:16:44 -44;2026-07-07;gemma4:e4b;16;7058;2990;10048;6;0.000000;2026-07-21 17:16:44;2026-07-21 17:16:44 -45;2026-07-07;qwen3.6:27b;12;1014;390;1404;3;0.000000;2026-07-21 17:16:44;2026-07-21 17:16:44 -46;2026-07-07;qwen3.6:35b-a3b;11;15415301;111182;15526483;884;0.000000;2026-07-21 17:16:44;2026-07-21 17:16:44 -47;2026-07-08;qwen3.5:122b;10;2277618;11754;2289372;93;0.000000;2026-07-21 17:16:45;2026-07-21 17:16:45 -48;2026-07-08;qwen3.6:35b-a3b;11;9031664;77790;9109454;456;0.000000;2026-07-21 17:16:45;2026-07-21 17:16:45 -49;2026-07-09;qwen3.5:122b;10;2827024;19262;2846286;142;0.000000;2026-07-21 17:16:45;2026-07-21 17:16:45 -50;2026-07-10;qwen3.5:122b;10;3868116;38611;3906727;227;0.000000;2026-07-21 17:16:45;2026-07-21 17:16:45 -51;2026-07-13;qwen3.5:122b;10;20094649;94008;20188657;757;0.000000;2026-07-21 17:16:45;2026-07-21 17:16:45 -52;2026-07-14;qwen3.5:122b;10;12528775;79653;12608428;542;0.000000;2026-07-21 17:16:46;2026-07-21 17:16:46 -53;2026-07-15;qwen3.5:122b;10;11289182;45920;11335102;364;0.000000;2026-07-21 17:16:46;2026-07-21 17:16:46 -54;2026-07-15;qwen3.6:35b;4;806190;8453;814643;61;0.000000;2026-07-21 17:16:46;2026-07-21 17:16:46 -55;2026-07-15;qwen3.6:35b-a3b;11;186368;2998;189366;13;0.000000;2026-07-21 17:16:46;2026-07-21 17:16:46 -56;2026-07-16;qwen3.5:122b;10;9700919;66542;9767461;463;0.000000;2026-07-21 17:16:46;2026-07-21 17:16:46 -57;2026-07-17;qwen3.6:27b;12;276110;2231;278341;17;0.000000;2026-07-21 17:16:46;2026-07-21 17:16:46 -58;2026-07-17;qwen3.5:122b;10;8225059;57088;8282147;434;0.000000;2026-07-21 17:16:46;2026-07-21 17:16:46 -59;2026-07-19;qwen3-embedding:4b;17;2766760;0;2766760;133;0.000000;2026-07-21 17:16:47;2026-07-21 17:16:47 -60;2026-07-19;qwen3.6:35b;4;790441;22128;812569;132;0.000000;2026-07-21 17:16:47;2026-07-21 17:16:47 -61;2026-07-19;qwen3.5:9b;13;458;2;460;1;0.000000;2026-07-21 17:16:47;2026-07-21 17:16:47 -62;2026-07-19;gemma4:12b;15;88;59;147;5;0.000000;2026-07-21 17:16:47;2026-07-21 17:16:47 -63;2026-07-20;qwen3-embedding:4b;17;65754019;0;65754019;1069;0.000000;2026-07-21 17:16:47;2026-07-21 17:16:47 -64;2026-07-20;qwen3.6:35b;4;73853;4975;78828;12;0.000000;2026-07-21 17:16:47;2026-07-21 17:16:47 -65;2026-07-21;qwen3-coder:30b;3;9521306;91483;9612789;472;0.000000;2026-07-21 17:16:48;2026-07-21 17:16:48 -66;2026-07-21;qwen3.6:35b;4;847707;10730;858437;55;0.000000;2026-07-21 17:16:48;2026-07-21 17:16:48 -67;2026-07-21;qwen3-embedding:4b;17;164186;0;164186;24;0.000000;2026-07-21 17:16:48;2026-07-21 17:16:48 -68;2026-07-21;kimi-linear:48b;2;8;11;19;1;0.000000;2026-07-21 17:16:48;2026-07-21 17:16:48 -69;2026-07-21;gemma4:31b;7;14;10;24;1;0.000000;2026-07-21 17:16:48;2026-07-21 17:16:48 -70;2026-07-21;qwen3-vl:8b;6;9;298;307;1;0.000000;2026-07-21 17:16:48;2026-07-21 17:16:48 -71;2026-07-21;qwen3.6:35b-a3b;11;23022654;86798;23109452;604;0.000000;2026-07-21 17:16:48;2026-07-21 17:16:48 -72;2026-07-21;qwen3.5:122b;10;788311;4504;792815;54;0.000000;2026-07-21 17:16:48;2026-07-21 17:16:48 \ No newline at end of file +1;2026-05-15;qwen3:30b-a3b;;9934;702;10636;1;0.000000;2026-07-23 08:18:56;2026-07-23 08:18:56 +2;2026-05-15;llama3.3:70b-instruct-q4_K_M;;0;0;0;6;0.000000;2026-07-23 08:18:56;2026-07-23 08:18:56 +3;2026-05-15;qwen2.5:32b-instruct-q4_K_M;;35244;1538;36782;12;0.000000;2026-07-23 08:18:56;2026-07-23 08:18:56 +4;2026-05-15;qwen2.5:7b-instruct;;244033;7374;251407;19;0.000000;2026-07-23 08:18:56;2026-07-23 08:18:56 +5;2026-05-15;invoice-extractor;;0;0;0;3;0.000000;2026-07-23 08:18:56;2026-07-23 08:18:56 +6;2026-05-18;qwen2.5:7b-instruct;;100553;522;101075;5;0.000000;2026-07-23 08:18:57;2026-07-23 08:18:57 +7;2026-05-18;qwen3:30b-a3b;;50585;228;50813;2;0.000000;2026-07-23 08:18:57;2026-07-23 08:18:57 +8;2026-05-18;llama3.3:70b-instruct-q4_K_M;;71431;410;71841;5;0.000000;2026-07-23 08:18:57;2026-07-23 08:18:57 +9;2026-05-19;qwen3.6:35b;4;91607;2010;93617;6;0.000000;2026-07-23 08:18:57;2026-07-23 08:18:57 +10;2026-05-19;gpt-oss:20b;;126604;1227;127831;8;0.000000;2026-07-23 08:18:57;2026-07-23 08:18:57 +11;2026-05-19;qwen3.5:4b;14;103928;863;104791;5;0.000000;2026-07-23 08:18:57;2026-07-23 08:18:57 +12;2026-05-28;qwen2.5:32b-instruct-q4_K_M;;7934;830;8764;9;0.000000;2026-07-23 08:18:58;2026-07-23 08:18:58 +13;2026-05-28;qwen2.5-coder:32b;;138;194;332;4;0.000000;2026-07-23 08:18:58;2026-07-23 08:18:58 +14;2026-05-29;qwen3.5:9b;13;110387;18601;128988;43;0.000000;2026-07-23 08:18:58;2026-07-23 08:18:58 +15;2026-05-29;qwen2.5:32b-instruct-q4_K_M;;95403;12185;107588;37;0.000000;2026-07-23 08:18:58;2026-07-23 08:18:58 +16;2026-06-24;gemma4:31b;7;1475;1281;2756;2;0.000000;2026-07-23 08:19:01;2026-07-23 08:19:01 +17;2026-06-24;qwen3.6:27b;12;2836;1403;4239;2;0.000000;2026-07-23 08:19:01;2026-07-23 08:19:01 +18;2026-06-24;qwen3.5:9b;13;6069;2102;8171;5;0.000000;2026-07-23 08:19:01;2026-07-23 08:19:01 +19;2026-06-25;qwen3.6:35b;4;546474;7016;553490;28;0.000000;2026-07-23 08:19:01;2026-07-23 08:19:01 +20;2026-06-25;gemma4:31b;7;75;64;139;1;0.000000;2026-07-23 08:19:01;2026-07-23 08:19:01 +21;2026-06-25;qwen3.6:27b;12;604;22;626;2;0.000000;2026-07-23 08:19:01;2026-07-23 08:19:01 +22;2026-06-25;gemma4:e4b;16;2121098;20002;2141100;87;0.000000;2026-07-23 08:19:01;2026-07-23 08:19:01 +23;2026-06-26;qwen3.5:122b;10;14170796;534286;14705082;2217;0.000000;2026-07-23 08:19:02;2026-07-23 08:19:02 +24;2026-06-26;qwen3.6:35b;4;8099213;234974;8334187;1217;0.000000;2026-07-23 08:19:02;2026-07-23 08:19:02 +25;2026-06-27;qwen3.6:27b;12;482580;9371;491951;81;0.000000;2026-07-23 08:19:02;2026-07-23 08:19:02 +26;2026-06-27;gemma4:31b;7;4011;2109;6120;5;0.000000;2026-07-23 08:19:02;2026-07-23 08:19:02 +27;2026-06-27;qwen3.6:35b-a3b;11;303190;6572;309762;53;0.000000;2026-07-23 08:19:02;2026-07-23 08:19:02 +28;2026-06-27;qwen3.6:35b;4;603564;13588;617152;102;0.000000;2026-07-23 08:19:02;2026-07-23 08:19:02 +29;2026-06-27;qwen3.5:122b;10;1768511;45646;1814157;275;0.000000;2026-07-23 08:19:02;2026-07-23 08:19:02 +30;2026-06-30;qwen3.5:9b;13;1288520;38257;1326777;217;0.000000;2026-07-23 08:19:02;2026-07-23 08:19:02 +31;2026-07-01;gemma4:31b;7;7156;1107;8263;14;0.000000;2026-07-23 08:19:03;2026-07-23 08:19:03 +32;2026-07-01;qwen3.6:27b;12;4943;1243;6186;14;0.000000;2026-07-23 08:19:03;2026-07-23 08:19:03 +33;2026-07-01;qwen3.6:35b-a3b;11;807;150;957;1;0.000000;2026-07-23 08:19:03;2026-07-23 08:19:03 +34;2026-07-02;qwen3.6:27b;12;688132;8708;696840;103;0.000000;2026-07-23 08:19:03;2026-07-23 08:19:03 +35;2026-07-02;qwen3.5:9b;13;14577;1232;15809;10;0.000000;2026-07-23 08:19:03;2026-07-23 08:19:03 +36;2026-07-02;qwen3.5:4b;14;39;97;136;3;0.000000;2026-07-23 08:19:03;2026-07-23 08:19:03 +37;2026-07-02;gemma4:31b;7;2366;182;2548;3;0.000000;2026-07-23 08:19:03;2026-07-23 08:19:03 +38;2026-07-03;qwen3.5:122b;10;441748;3713;445461;62;0.000000;2026-07-23 08:19:03;2026-07-23 08:19:03 +39;2026-07-03;qwen3.6:35b-a3b;11;4934769;50037;4984806;511;0.000000;2026-07-23 08:19:03;2026-07-23 08:19:03 +40;2026-07-06;qwen3.6:27b;12;1485;120;1605;4;0.000000;2026-07-23 08:19:04;2026-07-23 08:19:04 +41;2026-07-06;gemma4:31b;7;340;18;358;1;0.000000;2026-07-23 08:19:04;2026-07-23 08:19:04 +42;2026-07-06;qwen3.6:35b-a3b;11;32715414;65791;32781205;503;0.000000;2026-07-23 08:19:04;2026-07-23 08:19:04 +43;2026-07-06;qwen3.5:9b;13;7101183;28968;7130151;278;0.000000;2026-07-23 08:19:04;2026-07-23 08:19:04 +44;2026-07-07;gemma4:e4b;16;7058;2990;10048;6;0.000000;2026-07-23 08:19:04;2026-07-23 08:19:04 +45;2026-07-07;qwen3.6:27b;12;1014;390;1404;3;0.000000;2026-07-23 08:19:04;2026-07-23 08:19:04 +46;2026-07-07;qwen3.6:35b-a3b;11;15415301;111182;15526483;884;0.000000;2026-07-23 08:19:04;2026-07-23 08:19:04 +47;2026-07-08;qwen3.5:122b;10;2277618;11754;2289372;93;0.000000;2026-07-23 08:19:04;2026-07-23 08:19:04 +48;2026-07-08;qwen3.6:35b-a3b;11;9031664;77790;9109454;456;0.000000;2026-07-23 08:19:04;2026-07-23 08:19:04 +49;2026-07-09;qwen3.5:122b;10;2827024;19262;2846286;142;0.000000;2026-07-23 08:19:04;2026-07-23 08:19:04 +50;2026-07-10;qwen3.5:122b;10;3868116;38611;3906727;227;0.000000;2026-07-23 08:19:04;2026-07-23 08:19:04 +51;2026-07-13;qwen3.5:122b;10;20094649;94008;20188657;757;0.000000;2026-07-23 08:19:05;2026-07-23 08:19:05 +52;2026-07-14;qwen3.5:122b;10;12528775;79653;12608428;542;0.000000;2026-07-23 08:19:05;2026-07-23 08:19:05 +53;2026-07-15;qwen3.5:122b;10;11289182;45920;11335102;364;0.000000;2026-07-23 08:19:05;2026-07-23 08:19:05 +54;2026-07-15;qwen3.6:35b;4;806190;8453;814643;61;0.000000;2026-07-23 08:19:05;2026-07-23 08:19:05 +55;2026-07-15;qwen3.6:35b-a3b;11;186368;2998;189366;13;0.000000;2026-07-23 08:19:05;2026-07-23 08:19:05 +56;2026-07-16;qwen3.5:122b;10;9700919;66542;9767461;463;0.000000;2026-07-23 08:19:06;2026-07-23 08:19:06 +57;2026-07-17;qwen3.6:27b;12;276110;2231;278341;17;0.000000;2026-07-23 08:19:06;2026-07-23 08:19:06 +58;2026-07-17;qwen3.5:122b;10;8225059;57088;8282147;434;0.000000;2026-07-23 08:19:06;2026-07-23 08:19:06 +59;2026-07-19;qwen3-embedding:4b;17;2766760;0;2766760;133;0.000000;2026-07-23 08:19:06;2026-07-23 08:19:06 +60;2026-07-19;qwen3.6:35b;4;790441;22128;812569;132;0.000000;2026-07-23 08:19:06;2026-07-23 08:19:06 +61;2026-07-19;qwen3.5:9b;13;458;2;460;1;0.000000;2026-07-23 08:19:06;2026-07-23 08:19:06 +62;2026-07-19;gemma4:12b;15;88;59;147;5;0.000000;2026-07-23 08:19:06;2026-07-23 08:19:06 +63;2026-07-20;qwen3-embedding:4b;17;65754019;0;65754019;1069;0.000000;2026-07-23 08:19:07;2026-07-23 08:19:07 +64;2026-07-20;qwen3.6:35b;4;73853;4975;78828;12;0.000000;2026-07-23 08:19:07;2026-07-23 08:19:07 +65;2026-07-21;qwen3-embedding:4b;17;189568;0;189568;28;0.000000;2026-07-23 08:19:07;2026-07-23 08:19:07 +66;2026-07-21;qwen3-coder:30b;3;12622926;108628;12731554;580;0.000000;2026-07-23 08:19:07;2026-07-23 08:19:07 +67;2026-07-21;qwen3.6:35b;4;907643;11377;919020;59;0.000000;2026-07-23 08:19:07;2026-07-23 08:19:07 +68;2026-07-21;kimi-linear:48b;2;8;11;19;1;0.000000;2026-07-23 08:19:07;2026-07-23 08:19:07 +69;2026-07-21;gemma4:31b;7;14;10;24;1;0.000000;2026-07-23 08:19:07;2026-07-23 08:19:07 +70;2026-07-21;qwen3-vl:8b;6;9;298;307;1;0.000000;2026-07-23 08:19:07;2026-07-23 08:19:07 +71;2026-07-21;qwen3.6:35b-a3b;11;23022654;86798;23109452;604;0.000000;2026-07-23 08:19:07;2026-07-23 08:19:07 +72;2026-07-21;qwen3.5:122b;10;788311;4504;792815;54;0.000000;2026-07-23 08:19:07;2026-07-23 08:19:07 +73;2026-07-22;qwen3-embedding:4b;17;139591;0;139591;28;0.000000;2026-07-23 08:19:07;2026-07-23 08:19:07 +74;2026-07-22;qwen3-coder:30b;3;16590805;97287;16688092;443;0.000000;2026-07-23 08:19:07;2026-07-23 08:19:07 +75;2026-07-22;qwen3.6:35b;4;1067135;10650;1077785;58;0.000000;2026-07-23 08:19:07;2026-07-23 08:19:07 +76;2026-07-23;qwen3-embedding:4b;17;32397;0;32397;5;0.000000;2026-07-23 08:19:07;2026-07-23 08:19:07 +77;2026-07-27;qwen3-embedding:4b;17;218735;0;218735;37;0.000000;2026-07-28 17:00:17;2026-07-28 17:00:17 +78;2026-07-27;qwen3-vl:32b;5;8806;13830;22636;16;0.000000;2026-07-28 17:00:17;2026-07-28 17:00:17 +79;2026-07-27;qwen3-vl:8b;6;156132;336753;492885;42;0.000000;2026-07-28 17:00:17;2026-07-28 17:00:17 +80;2026-07-27;deepseek-ocr;8;6744;42408;49152;6;0.000000;2026-07-28 17:00:17;2026-07-28 17:00:17 +81;2026-07-27;qwen3-embedding:8b;9;2754866;0;2754866;61;0.000000;2026-07-28 17:00:17;2026-07-28 17:00:17 +82;2026-07-27;qwen3.6:35b;4;457085;16997;474082;31;0.000000;2026-07-28 17:00:17;2026-07-28 17:00:17 +83;2026-07-28;qwen3-vl:30b-a3b;;46891;11182;58073;16;0.000000;2026-07-28 17:00:18;2026-07-28 17:00:18 +84;2026-07-28;qwen3.6:35b;4;452575;14473;467048;49;0.000000;2026-07-28 17:00:18;2026-07-28 17:00:18 +85;2026-07-28;qwen3-vl:8b;6;47307;133834;181141;69;0.000000;2026-07-28 17:00:18;2026-07-28 17:00:18 +86;2026-07-28;qwen3-embedding:4b;17;370606;0;370606;49;0.000000;2026-07-28 17:00:18;2026-07-28 17:00:18 +87;2026-07-28;qwen3-embedding:8b;9;1070;0;1070;3;0.000000;2026-07-28 17:00:18;2026-07-28 17:00:18 +88;2026-07-28;qwen3-coder:30b;3;5610;1966;7576;4;0.000000;2026-07-28 17:00:18;2026-07-28 17:00:18 +89;2026-07-28;deepseek-ocr;8;80140;155990;236130;122;0.000000;2026-07-28 17:00:18;2026-07-28 17:00:18 +90;2026-07-28;qwen3-vl:32b;5;35818;36521;72339;13;0.000000;2026-07-28 17:00:18;2026-07-28 17:00:18 +91;2026-07-28;gemma4:31b;7;307;74;381;1;0.000000;2026-07-28 17:00:18;2026-07-28 17:00:18 +92;2026-07-28;kimi-linear:48b;2;0;0;0;1;0.000000;2026-07-28 17:00:18;2026-07-28 17:00:18 +93;2026-07-28;deepseek-v4:flash;1;19;2;21;1;0.000000;2026-07-28 17:00:18;2026-07-28 17:00:18 \ No newline at end of file diff --git a/database/seeders/data/ai_models.csv b/database/seeders/data/ai_models.csv deleted file mode 100644 index d9a5a96..0000000 --- a/database/seeders/data/ai_models.csv +++ /dev/null @@ -1,19 +0,0 @@ -id;category;order;name;provider;ram;license;role;link_label;link_url;archived_at;replaced_by_id;created_at;updated_at -1;reasoning_coding;1;deepseek-v4:flash;DeepSeek AI (CN);~102 GB RAM;MIT;"{""de"":""Flaggschiff: Analysen, Buchungslogik, komplexes Coding"",""en"":""Flagship: analysis, booking logic, complex coding""}";Hugging Face;https://huggingface.co/unsloth;;;2026-07-21 15:00:31;2026-07-21 15:00:31 -2;reasoning_coding;2;kimi-linear:48b;Moonshot AI (CN);30 GB RAM;MIT;"{""de"":""Alltags-Sprinter, lange Dokumente & Kontextverst\u00e4ndnis"",""en"":""Everyday sprinter, long documents & context understanding""}";Hugging Face;https://huggingface.co/moonshotai;;;2026-07-21 15:00:31;2026-07-21 15:00:31 -3;reasoning_coding;3;qwen3-coder:30b;Alibaba / Qwen (CN);18 GB RAM;Apache-2.0;"{""de"":""Coding-Spezialist f\u00fcr schnelle Iterationen & Agenten-Tasks"",""en"":""Coding specialist for fast iterations & agent tasks""}";Ollama;https://ollama.com/library/qwen3-coder;;;2026-07-21 15:00:31;2026-07-21 15:00:31 -4;reasoning_coding;4;qwen3.6:35b;Alibaba / Qwen (CN);23 GB RAM;Apache-2.0;"{""de"":""Reserve-Mittelklasse"",""en"":""Mid-range reserve""}";Ollama;https://ollama.com/library/qwen3.6;;;2026-07-21 15:00:31;2026-07-21 15:00:31 -5;vision_documents;1;qwen3-vl:32b;Alibaba / Qwen (CN);20 GB RAM;Apache-2.0;"{""de"":""Detailliertes Bildverst\u00e4ndnis: Screenshots, Diagramme, Belege"",""en"":""Detailed image understanding: screenshots, diagrams, receipts""}";Ollama;https://ollama.com/library/qwen3-vl;;;2026-07-21 15:00:31;2026-07-21 15:00:31 -6;vision_documents;2;qwen3-vl:8b;Alibaba / Qwen (CN);6,1 GB RAM;Apache-2.0;"{""de"":""Schnelle Vision-Variante f\u00fcr einfache Bildaufgaben"",""en"":""Fast vision variant for simple image tasks""}";Ollama;https://ollama.com/library/qwen3-vl;;;2026-07-21 15:00:31;2026-07-21 15:00:31 -7;vision_documents;3;gemma4:31b;Google;19 GB RAM;Gemma;"{""de"":""Bild-Input + stilsichere Texte"",""en"":""Image input + polished writing""}";Ollama;https://ollama.com/library/gemma4;;;2026-07-21 15:00:31;2026-07-21 15:00:31 -8;vision_documents;4;deepseek-ocr;DeepSeek AI (CN);6,7 GB RAM;MIT;"{""de"":""PDF\/Scan \u2192 Markdown"",""en"":""PDF\/scan \u2192 Markdown""}";Ollama;https://ollama.com/library/deepseek-ocr;;;2026-07-21 15:00:31;2026-07-21 15:00:31 -9;retrieval_search;1;qwen3-embedding:8b;Alibaba / Qwen (CN);4,7 GB RAM;Apache-2.0;"{""de"":""Vektoren f\u00fcr \u00c4hnlichkeitssuche"",""en"":""Vectors for similarity search""}";Ollama;https://ollama.com/library/qwen3-embedding;;;2026-07-21 15:00:31;2026-07-21 15:00:31 -10;reasoning_coding;1;qwen3.5:122b;;;;;;;2026-07-01;1;2026-07-21 15:00:31;2026-07-21 15:00:31 -11;reasoning_coding;2;qwen3.6:35b-a3b;;;;;;;2026-07-01;2;2026-07-21 15:00:31;2026-07-21 15:00:31 -12;reasoning_coding;3;qwen3.6:27b;;;;;;;2026-07-01;4;2026-07-21 15:00:31;2026-07-21 15:00:31 -13;reasoning_coding;4;qwen3.5:9b;;;;;;;2026-07-01;2;2026-07-21 15:00:32;2026-07-21 15:00:32 -14;reasoning_coding;5;qwen3.5:4b;;;;;;;2026-07-01;2;2026-07-21 15:00:32;2026-07-21 15:00:32 -15;vision_documents;1;gemma4:12b;;;;;;;2026-07-01;6;2026-07-21 15:00:32;2026-07-21 15:00:32 -16;vision_documents;2;gemma4:e4b;;;;;;;2026-07-01;6;2026-07-21 15:00:32;2026-07-21 15:00:32 -17;retrieval_search;1;qwen3-embedding:4b;;;;;;;2026-07-01;9;2026-07-21 15:00:32;2026-07-21 15:00:32 -18;retrieval_search;2;qwen3-reranker:8b;;;;;;;2026-07-01;;2026-07-21 15:00:32;2026-07-21 15:00:32 \ No newline at end of file diff --git a/database/seeders/data/contacts.csv b/database/seeders/data/contacts.csv deleted file mode 100644 index 0164c45..0000000 --- a/database/seeders/data/contacts.csv +++ /dev/null @@ -1,9 +0,0 @@ -id;published;name;sections;image;icons;created_at;updated_at -1;true;Sebastian Bürgin-Fix;"{""employees"":{""key"":""employees"",""role"":{""de_CH"":""Gründer & Software-Architekt"",""en_CH"":""Founder & Software Architect""}},""board_members"":{""key"":""board_members""}}";https://res.cloudinary.com/codebar/image/upload/e_background_removal/c_thumb,g_face,z_0.7,w_500,h_500,b_rgb:E4E6E9/f_auto,q_auto/www-codebar-ch/team/Sebastian_V3.webp;"{""email"":""sebastian.buergin@codebar.ch"",""linkedin"":""https:\/\/www.linkedin.com\/in\/fix-sebastian\/""}";2026-06-01 19:12:39;2026-06-01 19:12:39 -2;true;Melanie Bürgin-Fix;"{""board_members"":{""key"":""board_members""}}";https://res.cloudinary.com/codebar/image/upload/e_background_removal/c_thumb,g_face,z_0.7,w_500,h_500,b_rgb:E4E6E9/f_auto,q_auto/www-codebar-ch/team/Melanive_v3.webp;"{""email"":""melanie.buergin@codebar.ch"",""linkedin"":""https:\/\/www.linkedin.com\/in\/melanie-buergin""}";2026-06-01 19:12:39;2026-06-01 19:12:39 -3;true;Tobias Brogle;"{""employees"":{""key"":""employees"",""role"":{""de_CH"":""Applikationsentwickler"",""en_CH"":""Application Developer""}}}";https://res.cloudinary.com/codebar/image/upload/e_background_removal/c_thumb,g_face,z_0.7,w_500,h_500,b_rgb:E4E6E9/f_auto,q_auto/www-codebar-ch/team/Tobias_v3.webp;"{""email"":""tobias.brogle@codebar.ch"",""linkedin"":""https:\/\/www.linkedin.com\/in\/tobias-brogle-21010b315""} -";2026-06-01 19:12:39;2026-06-01 19:12:39 -4;true;Alexander Christoph Boll;"{""employees"":{""key"":""employees"",""role"":{""de_CH"":""Innovationsmanagement & Projektleitung"",""en_CH"":""Innovation Management & Project Lead""}}}";https://res.cloudinary.com/codebar/image/upload/e_background_removal/c_thumb,g_face,z_0.7,w_500,h_500,b_rgb:E4E6E9/f_auto,q_auto/www-codebar-ch/team/Alex_v3.webp;"{""email"":""alexander.boll@codebar.ch"",""linkedin"":""https:\/\/www.linkedin.com\/in\/alexanderboll""}";2026-06-01 19:12:39;2026-06-01 19:12:39 -5;false;Faissal Wahabali;"{""employees"":{""key"":""employees"",""role"":{""de_CH"":""Applikationsentwickler"",""en_CH"":""Application Developer""}}}";https://res.cloudinary.com/codebar/image/upload/w_400,h_400,f_auto,q_auto/www-paperflakes-ch/people/faissal_wahabali.webp;"{""email"":""faissal.wahabali@codebar.ch"",""linkedin"":""https:\/\/www.linkedin.com\/in\/faissaloux\/""}";2026-06-01 19:12:39;2026-06-01 19:12:39 -6;false;PST GmbH;"{""collaborations"":{""key"":""collaborations"",""role"":{""de_CH"":""Finanzen"",""en_CH"":""Finance""}}}";https://res.cloudinary.com/codebar/image/upload/w_400,h_400,f_auto,q_auto/www-paperflakes-ch/people/codebar.webp;"{""email"":""info@pstgmbh.ch"",""website"":""https:\/\/www.pstgmbh.ch""}";2026-06-01 19:12:39;2026-06-01 19:12:39 -7;true;Julian Leipert;"{""employees"":{""key"":""employees"",""role"":{""de_CH"":""Applikationsentwickler"",""en_CH"":""Application Developer""}}}";https://res.cloudinary.com/codebar/image/upload/e_background_removal/c_thumb,g_face,z_0.7,w_500,h_500,b_rgb:E4E6E9/f_auto,q_auto/www-codebar-ch/team/Julian_V3.webp;"{""email"":""julian.leipert@codebar.ch"",""linkedin"":null}";2026-06-01 19:12:39;2026-06-01 19:12:39 \ No newline at end of file diff --git a/database/seeders/data/network_users.csv b/database/seeders/data/network_users.csv new file mode 100644 index 0000000..90317b3 --- /dev/null +++ b/database/seeders/data/network_users.csv @@ -0,0 +1,7 @@ +id;network_key;name;role;avatar_disk;avatar_path;avatar_url;email;public_email;linkedin;phone;published;sort;created_at;updated_at +1;wieland-business-solutions;Dario Wieland;;;;;dario.wieland@business-solutions.gmbh;dario.wieland@business-solutions.gmbh;https://www.linkedin.com/in/dario-wieland/;+41 81 560 21 20;false;10;2026-07-23 08:16:38;2026-07-25 10:02:48 +2;pst;Sarah Fässler;;;;;sarah.faessler@pstgmbh.ch;sarah.faessler@pstgmbh.ch;;;false;10;2026-07-23 08:16:38;2026-07-23 08:16:38 +3;docuware;Vincenzo Carbone;DocuWare Schweiz;;;/images/placeholders/avatar-sample.svg;vincenzo.carbone@docuware.com;vincenzo.carbone@docuware.com;;;false;10;2026-07-23 08:16:38;2026-07-23 08:16:38 +4;odoo;Domenik Friedrich;;;;;domf@odoo.com;domf@odoo.com;;;false;10;2026-07-23 08:16:38;2026-07-23 08:16:38 +5;baselhack;BaselHack;;;;;info@baselhack.ch;info@baselhack.ch;;;false;10;2026-07-23 08:16:38;2026-07-23 08:16:38 +6;iway;Patrick Baumeler;;;;;patrick.baumeler@example.com;patrick.baumeler@example.com;https://www.linkedin.com/in/example-patrick;+41 61 000 00 03;false;10;2026-07-23 08:16:38;2026-07-23 08:16:38 \ No newline at end of file diff --git a/database/seeders/data/pages.csv b/database/seeders/data/pages.csv deleted file mode 100644 index 72ef42b..0000000 --- a/database/seeders/data/pages.csv +++ /dev/null @@ -1,15 +0,0 @@ -id;key;locale;robots;title;description;image;created_at;updated_at -1;start.index;de_CH;index,follow;Innovative Ideen zum Leben erwecken – codebar Solutions AG;Wir hören zu, verstehen dein Problem und begleiten dich von der ersten Idee bis zur Software im täglichen Einsatz. Ein kleines Team aus der Region Basel.;;2026-06-01 19:12:39;2026-07-22 00:00:00 -2;news.index;de_CH;index,follow;Neuigkeiten & Insights – codebar Solutions AG;Aktuelle News, Fachbeiträge und Trends rund um Softwareentwicklung, offene Technologien und digitale Innovation von codebar.;;2026-06-01 19:12:39;2026-06-01 19:12:39 -3;about-us.index;de_CH;index,follow;Team – die Menschen hinter codebar Solutions AG;Klein aus Überzeugung: kurze Wege, direkte Ansprechpersonen – und Ausbildung als Teil unserer Kultur. Lerne die Menschen hinter codebar kennen.;;2026-06-01 19:12:39;2026-07-22 00:00:00 -4;services.index;de_CH;index,follow;Expertise – Konzeption, Software, DMS & ERP | codebar Solutions AG;Vier Bereiche, ein Weg: von der ersten Idee bis zum laufenden Betrieb – Konzeption, Softwareentwicklung, Dokumentenmanagement und ERP.;;2026-06-01 19:12:39;2026-07-22 00:00:00 -5;products.index;de_CH;index,follow;Nutzerzentrierte Softwarelösungen – codebar Solutions AG;Nutzerzentrierte Softwarelösungen mit echtem Mehrwert – entwickelt mit offenen Technologien und Standards.;;2026-06-01 19:12:39;2026-06-01 19:12:39 -6;legal.imprint.index;de_CH;index,follow;Impressum – codebar Solutions AG;Rechtliche Angaben zur codebar Solutions AG.;;2026-06-01 19:12:39;2026-07-23 00:00:00 -7;contact.index;de_CH;index,follow;Kontakt – lass uns sprechen | codebar Solutions AG;Erzähl uns von deiner Idee oder deinem Projekt – wir hören zu. Telefon, E-Mail und unsere zwei Standorte in der Region Basel.;;2026-06-01 19:12:39;2026-07-22 00:00:00 -8;start.index;en_CH;index,follow;Bringing Innovative Ideas to Life – codebar Solutions AG;We listen, understand your problem and stay with you from the first idea to software in daily use. A small team based in the Basel region.;;2026-06-01 19:12:39;2026-07-22 00:00:00 -9;news.index;en_CH;index,follow;News & Insights – codebar Solutions AG;Latest news and expert insights on software development, open technologies and digital innovation from codebar.;;2026-06-01 19:12:39;2026-06-01 19:12:39 -10;about-us.index;en_CH;index,follow;Team – the People Behind codebar Solutions AG;Small by conviction: short paths, direct contacts – and training as part of our culture. Meet the people behind codebar.;;2026-06-01 19:12:39;2026-07-22 00:00:00 -11;services.index;en_CH;index,follow;Expertise – Concept, Software, DMS & ERP | codebar Solutions AG;Four areas, one path: from the first idea to day-to-day operations – concept design, software development, document management and ERP.;;2026-06-01 19:12:39;2026-07-22 00:00:00 -12;products.index;en_CH;index,follow;User-Centric Software Solutions – codebar Solutions AG;Our software solutions are built around the needs of users, delivering real value through open technologies and standards.;;2026-06-01 19:12:39;2026-06-01 19:12:39 -13;legal.imprint.index;en_CH;index,follow;Imprint – codebar Solutions AG;Legal details of codebar Solutions AG.;;2026-06-01 19:12:39;2026-07-23 00:00:00 -14;contact.index;en_CH;index,follow;Contact – Let's Talk | codebar Solutions AG;Tell us about your idea or your project – we'll listen. Phone, email and our two locations in the Basel region.;;2026-06-01 19:12:39;2026-07-22 00:00:00 diff --git a/database/seeders/data/technologies.csv b/database/seeders/data/technologies.csv deleted file mode 100644 index bb290f4..0000000 --- a/database/seeders/data/technologies.csv +++ /dev/null @@ -1,3 +0,0 @@ -id;published;locale;group;order;title;slug;teaser;content;image;tags;link;created_at;updated_at -1;true;de_CH;Backend;1;Laravel;laravel-framework;;;;"[""PHP""]";https://laravel.com/;2026-06-01 19:12:39;2026-06-01 19:12:39 -2;true;en_CH;Backend;1;Laravel;laravel-framework;;;;"[""PHP""]";https://laravel.com/;2026-06-01 19:12:39;2026-06-01 19:12:39 \ No newline at end of file diff --git a/database/seeders/data/users.csv b/database/seeders/data/users.csv index b16f662..78aa4ba 100644 --- a/database/seeders/data/users.csv +++ b/database/seeders/data/users.csv @@ -1,2 +1,2 @@ id;uuid;name;email;email_verified_at;password;locale;remember_token;created_at;updated_at;deleted_at -1;019e842c-5623-70e5-ade2-4dc853eb4072;codebar Solutions AG;info@codebar.ch;2026-06-01 19:12:38;$2y$12$e9pdQqV5Gd2BBo.z0Cq2re3zs8V/ux5TIz6iAD/uJIcFtZDZ.Bpzu;en_CH;;2026-06-01 19:12:38;2026-06-01 19:12:38; \ No newline at end of file +1;019f8d9e-6a6e-7092-ac74-440f5752b610;codebar Solutions AG;info@codebar.ch;2026-07-23 08:16:37;$2y$12$u0B5WMJrKZpglxA3i31DIO2LzEk2FtH5PmVmwKxgLzr.EEWJ9pQbS;en_CH;;2026-07-23 08:16:37;2026-07-23 08:16:37; \ No newline at end of file diff --git a/lang/de_CH.json b/lang/de_CH.json index 44022b0..2f3431e 100644 --- a/lang/de_CH.json +++ b/lang/de_CH.json @@ -1,75 +1,122 @@ { "+41 61 515 60 90": "+41 61 515 60 90", + "24/7 access": "24/7 Zugang", + "250 Mbit/s private network": "250 Mbit/s Private Virtual Network", + "3 months' rent per workstation": "3 Monatsmieten pro Arbeitsplatz", + ":count min": ":count Min.", + ":count min read": ":count Min. Lesezeit", + ":count news item on :topic|:count news items on :topic": ":count Neuigkeit zu :topic|:count Neuigkeiten zu :topic", + ":count news item|:count news items": ":count Neuigkeit|:count Neuigkeiten", + ":open to :close": ":open – :close", + "A direct line to the on-site tech team — one hour of consulting per month included.": "Direkter Draht zum Tech-Team vor Ort — 1 Stunde Beratung pro Monat inklusive.", + "A professional workspace in Oberwil — quiet, well-equipped, and embedded in a tech team.": "Ein professioneller Arbeitsplatz in Oberwil — ruhig, gut ausgestattet und Teil eines Tech-Teams.", + "AI": "KI", "About us": "Über uns", "Active": "Aktiv", - "AI": "KI", + "After": "Nachher", + "All": "Alle", + "All-inclusive — just bring your laptop.": "All-inclusive — bring einfach deinen Laptop mit.", "An entire weekend of building, learning and shipping: teams from the region take on real challenges and turn them into working prototypes. We are proud to help make that possible.": "Ein ganzes Wochenende bauen, lernen und liefern: Teams aus der Region nehmen echte Challenges an und machen daraus funktionierende Prototypen. Wir sind stolz, das mitzuermöglichen.", + "Assigned email": "Hinterlegte E-Mail-Adresse", + "Attention": "Achtung", "Author": "Autor", "Author: :name": "Autor: :name", "Authorized representatives": "Zeichnungsberechtigte Personen", - "Assigned email": "Hinterlegte E-Mail-Adresse", "Avatar": "Avatar", "Avatar URL": "Avatar-URL", + "Before": "Vorher", "Board of directors": "Verwaltungsrat", + "Book a viewing": "Besichtigung vereinbaren", "Branch office": "Zweigniederlassung", + "Building": "Gebäude", + "Business address & mailbox": "Geschäftsadresse & Briefkasten", "Certifications & Memberships": "Zertifizierungen & Mitgliedschaften", "Clear": "Leeren", + "Closed": "Geschlossen", + "Co-Working at codebar": "Co-Working bei codebar", "Collaboration": "Zusammenarbeit", "Collaboration Partner": "Projektpartner", "Company": "Unternehmen", "Company image": "Firmenbild", "Contact": "Kontakt", + "Contact Phone number": "Telefonnummer Kontakt", "Contact codebar to change your email address.": "Wende dich an codebar, um deine E-Mail-Adresse zu ändern.", "Contact email address": "E-Mail-Adresse Kontakt", "Contact persons": "Ansprechpersonen", - "Contact Phone number": "Telefonnummer Kontakt", "Contact us": "Kontaktiere uns", + "Contents": "Inhalt", + "Continue reading": "Weiterlesen", "Cover URL": "Cover-URL", + "Currently closed": "Jetzt geschlossen", + "Currently open": "Jetzt geöffnet", "DE": "DE", + "Deposit": "Kaution", "Deutsch": "Deutsch", "Disclaimer": "Haftungsausschluss", "DocuWare Silver Partner": "DocuWare Silber Partner", "Download template (JPG)": "Vorlage herunterladen (JPG)", + "EN": "EN", "Email": "E-Mail", "Employees": "Mitarbeitende", - "EN": "EN", "Ended": "Beendet", "English": "English", "Enter the email address stored in your network profile. If it is registered, we will send you a personal link — valid for one hour — to update your profile.": "Gib die E-Mail-Adresse ein, die in deinem Netzwerk-Profil hinterlegt ist. Ist sie registriert, senden wir dir einen persönlichen Link — eine Stunde gültig — um dein Profil zu aktualisieren.", + "Ergonomic chair": "Ergonomischer Bürostuhl", "Facts": "Fakten", + "Fibre upgrade up to 5 Gbit/s": "Glasfaser-Upgrade bis 5 Gbit/s", + "Filter by topic": "Nach Thema filtern", "Footer": "Fusszeile", "For several years we have been supporting BaselHack — the largest hackathon in the region — with infrastructure and material, and for the last two years as a Silver Sponsor.": "Seit mehreren Jahren unterstützen wir den BaselHack — den grössten Hackathon der Region — mit Infrastruktur und Material, in den letzten zwei Jahren als Silver Sponsor.", "Format": "Format", + "Framery One pod for calls and small meetings.": "Framery One Pod für Meetings oder Telefongespräche.", + "Friday": "Freitag", + "Fully-equipped kitchen": "Voll ausgestattete Küche", "Good software is not created alone. Our network is built on the open source spirit, on learning together as a community, and on partnerships that matter to us.": "Gute Software entsteht nicht im Alleingang. Unser Netzwerk lebt vom Open-Source-Gedanken, vom Lernen in der Community und von Partnerschaften, die uns wichtig sind.", "Google Maps": "Google Maps", "Headquarter": "Hauptsitz", + "Height-adjustable desk": "Höhenverstellbarer Arbeitsplatz", "Hello :name": "Hallo :name", "Home": "Start", + "IT & connectivity": "IT & Konnektivität", "If the email address is registered, we have sent you a link.": "Falls die E-Mail-Adresse registriert ist, haben wir dir einen Link gesendet.", "If you did not request this link, you can ignore this email.": "Falls du diesen Link nicht angefordert hast, kannst du diese E-Mail ignorieren.", "Imprint": "Impressum", - "Imprint disclaimer": "Alle Inhalte dieser Website wurden mit Sorgfalt erstellt. Für Richtigkeit, Vollständigkeit und Aktualität übernehmen wir keine Gewähr. Für Inhalte verlinkter Websites Dritter sind ausschliesslich deren Betreiber verantwortlich.", - "Imprint address street": "Hauptstrasse 91", - "Imprint address city": "CH-4455 Zunzgen", - "Imprint individual signature": "Einzelunterschrift", "Imprint Melanie Burgin residence": "von Rothenfluh, in Oberwil (BL)", "Imprint Melanie Burgin role": "Mitglied des Verwaltungsrates", - "Imprint representatives placeholder": "Angaben zu zeichnungsberechtigten Personen werden in Kürze veröffentlicht.", "Imprint Sebastian Burgin residence": "von Arlesheim, in Oberwil (BL)", "Imprint Sebastian Burgin role": "Präsident des Verwaltungsrates, Geschäftsführer", - "info(at)codebar.ch": "info(at)codebar.ch", + "Imprint address city": "CH-4455 Zunzgen", + "Imprint address street": "Hauptstrasse 91", + "Imprint disclaimer": "Alle Inhalte dieser Website wurden mit Sorgfalt erstellt. Für Richtigkeit, Vollständigkeit und Aktualität übernehmen wir keine Gewähr. Für Inhalte verlinkter Websites Dritter sind ausschliesslich deren Betreiber verantwortlich.", + "Imprint individual signature": "Einzelunterschrift", + "Imprint representatives placeholder": "Angaben zu zeichnungsberechtigten Personen werden in Kürze veröffentlicht.", + "In short": "Kurz gefasst", + "Includes video-call infrastructure for team meetings.": "Inkl. Video-Call-Infrastruktur für Team-Meetings.", + "Including coffee and a Quooker water dispenser.": "Inkl. Kaffee und Quooker Wasserspender.", "Infrastructure Partner": "Infrastrukturpartner", "Insights from our day-to-day work: what we are building, what we are learning, and what is happening at codebar.": "Einblicke aus unserem Alltag: was wir bauen, was wir lernen und was bei codebar gerade läuft.", - "Jobs": "Stellen", + "Integrated into a professionally managed enterprise infrastructure.": "Integriert in eine professionell verwaltete Enterprise-Infrastruktur.", "JPG, PNG, WebP or AVIF, 1:1, max. 2 MB.": "JPG, PNG, WebP oder AVIF, 1:1, max. 2 MB.", "JPG, PNG, WebP or AVIF, 3:1, max. 4 MB.": "JPG, PNG, WebP oder AVIF, 3:1, max. 4 MB.", + "Jobs": "Stellen", + "Jobs intro": "Wir sind ein kleines Team aus der Region Basel – klein aus Überzeugung. Bei uns gibt es keine Abteilungen, an die man Arbeit abgibt: Jede:r deckt mehrere Rollen ab, vom Kundengespräch bis zum Code. Wir entwickeln mit offenen Technologien wie Laravel, geben eigene Packages an die Community zurück und setzen alles, was wir anbieten, auch selbst ein. Wer bei uns anfängt, übernimmt vom ersten Tag Verantwortung in echten Projekten – und lernt ständig dazu.", + "Jobs no open positions": "Zurzeit sind keine Stellen ausgeschrieben.", + "Jobs open positions heading": "Offene Stellen", + "Jobs page header": "Lust, mit uns Software zu bauen? Hier erfährst du, wie wir arbeiten – und wie du an Bord kommst.", + "Jobs spontaneous body": "Du findest, wir sollten dich kennenlernen? Wir freuen uns auch ohne ausgeschriebene Stelle über deine Bewerbung – erzähl uns, was du kannst und was dich antreibt:", + "Jobs spontaneous heading": "Initiativbewerbung", + "Jobs training body": "Ausbildung gehört bei uns dazu. Seit zwei Jahren bieten wir Praktikumsplätze an – jeweils für ein bis anderthalb Jahre, mitten im Projektalltag statt am Rand. Du startest auf deinem Wissensstand, bekommst echte Aufgaben und eine Ansprechperson, die selbst programmiert. Offene Praktikumsstellen schreiben wir jeweils aus.", + "Jobs training heading": "Ausbildung & Praktika", + "Keyless entry via RFID & app — no physical keys.": "Keyless Entry per RFID & App — kein physischer Schlüssel.", "Kind regards": "Liebe Grüsse", + "LO-Next system by Lista Office AG, plus storage for personal materials.": "LO-Next-System von Lista Office AG, inkl. Stauflächen für persönliche Unterlagen.", "Language": "Sprache", "Last updated at": "Zuletzt aktualisiert am", "Last updated at: :date": "Zuletzt aktualisiert am: :date", "Learn more": "Mehr erfahren", "Legal": "Rechtliches", "Legal form AG": "Aktiengesellschaft (AG)", + "Location": "Standort", "Locations": "Standorte", "Logo black white": "Schwarz auf Weiss", "Logo colored": "Farbig (Original)", @@ -77,25 +124,46 @@ "Logo white black": "Weiss auf Schwarz", "Logos": "Logos", "Manage my profile": "Mein Profil verwalten", + "Managed enterprise infrastructure": "Managed Enterprise-Infrastruktur", "Media": "Medien", "Media intro": "Offizielle codebar-Logos für Presse und Partner.", + "Media usage": "Verwendung", + "Media usage allowed": "Die Logos dürfen für Presseberichte, Partnerseiten und Veranstaltungshinweise unverändert verwendet werden. Bitte behalte Proportionen, Farben und einen ruhigen Freiraum ringsum bei: die farbige Variante auf hellem Grund, die invertierte auf dunklem.", + "Media usage forbidden": "Nicht in Ordnung sind Verzerrungen, eigene Farbgebungen, Effekte oder das Logo als Bestandteil eines anderen Zeichens. Brauchst du ein weiteres Format oder bist du unsicher, melde dich kurz bei uns.", + "Meeting pod": "Meeting-Pod", + "Meeting room with 50″ monitor": "Sitzungszimmer mit 50″-Monitor", "Menu": "Menü", "Meta information": "Metainformationen", + "Minimum term": "Mindestlaufzeit", + "Monday": "Montag", + "Monitor & video camera": "Monitor & Videokamera", "More about :name": "Mehr über :name", "Name": "Name", "Network": "Netzwerk", "News": "Neuigkeiten", "No": "Nein", + "No news on this topic yet.": "Zu diesem Thema gibt es noch keine Neuigkeiten.", "Not published": "Nicht publiziert", + "Note": "Hinweis", + "Notice period": "Kündigungsfrist", "Oberwil": "Oberwil", "Office": "Büro", + "Office, kitchen and sanitary areas — every Wednesday morning.": "Büro, Küche und Sanitäranlagen — jeden Mittwochmorgen.", + "On request": "Auf Anfrage", + "On-site IT support": "IT-Unterstützung vor Ort", "Open Source": "Open Source", + "Opening hours": "Öffnungszeiten", + "Optional add-ons": "Optionale Zusatzleistungen", "Our partnership with :company is carried by people — first and foremost you. That is why our website shows not just companies but also the people behind them: if you like, you can be part of it in person, with your own profile.": "Unsere Partnerschaft mit :company lebt von Menschen – allen voran von dir. Darum zeigen wir auf unserer Website nicht nur Firmen, sondern auch die Menschen dahinter: Wenn du magst, bist du mit deinem eigenen Profil persönlich dabei.", + "Parking with EV charging": "Parkplatz mit E-Ladestation", + "Part :position": "Teil :position", + "Part :position of :total": "Teil :position von :total", "Partnerships": "Partnerschaften", "Phone": "Telefon", + "Play video": "Video abspielen", + "Pricing": "Preise", + "Printer & scanner": "Drucker & Scanner", "Privacy": "Datenschutz", - "Privacy page intro": "Wie die codebar Solutions AG Personendaten auf dieser Website gemäss Schweizer Datenschutzrecht bearbeitet.", - "Privacy last updated date": "30.06.2026", "Privacy changes body": "Wir können diese Datenschutzerklärung von Zeit zu Zeit anpassen. Die aktuelle Version findest du stets auf dieser Seite mit dem Datum der letzten Aktualisierung.", "Privacy changes heading": "Änderungen", "Privacy contact body": "Bei Fragen zum Datenschutz oder zur Ausübung deiner Rechte:", @@ -108,6 +176,8 @@ "Privacy data collected intro": "Beim Besuch dieser Website verarbeiten wir folgende Datenkategorien:", "Privacy data collected logs": "Server-Logdaten (IP-Adresse, Datum und Uhrzeit des Zugriffs, Browsertyp, besuchte Seiten)", "Privacy data collected session": "Technisch notwendige Session- und CSRF-Cookies für den sicheren Betrieb der Website", + "Privacy last updated date": "30.06.2026", + "Privacy page intro": "Wie die codebar Solutions AG Personendaten auf dieser Website gemäss Schweizer Datenschutzrecht bearbeitet.", "Privacy processors body": "Wir setzen folgende Dienstleister ein, die Personendaten in unserem Auftrag verarbeiten können:", "Privacy processors cloudinary": "Cloudinary – Auslieferung von Bildern und Medien (Datenschutz: cloudinary.com/privacy)", "Privacy processors fathom": "Fathom Analytics – datenschutzfreundliche Website-Statistiken (usefathom.com/privacy)", @@ -129,182 +199,135 @@ "Privacy security heading": "Datensicherheit", "Products": "Produkte", "Products born out of real project work: developed by us, in daily use, and continuously improved.": "Produkte, die aus echter Projektarbeit entstanden sind: von uns entwickelt, im täglichen Einsatz und laufend weiterentwickelt.", + "Professional, available to all members.": "Professionell, zur gemeinsamen Nutzung.", "Profile": "Profil", "Public email": "Öffentliche E-Mail-Adresse", "Published": "Publiziert", "Published at": "Veröffentlicht am", "Published at: :date": "Veröffentlicht am: :date", + "Rental conditions": "Mietkonditionen", "Request link": "Link anfordern", "Resources": "Ressourcen", "Role": "Rolle", + "Saturday": "Samstag", "Save changes": "Änderungen speichern", + "Series": "Serie", "Services": "Expertise", "Set by codebar.": "Wird von codebar gesetzt.", - "Shown publicly on the network page. Leave empty to hide it.": "Wird öffentlich auf der Netzwerkseite angezeigt. Leer lassen, um sie auszublenden.", "Set by codebar. Empty = Gravatar.": "Wird von codebar gesetzt. Leer = Gravatar.", + "Shown publicly on the network page. Leave empty to hide it.": "Wird öffentlich auf der Netzwerkseite angezeigt. Leer lassen, um sie auszublenden.", "Silver Sponsor, infrastructure": "Silver Sponsor, Infrastruktur", + "Single workstation": "Einzelplatz", "Skip to content": "Zum Inhalt springen", "Software Partner": "Softwarepartner", "Sponsoring & Community": "Sponsoring & Community", + "Static public IP": "Feste öffentliche IP-Adresse", + "Sunday": "Sonntag", "Team": "Team", "Technologies": "Technologien", "Terms": "AGB", - "Terms last updated date": "23.07.2026", - "The avatar is a raw upload — please convert it to Cloudinary.": "Der Avatar ist ein Roh-Upload — bitte nach Cloudinary konvertieren.", - "The company image is a raw upload — please convert it to Cloudinary.": "Das Firmenbild ist ein Roh-Upload — bitte nach Cloudinary konvertieren.", - "The largest hackathon in the Basel region": "Der grösste Hackathon der Region Basel", - "The tools and technologies we work with every day — chosen deliberately and mastered in depth.": "Die Werkzeuge und Technologien, mit denen wir täglich arbeiten — bewusst gewählt und in der Tiefe beherrscht.", - "Update my profile": "Mein Profil aktualisieren", - "Update network profile": "Netzwerk-Profil aktualisieren", - "Update to english language": "Auf Englisch wechseln", - "Update to german language": "Auf Deutsch wechseln", - "Update your codebar network profile": "Aktualisiere dein codebar Netzwerk-Profil", - "Upload": "Hochladen", - "Use the link below to create and manage your profile — it only goes live once you publish it. The link is valid for 96 hours.": "Über den folgenden Link erstellst und verwaltest du dein Profil — sichtbar wird es erst, wenn du es publizierst. Der Link ist 96 Stunden gültig.", - "Use this link to update your own profile. For anything else, please contact codebar.": "Über diesen Link aktualisierst du dein eigenes Profil. Für alles andere melde dich bei codebar.", - "Visible in the public network directory.": "Sichtbar im öffentlichen Netzwerk-Verzeichnis.", - "Visibility of the company is managed by codebar.": "Die Sichtbarkeit der Firma steuert codebar.", - "We build on open source — and give back. These are the projects and contributions we share with the community.": "Wir bauen auf Open Source — und geben etwas zurück. Das sind die Projekte und Beiträge, die wir mit der Community teilen.", - "Website": "Website", - "Yes": "Ja", - "You can request a new link at any time: enter your email address under \"Network → Profile\" in the website footer.": "Einen neuen Link kannst du jederzeit anfordern: Gib deine E-Mail-Adresse unter «Netzwerk → Profil» im Footer der Website ein.", - "You requested a link to update your profile in the codebar network. The link is valid for one hour and only applies to your own profile.": "Du hast einen Link angefordert, um dein Profil im codebar Netzwerk zu aktualisieren. Der Link ist eine Stunde gültig und gilt nur für dein eigenes Profil.", - "Your profile has been updated.": "Dein Profil wurde aktualisiert.", - "Your profile in the codebar network": "Dein Profil im codebar Netzwerk", - "Zefix": "Zefix", - "Zunzgen": "Zunzgen", - "24/7 access": "24/7 Zugang", - "Monday": "Montag", - "Tuesday": "Dienstag", - "Wednesday": "Mittwoch", - "Thursday": "Donnerstag", - "Friday": "Freitag", - "Saturday": "Samstag", - "Sunday": "Sonntag", - "Opening hours": "Öffnungszeiten", - "Currently open": "Jetzt geöffnet", - "Currently closed": "Jetzt geschlossen", - "Closed": "Geschlossen", - ":open to :close": ":open – :close", - "3 months' rent per workstation": "3 Monatsmieten pro Arbeitsplatz", - "250 Mbit/s private network": "250 Mbit/s Private Virtual Network", - "A direct line to the on-site tech team — one hour of consulting per month included.": "Direkter Draht zum Tech-Team vor Ort — 1 Stunde Beratung pro Monat inklusive.", - "A professional workspace in Oberwil — quiet, well-equipped, and embedded in a tech team.": "Ein professioneller Arbeitsplatz in Oberwil — ruhig, gut ausgestattet und Teil eines Tech-Teams.", - "All-inclusive — just bring your laptop.": "All-inclusive — bring einfach deinen Laptop mit.", - "Book a viewing": "Besichtigung vereinbaren", - "Building": "Gebäude", - "Business address & mailbox": "Geschäftsadresse & Briefkasten", - "Co-Working at codebar": "Co-Working bei codebar", - "codebar Solutions AG offers fully-equipped workstations in a professional co-working environment in Oberwil. Ideal for freelancers, start-ups, and remote employees in the Basel region looking for a quiet, professional alternative to working from home — with first-class infrastructure and flexible terms.": "codebar Solutions AG bietet vollausgestattete Arbeitsplätze in professionellem Co-Working-Ambiente in Oberwil. Ideal für Freelancer, Start-ups und Remote-Mitarbeitende, die in der Region Basel einen ruhigen, professionellen Arbeitsplatz ohne eigenes Büro suchen — mit erstklassiger Infrastruktur und flexiblen Konditionen.", - "Deposit": "Kaution", - "Ergonomic chair": "Ergonomischer Bürostuhl", - "Fibre upgrade up to 5 Gbit/s": "Glasfaser-Upgrade bis 5 Gbit/s", - "Framery One pod for calls and small meetings.": "Framery One Pod für Meetings oder Telefongespräche.", - "from CHF 250.00 / month": "ab CHF 250.00 / Monat", - "Fully-equipped kitchen": "Voll ausgestattete Küche", - "Height-adjustable desk": "Höhenverstellbarer Arbeitsplatz", - "Including coffee and a Quooker water dispenser.": "Inkl. Kaffee und Quooker Wasserspender.", - "Includes video-call infrastructure for team meetings.": "Inkl. Video-Call-Infrastruktur für Team-Meetings.", - "Integrated into a professionally managed enterprise infrastructure.": "Integriert in eine professionell verwaltete Enterprise-Infrastruktur.", - "IT & connectivity": "IT & Konnektivität", - "Keyless entry via RFID & app — no physical keys.": "Keyless Entry per RFID & App — kein physischer Schlüssel.", - "LO-Next system by Lista Office AG, plus storage for personal materials.": "LO-Next-System von Lista Office AG, inkl. Stauflächen für persönliche Unterlagen.", - "Location": "Standort", - "Managed enterprise infrastructure": "Managed Enterprise-Infrastruktur", - "Meeting pod": "Meeting-Pod", - "Meeting room with 50″ monitor": "Sitzungszimmer mit 50″-Monitor", - "Minimum term": "Mindestlaufzeit", - "Monitor & video camera": "Monitor & Videokamera", - "month": "Monat", - "months": "Monate", - "Notice period": "Kündigungsfrist", - "Office, kitchen and sanitary areas — every Wednesday morning.": "Büro, Küche und Sanitäranlagen — jeden Mittwochmorgen.", - "On request": "Auf Anfrage", - "On-site IT support": "IT-Unterstützung vor Ort", - "Optional add-ons": "Optionale Zusatzleistungen", - "Parking with EV charging": "Parkplatz mit E-Ladestation", - "Pricing": "Preise", - "Printer & scanner": "Drucker & Scanner", - "Professional, available to all members.": "Professionell, zur gemeinsamen Nutzung.", - "Rental conditions": "Mietkonditionen", - "Single workstation": "Einzelplatz", - "Static public IP": "Feste öffentliche IP-Adresse", - "Use the address as your registered company location.": "Nutzbar als Firmenadresse für dein Unternehmen.", - "Weekly professional cleaning": "Wöchentliche professionelle Reinigung", - "What’s included": "Das ist inbegriffen", - "Wilkhahn seating designed for long, healthy workdays.": "Wilkhahn Bürostühle — ergonomisch, für gesunde Arbeitstage.", - "With 5G failover for uninterrupted connectivity.": "Mit 5G-Fallback für Ausfallsicherheit.", - "Workplace": "Arbeitsplatz", - "Jobs page header": "Lust, mit uns Software zu bauen? Hier erfährst du, wie wir arbeiten – und wie du an Bord kommst.", - "Jobs intro": "Wir sind ein kleines Team aus der Region Basel – klein aus Überzeugung. Bei uns gibt es keine Abteilungen, an die man Arbeit abgibt: Jede:r deckt mehrere Rollen ab, vom Kundengespräch bis zum Code. Wir entwickeln mit offenen Technologien wie Laravel, geben eigene Packages an die Community zurück und setzen alles, was wir anbieten, auch selbst ein. Wer bei uns anfängt, übernimmt vom ersten Tag Verantwortung in echten Projekten – und lernt ständig dazu.", - "Jobs training heading": "Ausbildung & Praktika", - "Jobs training body": "Ausbildung gehört bei uns dazu. Seit zwei Jahren bieten wir Praktikumsplätze an – jeweils für ein bis anderthalb Jahre, mitten im Projektalltag statt am Rand. Du startest auf deinem Wissensstand, bekommst echte Aufgaben und eine Ansprechperson, die selbst programmiert. Offene Praktikumsstellen schreiben wir jeweils aus.", - "Jobs open positions heading": "Offene Stellen", - "Jobs no open positions": "Zurzeit sind keine Stellen ausgeschrieben.", - "Jobs spontaneous heading": "Initiativbewerbung", - "Jobs spontaneous body": "Du findest, wir sollten dich kennenlernen? Wir freuen uns auch ohne ausgeschriebene Stelle über deine Bewerbung – erzähl uns, was du kannst und was dich antreibt:", - "Terms page heading": "Allgemeine Geschäftsbedingungen", "Terms 1 heading": "1. Allgemeiner Teil", - "Terms 1.1 heading": "1.1 Anwendungsbereich", "Terms 1.1 body": "Diese Allgemeinen Geschäftsbedingungen (AGB) regeln die Zusammenarbeit zwischen der codebar Solutions AG (nachfolgend «codebar») und ihren Kunden. Sie gelten für sämtliche Angebote, Dienstleistungen und Produkte von codebar, sofern im Einzelvertrag nichts Abweichendes vereinbart ist. Abweichende Bedingungen des Kunden gelten nur, wenn sie von codebar ausdrücklich und schriftlich anerkannt wurden.", - "Terms 1.2 heading": "1.2 Angebote & Auftragserteilung", + "Terms 1.1 heading": "1.1 Anwendungsbereich", "Terms 1.2 body": "Offerten sind 30 Tage gültig, sofern nichts anderes vermerkt ist. Bei Softwareentwicklungsleistungen basieren Offerten auf Schätzungen; Aufwand und Termine können im Projektverlauf variieren. Ein Auftrag kommt zustande, wenn der Kunde das Angebot schriftlich bestätigt oder digital signiert. Änderungen des Leistungsumfangs (Change Requests) sind jederzeit möglich und werden nach Aufwand separat verrechnet, sofern nichts anderes vereinbart wird.", - "Terms 1.3 heading": "1.3 Kein Widerrufsrecht", + "Terms 1.2 heading": "1.2 Angebote & Auftragserteilung", "Terms 1.3 body": "Bestellungen können nach der Auftragserteilung nicht widerrufen werden. Dies gilt insbesondere für Lizenzbestellungen: Bestellte Lizenzen sind vom Widerruf ausgeschlossen.", - "Terms 1.4 heading": "1.4 Vertragsbeginn", + "Terms 1.3 heading": "1.3 Kein Widerrufsrecht", "Terms 1.4 body": "Dienstleistungen treten mit dem vereinbarten Datum oder, mangels Datum, mit dem Start der Arbeiten in Kraft.", - "Terms 1.5 heading": "1.5 Vertragslaufzeit & Vertragsende", + "Terms 1.4 heading": "1.4 Vertragsbeginn", "Terms 1.5 body": "Projektverträge enden mit der Übergabe bzw. Abnahme der Leistungen. Wiederkehrende Verträge (z. B. Support-, Wartungs- und Lizenzverträge) laufen grundsätzlich vom 1. Januar bis zum 31. Dezember. Die Verträge verlängern sich nicht automatisch; eine Fortführung erfolgt durch Erneuerung.", - "Terms 1.6 heading": "1.6 Preise", + "Terms 1.5 heading": "1.5 Vertragslaufzeit & Vertragsende", "Terms 1.6 body": "Alle Preise verstehen sich netto in Schweizer Franken (CHF), exklusive Mehrwertsteuer. Dienstleistungen werden nach effektivem Aufwand zu den vereinbarten Stundensätzen verrechnet, sofern nicht schriftlich ein Fixpreis vereinbart wurde.", - "Terms 1.7 heading": "1.7 Zahlungsbedingungen", + "Terms 1.6 heading": "1.6 Preise", "Terms 1.7 body": "Rechnungen sind innert 14 Tagen ohne Abzug zahlbar. Bei Zahlungsverzug wird ein Verzugszins von 5 % geschuldet.", - "Terms 1.8 heading": "1.8 Zahlungsverzug des Kunden", + "Terms 1.7 heading": "1.7 Zahlungsbedingungen", "Terms 1.8 body": "Bei Zahlungsverzug kann codebar laufende Arbeiten pausieren, Datenzugriffe einschränken (sofern das Hosting durch codebar erfolgt) sowie Verzugszins und entstandenen Aufwand in Rechnung stellen.", - "Terms 1.9 heading": "1.9 Ausserordentliche Kündigung", + "Terms 1.8 heading": "1.8 Zahlungsverzug des Kunden", "Terms 1.9 body": "Eine sofortige Vertragsauflösung ist möglich bei schwerem Vertragsbruch, bei Zahlungsunfähigkeit oder Insolvenz sowie bei Verweigerung der Mitwirkung durch den Kunden.", + "Terms 1.9 heading": "1.9 Ausserordentliche Kündigung", "Terms 2 heading": "2. Dienstleistungen & Projektabwicklung", - "Terms 2.1 heading": "2.1 Leistungserbringung", "Terms 2.1 body": "codebar erbringt alle Leistungen nach bestem Wissen und gemäss aktuellem Stand der Technik. Dienstleistungen können teilweise durch qualifizierte Dritte erbracht werden.", - "Terms 2.2 heading": "2.2 Projektmethodik", + "Terms 2.1 heading": "2.1 Leistungserbringung", "Terms 2.2 body": "Sofern nicht anders vereinbart, erfolgen Projekte agil (z. B. Scrum/Kanban). Der Kunde verpflichtet sich zur Priorisierung von Anforderungen, zu Feedback innerhalb nützlicher Frist sowie zu Freigaben pro Sprint bzw. Release.", - "Terms 2.3 heading": "2.3 Termine", + "Terms 2.2 heading": "2.2 Projektmethodik", "Terms 2.3 body": "Termine sind Schätzwerte und nicht verbindlich, sofern nicht ausdrücklich zugesichert. Verzögerungen aufgrund von Mitwirkungen des Kunden (z. B. fehlende Daten, Inputs oder Entscheidungen) verlängern die Termine entsprechend.", - "Terms 2.4 heading": "2.4 Übergabe & Abnahme", + "Terms 2.3 heading": "2.3 Termine", "Terms 2.4 body": "Die Abnahme erfolgt formal durch Abnahmeprotokoll, automatisch 14 Tage nach Bereitstellung, falls keine schriftliche Mängelmeldung erfolgt, oder durch produktiven Einsatz der Software.", - "Terms 2.5 heading": "2.5 Gewährleistung", + "Terms 2.4 heading": "2.4 Übergabe & Abnahme", "Terms 2.5 body": "codebar gewährleistet während 3 Monaten ab Abnahme, dass die Software den vereinbarten Anforderungen entspricht. Nicht als Mängel gelten neue Anforderungen oder Funktionswünsche, Drittprobleme (Hosting, Browser, Betriebssysteme, externe APIs) sowie Anpassungen durch den Kunden oder Dritte. Die Gewährleistung beschränkt sich auf Nachbesserung.", - "Terms 2.6 heading": "2.6 Selbstverschulden", + "Terms 2.5 heading": "2.5 Gewährleistung", "Terms 2.6 body": "Bei eigenmächtigen Änderungen (Code, Systeme, Deployments) oder unsachgemässer Nutzung entfallen sämtliche Ansprüche.", - "Terms 2.7 heading": "2.7 Change Management", + "Terms 2.6 heading": "2.6 Selbstverschulden", "Terms 2.7 body": "Sämtliche Änderungen des Leistungsumfangs gelten als Change Request und werden nach Aufwand verrechnet, sofern nicht schriftlich anders festgelegt.", - "Terms 2.8 heading": "2.8 Zusatzaufwand", + "Terms 2.7 heading": "2.7 Change Management", "Terms 2.8 body": "Separat verrechnet werden insbesondere die Fehleranalyse bei Fremdsoftware oder Kundensystemen, die Wiederherstellung nach Datenverlust ohne Backupvertrag, zusätzliche Iterationen aufgrund fehlender Kundenauskünfte sowie Arbeiten ausserhalb der üblichen Arbeitszeiten.", - "Terms 2.9 heading": "2.9 Mitwirkungspflichten des Kunden", + "Terms 2.8 heading": "2.8 Zusatzaufwand", "Terms 2.9 body": "Der Kunde erbringt alle notwendigen Mitwirkungen rechtzeitig, insbesondere die Bereitstellung von Systemzugängen und Testdaten, rechtzeitiges Feedback, Freigaben und Ansprechpersonen, die Sicherstellung der IT-Sicherheit seiner Infrastruktur sowie die Durchführung eigener Abnahmetests. Kommt der Kunde seinen Pflichten nicht nach, kann codebar den Mehraufwand verrechnen.", + "Terms 2.9 heading": "2.9 Mitwirkungspflichten des Kunden", "Terms 3 heading": "3. Nutzungsrechte & geistiges Eigentum", - "Terms 3.1 heading": "3.1 Eigentum & Nutzungsrechte", "Terms 3.1 body": "Sofern im Einzelvertrag nichts anderes festgelegt ist, behält codebar sämtliche Rechte an generischen Komponenten, Frameworks, Libraries, Tools und Entwicklungs-Know-how. Der Kunde erhält ein nicht-exklusives, zeitlich unbeschränktes Nutzungsrecht an den spezifisch für ihn entwickelten Teilen.", - "Terms 3.2 heading": "3.2 Herausgabe von Quellcode", + "Terms 3.1 heading": "3.1 Eigentum & Nutzungsrechte", "Terms 3.2 body": "Der Kunde erhält den Quellcode, sofern dies Vertragsbestandteil ist. Ohne besondere Vereinbarung erhält der Kunde Zugriff auf Build-Artefakte, jedoch keinen Anspruch auf interne Tools, Frameworks oder Prozessdokumentationen.", - "Terms 3.3 heading": "3.3 Drittsoftware & Open Source", + "Terms 3.2 heading": "3.2 Herausgabe von Quellcode", "Terms 3.3 body": "Beim Einsatz von Drittsoftware oder Open-Source-Komponenten gelten deren jeweilige Lizenzbedingungen.", + "Terms 3.3 heading": "3.3 Drittsoftware & Open Source", "Terms 4 heading": "4. Haftung", - "Terms 4.1 heading": "4.1 Haftungsumfang", "Terms 4.1 body": "Die Haftung von codebar ist – soweit gesetzlich zulässig – beschränkt auf den Wert der letzten drei Monatsrechnungen, höchstens jedoch CHF 50'000, je nachdem, welcher Betrag tiefer ist.", - "Terms 4.2 heading": "4.2 Haftungsausschlüsse", + "Terms 4.1 heading": "4.1 Haftungsumfang", "Terms 4.2 body": "codebar haftet nicht für indirekte Schäden (z. B. Umsatzverlust, Produktionsausfall oder Datenverlust), Probleme durch Drittsoftware oder externe Systeme, Hosting- oder Infrastrukturfehler des Kunden sowie Sicherheitsvorfälle ohne entsprechenden Sicherheitsvertrag.", - "Terms 4.3 heading": "4.3 Datensicherung", + "Terms 4.2 heading": "4.2 Haftungsausschlüsse", "Terms 4.3 body": "Der Kunde ist für Backups verantwortlich, sofern kein Hosting- oder Backupvertrag abgeschlossen wurde.", - "Terms 5 heading": "5. Support, Wartung & SLAs", + "Terms 4.3 heading": "4.3 Datensicherung", "Terms 5 body": "Supportleistungen erfolgen nach Aufwand, sofern kein Wartungsvertrag besteht. SLA-Level werden in separaten Verträgen geregelt.", - "Terms 6 heading": "6. Vertraulichkeit", + "Terms 5 heading": "5. Support, Wartung & SLAs", "Terms 6 body": "Beide Parteien verpflichten sich zur Geheimhaltung aller vertraulichen Informationen, auch über das Vertragsende hinaus. codebar darf die Zusammenarbeit als Referenz erwähnen, sofern der Kunde nicht schriftlich widerspricht.", - "Terms 7 heading": "7. Übertragung", + "Terms 6 heading": "6. Vertraulichkeit", "Terms 7 body": "codebar kann Verträge oder Teile davon an verbundene Unternehmen übertragen.", - "Terms 8 heading": "8. Salvatorische Klausel", + "Terms 7 heading": "7. Übertragung", "Terms 8 body": "Sollten einzelne Bestimmungen dieser AGB ungültig sein, werden sie durch Bestimmungen ersetzt, die dem wirtschaftlichen Zweck am nächsten kommen. Die Gültigkeit der übrigen Bestimmungen bleibt unberührt.", - "Terms 9 heading": "9. Gerichtsstand & anwendbares Recht", + "Terms 8 heading": "8. Salvatorische Klausel", "Terms 9 body": "Es gilt Schweizer Recht. Gerichtsstand ist der Sitz der codebar Solutions AG, sofern nicht anders vereinbart.", - "Terms language body": "Bei Abweichungen zwischen der deutschen und der englischen Fassung dieser AGB ist die deutsche Fassung massgebend." + "Terms 9 heading": "9. Gerichtsstand & anwendbares Recht", + "Terms language body": "Bei Abweichungen zwischen der deutschen und der englischen Fassung dieser AGB ist die deutsche Fassung massgebend.", + "Terms last updated date": "23.07.2026", + "Terms page heading": "Allgemeine Geschäftsbedingungen", + "The avatar is a raw upload — please convert it to Cloudinary.": "Der Avatar ist ein Roh-Upload — bitte nach Cloudinary konvertieren.", + "The company image is a raw upload — please convert it to Cloudinary.": "Das Firmenbild ist ein Roh-Upload — bitte nach Cloudinary konvertieren.", + "The largest hackathon in the Basel region": "Der grösste Hackathon der Region Basel", + "The tools and technologies we work with every day — chosen deliberately and mastered in depth.": "Die Werkzeuge und Technologien, mit denen wir täglich arbeiten — bewusst gewählt und in der Tiefe beherrscht.", + "Thursday": "Donnerstag", + "Tip": "Tipp", + "Tuesday": "Dienstag", + "Update my profile": "Mein Profil aktualisieren", + "Update network profile": "Netzwerk-Profil aktualisieren", + "Update to english language": "Auf Englisch wechseln", + "Update to german language": "Auf Deutsch wechseln", + "Update your codebar network profile": "Aktualisiere dein codebar Netzwerk-Profil", + "Upload": "Hochladen", + "Use the address as your registered company location.": "Nutzbar als Firmenadresse für dein Unternehmen.", + "Use the link below to create and manage your profile — it only goes live once you publish it. The link is valid for 96 hours.": "Über den folgenden Link erstellst und verwaltest du dein Profil — sichtbar wird es erst, wenn du es publizierst. Der Link ist 96 Stunden gültig.", + "Use this link to update your own profile. For anything else, please contact codebar.": "Über diesen Link aktualisierst du dein eigenes Profil. Für alles andere melde dich bei codebar.", + "Visibility of the company is managed by codebar.": "Die Sichtbarkeit der Firma steuert codebar.", + "Visible in the public network directory.": "Sichtbar im öffentlichen Netzwerk-Verzeichnis.", + "We build on open source — and give back. These are the projects and contributions we share with the community.": "Wir bauen auf Open Source — und geben etwas zurück. Das sind die Projekte und Beiträge, die wir mit der Community teilen.", + "Website": "Website", + "Wednesday": "Mittwoch", + "Weekly professional cleaning": "Wöchentliche professionelle Reinigung", + "What’s included": "Das ist inbegriffen", + "Wilkhahn seating designed for long, healthy workdays.": "Wilkhahn Bürostühle — ergonomisch, für gesunde Arbeitstage.", + "With 5G failover for uninterrupted connectivity.": "Mit 5G-Fallback für Ausfallsicherheit.", + "Workplace": "Arbeitsplatz", + "Written by": "Geschrieben von", + "Yes": "Ja", + "You can request a new link at any time: enter your email address under \"Network → Profile\" in the website footer.": "Einen neuen Link kannst du jederzeit anfordern: Gib deine E-Mail-Adresse unter «Netzwerk → Profil» im Footer der Website ein.", + "You requested a link to update your profile in the codebar network. The link is valid for one hour and only applies to your own profile.": "Du hast einen Link angefordert, um dein Profil im codebar Netzwerk zu aktualisieren. Der Link ist eine Stunde gültig und gilt nur für dein eigenes Profil.", + "Your profile has been updated.": "Dein Profil wurde aktualisiert.", + "Your profile in the codebar network": "Dein Profil im codebar Netzwerk", + "Zefix": "Zefix", + "Zunzgen": "Zunzgen", + "codebar Solutions AG offers fully-equipped workstations in a professional co-working environment in Oberwil. Ideal for freelancers, start-ups, and remote employees in the Basel region looking for a quiet, professional alternative to working from home — with first-class infrastructure and flexible terms.": "codebar Solutions AG bietet vollausgestattete Arbeitsplätze in professionellem Co-Working-Ambiente in Oberwil. Ideal für Freelancer, Start-ups und Remote-Mitarbeitende, die in der Region Basel einen ruhigen, professionellen Arbeitsplatz ohne eigenes Büro suchen — mit erstklassiger Infrastruktur und flexiblen Konditionen.", + "from CHF 250.00 / month": "ab CHF 250.00 / Monat", + "info(at)codebar.ch": "info(at)codebar.ch", + "month": "Monat", + "months": "Monate" } diff --git a/lang/de_CH/components.php b/lang/de_CH/components.php index 3d7c934..d076128 100644 --- a/lang/de_CH/components.php +++ b/lang/de_CH/components.php @@ -27,6 +27,7 @@ 'home' => 'Zurück zur Startseite.', 'services' => 'Vier Bereiche, ein Weg.', 'team' => 'Die Menschen hinter codebar.', + 'news' => 'Einblicke aus unserem Alltag.', 'ai' => 'KI auf eigener Infrastruktur.', 'network' => 'Unsere Partner und Communities.', 'contact' => 'Lass uns sprechen.', @@ -36,6 +37,9 @@ ], 'team' => [ 'header' => 'Klein aus Überzeugung: Bei uns arbeitest du direkt mit den Menschen, die deine Lösung verstehen – und bauen.', + 'working_title' => 'Wie wir arbeiten', + 'working_body' => 'Ein kleines Team, keine Zwischenebene: Wer dein Projekt baut, sitzt auch im Gespräch. Das spart einen Übersetzungsschritt und macht Zusagen verbindlich.', + 'learning_body' => 'Ausbildung und Wissenstransfer gehören für uns dazu. Wir geben Wissen im Team weiter, statt es auf einzelnen Köpfen liegen zu lassen – das hält Projekte unabhängig von einzelnen Personen und uns als Team lernfähig.', ], 'contact' => [ 'header' => 'Du hast eine Idee oder ein Projekt? Erzähl uns davon – wir hören zu.', @@ -50,6 +54,9 @@ 'llm_teaser' => 'Seit ein paar Monaten arbeiten wir uns in das Thema ein, Use Case für Use Case. Hier dokumentieren wir laufend, welche Modelle wir einsetzen – und wie intensiv.', 'to_models' => 'Zu den Modellen', 'to_analytics' => 'Zur Nutzungsstatistik', + 'local_title' => 'Warum lokal?', + 'local_body' => 'Kundendaten verlassen unsere Infrastruktur nicht. Das ist der Hauptgrund, warum wir Open-Source-Modelle selbst betreiben, statt Anfragen an einen Cloud-Anbieter zu schicken. Was wir dafür in Kauf nehmen: etwas weniger Leistung an der Spitze – dafür volle Kontrolle darüber, wo Daten liegen und was sie kosten.', + 'usage_body' => 'Wir setzen KI dort ein, wo sie uns messbar Arbeit abnimmt: beim Lesen von Dokumenten, beim Einordnen von Belegen, beim Schreiben und Prüfen von Code. Wo sie das nicht tut, lassen wir es. Die Nutzungsstatistik zeigt ungefiltert, wie oft das tatsächlich vorkommt.', 'stats' => [ 'tokens_month' => 'Tokens diesen Monat', 'requests_month' => 'Anfragen diesen Monat', @@ -80,7 +87,7 @@ 'total' => 'Total', ], 'empty' => 'Noch keine Nutzungsdaten vorhanden.', - 'last_synced' => 'Zuletzt aktualisiert am :datetime, Daten bis und mit :date.', + 'last_synced' => 'Zuletzt aktualisiert am :datetime.', 'back' => 'Zurück zu unseren LLMs', ], 'ai_llm' => [ diff --git a/lang/en_CH.json b/lang/en_CH.json index 1ab8328..2394464 100644 --- a/lang/en_CH.json +++ b/lang/en_CH.json @@ -1,73 +1,120 @@ { "+41 61 515 60 90": "+41 61 515 60 90", + "24/7 access": "24/7 access", + "250 Mbit/s private network": "250 Mbit/s private network", + "3 months' rent per workstation": "3 months' rent per workstation", + ":count min": ":count min", + ":count min read": ":count min read", + ":count news item on :topic|:count news items on :topic": ":count news item on :topic|:count news items on :topic", + ":count news item|:count news items": ":count news item|:count news items", + ":open to :close": ":open to :close", + "A direct line to the on-site tech team — one hour of consulting per month included.": "A direct line to the on-site tech team — one hour of consulting per month included.", + "A professional workspace in Oberwil — quiet, well-equipped, and embedded in a tech team.": "A professional workspace in Oberwil — quiet, well-equipped, and embedded in a tech team.", + "AI": "AI", "About us": "About us", "Active": "Active", - "AI": "AI", + "After": "After", + "All": "All", + "All-inclusive — just bring your laptop.": "All-inclusive — just bring your laptop.", "An entire weekend of building, learning and shipping: teams from the region take on real challenges and turn them into working prototypes. We are proud to help make that possible.": "An entire weekend of building, learning and shipping: teams from the region take on real challenges and turn them into working prototypes. We are proud to help make that possible.", + "Assigned email": "Assigned email", + "Attention": "Attention", "Author": "Author", "Author: :name": "Author: :name", "Authorized representatives": "Authorised representatives", - "Assigned email": "Assigned email", "Avatar": "Avatar", "Avatar URL": "Avatar URL", + "Before": "Before", "Board of directors": "Board of directors", + "Book a viewing": "Book a viewing", "Branch office": "Branch office", + "Building": "Building", + "Business address & mailbox": "Business address & mailbox", "Certifications & Memberships": "Certifications & memberships", "Clear": "Clear", + "Closed": "Closed", + "Co-Working at codebar": "Co-Working at codebar", "Collaboration": "Collaboration", "Collaboration Partner": "Project partners", "Company": "Company", "Company image": "Company image", "Contact": "Contact", + "Contact Phone number": "Contact Phone number", "Contact codebar to change your email address.": "Contact codebar to change your email address.", "Contact email address": "Contact email address", "Contact persons": "Contact persons", - "Contact Phone number": "Contact Phone number", "Contact us": "Contact us", + "Contents": "Contents", + "Continue reading": "Continue reading", "Cover URL": "Cover URL", + "Currently closed": "Currently closed", + "Currently open": "Open now", "DE": "DE", + "Deposit": "Deposit", "Deutsch": "Deutsch", "Disclaimer": "Disclaimer", "DocuWare Silver Partner": "DocuWare Silver Partner", "Download template (JPG)": "Download template (JPG)", - "Email": "Email", "EN": "EN", + "Email": "Email", "Ended": "Ended", "English": "English", "Enter the email address stored in your network profile. If it is registered, we will send you a personal link — valid for one hour — to update your profile.": "Enter the email address stored in your network profile. If it is registered, we will send you a personal link — valid for one hour — to update your profile.", + "Ergonomic chair": "Ergonomic chair", "Facts": "Facts", + "Fibre upgrade up to 5 Gbit/s": "Fibre upgrade up to 5 Gbit/s", + "Filter by topic": "Filter by topic", "Footer": "Footer", "For several years we have been supporting BaselHack — the largest hackathon in the region — with infrastructure and material, and for the last two years as a Silver Sponsor.": "For several years we have been supporting BaselHack — the largest hackathon in the region — with infrastructure and material, and for the last two years as a Silver Sponsor.", "Format": "Format", + "Framery One pod for calls and small meetings.": "Framery One pod for calls and small meetings.", + "Friday": "Friday", + "Fully-equipped kitchen": "Fully-equipped kitchen", "Good software is not created alone. Our network is built on the open source spirit, on learning together as a community, and on partnerships that matter to us.": "Good software is never built alone. Our network is rooted in the open-source spirit, in learning as a community, and in partnerships that matter to us.", "Google Maps": "Google Maps", "Headquarter": "Headquarters", + "Height-adjustable desk": "Height-adjustable desk", "Hello :name": "Hello :name", + "IT & connectivity": "IT & connectivity", "If the email address is registered, we have sent you a link.": "If the email address is registered, we have sent you a link.", "If you did not request this link, you can ignore this email.": "If you did not request this link, you can ignore this email.", "Imprint": "Imprint", - "Imprint disclaimer": "All content on this website has been prepared with care. We accept no liability for its accuracy, completeness or timeliness. The operators of linked third-party websites are solely responsible for their content.", - "Imprint address street": "Hauptstrasse 91", - "Imprint address city": "CH-4455 Zunzgen", - "Imprint individual signature": "Individual signing authority", "Imprint Melanie Burgin residence": "from Rothenfluh, resident in Oberwil (BL)", "Imprint Melanie Burgin role": "Member of the Board of Directors", - "Imprint representatives placeholder": "Details of authorized representatives will be published here shortly.", "Imprint Sebastian Burgin residence": "from Arlesheim, resident in Oberwil (BL)", "Imprint Sebastian Burgin role": "Chairman of the Board of Directors and Managing Director", - "info(at)codebar.ch": "info(at)codebar.ch", + "Imprint address city": "CH-4455 Zunzgen", + "Imprint address street": "Hauptstrasse 91", + "Imprint disclaimer": "All content on this website has been prepared with care. We accept no liability for its accuracy, completeness or timeliness. The operators of linked third-party websites are solely responsible for their content.", + "Imprint individual signature": "Individual signing authority", + "Imprint representatives placeholder": "Details of authorized representatives will be published here shortly.", + "In short": "In short", + "Includes video-call infrastructure for team meetings.": "Includes video-call infrastructure for team meetings.", + "Including coffee and a Quooker water dispenser.": "Including coffee and a Quooker water dispenser.", "Infrastructure Partner": "Infrastructure partners", "Insights from our day-to-day work: what we are building, what we are learning, and what is happening at codebar.": "Insights from our day-to-day work: what we are building, what we are learning, and what is happening at codebar.", - "Jobs": "Jobs", + "Integrated into a professionally managed enterprise infrastructure.": "Integrated into a professionally managed enterprise infrastructure.", "JPG, PNG, WebP or AVIF, 1:1, max. 2 MB.": "JPG, PNG, WebP or AVIF, 1:1, max. 2 MB.", "JPG, PNG, WebP or AVIF, 3:1, max. 4 MB.": "JPG, PNG, WebP or AVIF, 3:1, max. 4 MB.", + "Jobs": "Jobs", + "Jobs intro": "We're a small team based in the Basel region – small by conviction. There are no departments to hand work off to: everyone covers several roles, from client conversations to code. We build with open technologies such as Laravel, give our own packages back to the community, and use everything we offer ourselves. Whoever joins us takes on responsibility in real projects from day one – and keeps learning.", + "Jobs no open positions": "There are currently no open positions.", + "Jobs open positions heading": "Open positions", + "Jobs page header": "Fancy building software with us? Here's how we work – and how to get on board.", + "Jobs spontaneous body": "Think we should meet? We welcome your application even without an advertised position – tell us what you can do and what drives you:", + "Jobs spontaneous heading": "Speculative application", + "Jobs training body": "Training is part of who we are. For two years now, we've been offering internships – one to one and a half years each, right inside real projects rather than on the sidelines. You start at your level, get real tasks and a contact person who codes themselves. We advertise open internship positions as they come up.", + "Jobs training heading": "Training & internships", + "Keyless entry via RFID & app — no physical keys.": "Keyless entry via RFID & app — no physical keys.", "Kind regards": "Kind regards", + "LO-Next system by Lista Office AG, plus storage for personal materials.": "LO-Next system by Lista Office AG, plus storage for personal materials.", "Language": "Language", "Last updated at": "Last updated at", "Last updated at: :date": "Last updated at: :date", "Learn more": "Learn more", "Legal": "Legal", "Legal form AG": "Aktiengesellschaft (AG)", + "Location": "Location", "Locations": "Locations", "Logo black white": "Black on white", "Logo colored": "Colour (original)", @@ -75,25 +122,46 @@ "Logo white black": "White on black", "Logos": "Logos", "Manage my profile": "Manage my profile", + "Managed enterprise infrastructure": "Managed enterprise infrastructure", "Media": "Media", "Media intro": "Official codebar logos for press and partners.", + "Media usage": "Usage", + "Media usage allowed": "You may use these logos unchanged for press coverage, partner pages and event listings. Please keep the proportions, the colours and a calm margin around them: the colour version on a light background, the inverted one on a dark background.", + "Media usage forbidden": "What does not work: distortion, custom colours, effects, or the logo as part of another mark. If you need a different format or you are unsure, just get in touch.", + "Meeting pod": "Meeting pod", + "Meeting room with 50″ monitor": "Meeting room with 50″ monitor", "Menu": "Menu", "Meta information": "Meta information", + "Minimum term": "Minimum term", + "Monday": "Monday", + "Monitor & video camera": "Monitor & video camera", "More about :name": "More about :name", "Name": "Name", "Network": "Network", "News": "News", "No": "No", + "No news on this topic yet.": "No news on this topic yet.", "Not published": "Not published", + "Note": "Note", + "Notice period": "Notice period", "Oberwil": "Oberwil", "Office": "Office", + "Office, kitchen and sanitary areas — every Wednesday morning.": "Office, kitchen and sanitary areas — every Wednesday morning.", + "On request": "On request", + "On-site IT support": "On-site IT support", "Open Source": "Open Source", + "Opening hours": "Opening hours", + "Optional add-ons": "Optional add-ons", "Our partnership with :company is carried by people — first and foremost you. That is why our website shows not just companies but also the people behind them: if you like, you can be part of it in person, with your own profile.": "Our partnership with :company is carried by people — first and foremost you. That is why our website shows not just companies but also the people behind them: if you like, you can be part of it in person, with your own profile.", + "Parking with EV charging": "Parking with EV charging", + "Part :position": "Part :position", + "Part :position of :total": "Part :position of :total", "Partnerships": "Partnerships", "Phone": "Phone", + "Play video": "Play video", + "Pricing": "Pricing", + "Printer & scanner": "Printer & scanner", "Privacy": "Privacy", - "Privacy page intro": "How codebar Solutions AG processes personal data on this website under Swiss data protection law.", - "Privacy last updated date": "30 June 2026", "Privacy changes body": "We may update this privacy policy from time to time. The current version is always available on this page with the date of the last update.", "Privacy changes heading": "Changes to this policy", "Privacy contact body": "For questions about data protection or to exercise your rights:", @@ -106,6 +174,8 @@ "Privacy data collected intro": "When you visit this website, we process the following categories of data:", "Privacy data collected logs": "Server log data (IP address, date and time of access, browser type, pages visited)", "Privacy data collected session": "Technically necessary session and CSRF cookies for secure operation of the website", + "Privacy last updated date": "30 June 2026", + "Privacy page intro": "How codebar Solutions AG processes personal data on this website under Swiss data protection law.", "Privacy processors body": "We use the following third-party services that may process personal data on our behalf:", "Privacy processors cloudinary": "Cloudinary – delivery of images and media assets (privacy policy: cloudinary.com/privacy)", "Privacy processors fathom": "Fathom Analytics – privacy-friendly website statistics (usefathom.com/privacy)", @@ -127,182 +197,135 @@ "Privacy security heading": "Data security", "Products": "Products", "Products born out of real project work: developed by us, in daily use, and continuously improved.": "Products born out of real project work: developed by us, in daily use, and continuously improved.", + "Professional, available to all members.": "Professional, available to all members.", "Profile": "Profile", "Public email": "Public email", "Published": "Published", "Published at": "Published at", "Published at: :date": "Published at: :date", + "Rental conditions": "Rental conditions", "Request link": "Request link", "Resources": "Resources", "Role": "Role", + "Saturday": "Saturday", "Save changes": "Save changes", + "Series": "Series", "Services": "Expertise", "Set by codebar.": "Set by codebar.", - "Shown publicly on the network page. Leave empty to hide it.": "Shown publicly on the network page. Leave empty to hide it.", "Set by codebar. Empty = Gravatar.": "Set by codebar. Empty = Gravatar.", + "Shown publicly on the network page. Leave empty to hide it.": "Shown publicly on the network page. Leave empty to hide it.", "Silver Sponsor, infrastructure": "Silver Sponsor, infrastructure", + "Single workstation": "Single workstation", "Skip to content": "Skip to content", "Software Partner": "Software partners", "Sponsoring & Community": "Sponsoring & community", + "Static public IP": "Static public IP", + "Sunday": "Sunday", "Team": "Team", "Technologies": "Technologies", "Terms": "Terms", - "Terms last updated date": "23 July 2026", - "The avatar is a raw upload — please convert it to Cloudinary.": "The avatar is a raw upload — please convert it to Cloudinary.", - "The company image is a raw upload — please convert it to Cloudinary.": "The company image is a raw upload — please convert it to Cloudinary.", - "The largest hackathon in the Basel region": "The largest hackathon in the Basel region", - "The tools and technologies we work with every day — chosen deliberately and mastered in depth.": "The tools and technologies we work with every day — chosen deliberately and mastered in depth.", - "Update my profile": "Update my profile", - "Update network profile": "Update network profile", - "Update to english language": "Update to english language", - "Update to german language": "Update to german language", - "Update your codebar network profile": "Update your codebar network profile", - "Upload": "Upload", - "Use the link below to create and manage your profile — it only goes live once you publish it. The link is valid for 96 hours.": "Use the link below to create and manage your profile — it only goes live once you publish it. The link is valid for 96 hours.", - "Use this link to update your own profile. For anything else, please contact codebar.": "Use this link to update your own profile. For anything else, please contact codebar.", - "Visible in the public network directory.": "Visible in the public network directory.", - "Visibility of the company is managed by codebar.": "Visibility of the company is managed by codebar.", - "We build on open source — and give back. These are the projects and contributions we share with the community.": "We build on open source — and give back. These are the projects and contributions we share with the community.", - "Website": "Website", - "Yes": "Yes", - "You can request a new link at any time: enter your email address under \"Network → Profile\" in the website footer.": "You can request a new link at any time: enter your email address under \"Network → Profile\" in the website footer.", - "You requested a link to update your profile in the codebar network. The link is valid for one hour and only applies to your own profile.": "You requested a link to update your profile in the codebar network. The link is valid for one hour and only applies to your own profile.", - "Your profile has been updated.": "Your profile has been updated.", - "Your profile in the codebar network": "Your profile in the codebar network", - "Zefix": "Zefix", - "Zunzgen": "Zunzgen", - "24/7 access": "24/7 access", - "Monday": "Monday", - "Tuesday": "Tuesday", - "Wednesday": "Wednesday", - "Thursday": "Thursday", - "Friday": "Friday", - "Saturday": "Saturday", - "Sunday": "Sunday", - "Opening hours": "Opening hours", - "Currently open": "Open now", - "Currently closed": "Currently closed", - "Closed": "Closed", - ":open to :close": ":open to :close", - "3 months' rent per workstation": "3 months' rent per workstation", - "250 Mbit/s private network": "250 Mbit/s private network", - "A direct line to the on-site tech team — one hour of consulting per month included.": "A direct line to the on-site tech team — one hour of consulting per month included.", - "A professional workspace in Oberwil — quiet, well-equipped, and embedded in a tech team.": "A professional workspace in Oberwil — quiet, well-equipped, and embedded in a tech team.", - "All-inclusive — just bring your laptop.": "All-inclusive — just bring your laptop.", - "Book a viewing": "Book a viewing", - "Building": "Building", - "Business address & mailbox": "Business address & mailbox", - "Co-Working at codebar": "Co-Working at codebar", - "codebar Solutions AG offers fully-equipped workstations in a professional co-working environment in Oberwil. Ideal for freelancers, start-ups, and remote employees in the Basel region looking for a quiet, professional alternative to working from home — with first-class infrastructure and flexible terms.": "codebar Solutions AG offers fully-equipped workstations in a professional co-working environment in Oberwil. Ideal for freelancers, start-ups, and remote employees in the Basel region looking for a quiet, professional alternative to working from home — with first-class infrastructure and flexible terms.", - "Deposit": "Deposit", - "Ergonomic chair": "Ergonomic chair", - "Fibre upgrade up to 5 Gbit/s": "Fibre upgrade up to 5 Gbit/s", - "Framery One pod for calls and small meetings.": "Framery One pod for calls and small meetings.", - "from CHF 250.00 / month": "from CHF 250.00 / month", - "Fully-equipped kitchen": "Fully-equipped kitchen", - "Height-adjustable desk": "Height-adjustable desk", - "Including coffee and a Quooker water dispenser.": "Including coffee and a Quooker water dispenser.", - "Includes video-call infrastructure for team meetings.": "Includes video-call infrastructure for team meetings.", - "Integrated into a professionally managed enterprise infrastructure.": "Integrated into a professionally managed enterprise infrastructure.", - "IT & connectivity": "IT & connectivity", - "Keyless entry via RFID & app — no physical keys.": "Keyless entry via RFID & app — no physical keys.", - "LO-Next system by Lista Office AG, plus storage for personal materials.": "LO-Next system by Lista Office AG, plus storage for personal materials.", - "Location": "Location", - "Managed enterprise infrastructure": "Managed enterprise infrastructure", - "Meeting pod": "Meeting pod", - "Meeting room with 50″ monitor": "Meeting room with 50″ monitor", - "Minimum term": "Minimum term", - "Monitor & video camera": "Monitor & video camera", - "month": "month", - "months": "months", - "Notice period": "Notice period", - "Office, kitchen and sanitary areas — every Wednesday morning.": "Office, kitchen and sanitary areas — every Wednesday morning.", - "On request": "On request", - "On-site IT support": "On-site IT support", - "Optional add-ons": "Optional add-ons", - "Parking with EV charging": "Parking with EV charging", - "Pricing": "Pricing", - "Printer & scanner": "Printer & scanner", - "Professional, available to all members.": "Professional, available to all members.", - "Rental conditions": "Rental conditions", - "Single workstation": "Single workstation", - "Static public IP": "Static public IP", - "Use the address as your registered company location.": "Use the address as your registered company location.", - "Weekly professional cleaning": "Weekly professional cleaning", - "What’s included": "What’s included", - "Wilkhahn seating designed for long, healthy workdays.": "Wilkhahn seating designed for long, healthy workdays.", - "With 5G failover for uninterrupted connectivity.": "With 5G failover for uninterrupted connectivity.", - "Workplace": "Workplace", - "Jobs page header": "Fancy building software with us? Here's how we work – and how to get on board.", - "Jobs intro": "We're a small team based in the Basel region – small by conviction. There are no departments to hand work off to: everyone covers several roles, from client conversations to code. We build with open technologies such as Laravel, give our own packages back to the community, and use everything we offer ourselves. Whoever joins us takes on responsibility in real projects from day one – and keeps learning.", - "Jobs training heading": "Training & internships", - "Jobs training body": "Training is part of who we are. For two years now, we've been offering internships – one to one and a half years each, right inside real projects rather than on the sidelines. You start at your level, get real tasks and a contact person who codes themselves. We advertise open internship positions as they come up.", - "Jobs open positions heading": "Open positions", - "Jobs no open positions": "There are currently no open positions.", - "Jobs spontaneous heading": "Speculative application", - "Jobs spontaneous body": "Think we should meet? We welcome your application even without an advertised position – tell us what you can do and what drives you:", - "Terms page heading": "Terms & Conditions", "Terms 1 heading": "1. General provisions", - "Terms 1.1 heading": "1.1 Scope of application", "Terms 1.1 body": "These General Terms and Conditions (GTC) govern the collaboration between codebar Solutions AG (hereinafter “codebar”) and its customers. They apply to all offers, services and products of codebar unless otherwise agreed in the individual contract. Deviating terms of the customer apply only if expressly acknowledged by codebar in writing.", - "Terms 1.2 heading": "1.2 Offers & placing of orders", + "Terms 1.1 heading": "1.1 Scope of application", "Terms 1.2 body": "Offers are valid for 30 days unless stated otherwise. For software development services, offers are based on estimates; effort and deadlines may vary over the course of the project. An order is concluded when the customer confirms the offer in writing or signs it digitally. Changes to the scope of services (change requests) are possible at any time and are charged separately on a time-and-materials basis unless otherwise agreed.", - "Terms 1.3 heading": "1.3 No right of revocation", + "Terms 1.2 heading": "1.2 Offers & placing of orders", "Terms 1.3 body": "Orders cannot be revoked once placed. This applies in particular to licence orders: ordered licences are excluded from revocation.", - "Terms 1.4 heading": "1.4 Start of contract", + "Terms 1.3 heading": "1.3 No right of revocation", "Terms 1.4 body": "Services take effect on the agreed date or, in the absence of a date, upon commencement of the work.", - "Terms 1.5 heading": "1.5 Contract term & termination", + "Terms 1.4 heading": "1.4 Start of contract", "Terms 1.5 body": "Project contracts end with the handover or acceptance of the deliverables. Recurring contracts (e.g. support, maintenance and licence contracts) generally run from 1 January to 31 December. Contracts do not renew automatically; continuation requires renewal.", - "Terms 1.6 heading": "1.6 Prices", + "Terms 1.5 heading": "1.5 Contract term & termination", "Terms 1.6 body": "All prices are net in Swiss francs (CHF), excluding VAT. Services are charged based on actual effort at the agreed hourly rates unless a fixed price has been agreed in writing.", - "Terms 1.7 heading": "1.7 Payment terms", + "Terms 1.6 heading": "1.6 Prices", "Terms 1.7 body": "Invoices are payable within 14 days without deduction. In the event of late payment, default interest of 5% is owed.", - "Terms 1.8 heading": "1.8 Customer default of payment", + "Terms 1.7 heading": "1.7 Payment terms", "Terms 1.8 body": "In the event of default of payment, codebar may pause ongoing work, restrict data access (where hosting is provided by codebar), and charge default interest as well as the resulting effort.", - "Terms 1.9 heading": "1.9 Extraordinary termination", + "Terms 1.8 heading": "1.8 Customer default of payment", "Terms 1.9 body": "Immediate termination of the contract is possible in the event of a serious breach of contract, insolvency or bankruptcy, or refusal of cooperation by the customer.", + "Terms 1.9 heading": "1.9 Extraordinary termination", "Terms 2 heading": "2. Services & project delivery", - "Terms 2.1 heading": "2.1 Provision of services", "Terms 2.1 body": "codebar provides all services to the best of its knowledge and in accordance with the current state of the art. Services may be provided in part by qualified third parties.", - "Terms 2.2 heading": "2.2 Project methodology", + "Terms 2.1 heading": "2.1 Provision of services", "Terms 2.2 body": "Unless otherwise agreed, projects are carried out using agile methods (e.g. Scrum/Kanban). The customer undertakes to prioritise requirements, provide feedback within a reasonable time, and grant approvals per sprint or release.", - "Terms 2.3 heading": "2.3 Deadlines", + "Terms 2.2 heading": "2.2 Project methodology", "Terms 2.3 body": "Deadlines are estimates and not binding unless expressly guaranteed. Delays caused by the customer (e.g. missing data, input or decisions) extend the deadlines accordingly.", - "Terms 2.4 heading": "2.4 Handover & acceptance", + "Terms 2.3 heading": "2.3 Deadlines", "Terms 2.4 body": "Acceptance takes place formally by acceptance protocol, automatically 14 days after delivery if no written notice of defects is received, or through productive use of the software.", - "Terms 2.5 heading": "2.5 Warranty", + "Terms 2.4 heading": "2.4 Handover & acceptance", "Terms 2.5 body": "codebar warrants for 3 months from acceptance that the software complies with the agreed requirements. The following are not considered defects: new requirements or feature requests, third-party issues (hosting, browsers, operating systems, external APIs), and modifications made by the customer or third parties. The warranty is limited to rectification.", - "Terms 2.6 heading": "2.6 Customer's own fault", + "Terms 2.5 heading": "2.5 Warranty", "Terms 2.6 body": "In the case of unauthorised modifications (code, systems, deployments) or improper use, all claims lapse.", - "Terms 2.7 heading": "2.7 Change management", + "Terms 2.6 heading": "2.6 Customer's own fault", "Terms 2.7 body": "All changes to the scope of services are considered change requests and are charged on a time-and-materials basis unless otherwise agreed in writing.", - "Terms 2.8 heading": "2.8 Additional effort", + "Terms 2.7 heading": "2.7 Change management", "Terms 2.8 body": "The following in particular are charged separately: fault analysis of third-party software or customer systems, restoration after data loss without a backup contract, additional iterations due to missing information from the customer, and work outside normal working hours.", - "Terms 2.9 heading": "2.9 Customer's duties to cooperate", + "Terms 2.8 heading": "2.8 Additional effort", "Terms 2.9 body": "The customer provides all necessary cooperation in good time, in particular the provision of system access and test data, timely feedback, approvals and contact persons, ensuring the IT security of its own infrastructure, and carrying out its own acceptance tests. If the customer fails to meet these obligations, codebar may charge for the additional effort.", + "Terms 2.9 heading": "2.9 Customer's duties to cooperate", "Terms 3 heading": "3. Rights of use & intellectual property", - "Terms 3.1 heading": "3.1 Ownership & rights of use", "Terms 3.1 body": "Unless otherwise specified in the individual contract, codebar retains all rights to generic components, frameworks, libraries, tools and development know-how. The customer receives a non-exclusive right of use, unlimited in time, to the parts developed specifically for the customer.", - "Terms 3.2 heading": "3.2 Delivery of source code", + "Terms 3.1 heading": "3.1 Ownership & rights of use", "Terms 3.2 body": "The customer receives the source code if this is part of the contract. Without a specific agreement, the customer receives access to build artefacts but has no claim to internal tools, frameworks or process documentation.", - "Terms 3.3 heading": "3.3 Third-party software & open source", + "Terms 3.2 heading": "3.2 Delivery of source code", "Terms 3.3 body": "Where third-party software or open-source components are used, their respective licence terms apply.", + "Terms 3.3 heading": "3.3 Third-party software & open source", "Terms 4 heading": "4. Liability", - "Terms 4.1 heading": "4.1 Scope of liability", "Terms 4.1 body": "To the extent permitted by law, codebar's liability is limited to the value of the last three monthly invoices or a maximum of CHF 50,000, whichever is lower.", - "Terms 4.2 heading": "4.2 Exclusions of liability", + "Terms 4.1 heading": "4.1 Scope of liability", "Terms 4.2 body": "codebar is not liable for indirect damage (e.g. loss of revenue, production downtime or data loss), problems caused by third-party software or external systems, hosting or infrastructure failures on the customer's side, or security incidents in the absence of a corresponding security contract.", - "Terms 4.3 heading": "4.3 Data backup", + "Terms 4.2 heading": "4.2 Exclusions of liability", "Terms 4.3 body": "The customer is responsible for backups unless a hosting or backup contract has been concluded.", - "Terms 5 heading": "5. Support, maintenance & SLAs", + "Terms 4.3 heading": "4.3 Data backup", "Terms 5 body": "Support services are provided on a time-and-materials basis unless a maintenance contract exists. SLA levels are governed by separate contracts.", - "Terms 6 heading": "6. Confidentiality", + "Terms 5 heading": "5. Support, maintenance & SLAs", "Terms 6 body": "Both parties undertake to keep all confidential information secret, including beyond the end of the contract. codebar may refer to the collaboration as a reference unless the customer objects in writing.", - "Terms 7 heading": "7. Assignment", + "Terms 6 heading": "6. Confidentiality", "Terms 7 body": "codebar may assign contracts or parts thereof to affiliated companies.", - "Terms 8 heading": "8. Severability clause", + "Terms 7 heading": "7. Assignment", "Terms 8 body": "Should individual provisions of these GTC be invalid, they shall be replaced by provisions that come closest to the economic purpose. The validity of the remaining provisions remains unaffected.", - "Terms 9 heading": "9. Place of jurisdiction & applicable law", + "Terms 8 heading": "8. Severability clause", "Terms 9 body": "Swiss law applies. The place of jurisdiction is the registered office of codebar Solutions AG, unless otherwise agreed.", - "Terms language body": "In the event of discrepancies between the German and English versions of these GTC, the German version prevails." + "Terms 9 heading": "9. Place of jurisdiction & applicable law", + "Terms language body": "In the event of discrepancies between the German and English versions of these GTC, the German version prevails.", + "Terms last updated date": "23 July 2026", + "Terms page heading": "Terms & Conditions", + "The avatar is a raw upload — please convert it to Cloudinary.": "The avatar is a raw upload — please convert it to Cloudinary.", + "The company image is a raw upload — please convert it to Cloudinary.": "The company image is a raw upload — please convert it to Cloudinary.", + "The largest hackathon in the Basel region": "The largest hackathon in the Basel region", + "The tools and technologies we work with every day — chosen deliberately and mastered in depth.": "The tools and technologies we work with every day — chosen deliberately and mastered in depth.", + "Thursday": "Thursday", + "Tip": "Tip", + "Tuesday": "Tuesday", + "Update my profile": "Update my profile", + "Update network profile": "Update network profile", + "Update to english language": "Update to english language", + "Update to german language": "Update to german language", + "Update your codebar network profile": "Update your codebar network profile", + "Upload": "Upload", + "Use the address as your registered company location.": "Use the address as your registered company location.", + "Use the link below to create and manage your profile — it only goes live once you publish it. The link is valid for 96 hours.": "Use the link below to create and manage your profile — it only goes live once you publish it. The link is valid for 96 hours.", + "Use this link to update your own profile. For anything else, please contact codebar.": "Use this link to update your own profile. For anything else, please contact codebar.", + "Visibility of the company is managed by codebar.": "Visibility of the company is managed by codebar.", + "Visible in the public network directory.": "Visible in the public network directory.", + "We build on open source — and give back. These are the projects and contributions we share with the community.": "We build on open source — and give back. These are the projects and contributions we share with the community.", + "Website": "Website", + "Wednesday": "Wednesday", + "Weekly professional cleaning": "Weekly professional cleaning", + "What’s included": "What’s included", + "Wilkhahn seating designed for long, healthy workdays.": "Wilkhahn seating designed for long, healthy workdays.", + "With 5G failover for uninterrupted connectivity.": "With 5G failover for uninterrupted connectivity.", + "Workplace": "Workplace", + "Written by": "Written by", + "Yes": "Yes", + "You can request a new link at any time: enter your email address under \"Network → Profile\" in the website footer.": "You can request a new link at any time: enter your email address under \"Network → Profile\" in the website footer.", + "You requested a link to update your profile in the codebar network. The link is valid for one hour and only applies to your own profile.": "You requested a link to update your profile in the codebar network. The link is valid for one hour and only applies to your own profile.", + "Your profile has been updated.": "Your profile has been updated.", + "Your profile in the codebar network": "Your profile in the codebar network", + "Zefix": "Zefix", + "Zunzgen": "Zunzgen", + "codebar Solutions AG offers fully-equipped workstations in a professional co-working environment in Oberwil. Ideal for freelancers, start-ups, and remote employees in the Basel region looking for a quiet, professional alternative to working from home — with first-class infrastructure and flexible terms.": "codebar Solutions AG offers fully-equipped workstations in a professional co-working environment in Oberwil. Ideal for freelancers, start-ups, and remote employees in the Basel region looking for a quiet, professional alternative to working from home — with first-class infrastructure and flexible terms.", + "from CHF 250.00 / month": "from CHF 250.00 / month", + "info(at)codebar.ch": "info(at)codebar.ch", + "month": "month", + "months": "months" } diff --git a/lang/en_CH/components.php b/lang/en_CH/components.php index 010b9ec..3b3d6c9 100644 --- a/lang/en_CH/components.php +++ b/lang/en_CH/components.php @@ -27,6 +27,7 @@ 'home' => 'Back to the start page.', 'services' => 'Four areas, one path.', 'team' => 'The people behind codebar.', + 'news' => 'Insights from our day-to-day work.', 'ai' => 'AI on our own infrastructure.', 'network' => 'Our partners and communities.', 'contact' => 'Let\'s talk.', @@ -36,6 +37,9 @@ ], 'team' => [ 'header' => 'Small by conviction: you work directly with the people who understand your solution – and build it.', + 'working_title' => 'How we work', + 'working_body' => 'A small team with no layer in between: the people who build your project are the ones in the room. That saves a translation step and makes commitments binding.', + 'learning_body' => 'Training and knowledge transfer are part of how we work. We pass knowledge around the team rather than leaving it with individuals – which keeps projects independent of any single person and keeps us learning.', ], 'contact' => [ 'header' => 'Got an idea or a project? Tell us about it – we\'ll listen.', @@ -50,6 +54,9 @@ 'llm_teaser' => 'For a few months now, we\'ve been working our way into the topic, use case by use case. Here we keep track of which models we run – and how intensively.', 'to_models' => 'View the models', 'to_analytics' => 'View usage analytics', + 'local_title' => 'Why local?', + 'local_body' => 'Customer data does not leave our infrastructure. That is the main reason we run open-source models ourselves instead of sending requests to a cloud provider. The trade-off: a little less performance at the top end – in exchange for full control over where data sits and what it costs.', + 'usage_body' => 'We use AI where it measurably takes work off our hands: reading documents, sorting receipts, writing and reviewing code. Where it does not, we leave it alone. The usage figures show, unfiltered, how often that actually happens.', 'stats' => [ 'tokens_month' => 'Tokens this month', 'requests_month' => 'Requests this month', @@ -80,7 +87,7 @@ 'total' => 'Total', ], 'empty' => 'No usage data available yet.', - 'last_synced' => 'Last updated on :datetime, data up to and including :date.', + 'last_synced' => 'Last updated on :datetime.', 'back' => 'Back to our LLMs', ], 'ai_llm' => [ diff --git a/package-lock.json b/package-lock.json index 9c6822e..e25f0a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1269,9 +1269,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.11.5", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.5.tgz", - "integrity": "sha512-xJo6a6YZnwZfnyGmQKWMbVOcii7XRibjOskRh+WJ9UHQoX16xrQrcIgAMQOzfvs8XiLMx6ih/fsLPF73iY2D1A==", + "version": "2.11.6", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.6.tgz", + "integrity": "sha512-69D/imtToCsIcAl8WBS2YaRwA4jO/j0HhU+hELqMEu9f54MoUtI6+XH5mrKU8rEFNEk/Ui1I2MK4/JkWacclGw==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -1518,9 +1518,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.397", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.397.tgz", - "integrity": "sha512-khGTy9U9x02KEtsKM8vx5A62BsRmcOsIgDpWr1ImE32Ax8GxHGPHZf+Eu9H8zOOyHJnB0jTbseyTHbq2XCT8yw==", + "version": "1.5.398", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.398.tgz", + "integrity": "sha512-AsvhAxopJGh6museTDMIjn6JpDYOfgu4RLlygomt87MUwBUqTfd/1EiPtx10/LZE8xpTvkP2E9Gafq7lkLtodQ==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -1530,9 +1530,9 @@ "license": "MIT" }, "node_modules/enhanced-resolve": { - "version": "5.24.3", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.3.tgz", - "integrity": "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==", + "version": "5.24.4", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.4.tgz", + "integrity": "sha512-GVoi+ICHocoOIU7qVVM48wOJziRsqrsyqlI0Ce0LdowRn6v3bcH2zUa9kp85ncx0nwIb9/HOCOLS3fdThDG/XQ==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", diff --git a/public/fonts/poppins/poppins-400-normal-latin-ext.woff2 b/public/fonts/poppins/poppins-400-normal-latin-ext.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..a3d7ce40bc47e52538447773e04826207f9de349 GIT binary patch literal 5640 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 literal 0 HcmV?d00001 diff --git a/public/fonts/poppins/poppins-400-normal-latin.woff2 b/public/fonts/poppins/poppins-400-normal-latin.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..ed45aa4a706502ad78d4573f5d0c5cd87a9b6f63 GIT binary patch literal 7900 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 GA=3d?@fY|2 literal 0 HcmV?d00001 diff --git a/public/fonts/poppins/poppins-600-normal-latin-ext.woff2 b/public/fonts/poppins/poppins-600-normal-latin-ext.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..78d89a548a01ac29f1083d15edcb64fe12af083f GIT binary patch literal 5552 zcmV;h6;JASPew8T0RR9102QzR3;+NC05e<w02N*U0RR9100000000000000000000 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 literal 0 HcmV?d00001 diff --git a/public/fonts/poppins/poppins-600-normal-latin.woff2 b/public/fonts/poppins/poppins-600-normal-latin.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..e721556432de30ff22d4cc2b99f892f4bdc5cfbd GIT binary patch literal 7992 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 literal 0 HcmV?d00001 diff --git a/public/fonts/poppins/poppins-700-normal-latin-ext.woff2 b/public/fonts/poppins/poppins-700-normal-latin-ext.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..05e439e0616b18588b9f91f22c68dcba31a673bf GIT binary patch literal 5448 zcmV-O6}RelPew8T0RR9102N383;+NC05Z$~02KEC0RR9100000000000000000000 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 literal 0 HcmV?d00001 diff --git a/public/fonts/poppins/poppins-700-normal-latin.woff2 b/public/fonts/poppins/poppins-700-normal-latin.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..568b553cf6ea1bdacd1ebfa0ff56d2ffab2ca6b4 GIT binary patch literal 7848 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 G0ssI{@eXDH literal 0 HcmV?d00001 diff --git a/resources/css/app.css b/resources/css/app.css index ea11ba8..0d2fe3d 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1,6 +1,12 @@ @import 'tailwindcss'; @theme { + /* ── Colour ─────────────────────────────────────────────────────────────── + Contrast against white, measured: brand 13.1:1, brand-strong 15.9:1 — + both directions pass AA, so white-on-brand is safe too. muted (gray-600) + 7.6:1. border-strong / placeholder (gray-500) 4.8:1, the lightest gray a + form control boundary may use and still clear the 3:1 of WCAG 1.4.11. + gray-400 measures 2.6:1 and must never carry meaning. */ --color-brand: #500472; --color-brand-strong: #3a0354; @@ -8,11 +14,58 @@ --color-surface: var(--color-gray-50); --color-border: var(--color-gray-200); --color-border-soft: var(--color-gray-100); + --color-border-strong: var(--color-gray-500); --color-muted: var(--color-gray-600); + --color-hint: var(--color-gray-500); /* Two radii: pills/buttons and panels/cards */ --radius-pill: 0.375rem; --radius-panel: 0.75rem; + + /* ── Type scale ─────────────────────────────────────────────────────────── + Fluid rather than stepped: every heading interpolates between its 360px + and its 768px size, so the responsive behaviour lives in the token and no + component needs a `md:` variant to shrink on a phone. */ + --text-display: clamp(1.875rem, 1.544rem + 1.47vw, 2.25rem); /* 30 → 36 */ + --text-display--line-height: 1.15; + --text-display--letter-spacing: -0.02em; + + --text-title: clamp(1.5rem, 1.169rem + 1.47vw, 1.875rem); /* 24 → 30 */ + --text-title--line-height: 1.25; + --text-title--letter-spacing: -0.015em; + + --text-heading: clamp(1.25rem, 1.03rem + 0.98vw, 1.5rem); /* 20 → 24 */ + --text-heading--line-height: 1.3; + + --text-subheading: clamp(1.125rem, 1.015rem + 0.49vw, 1.25rem); /* 18 → 20 */ + --text-subheading--line-height: 1.4; + + /* The lead paragraph under a page title. Same size as a heading, set light. */ + --text-lead: clamp(1.25rem, 1.03rem + 0.98vw, 1.5rem); + --text-lead--line-height: 1.5; + + /* ── Spacing rhythm ─────────────────────────────────────────────────────── + One vertical step between page sections. Change this number and the whole + site re-spaces. */ + --spacing-section: 2rem; + + /* One control height across inputs, selects and buttons so anything sharing + a row lines up. 44px is also the WCAG 2.5.8 touch target. `control-sm` is + for dense desktop UI only and must never be the sole target on mobile. */ + --spacing-control: 2.75rem; + --spacing-control-sm: 2.25rem; + + /* The shared page frame — the header, the body and the footer all sit in it. */ + --container-frame: 60rem; + + /* The article reading column, derived rather than set: it has to equal the + frame minus its two lg gutters, because that is what puts the first + character of a paragraph under the first letter of the logo. Deriving it + means widening the frame can no longer break that alignment by accident. */ + --container-reading: calc(var(--container-frame) - 4rem); + + /* The one shadow in the system: a popover lifting off the page. */ + --shadow-pop: 0 10px 30px -12px rgb(0 0 0 / 0.25); } @plugin "@tailwindcss/typography"; @@ -37,29 +90,415 @@ } } -/* Optimize font loading */ +/* Self-hosted webfonts, latin + latin-ext subsets. See public/fonts/. */ @font-face { font-family: 'Poppins'; font-style: normal; font-weight: 400; font-display: swap; - src: url('../fonts/poppins/poppins-regular.woff2') format('woff2'); + src: url('/fonts/poppins/poppins-400-normal-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: 'Poppins'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('/fonts/poppins/poppins-400-normal-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'Poppins'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url('/fonts/poppins/poppins-600-normal-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: 'Poppins'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url('/fonts/poppins/poppins-600-normal-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'Poppins'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url('/fonts/poppins/poppins-700-normal-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: 'Poppins'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url('/fonts/poppins/poppins-700-normal-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } /* Critical CSS for above-the-fold content */ body { font-family: 'Poppins', ui-sans-serif, system-ui, sans-serif; - @apply text-gray-800; + color: var(--color-gray-800); /* Optimize text rendering */ text-rendering: optimizeSpeed; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } -/* Optimize images */ -img { - max-width: 100%; - height: auto; +/* Optimize images. + Must live in @layer base: an unlayered rule outranks every Tailwind utility, so + `height: auto` silently beat `size-14` and let flex stretch avatars into ovals. */ +@layer base { + img { + max-width: 100%; + height: auto; + } +} + +/* ───────────────────────────────────────────────────────────────────────────── + News: the editorial reading layer. + + Every element on the page shares one width: --container-reading, the header's inner + width, so the first character of a paragraph sits under the first letter of the logo. + There is no breakout — text, images, tables and the table of contents all start and + end together. + ───────────────────────────────────────────────────────────────────────────── */ +.news-body { + /* Mirrors the frame's px-4 / sm:px-6 / lg:px-8. It used to be a flat 1rem, which + left the article text overhanging the header by 8px a side between 768px and + 1024px — the one width where the frame's gutter is 1.5rem. */ + --news-gutter: 1rem; + + display: grid; + /* The same frame as every other page, so the article aligns with the header. */ + max-width: var(--container-frame); + margin-inline: auto; + grid-template-columns: + [full-start] minmax(var(--news-gutter), 1fr) + /* min() rather than minmax(): the measure must not shrink while there is + still room in the margins. */ + [text-start] min(var(--container-reading), 100% - 2 * var(--news-gutter)) [text-end] + minmax(var(--news-gutter), 1fr) [full-end]; +} + +@media (width >= 40rem) { + .news-body { + --news-gutter: 1.5rem; + } +} + +@media (width >= 64rem) { + .news-body { + --news-gutter: 2rem; + } +} + +/* The width classes are kept so `width="wide"` in an article file stays valid, but + they all resolve to the same column. */ +.news-body > *, +.news-block--wide, +.news-block--full { + grid-column: text; +} + +/* The reading typography. One typeface across the whole site — Poppins, a notch larger + and more open than elsewhere because these are long texts. Deliberately not on + .news-body: that class is only the grid. */ +.news-prose { + font-size: 1.125rem; + line-height: 1.75; + color: var(--color-gray-800); +} + +/* Spacing lives on one side only. .news-prose is a grid container, and grid never + collapses adjacent margins — a bottom margin plus the next element's top margin + added up to double the intended rhythm. */ +.news-prose > * + * { + margin-block-start: 1.4rem; +} + +/* Sizes come from the shared type scale so an article heading is the same size + as the equivalent heading anywhere else on the site. */ +.news-prose > h2, +.news-prose > h3 { + text-wrap: balance; + scroll-margin-top: 5rem; + color: var(--color-gray-900); +} + +.news-prose > h2 { + margin-block-start: 3rem; + font-size: var(--text-title); + line-height: var(--text-title--line-height); + letter-spacing: var(--text-title--letter-spacing); + font-weight: 700; +} + +.news-prose > h3 { + margin-block-start: 2.25rem; + font-size: var(--text-subheading); + line-height: var(--text-subheading--line-height); + font-weight: 600; +} + +/* A heading and the paragraph under it belong together. */ +.news-prose > h2 + *, +.news-prose > h3 + * { + margin-block-start: 0.6rem; +} + +.news-prose ul, +.news-prose ol { + padding-inline-start: 1.5rem; +} + +/* Nested lists too — targeting only direct children left sub-items without any + marker at all, which read as stray paragraphs. */ +.news-prose ul { + list-style: disc; +} + +.news-prose ol { + list-style: decimal; +} + +.news-prose ul ul { + list-style: circle; +} + +.news-prose li + li { + margin-block-start: 0.4rem; +} + +.news-prose li > ul, +.news-prose li > ol { + margin-block-start: 0.4rem; +} + +.news-prose a { + color: var(--color-brand); + text-decoration: underline; + text-underline-offset: 0.2em; + text-decoration-thickness: 1px; +} + +.news-prose a:hover { + text-decoration-thickness: 2px; +} + +.news-prose strong { + font-weight: 600; +} + +.news-prose code { + font-size: 0.9em; + background-color: var(--color-surface); + border-radius: var(--radius-pill); + padding: 0.1em 0.35em; +} + +.news-prose pre { + overflow-x: auto; + border-radius: var(--radius-panel); + background-color: var(--color-zinc-950); + color: var(--color-zinc-100); + padding: 1.25rem; + font-size: 0.9rem; + line-height: 1.6; +} + +.news-prose pre code { + background: none; + padding: 0; + font-size: inherit; + color: inherit; +} + +/* Tables get their own scroll container so the page body never scrolls sideways. */ +.news-prose > table { + display: block; + overflow-x: auto; + width: 100%; + font-size: 1rem; + border-collapse: collapse; +} + +.news-prose th, +.news-prose td { + text-align: start; + padding: 0.75rem 1.5rem 0.75rem 0; + border-block-end: 1px solid var(--color-border-soft); +} + +.news-prose th { + font-size: 0.875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-muted); + border-block-end-color: var(--color-border); +} + +.news-block { + margin-block-start: 2.5rem; +} + +.news-caption { + margin-block-start: 0.75rem; + font-size: 0.875rem; + line-height: 1.5; + color: var(--color-muted); + text-align: center; +} + +.news-quote { + margin-block-start: 3rem; + text-align: center; +} + +.news-quote__text { + font-size: var(--text-title); + line-height: var(--text-title--line-height); + color: var(--color-gray-900); + text-wrap: balance; +} + +.news-quote__cite { + display: block; + margin-block-start: 1rem; + font-size: 0.875rem; + font-style: normal; + color: var(--color-muted); +} + +.news-callout { + border-width: 1px; + border-radius: var(--radius-panel); + padding: 1.5rem; + font-size: 1.15rem; + line-height: 1.6; +} + +.news-callout__title { + font-size: 0.875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.news-callout__body > p:first-child { + margin-block-start: 0.5rem; +} + +.news-steps { + list-style: none; + padding-inline-start: 0; +} + +.news-steps__item { + display: flex; + gap: 1.25rem; +} + +.news-steps__item + .news-steps__item { + margin-block-start: 1.75rem; +} + +.news-steps__number { + flex-shrink: 0; + display: grid; + place-items: center; + width: 2.25rem; + height: 2.25rem; + border: 1px solid var(--color-brand); + border-radius: 9999px; + font-size: 0.875rem; + font-weight: 600; + color: var(--color-brand); +} + +.news-steps__title { + font-weight: 600; + color: var(--color-gray-900); +} + +.news-steps__body { + margin-block-start: 0.35rem; + font-size: 1.2rem; + line-height: 1.6; + color: var(--color-gray-600); +} + +/* The table of contents: a quiet panel above the article, aligned with the reading + column. Navigation, not prose — it keeps the UI face and drops the underline the + body text sets on links. */ +.news-toc { + font-size: 0.9375rem; + line-height: 1.5; + border-radius: var(--radius-panel); + background-color: var(--color-surface); + padding: 1.25rem 1.5rem; +} + +.news-toc__label { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--color-muted); +} + +.news-toc__list { + margin-block-start: 0.75rem; + list-style: none; + padding-inline-start: 0; + border-inline-start: 1px solid var(--color-border); +} + +.news-toc__list > li + li { + margin-block-start: 0.5rem; +} + +.news-toc a { + display: block; + padding-inline-start: 1rem; + color: var(--color-gray-700); + text-decoration: none; + transition: color 0.15s; +} + +.news-toc__sub a { + padding-inline-start: 2.25rem; + font-size: 0.875rem; + color: var(--color-muted); +} + +.news-toc a:hover { + color: var(--color-brand); +} + +/* These links are styled here rather than in Blade, so their focus ring lives here + too — a keyboard user must be able to see where they are in the contents. */ +.news-toc a:focus-visible { + outline: 2px solid var(--color-brand); + outline-offset: 2px; + border-radius: var(--radius-pill); +} + +.news-toc a[aria-current='true'] { + color: var(--color-brand); + font-weight: 600; + box-shadow: inset 2px 0 0 var(--color-brand); } .legal-prose ul { diff --git a/resources/js/app.js b/resources/js/app.js index 67b30c2..917d02c 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -138,6 +138,70 @@ Alpine.data('navigation', () => ({ }, })) +// Thin progress bar at the top of a long article. Width is written straight to the +// style attribute because the CSP build of Alpine cannot evaluate inline expressions. +Alpine.data('readingProgress', () => ({ + init() { + this.update() + this.onScroll = () => this.update() + window.addEventListener('scroll', this.onScroll, { passive: true }) + window.addEventListener('resize', this.onScroll, { passive: true }) + }, + + destroy() { + window.removeEventListener('scroll', this.onScroll) + window.removeEventListener('resize', this.onScroll) + }, + + update() { + const article = document.getElementById('article-body') + if (!article) return + + const start = article.offsetTop + const distance = article.offsetHeight - window.innerHeight + const progress = distance <= 0 ? 1 : (window.scrollY - start) / distance + + this.$refs.bar.style.width = `${Math.min(100, Math.max(0, progress * 100))}%` + }, +})) + +// Marks the table-of-contents entry belonging to the section currently on screen. +Alpine.data('tableOfContents', () => ({ + init() { + const headings = Array.from(document.querySelectorAll('#article-body h2[id], #article-body h3[id]')) + if (headings.length === 0) return + + this.observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (!entry.isIntersecting) return + this.$root.querySelectorAll('a[data-anchor]').forEach((link) => { + link.setAttribute('aria-current', link.dataset.anchor === entry.target.id ? 'true' : 'false') + }) + }) + }, + { rootMargin: '-80px 0px -70% 0px' }, + ) + + headings.forEach((heading) => this.observer.observe(heading)) + }, + + destroy() { + this.observer?.disconnect() + }, +})) + +// Click-to-load video: nothing is requested from the video host until asked for. +Alpine.data('videoEmbed', () => ({ + loaded: false, + embedSrc: '', + + load() { + this.embedSrc = `${this.$root.dataset.src}${this.$root.dataset.src.includes('?') ? '&' : '?'}autoplay=1` + this.loaded = true + }, +})) + Alpine.data('tabs', () => ({ active: 0, diff --git a/resources/views/app/about-us/index.blade.php b/resources/views/app/about-us/index.blade.php index 0a8b761..eff2756 100644 --- a/resources/views/app/about-us/index.blade.php +++ b/resources/views/app/about-us/index.blade.php @@ -1,6 +1,14 @@ <x-app-layout :page="$page" :preconnect-cloudinary="true" :schema="$schema"> <x-layout.page-header :title="__('Team')" :intro="__('components.team.header')"/> + <x-layout.section> + <x-h2 :title="__('components.team.working_title')"/> + <x-ui.prose class="mt-4"> + <p>{{ __('components.team.working_body') }}</p> + <p>{{ __('components.team.learning_body') }}</p> + </x-ui.prose> + </x-layout.section> + <x-layout.section> <x-h2 :title="__('Employees')"/> @if(!empty($contacts->employees) && $contacts->employees->count()) diff --git a/resources/views/app/ai/index.blade.php b/resources/views/app/ai/index.blade.php index dac331b..8ce89d0 100644 --- a/resources/views/app/ai/index.blade.php +++ b/resources/views/app/ai/index.blade.php @@ -5,27 +5,22 @@ <x-layout.section-header :title="__('components.ai_llm.title')" :intro="__('components.ai.llm_teaser')"/> @if ($llmSummary['requests'] > 0) - <x-layout.grid :cols="2" class="mt-4"> - <x-card.stat-card - :label="__('components.ai.stats.tokens_month')" - :value="\App\Helpers\Facades\HelperNumber::abbreviate($llmSummary['total_tokens'])" - :input="\App\Helpers\Facades\HelperNumber::abbreviate($llmSummary['prompt_tokens'])" - :output="\App\Helpers\Facades\HelperNumber::abbreviate($llmSummary['completion_tokens'])"/> - <x-card.stat-card - :label="__('components.ai.stats.requests_month')" - :value="\App\Helpers\Facades\HelperNumber::format($llmSummary['requests'], 0)"/> - </x-layout.grid> + <x-ai-llm.usage-summary :summary="$llmSummary"/> @endif <div class="mt-4 flex flex-wrap gap-x-8 gap-y-2"> - <x-ui.link :href="localized_route('ai.llm.index')" class="inline-block text-base"> - {{ __('components.ai.to_models') }} <span aria-hidden="true">→</span> - </x-ui.link> + <x-ui.arrow-link :href="localized_route('ai.llm.index')" :label="__('components.ai.to_models')"/> @if ($llmSummary['requests'] > 0) - <x-ui.link :href="localized_route('ai.llm.analytics.index')" class="inline-block text-base"> - {{ __('components.ai.to_analytics') }} <span aria-hidden="true">→</span> - </x-ui.link> + <x-ui.arrow-link :href="localized_route('ai.llm.analytics.index')" :label="__('components.ai.to_analytics')"/> @endif </div> </x-layout.section> + + <x-layout.section> + <x-h2 :title="__('components.ai.local_title')"/> + <x-ui.prose class="mt-4"> + <p>{{ __('components.ai.local_body') }}</p> + <p>{{ __('components.ai.usage_body') }}</p> + </x-ui.prose> + </x-layout.section> </x-app-layout> diff --git a/resources/views/app/ai/llm/analytics.blade.php b/resources/views/app/ai/llm/analytics.blade.php index f5db7b1..6c4e508 100644 --- a/resources/views/app/ai/llm/analytics.blade.php +++ b/resources/views/app/ai/llm/analytics.blade.php @@ -1,5 +1,9 @@ <x-app-layout :page="$page"> - <x-layout.page-header :title="__('components.ai_llm_analytics.title')" :intro="__('components.ai_llm_analytics.intro')"/> + <x-layout.page-header :title="__('components.ai_llm_analytics.title')" :intro="__('components.ai_llm_analytics.intro')" :breadcrumbs="[ + ['label' => __('AI'), 'url' => localized_route('ai.index')], + ['label' => __('components.ai_llm.title'), 'url' => localized_route('ai.llm.index')], + ['label' => __('components.ai_llm_analytics.title')], + ]"/> <x-layout.section> <form method="GET" action="{{ url()->current() }}" x-data="autoSubmit" x-on:change="submit" @@ -50,12 +54,11 @@ class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> </x-layout.section> @endif - @if ($lastSyncedAt && $latestDate) - <x-layout.section class="mt-4 text-sm text-muted"> + @if ($lastSyncedAt) + <x-layout.section class="mt-4 text-xs text-muted"> <p> {{ __('components.ai_llm_analytics.last_synced', [ 'datetime' => \App\Helpers\Facades\HelperDate::formatDateTime($lastSyncedAt), - 'date' => \App\Helpers\Facades\HelperDate::formatDate($latestDate), ]) }} </p> </x-layout.section> @@ -114,8 +117,7 @@ class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> @endif <x-layout.section> - <x-ui.link :href="localized_route('ai.llm.index')" class="inline-block text-base"> - <span aria-hidden="true">←</span> {{ __('components.ai_llm_analytics.back') }} - </x-ui.link> + <x-ui.arrow-link :href="localized_route('ai.llm.index')" direction="back" + :label="__('components.ai_llm_analytics.back')"/> </x-layout.section> </x-app-layout> diff --git a/resources/views/app/ai/llm/index.blade.php b/resources/views/app/ai/llm/index.blade.php index 3f174f9..d225a78 100644 --- a/resources/views/app/ai/llm/index.blade.php +++ b/resources/views/app/ai/llm/index.blade.php @@ -1,5 +1,8 @@ <x-app-layout :page="$page"> - <x-layout.page-header :title="__('components.ai_llm.title')" :intro="__('components.ai_llm.intro')"/> + <x-layout.page-header :title="__('components.ai_llm.title')" :intro="__('components.ai_llm.intro')" :breadcrumbs="[ + ['label' => __('AI'), 'url' => localized_route('ai.index')], + ['label' => __('components.ai_llm.title')], + ]"/> @foreach ($groups as $group) <x-layout.section> @@ -48,21 +51,10 @@ <x-layout.section> <x-layout.section-header :title="__('components.ai_llm.stats.title')" :intro="__('components.ai_llm.stats.intro')"/> - <x-layout.grid :cols="2" class="mt-4"> - <x-card.stat-card - :label="__('components.ai.stats.tokens_month')" - :value="\App\Helpers\Facades\HelperNumber::abbreviate($llmSummary['total_tokens'])" - :input="\App\Helpers\Facades\HelperNumber::abbreviate($llmSummary['prompt_tokens'])" - :output="\App\Helpers\Facades\HelperNumber::abbreviate($llmSummary['completion_tokens'])"/> - <x-card.stat-card - :label="__('components.ai.stats.requests_month')" - :value="\App\Helpers\Facades\HelperNumber::format($llmSummary['requests'], 0)"/> - </x-layout.grid> + <x-ai-llm.usage-summary :summary="$llmSummary"/> <div class="mt-4"> - <x-ui.link :href="localized_route('ai.llm.analytics.index')" class="inline-block text-base"> - {{ __('components.ai.to_analytics') }} <span aria-hidden="true">→</span> - </x-ui.link> + <x-ui.arrow-link :href="localized_route('ai.llm.analytics.index')" :label="__('components.ai.to_analytics')"/> </div> </x-layout.section> @endif diff --git a/resources/views/app/media/index.blade.php b/resources/views/app/media/index.blade.php index 8976bdd..d106131 100644 --- a/resources/views/app/media/index.blade.php +++ b/resources/views/app/media/index.blade.php @@ -17,4 +17,12 @@ @endforeach </x-layout.grid> </x-layout.section> + + <x-layout.section> + <x-h2 :title="__('Media usage')"/> + <x-ui.prose class="mt-4"> + <p>{{ __('Media usage allowed') }}</p> + <p>{{ __('Media usage forbidden') }}</p> + </x-ui.prose> + </x-layout.section> </x-app-layout> diff --git a/resources/views/app/network/index.blade.php b/resources/views/app/network/index.blade.php index 1612cc1..4e86f96 100644 --- a/resources/views/app/network/index.blade.php +++ b/resources/views/app/network/index.blade.php @@ -9,8 +9,8 @@ <x-layout.grid gap="gap-6" class="mt-4"> @foreach($networks as $network) - <div class="flex flex-col overflow-hidden rounded-panel border border-gray-200"> - <div class="relative hidden sm:flex h-20 items-center {{ $network->cover_url ? 'justify-start' : 'justify-center' }} border-b border-gray-100 bg-gray-50 px-4"> + <div class="flex flex-col overflow-hidden rounded-panel border border-border"> + <div class="relative hidden sm:flex h-20 items-center {{ $network->cover_url ? 'justify-start' : 'justify-center' }} border-b border-border-soft bg-surface px-4"> @php $drawing = 'images/network/'.$network->key.'.svg'; @endphp @@ -25,14 +25,9 @@ @endif @if($network->tier_label) - @php - $tierBadge = str_contains($network->tier_label, 'Silver') - ? 'bg-linear-to-b from-gray-100 via-white to-gray-300 text-gray-700 ring-gray-400/40' - : 'bg-white/90 text-muted ring-gray-400/20'; - @endphp - <span class="absolute bottom-2 right-2 inline-flex items-center rounded-pill px-1.5 py-0.5 text-xs font-medium ring-1 ring-inset {{ $tierBadge }}"> - {{ $network->tier_label }} - </span> + <x-ui.badge :label="$network->tier_label" size="xs" + :variant="str_contains($network->tier_label, 'Silver') ? 'metal' : 'default'" + class="absolute bottom-2 right-2"/> @endif </div> @@ -40,14 +35,9 @@ <div class="flex flex-col gap-0"> <div class="flex min-w-0 items-center justify-between gap-2"> <span class="truncate whitespace-nowrap text-base font-bold text-gray-800">{{ $network->name }}</span> - @if($network->website) - <a href="{{ $network->website }}" target="_blank" rel="noopener noreferrer" - aria-label="{{ $network->websiteHost() }}" - title="{{ $network->websiteHost() }}" - class="flex size-7 shrink-0 items-center justify-center text-muted hover:text-gray-800"> - <x-icon.website class="size-5"/> - </a> - @endif + <x-ui.social-links :name="$network->name" class="-mr-2 shrink-0 sm:mr-0" + :links="['website' => $network->website]" + :titles="['website' => $network->websiteHost()]"/> </div> @if($network->excerpt) @@ -67,7 +57,7 @@ class="flex size-7 shrink-0 items-center justify-center text-muted hover:text-gr @if($network->page_slug) <a href="{{ localized_route('network.show', ['slug' => $network->page_slug]) }}" title="{{ __('More about :name', ['name' => $network->name]) }}" - class="flex items-center justify-between gap-2 bg-brand px-4 py-2.5 text-sm font-medium text-white transition hover:bg-brand-strong"> + class="flex min-h-control items-center justify-between gap-2 bg-brand px-4 text-sm font-medium text-white transition hover:bg-brand-strong focus:outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-white"> {{ __('Learn more') }} <x-icon.arrow-right class="size-4"/> </a> diff --git a/resources/views/app/network/manage.blade.php b/resources/views/app/network/manage.blade.php index f8e603d..8cddd4b 100644 --- a/resources/views/app/network/manage.blade.php +++ b/resources/views/app/network/manage.blade.php @@ -1,16 +1,18 @@ <x-app-layout :page="$page"> <x-layout.page-header :title="__('Manage my profile')" - :intro="__('Use this link to update your own profile. For anything else, please contact codebar.')"/> + :intro="__('Use this link to update your own profile. For anything else, please contact codebar.')" + :breadcrumbs="[ + ['label' => __('Network'), 'url' => localized_route('network.index')], + ['label' => __('Manage my profile')], + ]"/> <x-layout.section> @if(session('status')) - <p class="mt-6 rounded-panel bg-gray-400/10 px-4 py-3 text-gray-800 ring-1 ring-gray-400/20 ring-inset"> - {{ session('status') }} - </p> + <x-ui.alert>{{ session('status') }}</x-ui.alert> @endif - <p class="mt-6 text-sm text-muted" title="{{ __('Contact codebar to change your email address.') }}"> + <p class="mt-section text-sm text-muted" title="{{ __('Contact codebar to change your email address.') }}"> {{ __('Assigned email') }}: {{ $networkUser->email }} </p> @@ -18,7 +20,7 @@ @csrf @method('PUT') - <x-ui.panel class="px-6 py-6"> + <x-ui.panel class="space-y-6 p-6"> <div class="flex items-center gap-3"> <img src="{{ $networkUser->avatarDisplayUrl(96) }}" alt="{{ $networkUser->name }}" @@ -31,146 +33,116 @@ class="size-12 shrink-0 rounded-full bg-gray-100 object-cover"> </div> </div> - <div class="mt-4"> - <p class="block text-sm font-medium text-gray-800">{{ __('Published') }}</p> - <label for="published" class="mt-1 inline-flex cursor-pointer items-center gap-2"> + <x-form.field name="published" :label="__('Published')" + :help="__('Visible in the public network directory.')"> + {{-- The whole row is the target, so the 44px comes from the label. --}} + <label for="published" class="inline-flex min-h-control cursor-pointer items-center gap-2"> <input type="hidden" name="published" value="0"> <input type="checkbox" id="published" name="published" value="1" @checked(old('published', $networkUser->published)) - class="rounded border-gray-300 text-brand focus:ring-brand"> + class="size-5 rounded-pill border-border-strong text-brand focus:ring-brand"> <x-ui.badge :label="old('published', $networkUser->published) ? __('Published') : __('Not published')"/> </label> - <p class="mt-1 text-sm text-muted">{{ __('Visible in the public network directory.') }}</p> - </div> - - <div class="mt-4"> - <label for="public_email" class="block text-sm font-medium text-gray-800">{{ __('Public email') }}</label> - <input type="email" id="public_email" name="public_email" - value="{{ old('public_email', $networkUser->public_email) }}" - placeholder="{{ $networkUser->email }}" - class="mt-1 w-full rounded-md border border-gray-300 px-4 py-2 text-gray-800 placeholder-gray-400 focus:border-brand focus:outline-none focus:ring-1 focus:ring-brand"> - <p class="mt-1 text-sm text-muted">{{ __('Shown publicly on the network page. Leave empty to hide it.') }}</p> - @error('public_email') - <p class="mt-1 text-sm text-red-600">{{ $message }}</p> - @enderror - </div> - - <div class="mt-4"> - <label for="name" class="block text-sm font-medium text-gray-800">{{ __('Name') }}</label> - <input type="text" id="name" name="name" value="{{ old('name', $networkUser->name) }}" - class="mt-1 w-full rounded-md border border-gray-300 px-4 py-2 text-gray-800 focus:border-brand focus:outline-none focus:ring-1 focus:ring-brand"> - @error('name') - <p class="mt-1 text-sm text-red-600">{{ $message }}</p> - @enderror - </div> - - <div class="mt-4"> - <label for="linkedin" class="block text-sm font-medium text-gray-800">LinkedIn</label> - <input type="url" id="linkedin" name="linkedin" value="{{ old('linkedin', $networkUser->linkedin) }}" - placeholder="https://www.linkedin.com/in/..." - class="mt-1 w-full rounded-md border border-gray-300 px-4 py-2 text-gray-800 placeholder-gray-400 focus:border-brand focus:outline-none focus:ring-1 focus:ring-brand"> - @error('linkedin') - <p class="mt-1 text-sm text-red-600">{{ $message }}</p> - @enderror - </div> - - <div class="mt-4"> - <label for="phone" class="block text-sm font-medium text-gray-800">{{ __('Phone') }}</label> - <input type="text" id="phone" name="phone" value="{{ old('phone', $networkUser->phone) }}" - placeholder="+41 ..." - class="mt-1 w-full rounded-md border border-gray-300 px-4 py-2 text-gray-800 placeholder-gray-400 focus:border-brand focus:outline-none focus:ring-1 focus:ring-brand"> - @error('phone') - <p class="mt-1 text-sm text-red-600">{{ $message }}</p> - @enderror - </div> - - <p class="mt-6 font-bold text-gray-800">{{ __('Avatar') }}</p> - - <div class="mt-2"> - <p class="block text-sm font-medium text-gray-800">{{ __('Avatar URL') }}</p> - @if($networkUser->avatar_url) - <a href="{{ $networkUser->avatar_url }}" target="_blank" rel="noopener noreferrer" - class="mt-1 block truncate text-sm text-brand underline">{{ $networkUser->avatar_url }}</a> - @else - <p class="mt-1 text-sm text-muted">—</p> - @endif - <p class="mt-1 text-sm text-muted">{{ __('Set by codebar. Empty = Gravatar.') }}</p> - </div> + </x-form.field> + + <x-form.field name="public_email" :label="__('Public email')" + :help="__('Shown publicly on the network page. Leave empty to hide it.')"> + <x-form.input name="public_email" type="email" :value="$networkUser->public_email" + :placeholder="$networkUser->email" described-by="public_email-help"/> + </x-form.field> + + <x-form.field name="name" :label="__('Name')"> + <x-form.input name="name" :value="$networkUser->name"/> + </x-form.field> + + <x-form.field name="linkedin" label="LinkedIn"> + <x-form.input name="linkedin" type="url" :value="$networkUser->linkedin" + placeholder="https://www.linkedin.com/in/..."/> + </x-form.field> + + <x-form.field name="phone" :label="__('Phone')"> + <x-form.input name="phone" :value="$networkUser->phone" placeholder="+41 ..."/> + </x-form.field> + + <div class="space-y-6 border-t border-border-soft pt-6"> + <x-h3 :title="__('Avatar')"/> + + {{-- Read-only, so no <label>: there is no control to label. --}} + <div class="space-y-1.5"> + <p class="text-sm font-medium text-gray-800">{{ __('Avatar URL') }}</p> + @if($networkUser->avatar_url) + <x-ui.link :href="$networkUser->avatar_url" target="_blank" + class="block truncate text-sm text-brand underline">{{ $networkUser->avatar_url }}</x-ui.link> + @else + <p class="text-sm text-muted">—</p> + @endif + <p class="text-sm text-muted">{{ __('Set by codebar. Empty = Gravatar.') }}</p> + </div> - <div class="mt-4"> @if($networkUser->avatar_path) <p class="text-sm text-muted">{{ __('The avatar is a raw upload — please convert it to Cloudinary.') }}</p> @else - <label for="avatar" class="block text-sm font-medium text-gray-800">{{ __('Upload') }}</label> - <input type="file" id="avatar" name="avatar" accept="image/jpeg,image/png,image/webp,image/avif" - class="mt-1 w-full text-sm text-gray-800 file:mr-3 file:rounded-pill file:border-0 file:bg-brand file:px-4 file:py-1.5 file:text-sm file:font-medium file:text-white hover:file:bg-brand-strong"> - <p class="mt-1 text-sm text-muted"> - {{ __('JPG, PNG, WebP or AVIF, 1:1, max. 2 MB.') }} - <a href="{{ asset('images/templates/avatar-template.jpg') }}" download - class="text-brand underline">{{ __('Download template (JPG)') }}</a> - </p> + <x-form.field name="avatar" :label="__('Upload')"> + <x-form.file name="avatar" accept="image/jpeg,image/png,image/webp,image/avif"/> + + <x-slot:help> + {{ __('JPG, PNG, WebP or AVIF, 1:1, max. 2 MB.') }} + <x-ui.link :href="asset('images/templates/avatar-template.jpg')" download="avatar-template.jpg" + class="text-brand underline">{{ __('Download template (JPG)') }}</x-ui.link> + </x-slot:help> + </x-form.field> @endif - @error('avatar') - <p class="mt-1 text-sm text-red-600">{{ $message }}</p> - @enderror </div> </x-ui.panel> - <x-ui.panel class="mt-4 px-6 py-6"> + <x-ui.panel class="mt-4 space-y-6 p-6"> <div class="flex items-center justify-between gap-4"> <p class="font-bold text-gray-800">{{ $network?->name ?? $networkUser->network_key }}</p> <p class="text-sm text-muted">{{ __('Visibility of the company is managed by codebar.') }}</p> </div> - <div class="mt-4"> - <label for="website" class="block text-sm font-medium text-gray-800">{{ __('Website') }}</label> - <input type="url" id="website" name="website" value="{{ old('website', $network?->website) }}" - placeholder="https://..." - class="mt-1 w-full rounded-md border border-gray-300 px-4 py-2 text-gray-800 placeholder-gray-400 focus:border-brand focus:outline-none focus:ring-1 focus:ring-brand"> - @error('website') - <p class="mt-1 text-sm text-red-600">{{ $message }}</p> - @enderror - </div> - - <p class="mt-6 font-bold text-gray-800">{{ __('Company image') }}</p> - - <div class="mt-2"> - <p class="block text-sm font-medium text-gray-800">{{ __('Cover URL') }}</p> - @if($network?->cover_url) - <div class="mt-1 flex h-20 items-center rounded-md border border-gray-200 bg-gray-50 px-4"> - <img src="{{ $network->cover_url }}" alt="{{ $network->name }}" loading="lazy" - class="max-h-14 w-auto"> - </div> - <a href="{{ $network->cover_url }}" target="_blank" rel="noopener noreferrer" - class="mt-1 block truncate text-sm text-brand underline">{{ $network->cover_url }}</a> - @else - <p class="mt-1 text-sm text-muted">—</p> - @endif - <p class="mt-1 text-sm text-muted">{{ __('Set by codebar.') }}</p> - </div> + <x-form.field name="website" :label="__('Website')"> + <x-form.input name="website" type="url" :value="$network?->website" placeholder="https://..."/> + </x-form.field> + + <div class="space-y-6 border-t border-border-soft pt-6"> + <x-h3 :title="__('Company image')"/> + + {{-- Read-only, so no <label>: there is no control to label. --}} + <div class="space-y-1.5"> + <p class="text-sm font-medium text-gray-800">{{ __('Cover URL') }}</p> + @if($network?->cover_url) + <div class="flex h-20 items-center rounded-panel border border-border bg-surface px-4"> + <img src="{{ $network->cover_url }}" alt="{{ $network->name }}" loading="lazy" + class="max-h-14 w-auto"> + </div> + <x-ui.link :href="$network->cover_url" target="_blank" + class="block truncate text-sm text-brand underline">{{ $network->cover_url }}</x-ui.link> + @else + <p class="text-sm text-muted">—</p> + @endif + <p class="text-sm text-muted">{{ __('Set by codebar.') }}</p> + </div> - <div class="mt-4"> @if($network?->cover_path) <p class="text-sm text-muted">{{ __('The company image is a raw upload — please convert it to Cloudinary.') }}</p> @else - <label for="cover" class="block text-sm font-medium text-gray-800">{{ __('Upload') }}</label> - <input type="file" id="cover" name="cover" accept="image/jpeg,image/png,image/webp,image/avif" - class="mt-1 w-full text-sm text-gray-800 file:mr-3 file:rounded-pill file:border-0 file:bg-brand file:px-4 file:py-1.5 file:text-sm file:font-medium file:text-white hover:file:bg-brand-strong"> - <p class="mt-1 text-sm text-muted"> - {{ __('JPG, PNG, WebP or AVIF, 3:1, max. 4 MB.') }} - <a href="{{ asset('images/templates/cover-template.jpg') }}" download - class="text-brand underline">{{ __('Download template (JPG)') }}</a> - </p> + <x-form.field name="cover" :label="__('Upload')"> + <x-form.file name="cover" accept="image/jpeg,image/png,image/webp,image/avif"/> + + <x-slot:help> + {{ __('JPG, PNG, WebP or AVIF, 3:1, max. 4 MB.') }} + <x-ui.link :href="asset('images/templates/cover-template.jpg')" download="cover-template.jpg" + class="text-brand underline">{{ __('Download template (JPG)') }}</x-ui.link> + </x-slot:help> + </x-form.field> @endif - @error('cover') - <p class="mt-1 text-sm text-red-600">{{ $message }}</p> - @enderror </div> </x-ui.panel> - <div class="mt-4 flex justify-end"> + <x-form.actions> <x-ui.button :label="__('Save changes')"/> - </div> + </x-form.actions> </form> </x-layout.section> </x-app-layout> diff --git a/resources/views/app/network/pages/baselhack.blade.php b/resources/views/app/network/pages/baselhack.blade.php index badeb68..41898d3 100644 --- a/resources/views/app/network/pages/baselhack.blade.php +++ b/resources/views/app/network/pages/baselhack.blade.php @@ -39,7 +39,7 @@ <p class="text-sm uppercase tracking-wide text-muted">{{ __('Website') }}</p> @if($network->website) <a href="{{ $network->website }}" target="_blank" rel="noopener noreferrer" - class="mt-1 block text-gray-800 transition hover:text-brand"> + class="mt-1 block rounded-pill focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand text-gray-800 transition hover:text-brand"> {{ $network->websiteHost() }} ↗ </a> @endif @@ -52,7 +52,7 @@ class="mt-1 block text-gray-800 transition hover:text-brand"> <x-layout.section-header :title="__('Contact persons')"/> <x-layout.grid :cols="2" class="mt-4 max-w-2xl"> @foreach($users as $user) - <div class="rounded-panel border border-gray-200 p-4"> + <div class="rounded-panel border border-border p-4"> <x-card.network-user-card :user="$user" :divided="false"/> </div> @endforeach diff --git a/resources/views/app/network/request.blade.php b/resources/views/app/network/request.blade.php index 7527dd4..5474534 100644 --- a/resources/views/app/network/request.blade.php +++ b/resources/views/app/network/request.blade.php @@ -1,29 +1,27 @@ <x-app-layout :page="$page"> <x-layout.page-header :title="__('Update network profile')" - :intro="__('Enter the email address stored in your network profile. If it is registered, we will send you a personal link — valid for one hour — to update your profile.')"/> + :intro="__('Enter the email address stored in your network profile. If it is registered, we will send you a personal link — valid for one hour — to update your profile.')" + :breadcrumbs="[ + ['label' => __('Network'), 'url' => localized_route('network.index')], + ['label' => __('Update network profile')], + ]"/> <x-layout.section> @if(session('status')) - <p class="mt-6 max-w-2xl rounded-panel bg-gray-400/10 px-4 py-3 text-gray-800 ring-1 ring-gray-400/20 ring-inset"> - {{ session('status') }} - </p> + <x-ui.alert class="max-w-2xl">{{ session('status') }}</x-ui.alert> @endif - <form method="POST" action="{{ localized_route('network.request.store') }}" class="mt-6 max-w-md"> + <form method="POST" action="{{ localized_route('network.request.store') }}" class="mt-section max-w-md"> @csrf - <label for="email" class="block text-sm font-medium text-gray-800">{{ __('Email') }}</label> - <input type="email" id="email" name="email" value="{{ old('email') }}" required autofocus - class="mt-1 w-full rounded-md border border-gray-300 px-4 py-2 text-gray-800 placeholder-gray-400 focus:border-brand focus:outline-none focus:ring-1 focus:ring-brand"> + <x-form.field name="email" :label="__('Email')"> + <x-form.input name="email" type="email" required autofocus/> + </x-form.field> - @error('email') - <p class="mt-1 text-sm text-red-600">{{ $message }}</p> - @enderror - - <div class="mt-4"> + <x-form.actions> <x-ui.button :label="__('Request link')"/> - </div> + </x-form.actions> </form> </x-layout.section> </x-app-layout> diff --git a/resources/views/app/news/index.blade.php b/resources/views/app/news/index.blade.php index 33be203..2ef30bc 100644 --- a/resources/views/app/news/index.blade.php +++ b/resources/views/app/news/index.blade.php @@ -1,17 +1,82 @@ -<x-app-layout :page="$page" :schema="$schema"> +@php + $urlFor = fn ($entry) => localized_route('news.show', ['locale' => app()->getLocale(), 'news' => $entry]); + $kickerFor = fn ($entry) => $entry->series?->title ?? (is_array($entry->tags) ? ($entry->tags[0] ?? null) : null); + $indexUrl = localized_route('news.index'); +@endphp + +<x-app-layout :page="$page" :schema="$schema" :preconnect-cloudinary="true"> <x-layout.page-header :title="__('News')" :intro="__('Insights from our day-to-day work: what we are building, what we are learning, and what is happening at codebar.')"/> - <x-layout.section> - <x-layout.list> - @foreach($news as $entry) - <x-card.item-card - :url="localized_route('news.show', ['locale' => app()->getLocale(),'news' => $entry])" - :title="$entry->title" - :teaser="$entry->teaser" - :tags="$entry->tags" - :level="2"/> - @endforeach - </x-layout.list> - </x-layout.section> + + @if($topics->isNotEmpty()) + <x-layout.section> + <nav class="flex flex-wrap gap-2" aria-label="{{ __('Filter by topic') }}"> + <x-ui.badge :href="$indexUrl" :label="__('All')" size="md" + :variant="$activeTopic === null ? 'solid' : 'outline'" + :aria-current="$activeTopic === null ? 'page' : false"/> + + @foreach($topics as $topic) + @php($isActive = $activeTopic !== null && $activeTopic->is($topic)) + <x-ui.badge :href="$indexUrl . '?thema=' . $topic->slug" :label="$topic->title" size="md" + :variant="$isActive ? 'solid' : 'outline'" + :aria-current="$isActive ? 'page' : false"/> + @endforeach + </nav> + + {{-- Rendered in every state, filtered or not, so switching topics never adds + or removes a line and the articles below stay where they are. --}} + <p class="mt-4 text-sm text-muted"> + @if($activeTopic !== null) + {{ trans_choice(':count news item on :topic|:count news items on :topic', $total, ['count' => $total, 'topic' => $activeTopic->title]) }} + @else + {{ trans_choice(':count news item|:count news items', $total, ['count' => $total]) }} + @endif + </p> + </x-layout.section> + @endif + + @if($lead) + <x-layout.section> + <x-news.card + :lead="true" + :url="$urlFor($lead)" + :title="$lead->title" + :teaser="$lead->teaser" + :image="$lead->hero_image" + :kicker="$kickerFor($lead)" + :published-at="$lead->published_at" + :reading-minutes="$lead->reading_minutes" + :author-name="$lead->authorName()" + :author-image="$lead->authorContact?->image" + :level="2"/> + </x-layout.section> + @endif + + @if($news->isNotEmpty()) + <x-layout.section> + <div class="mt-10 divide-y divide-border-soft border-t border-border"> + @foreach($news as $entry) + <x-news.card + class="py-10" + :url="$urlFor($entry)" + :title="$entry->title" + :teaser="$entry->teaser" + :image="$entry->hero_image" + :kicker="$kickerFor($entry)" + :published-at="$entry->published_at" + :reading-minutes="$entry->reading_minutes" + :author-name="$entry->authorName()" + :author-image="$entry->authorContact?->image" + :level="2"/> + @endforeach + </div> + </x-layout.section> + @endif + + @if($lead === null) + <x-layout.section> + <p class="text-lg text-muted">{{ __('No news on this topic yet.') }}</p> + </x-layout.section> + @endif </x-app-layout> diff --git a/resources/views/app/news/show.blade.php b/resources/views/app/news/show.blade.php index 358ed76..cbf14d6 100644 --- a/resources/views/app/news/show.blade.php +++ b/resources/views/app/news/show.blade.php @@ -1,26 +1,142 @@ -<x-app-layout :page="$page" :schema="$schema"> +@php + use App\Support\NewsImage; - @if(!empty($tags) && $tags->count()) - <x-layout.section> - <x-data.tag-list :tags="$tags"/> - </x-layout.section> - @endif + $heroSrc = NewsImage::src($news->hero_image, 1280); + $seriesUrl = fn ($part) => localized_route('news.show', ['locale' => app()->getLocale(), 'news' => $part]); +@endphp - <x-h1 :title="$title"/> - <x-h1-teaser :teaser="$teaser"/> +<x-app-layout :page="$page" :schema="$schema" :wide="true" :preconnect-cloudinary="true"> - <x-layout.section> - <x-ui.prose> - {!! $content !!} - </x-ui.prose> - - <div class="mt-8"> - <x-h2 :title="__('Meta information')"/> - <x-data.meta-badges class="mt-6" :items="[ - __('Published at') => __('Published at: :date', ['date' => $published_at]), - __('Last updated at') => __('Last updated at: :date', ['date' => $last_updated_at]), - __('Author') => __('Author: :name', ['name' => $author]), + {{-- Reading progress --}} + <div x-data="readingProgress" class="fixed inset-x-0 top-0 z-40 h-0.5 bg-transparent" aria-hidden="true"> + <div x-ref="bar" class="h-full bg-brand transition-[width] duration-150" style="width: 0"></div> + </div> + + <article> + {{-- Title block first, image after: the reading order of «Quiet Reader» --}} + <div class="news-body"> + <x-breadcrumbs :items="[ + ['label' => __('News'), 'url' => localized_route('news.index')], + ['label' => $title], ]"/> + + {{-- Category, date and reading time on one line — the byline below carries + nothing but the author. --}} + <p class="flex flex-wrap items-center gap-x-2 text-sm"> + @if($news->series) + <span class="uppercase tracking-[0.15em] text-brand"> + {{ $news->series->title }}@if($news->series_position), {{ __('Part :position', ['position' => $news->series_position]) }}@endif + </span> + @elseif($tags->isNotEmpty()) + <span class="uppercase tracking-[0.15em] text-brand">{{ $tags->first() }}</span> + @endif + + @if($news->published_at) + <span class="text-muted" aria-hidden="true">·</span> + <time class="text-muted" datetime="{{ $news->published_at->toDateString() }}"> + {{ $news->published_at->translatedFormat('j. F Y') }} + </time> + @endif + + @if($news->reading_minutes) + <span class="text-muted" aria-hidden="true">·</span> + <span class="text-muted">{{ __(':count min read', ['count' => $news->reading_minutes]) }}</span> + @endif + </p> + + {{-- The same h1 and the same lead as every other page on the site. --}} + <x-h1 :title="$title" class="mt-4 mb-0"/> + + @if($teaser) + <p class="mt-4 max-w-3xl text-lead font-light text-gray-800">{{ $teaser }}</p> + @endif + </div> + + {{-- Hero, then the author. The aspect ratio tightens with the viewport so a 3:1 + band does not collapse into a strip on a phone. --}} + @if($heroSrc) + <div class="news-body mt-10"> + <figure class="m-0"> + <img src="{{ NewsImage::src($news->hero_image, 1280) }}" + @if($srcset = NewsImage::srcset($news->hero_image, 1280)) srcset="{{ $srcset }}" sizes="(min-width: 960px) 896px, 100vw" @endif + alt="{{ $news->hero_alt ?: '' }}" + width="1800" height="600" fetchpriority="high" decoding="async" + class="w-full aspect-[4/3] sm:aspect-[16/9] lg:aspect-[3/1] object-cover"> + @if($news->hero_caption) + <figcaption class="news-caption">{{ $news->hero_caption }}</figcaption> + @endif + </figure> + </div> + @endif + + <div class="news-body {{ $heroSrc ? 'mt-8' : 'mt-10' }}"> + <x-news.byline class="border-t border-border pt-6" + :author-name="$authorName" + :author-role="$authorRole" + :author-image="$authorImage"/> </div> - </x-layout.section> + + {{-- The table of contents is a band above the article, spanning the full content + width. Nothing sits beside the reading column. --}} + <div class="news-body mt-14"> + <x-news.table-of-contents :headings="$headings"/> + </div> + + {{-- The article body. Markdown directives render their own block components. --}} + <div id="article-body" class="news-body news-prose mt-14"> + {!! $content !!} + </div> + + <div class="news-body mt-16"> + @if($tags->isNotEmpty()) + <div class="flex flex-wrap gap-2 border-t border-border pt-6"> + @foreach($tags as $tag) + <x-ui.badge :label="$tag" variant="outline" size="md"/> + @endforeach + </div> + @endif + + @if($authorName) + <div class="mt-12 flex gap-5 border-t border-border-soft pt-8"> + <x-news.avatar :image="$authorImage" :px="120" class="size-14"/> + <div> + <p class="text-sm text-muted">{{ __('Written by') }}</p> + <p class="text-lg font-semibold text-gray-900">{{ $authorName }}</p> + @if($authorRole) + <p class="text-sm text-muted">{{ $authorRole }}</p> + @endif + @if($authorLinkedin) + <x-ui.link :href="$authorLinkedin" target="_blank" label="LinkedIn →" + class="mt-2 inline-flex min-h-control items-center text-sm font-medium text-brand hover:underline sm:min-h-0"/> + @endif + </div> + </div> + @endif + </div> + + @if($news->series && $seriesParts->count() > 1) + <div class="news-body mt-20"> + <x-news.series-nav :series="$news->series" :parts="$seriesParts" :current="$news" :url-for="$seriesUrl"/> + </div> + @endif + + @if($related->isNotEmpty()) + <div class="news-body mt-20"> + <section class="border-t border-border pt-10"> + <x-h2 :title="__('Continue reading')"/> + <div class="mt-8 grid gap-8 sm:grid-cols-3"> + @foreach($related as $item) + <x-news.card + :compact="true" + :url="localized_route('news.show', ['locale' => app()->getLocale(), 'news' => $item])" + :title="$item->title" + :image="$item->hero_image" + :reading-minutes="$item->reading_minutes" + :level="3"/> + @endforeach + </div> + </section> + </div> + @endif + </article> </x-app-layout> diff --git a/resources/views/app/open-source/show.blade.php b/resources/views/app/open-source/show.blade.php index 73fbfc5..411c59e 100644 --- a/resources/views/app/open-source/show.blade.php +++ b/resources/views/app/open-source/show.blade.php @@ -1,7 +1,9 @@ <x-app-layout :page="$page" :schema="$schema"> - <x-h1 :title="$name"/> - <x-h1-teaser :teaser="$teaser"/> + <x-layout.page-header :title="$name" :intro="$teaser" :breadcrumbs="[ + ['label' => __('Open Source'), 'url' => localized_route('open-source.index')], + ['label' => $name], + ]"/> @if(filled($tags)) <x-layout.section> @@ -17,7 +19,10 @@ @if(filled($link)) <x-layout.section> - <x-ui.link :href="$link" target="_blank" label="{{ __('View on GitHub') }}"/> + <x-ui.button variant="outline" :href="$link" target="_blank" :label="__('View on GitHub')"> + {{ __('View on GitHub') }} + <x-icon.external-link class="size-4"/> + </x-ui.button> </x-layout.section> @endif diff --git a/resources/views/app/products/show.blade.php b/resources/views/app/products/show.blade.php index a62d606..184b94b 100644 --- a/resources/views/app/products/show.blade.php +++ b/resources/views/app/products/show.blade.php @@ -1,7 +1,9 @@ <x-app-layout :page="$page"> - <x-h1 :title="$headline ?? $name"/> - <x-h1-teaser :teaser="$teaser"/> + <x-layout.page-header :title="$headline ?? $name" :intro="$teaser" :breadcrumbs="[ + ['label' => __('Products'), 'url' => localized_route('products.index')], + ['label' => $name], + ]"/> <x-layout.section> <x-ui.prose> diff --git a/resources/views/app/services/index.blade.php b/resources/views/app/services/index.blade.php index 7ba6148..3196e13 100644 --- a/resources/views/app/services/index.blade.php +++ b/resources/views/app/services/index.blade.php @@ -4,21 +4,7 @@ <x-layout.section> <x-layout.list> @foreach($services as $entry) - <div class="py-4"> - <div class="flex flex-col gap-1"> - <h2 class="text-xl font-semibold text-gray-800"> - {{ $entry->name }} - </h2> - - @if(filled($entry->teaser)) - <div class="text-muted"> - {{ $entry->teaser }} - </div> - @endif - - <x-data.tag-list :tags="$entry->tags" class="mt-1"/> - </div> - </div> + <x-card.item-card :title="$entry->name" :teaser="$entry->teaser" :tags="$entry->tags" :level="2"/> @endforeach </x-layout.list> </x-layout.section> diff --git a/resources/views/app/services/show.blade.php b/resources/views/app/services/show.blade.php index a8f7c81..0e550a9 100644 --- a/resources/views/app/services/show.blade.php +++ b/resources/views/app/services/show.blade.php @@ -1,7 +1,9 @@ <x-app-layout :page="$page"> - <x-h1 :title="$name"/> - <x-h1-teaser :teaser="$teaser"/> + <x-layout.page-header :title="$name" :intro="$teaser" :breadcrumbs="[ + ['label' => __('Services'), 'url' => localized_route('services.index')], + ['label' => $name], + ]"/> <x-layout.section> <x-ui.prose> diff --git a/resources/views/app/start/index.blade.php b/resources/views/app/start/index.blade.php index e78837a..dd1a78a 100644 --- a/resources/views/app/start/index.blade.php +++ b/resources/views/app/start/index.blade.php @@ -2,6 +2,8 @@ <x-intro/> + <x-news.latest :articles="$latestNews"/> + <x-explore/> </x-app-layout> diff --git a/resources/views/app/technologies/show.blade.php b/resources/views/app/technologies/show.blade.php index a8f7c81..4a5f33c 100644 --- a/resources/views/app/technologies/show.blade.php +++ b/resources/views/app/technologies/show.blade.php @@ -1,7 +1,9 @@ <x-app-layout :page="$page"> - <x-h1 :title="$name"/> - <x-h1-teaser :teaser="$teaser"/> + <x-layout.page-header :title="$name" :intro="$teaser" :breadcrumbs="[ + ['label' => __('Technologies'), 'url' => localized_route('technologies.index')], + ['label' => $name], + ]"/> <x-layout.section> <x-ui.prose> diff --git a/resources/views/components/ai-llm/model-row.blade.php b/resources/views/components/ai-llm/model-row.blade.php index 0d08dea..4fbc4d3 100644 --- a/resources/views/components/ai-llm/model-row.blade.php +++ b/resources/views/components/ai-llm/model-row.blade.php @@ -5,9 +5,9 @@ <div class="font-semibold text-lg text-gray-800">{{ $model->name }}</div> <div class="text-muted">{{ $model->localizedRole() }}</div> <div class="flex flex-wrap gap-2"> - <x-ui.badge :label="$model->provider" :title="__('components.ai_llm.tooltips.provider')" class="text-xs"/> - <x-ui.badge :label="$model->ram" :title="__('components.ai_llm.tooltips.ram')" class="text-xs"/> - <x-ui.badge :label="$model->licenseLabel()" :title="$model->licenseTooltip()" class="text-xs"/> + <x-ui.badge :label="$model->provider" :title="__('components.ai_llm.tooltips.provider')" size="xs"/> + <x-ui.badge :label="$model->ram" :title="__('components.ai_llm.tooltips.ram')" size="xs"/> + <x-ui.badge :label="$model->licenseLabel()" :title="$model->licenseTooltip()" size="xs"/> </div> </div> <div class="sm:justify-self-end"> @@ -17,7 +17,7 @@ target="_blank" :title="__('components.ai_llm.tooltips.link')" variant="brand" - class="text-xs whitespace-nowrap"> + class="whitespace-nowrap"> <x-icon.external-link class="ml-1 size-3"/> </x-ui.badge-link> </div> diff --git a/resources/views/components/ai-llm/usage-summary.blade.php b/resources/views/components/ai-llm/usage-summary.blade.php new file mode 100644 index 0000000..dc5ad25 --- /dev/null +++ b/resources/views/components/ai-llm/usage-summary.blade.php @@ -0,0 +1,17 @@ +@props(['summary']) + +@php + use App\Helpers\Facades\HelperNumber; +@endphp + +{{-- The tokens/requests pair shown on both the AI overview and the LLM page. --}} +<x-layout.grid :cols="2" class="mt-4"> + <x-card.stat-card + :label="__('components.ai.stats.tokens_month')" + :value="HelperNumber::abbreviate($summary['total_tokens'])" + :input="HelperNumber::abbreviate($summary['prompt_tokens'])" + :output="HelperNumber::abbreviate($summary['completion_tokens'])"/> + <x-card.stat-card + :label="__('components.ai.stats.requests_month')" + :value="HelperNumber::format($summary['requests'], 0)"/> +</x-layout.grid> diff --git a/resources/views/components/breadcrumbs.blade.php b/resources/views/components/breadcrumbs.blade.php new file mode 100644 index 0000000..3b5ce94 --- /dev/null +++ b/resources/views/components/breadcrumbs.blade.php @@ -0,0 +1,35 @@ +@props(['items' => []]) + +@php + // Home is implicit — callers pass only the trail below it. The last entry is the + // page you are on: never a link, de-emphasised, and marked aria-current="page". + $crumbs = collect($items)->filter(fn ($item) => filled($item['label'] ?? null))->values(); +@endphp + +@if($crumbs->isNotEmpty()) + <nav aria-label="{{ __('Breadcrumb') }}" {{ $attributes->merge(['class' => 'mb-3']) }}> + <ol class="flex flex-wrap items-center gap-x-2 text-sm text-muted"> + <li class="flex items-center gap-2"> + <x-ui.link :href="localized_route('start.index')" :label="__('Home')" + class="inline-flex min-h-control items-center sm:min-h-0"/> + <span class="text-gray-300" aria-hidden="true">/</span> + </li> + + @foreach($crumbs as $crumb) + <li class="flex items-center gap-2"> + @if($loop->last || blank($crumb['url'] ?? null)) + <span class="inline-flex min-h-control items-center font-medium text-gray-800 sm:min-h-0" + @if($loop->last) aria-current="page" @endif>{{ $crumb['label'] }}</span> + @else + <x-ui.link :href="$crumb['url']" :label="$crumb['label']" + class="inline-flex min-h-control items-center sm:min-h-0"/> + @endif + + @unless($loop->last) + <span class="text-gray-300" aria-hidden="true">/</span> + @endunless + </li> + @endforeach + </ol> + </nav> +@endif diff --git a/resources/views/components/card/item-card-body.blade.php b/resources/views/components/card/item-card-body.blade.php new file mode 100644 index 0000000..2736ebd --- /dev/null +++ b/resources/views/components/card/item-card-body.blade.php @@ -0,0 +1,33 @@ +@props(['title', 'teaser' => null, 'tags' => [], 'publishedAt' => null, 'level' => 3, 'linked' => true]) + +{{-- The contents of a list row. Split out of x-card.item-card so a row with a + detail page and a row without one are the same markup inside two wrappers. --}} +<div class="flex flex-col gap-1"> + <h{{ $level }} class="text-subheading font-semibold text-gray-800 {{ $linked ? 'group-hover:text-brand' : '' }}"> + {{ $title }} + </h{{ $level }}> + + @if(filled($teaser)) + <div class="text-muted">{{ $teaser }}</div> + @endif + + {{-- Topic and date share one caption line, the same order the news card uses, + so the latest-news block and the news index read as one thing. Rows without + a date — services, products, technologies — keep the bare tag list. --}} + @if(filled($tags) || $publishedAt) + <div class="mt-1 flex flex-wrap items-center gap-x-2 gap-y-2 text-sm text-muted"> + <x-data.tag-list :tags="$tags"/> + + @if($publishedAt) + <time datetime="{{ $publishedAt->toDateString() }}">{{ $publishedAt->translatedFormat('j. F Y') }}</time> + @endif + </div> + @endif + + @if($linked) + <span class="mt-2 inline-flex items-center gap-1 text-sm font-medium text-brand"> + {{ __('Learn more') }} + <x-icon.arrow-right class="size-4 transition-transform group-hover:translate-x-1"/> + </span> + @endif +</div> diff --git a/resources/views/components/card/item-card.blade.php b/resources/views/components/card/item-card.blade.php index e80fe5d..2314b64 100644 --- a/resources/views/components/card/item-card.blade.php +++ b/resources/views/components/card/item-card.blade.php @@ -1,24 +1,15 @@ -@props(['url', 'title', 'teaser' => null, 'tags' => [], 'target' => '_self', 'level' => 3]) - -<a href="{{ $url }}" - @if($target !== '_self') target="{{ $target }}" rel="noopener noreferrer" @endif - {{ $attributes->merge(['class' => 'group block py-4 transition hover:bg-gray-50/50 rounded']) }}> - <div class="flex flex-col gap-1"> - <h{{ $level }} class="text-xl font-semibold text-gray-800 group-hover:text-brand"> - {{ $title }} - </h{{ $level }}> - - @if(filled($teaser)) - <div class="text-muted"> - {{ $teaser }} - </div> - @endif - - <x-data.tag-list :tags="$tags" class="mt-1"/> - - <span class="mt-2 inline-flex items-center gap-1 text-sm font-medium text-brand"> - {{ __('Learn more') }} - <x-icon.arrow-right class="size-4 transition-transform group-hover:translate-x-1"/> - </span> +@props(['url' => null, 'title', 'teaser' => null, 'tags' => [], 'publishedAt' => null, 'target' => '_self', 'level' => 3]) + +{{-- The list row behind services, products, technologies, open source and the + latest-news block. Given no url it renders the same row without the link and + without the «Learn more» affordance, so an index whose entries have no detail + page still reads as the same list. --}} +@if(filled($url)) + <x-card.linkable :url="$url" :target="$target" {{ $attributes }}> + <x-card.item-card-body :title="$title" :teaser="$teaser" :tags="$tags" :published-at="$publishedAt" :level="$level"/> + </x-card.linkable> +@else + <div {{ $attributes->merge(['class' => 'py-4']) }}> + <x-card.item-card-body :title="$title" :teaser="$teaser" :tags="$tags" :published-at="$publishedAt" :level="$level" :linked="false"/> </div> -</a> +@endif diff --git a/resources/views/components/card/linkable.blade.php b/resources/views/components/card/linkable.blade.php new file mode 100644 index 0000000..1fd30a4 --- /dev/null +++ b/resources/views/components/card/linkable.blade.php @@ -0,0 +1,18 @@ +@props(['url', 'target' => '_self', 'surface' => 'plain']) + +@php + // Every clickable surface on the site — list rows, explore tiles, series + // previous/next — shares this hover tint and focus ring. Add a surface here + // rather than another hand-rolled hover somewhere in a view. + $surfaces = [ + 'plain' => 'rounded-pill py-4', + 'panel' => 'rounded-panel border border-border p-4', + 'panel-lg' => 'rounded-panel border border-border p-5', + ]; +@endphp + +<a href="{{ $url }}" + @if($target !== '_self') target="{{ $target }}" rel="noopener noreferrer" @endif + {{ $attributes->merge(['class' => 'group block cursor-pointer transition hover:bg-gray-50/50 focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand '.($surfaces[$surface] ?? $surfaces['plain'])]) }}> + {{ $slot }} +</a> diff --git a/resources/views/components/card/nav-card.blade.php b/resources/views/components/card/nav-card.blade.php index a511d8c..b04fe8c 100644 --- a/resources/views/components/card/nav-card.blade.php +++ b/resources/views/components/card/nav-card.blade.php @@ -1,18 +1,8 @@ -@props(['url', 'label', 'teaser', 'route' => null]) - -@php - // One accent hue per main page so the explore tiles carry a bit of colour. - $accent = match ($route) { - 'services.index' => 'border-l-sky-400', - 'ai.index' => 'border-l-violet-400', - 'network.index' => 'border-l-emerald-400', - 'contact.index' => 'border-l-rose-400', - default => 'border-l-brand', - }; -@endphp +@props(['url', 'label', 'teaser']) +{{-- The wash mirrors the logo strip: fuchsia-600 → brand → blue-600, left to right. --}} <a href="{{ $url }}" - class="group flex flex-col gap-1 rounded-panel border border-gray-200 border-l-4 {{ $accent }} p-4 transition hover:bg-gray-50/50"> + {{ $attributes->merge(['class' => 'group flex cursor-pointer flex-col gap-1 rounded-panel border border-border bg-linear-to-r from-fuchsia-600/10 via-brand/10 to-blue-600/10 p-4 transition-colors hover:border-gray-300 hover:from-fuchsia-600/20 hover:via-brand/20 hover:to-blue-600/20 focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand']) }}> <span class="flex items-center justify-between gap-2"> <span class="text-base font-bold text-gray-800 group-hover:text-brand">{{ $label }}</span> <x-icon.arrow-right class="size-4 shrink-0 text-brand transition-transform group-hover:translate-x-1"/> diff --git a/resources/views/components/card/network-user-card.blade.php b/resources/views/components/card/network-user-card.blade.php index acf584e..7fbf6b1 100644 --- a/resources/views/components/card/network-user-card.blade.php +++ b/resources/views/components/card/network-user-card.blade.php @@ -4,8 +4,8 @@ use App\Support\CloudinaryUrl; @endphp -<div class="{{ $divided ? 'border-t border-dashed border-gray-200 pt-3 mt-3' : '' }}"> - <div class="flex items-center gap-2 min-w-0"> +<div class="{{ $divided ? 'mt-3 border-t border-dashed border-border pt-3' : '' }}"> + <div class="flex min-w-0 items-center gap-2"> @if($user->avatar_url) <img src="{{ CloudinaryUrl::src($user->avatar_url, 64) }}" srcset="{{ CloudinaryUrl::srcset($user->avatar_url, 64) }}" @@ -17,6 +17,7 @@ class="size-8 shrink-0 rounded-full object-cover"> alt="{{ $user->name }}" loading="lazy" class="size-8 shrink-0 rounded-full bg-gray-100 object-cover"> @endif + <span class="min-w-0 flex-1 leading-tight"> <span class="block truncate whitespace-nowrap text-base font-bold text-gray-800">{{ $user->name }}</span> @if($user->role) @@ -24,30 +25,10 @@ class="size-8 shrink-0 rounded-full bg-gray-100 object-cover"> @endif </span> - @if($user->linkedin || $user->public_email || $user->phone) - <span class="flex shrink-0 items-center gap-3"> - @if($user->linkedin) - <a href="{{ $user->linkedin }}" target="_blank" rel="noopener noreferrer" aria-label="LinkedIn" - title="LinkedIn" - class="text-muted hover:text-gray-800"> - <x-icon.linkedin/> - </a> - @endif - @if($user->public_email) - <a href="mailto:{{ $user->public_email }}" aria-label="E-Mail" - title="{{ $user->public_email }}" - class="text-muted hover:text-gray-800"> - <x-icon.email/> - </a> - @endif - @if($user->phone) - <a href="tel:{{ preg_replace('/[^0-9+]/', '', $user->phone) }}" aria-label="{{ __('Phone') }}" - title="{{ $user->phone }}" - class="text-muted hover:text-gray-800"> - <x-icon.phone/> - </a> - @endif - </span> - @endif + <x-ui.social-links :name="$user->name" class="shrink-0 sm:gap-3" :links="[ + 'linkedin' => $user->linkedin, + 'email' => $user->public_email, + 'phone' => $user->phone, + ]"/> </div> </div> diff --git a/resources/views/components/card/person-card.blade.php b/resources/views/components/card/person-card.blade.php index 0d77bbd..4616505 100644 --- a/resources/views/components/card/person-card.blade.php +++ b/resources/views/components/card/person-card.blade.php @@ -4,67 +4,25 @@ use App\Support\CloudinaryUrl; @endphp -<div class="flex flex-row rounded-panel overflow-hidden transition group"> - <div class="{{ $imageContainerClassAttributes ?? 'h-32 w-32 flex-shrink-0 overflow-hidden' }}"> +<div class="group flex flex-row overflow-hidden rounded-panel transition"> + <div class="{{ $imageContainerClassAttributes ?? 'size-32 shrink-0 overflow-hidden' }}"> <img src="{{ CloudinaryUrl::src($image, 256) }}" - srcset="{{ CloudinaryUrl::srcset($image, 256) }}" - sizes="128px" - width="128" - height="128" - alt="{{ $name }}" - loading="lazy" - class="w-full h-full object-cover transition-transform duration-300 ease-in-out group-hover:scale-105" /> + srcset="{{ CloudinaryUrl::srcset($image, 256) }}" + sizes="128px" + width="128" + height="128" + alt="{{ $name }}" + loading="lazy" + class="size-full object-cover transition-transform duration-300 ease-in-out group-hover:scale-105"/> </div> - <div class="flex flex-col justify-center p-4 space-y-1"> - <div class="text-base font-bold text-gray-800 leading-tight"> - {{ $name }} - </div> + <div class="flex min-w-0 flex-col justify-center p-4"> + <div class="text-base leading-tight font-bold text-gray-800">{{ $name }}</div> - @if (!blank($role)) - <div class="text-sm text-muted leading-snug"> - {{ $role }} - </div> + @if(filled($role)) + <div class="mt-1 text-sm leading-snug text-muted">{{ $role }}</div> @endif - @php $icons = collect($icons); @endphp - - @if ($icons->isNotEmpty()) - <div class="flex gap-3 pt-2"> - @foreach ($icons as $type => $url) - @if (filled($url)) - @switch($type) - @case('linkedin') - <a href="{{ $url }}" target="_blank" rel="noopener noreferrer" aria-label="LinkedIn" - class="text-muted hover:text-gray-800"> - <x-icon.linkedin/> - </a> - @break - - @case('github') - <a href="{{ $url }}" target="_blank" rel="noopener noreferrer" aria-label="GitHub" - class="text-muted hover:text-gray-800"> - <x-icon.github/> - </a> - @break - - @case('email') - <a href="mailto:{{ $url }}" aria-label="E-Mail" - class="text-muted hover:text-gray-800"> - <x-icon.email/> - </a> - @break - - @case('website') - <a href="{{ $url }}" target="_blank" rel="noopener noreferrer" aria-label="Website" - class="text-muted hover:text-gray-800"> - <x-icon.website/> - </a> - @break - @endswitch - @endif - @endforeach - </div> - @endif + <x-ui.social-links :links="$icons" :name="$name" class="mt-1 -ml-2.5 sm:mt-2 sm:ml-0 sm:gap-3"/> </div> </div> diff --git a/resources/views/components/data/meta-badges.blade.php b/resources/views/components/data/meta-badges.blade.php index cd4aa67..9b3116e 100644 --- a/resources/views/components/data/meta-badges.blade.php +++ b/resources/views/components/data/meta-badges.blade.php @@ -2,6 +2,6 @@ <div {{ $attributes->merge(['class' => 'flex flex-col gap-y-2 md:flex-row md:items-center md:gap-x-2']) }}> @foreach($items as $title => $label) - <x-ui.badge :label="$label" :title="$title" class="text-sm self-start"/> + <x-ui.badge :label="$label" :title="$title" class="self-start"/> @endforeach </div> diff --git a/resources/views/components/data/tag-list.blade.php b/resources/views/components/data/tag-list.blade.php index 6042af9..c2aab0a 100644 --- a/resources/views/components/data/tag-list.blade.php +++ b/resources/views/components/data/tag-list.blade.php @@ -6,7 +6,7 @@ <ul {{ $attributes->merge(['class' => 'flex flex-wrap gap-2']) }}> @foreach($tags as $tag) <li> - <x-ui.badge :label="$tag" class="text-xs"/> + <x-ui.badge :label="$tag" size="xs"/> </li> @endforeach </ul> diff --git a/resources/views/components/demo/bar.blade.php b/resources/views/components/demo/bar.blade.php new file mode 100644 index 0000000..2916e0c --- /dev/null +++ b/resources/views/components/demo/bar.blade.php @@ -0,0 +1,15 @@ +@props(['href', 'label', 'title' => null, 'inset' => false]) + +{{-- The «back to all variants» strip above a demo page. Shared by the standalone + prototype shell and the variants that render inside the real app layout; the + latter pass inset so the bar can break out of the page frame. --}} +<div @class([ + 'flex items-center justify-between gap-4 bg-zinc-950 px-4 py-2 text-white sm:px-6 lg:px-8', + 'sticky top-0 z-50 text-sm' => ! $inset, + '-mx-4 mb-6 text-xs sm:-mx-6 lg:-mx-8' => $inset, +])> + <a href="{{ $href }}" class="font-semibold transition hover:text-white/70 focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"> + ← {{ $label }} + </a> + <span class="truncate text-white/50">{{ $title }}</span> +</div> diff --git a/resources/views/components/explore.blade.php b/resources/views/components/explore.blade.php index 7fa51f8..7b680d0 100644 --- a/resources/views/components/explore.blade.php +++ b/resources/views/components/explore.blade.php @@ -1,6 +1,8 @@ @php + // News is part of the reading chain but not of this grid: the latest-articles block + // sits directly above it on the start page, and start.index is the page we are on. $cards = collect(\App\Support\PageNavigation::pages()) - ->reject(fn (array $page) => $page['route'] === 'start.index'); + ->reject(fn (array $page) => in_array($page['route'], ['start.index', 'news.index'], true)); @endphp <x-layout.section> @@ -8,7 +10,7 @@ <x-layout.grid class="mt-4"> @foreach ($cards as $card) - <x-card.nav-card :url="localized_route($card['route'])" :route="$card['route']" :label="$card['label']" :teaser="$card['teaser']"/> + <x-card.nav-card :url="localized_route($card['route'])" :label="$card['label']" :teaser="$card['teaser']"/> @endforeach </x-layout.grid> </x-layout.section> diff --git a/resources/views/components/form/actions.blade.php b/resources/views/components/form/actions.blade.php new file mode 100644 index 0000000..143bc42 --- /dev/null +++ b/resources/views/components/form/actions.blade.php @@ -0,0 +1,7 @@ +{{-- The action row every form ends with. Stacked on a phone with the primary + action on top (flex-col-reverse puts the last child first), side by side and + right-aligned from sm up. Children stretch to full width on mobile without + needing a single class of their own. --}} +<div {{ $attributes->merge(['class' => 'mt-section flex flex-col-reverse gap-3 sm:flex-row sm:justify-end']) }}> + {{ $slot }} +</div> diff --git a/resources/views/components/form/combobox.blade.php b/resources/views/components/form/combobox.blade.php index 8cee91f..ae08117 100644 --- a/resources/views/components/form/combobox.blade.php +++ b/resources/views/components/form/combobox.blade.php @@ -4,37 +4,39 @@ <div class="block w-full" x-data="combobox" x-on:click.outside="close"> <label class="block"> - <span class="text-xs font-medium text-gray-500">{{ $label }}</span> - <span class="relative mt-1 block"> - <input type="text" name="{{ $name }}" value="{{ $value }}" placeholder="{{ $placeholder }}" - autocomplete="off" data-1p-ignore data-lpignore="true" data-form-type="other" - role="combobox" aria-autocomplete="list" aria-controls="{{ $listboxId }}" - x-bind:aria-expanded="aria_expanded" - x-ref="input" x-on:focus="open" x-on:input="filter" - x-on:keydown.escape="close" - x-on:keydown.down.prevent="highlightNext" - x-on:keydown.up.prevent="highlightPrevious" - x-on:keydown.home.prevent="highlightFirst" - x-on:keydown.end.prevent="highlightLast" - x-on:keydown.enter.prevent="selectActive" - x-on:keydown.tab="close" - class="block w-full rounded-pill border border-gray-300 bg-white py-2 pl-3 pr-16 text-sm text-gray-800 placeholder-gray-500 focus:border-brand focus:outline-none focus:ring-1 focus:ring-brand"/> + <span class="block text-sm font-medium text-gray-800">{{ $label }}</span> + <span class="relative mt-1.5 block"> + {{-- The same x-form.input every other control uses, so the filter row on + the analytics page lines up with a submit button beside it. --}} + <x-form.input type="text" :name="$name" :value="$value" :placeholder="$placeholder" + autocomplete="off" data-1p-ignore data-lpignore="true" data-form-type="other" + role="combobox" aria-autocomplete="list" aria-controls="{{ $listboxId }}" + x-bind:aria-expanded="aria_expanded" + x-ref="input" x-on:focus="open" x-on:input="filter" + x-on:keydown.escape="close" + x-on:keydown.down.prevent="highlightNext" + x-on:keydown.up.prevent="highlightPrevious" + x-on:keydown.home.prevent="highlightFirst" + x-on:keydown.end.prevent="highlightLast" + x-on:keydown.enter.prevent="selectActive" + x-on:keydown.tab="close" + class="pr-20"/> <button type="button" x-on:click="clear" x-show="hasValue" x-cloak - class="absolute right-8 top-1/2 -translate-y-1/2 p-1 text-gray-500 hover:text-muted" + class="absolute right-10 top-1/2 grid size-control -translate-y-1/2 place-items-center rounded-pill text-muted transition hover:text-gray-800 focus:outline-none focus-visible:outline-2 focus-visible:outline-brand" aria-label="{{ __('Clear') }}"> <x-icon.close/> </button> - <x-icon.chevron-down class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 size-4 text-gray-500"/> + <x-icon.chevron-down class="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-muted"/> </span> </label> <div class="relative"> <ul x-ref="list" x-show="isOpen" x-cloak id="{{ $listboxId }}" role="listbox" aria-label="{{ $label }}" - class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-pill border border-border bg-white py-1 text-sm shadow-lg"> + class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-panel border border-border bg-white py-1 text-base shadow-pop"> @foreach ($options as $option) <li x-on:click="select" data-value="{{ $option }}" id="combobox-{{ $name }}-option-{{ $loop->index }}" role="option" aria-selected="false" - class="cursor-pointer px-3 py-2 text-gray-800 hover:bg-gray-100">{{ $option }}</li> + class="flex min-h-control cursor-pointer items-center px-4 text-gray-800 hover:bg-gray-100">{{ $option }}</li> @endforeach </ul> </div> diff --git a/resources/views/components/form/field.blade.php b/resources/views/components/form/field.blade.php new file mode 100644 index 0000000..66bb1c7 --- /dev/null +++ b/resources/views/components/form/field.blade.php @@ -0,0 +1,21 @@ +@props(['name', 'label' => null, 'help' => null]) + +{{-- One field pattern for the whole app: label → control → help → error, with the + same vertical rhythm every time. Pass help as a prop for plain text or as a + <x-slot:help> when it needs a link inside. --}} +<div {{ $attributes->merge(['class' => 'space-y-1.5']) }}> + @if(filled($label)) + <label for="{{ $name }}" class="block text-sm font-medium text-gray-800">{{ $label }}</label> + @endif + + {{ $slot }} + + @if(filled($help)) + <p id="{{ $name }}-help" class="text-sm text-muted">{{ $help }}</p> + @endif + + @error($name) + {{-- The message itself carries the meaning; the red is only reinforcement. --}} + <p id="{{ $name }}-error" class="text-sm text-red-600">{{ $message }}</p> + @enderror +</div> diff --git a/resources/views/components/form/file.blade.php b/resources/views/components/form/file.blade.php new file mode 100644 index 0000000..bc21e4d --- /dev/null +++ b/resources/views/components/form/file.blade.php @@ -0,0 +1,16 @@ +@props(['name', 'accept' => null, 'id' => null]) + +@php + $id ??= $name; + $hasError = $errors->has($name); +@endphp + +{{-- The file-selector button is a button, so it carries the button's pill radius + and brand fill, and the shared control height keeps it in line with a text + input sitting above it. --}} +<input type="file" + id="{{ $id }}" + name="{{ $name }}" + @if(filled($accept)) accept="{{ $accept }}" @endif + @if($hasError) aria-invalid="true" aria-describedby="{{ $id }}-error" @endif + {{ $attributes->merge(['class' => 'block h-control w-full text-sm text-gray-800 file:mr-3 file:h-control file:rounded-pill file:border-0 file:bg-brand file:px-4 file:text-sm file:font-medium file:text-white hover:file:bg-brand-strong focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand']) }}> diff --git a/resources/views/components/form/input.blade.php b/resources/views/components/form/input.blade.php new file mode 100644 index 0000000..5c6c7ae --- /dev/null +++ b/resources/views/components/form/input.blade.php @@ -0,0 +1,31 @@ +@props([ + 'name', + 'type' => 'text', + 'value' => null, + 'placeholder' => null, + 'id' => null, + 'describedBy' => null, +]) + +@php + $id ??= $name; + $hasError = $errors->has($name); + + // The one control appearance in the app. Every text control — including the + // combobox — renders through this component, so heights, padding, radius and + // focus ring can never drift apart. border-strong and hint are the lightest + // grays that still clear WCAG 1.4.11 (3:1) and 1.4.3 (4.5:1) on white. + $describedBy = collect([ + $describedBy, + $hasError ? $id.'-error' : null, + ])->filter()->implode(' '); +@endphp + +<input type="{{ $type }}" + id="{{ $id }}" + name="{{ $name }}" + value="{{ old($name, $value) }}" + @if(filled($placeholder)) placeholder="{{ $placeholder }}" @endif + @if($hasError) aria-invalid="true" @endif + @if(filled($describedBy)) aria-describedby="{{ $describedBy }}" @endif + {{ $attributes->merge(['class' => 'block h-control w-full rounded-pill border border-border-strong bg-white px-4 text-base text-gray-800 placeholder-hint transition focus:border-brand focus:outline-none focus:ring-1 focus:ring-brand aria-invalid:border-red-600 aria-invalid:focus:border-red-600 aria-invalid:focus:ring-red-600']) }}> diff --git a/resources/views/components/h1-teaser.blade.php b/resources/views/components/h1-teaser.blade.php deleted file mode 100644 index 51e4ca7..0000000 --- a/resources/views/components/h1-teaser.blade.php +++ /dev/null @@ -1,3 +0,0 @@ -@props(['teaser']) - -<span class="-mt-3 text-xl md:text-2xl font-semibold">{{ $teaser }}</span> \ No newline at end of file diff --git a/resources/views/components/h1.blade.php b/resources/views/components/h1.blade.php index 13c3e1d..023cc8b 100644 --- a/resources/views/components/h1.blade.php +++ b/resources/views/components/h1.blade.php @@ -1,3 +1,5 @@ @props(['title']) -<h1 class="mb-3 text-3xl md:text-4xl font-bold">{{ $title }}</h1> \ No newline at end of file +{{-- Size, line height and tracking come from the --text-display token, which is + fluid, so no md: step is needed to shrink this on a phone. --}} +<h1 {{ $attributes->merge(['class' => 'mb-3 text-display font-bold text-balance']) }}>{{ $title }}</h1> diff --git a/resources/views/components/h2.blade.php b/resources/views/components/h2.blade.php index 23b3c73..bad15c5 100644 --- a/resources/views/components/h2.blade.php +++ b/resources/views/components/h2.blade.php @@ -1,3 +1,3 @@ @props(['title']) -<h2 class="mb-2 text-xl md:text-2xl font-semibold">{{ $title }}</h2> \ No newline at end of file +<h2 {{ $attributes->merge(['class' => 'mb-2 text-heading font-semibold text-balance']) }}>{{ $title }}</h2> diff --git a/resources/views/components/h3.blade.php b/resources/views/components/h3.blade.php index 68ec40b..d8face7 100644 --- a/resources/views/components/h3.blade.php +++ b/resources/views/components/h3.blade.php @@ -1,3 +1,3 @@ @props(['title']) -<h3 class="mb-3 text-lg md:text-xl font-bold text-gray-950 tracking-tight">{{ $title }}</h3> \ No newline at end of file +<h3 {{ $attributes->merge(['class' => 'mb-3 text-subheading font-bold tracking-tight text-gray-950']) }}>{{ $title }}</h3> diff --git a/resources/views/components/layout/page-header.blade.php b/resources/views/components/layout/page-header.blade.php index 9d4cd59..876951d 100644 --- a/resources/views/components/layout/page-header.blade.php +++ b/resources/views/components/layout/page-header.blade.php @@ -1,4 +1,4 @@ -@props(['title', 'intro' => null, 'page' => null]) +@props(['title', 'intro' => null, 'page' => null, 'breadcrumbs' => []]) @php // Explicit intro wins; otherwise fall back to the page's SEO description @@ -6,9 +6,13 @@ $context = $intro ?? $page?->description; @endphp +<x-breadcrumbs :items="$breadcrumbs"/> + <x-h1 :title="$title"/> @if(filled($context)) - {{-- Lead text: one step above the body copy (text-lg) so the header keeps its hierarchy. --}} - <p class="max-w-3xl text-xl md:text-2xl font-light leading-normal text-gray-800">{{ $context }}</p> + {{-- One lead treatment for the whole site. Detail pages used to set this + semibold and index pages light, so the same slot read as two different + things depending on where you had come from. --}} + <p class="max-w-3xl text-lead font-light text-gray-800">{{ $context }}</p> @endif diff --git a/resources/views/components/layout/section.blade.php b/resources/views/components/layout/section.blade.php index 4a462e5..35ad36b 100644 --- a/resources/views/components/layout/section.blade.php +++ b/resources/views/components/layout/section.blade.php @@ -1,3 +1,5 @@ -<section {{ $attributes->merge(['class' => 'mt-6 text-lg text-gray-800']) }}> +{{-- The one vertical rhythm between page sections. It comes from --spacing-section, + so the whole site re-spaces from a single number in app.css. --}} +<section {{ $attributes->merge(['class' => 'mt-section text-lg text-gray-800']) }}> {{ $slot }} </section> diff --git a/resources/views/components/nav/link.blade.php b/resources/views/components/nav/link.blade.php new file mode 100644 index 0000000..9377ea3 --- /dev/null +++ b/resources/views/components/nav/link.blade.php @@ -0,0 +1,33 @@ +@props(['route', 'label', 'variant' => 'desktop']) + +@php + use Illuminate\Support\Str; + + // Route names are locale-prefixed (de-ch.services.show), so drop the prefix and + // compare sections: a detail page keeps its section lit — services.show still + // marks «Services» as the page you are on. + $current = Str::after(request()->route()?->getName() ?? '', '.'); + $isActive = filled($current) && Str::before($current, '.') === Str::before($route, '.'); + + $variants = [ + 'desktop' => [ + 'base' => 'rounded-pill px-1 text-xl transition md:text-2xl focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand', + 'idle' => 'hover:text-brand', + 'active' => 'font-semibold text-brand', + ], + 'mobile' => [ + 'base' => 'flex min-h-control items-center justify-center px-4 text-xl transition focus:outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-brand', + 'idle' => 'text-gray-800 hover:text-brand', + 'active' => 'bg-brand/10 font-semibold text-brand', + ], + ]; + + $style = $variants[$variant] ?? $variants['desktop']; +@endphp + +<a href="{{ localized_route($route) }}" + title="{{ $label }}" + @if($isActive) aria-current="page" @endif + {{ $attributes->merge(['class' => $style['base'].' '.($isActive ? $style['active'] : $style['idle'])]) }}> + {{ $label }} +</a> diff --git a/resources/views/components/nav/locale-switch.blade.php b/resources/views/components/nav/locale-switch.blade.php new file mode 100644 index 0000000..ced0b2c --- /dev/null +++ b/resources/views/components/nav/locale-switch.blade.php @@ -0,0 +1,25 @@ +@props(['locales' => [], 'separator' => true]) + +{{-- Real links, not a form: crawlers cannot submit forms, so a form would leave the + two language versions connected only by the hreflang tags in <head>. + SetLanguage reads the locale from the URL and persists it, so no POST is needed. --}} +@if(! empty($locales)) + <div {{ $attributes->merge(['class' => 'flex items-center gap-2']) }}> + @foreach($locales as $language) + <a href="{{ locale_switch_url($language->value) }}" + hreflang="{{ str_replace('_', '-', $language->value) }}" + @if($language->value === app()->getLocale()) aria-current="true" @endif + title="{{ __('Update to :lang language', ['lang' => $language->getLabel()]) }}" + @class([ + 'grid min-h-control min-w-control place-items-center rounded-pill px-2 text-base transition focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand sm:min-h-0 sm:min-w-0', + 'font-semibold text-brand' => $language->value === app()->getLocale(), + 'text-gray-800 hover:text-brand' => $language->value !== app()->getLocale(), + ])> + {{ $language->getLabel() }} + </a> + @if($separator && ! $loop->last) + <span class="font-light text-gray-300" aria-hidden="true">|</span> + @endif + @endforeach + </div> +@endif diff --git a/resources/views/components/news/avatar.blade.php b/resources/views/components/news/avatar.blade.php new file mode 100644 index 0000000..5a440d4 --- /dev/null +++ b/resources/views/components/news/avatar.blade.php @@ -0,0 +1,15 @@ +@props(['image', 'px' => 96]) + +@php + use App\Support\CloudinaryUrl; +@endphp + +{{-- The author portrait, in the three places an article shows one: the card meta + line, the byline under the hero and the box at the foot of the article. Only + the display size differs, and that comes in as a class. --}} +@if(filled($image)) + <img src="{{ CloudinaryUrl::src($image, $px) }}" + srcset="{{ CloudinaryUrl::srcset($image, $px) }}" + alt="" width="{{ $px }}" height="{{ $px }}" loading="lazy" decoding="async" + {{ $attributes->merge(['class' => 'shrink-0 rounded-full bg-surface object-cover']) }}> +@endif diff --git a/resources/views/components/news/byline.blade.php b/resources/views/components/news/byline.blade.php new file mode 100644 index 0000000..0eafca4 --- /dev/null +++ b/resources/views/components/news/byline.blade.php @@ -0,0 +1,20 @@ +@props([ + 'authorName' => null, + 'authorRole' => null, + 'authorImage' => null, +]) + +{{-- Author only. Date and reading time live in the kicker line above the title — having + them here as well made this row read as a crowded two-column table. --}} +@if($authorName) + <div {{ $attributes->merge(['class' => 'flex items-center gap-4']) }}> + <x-news.avatar :image="$authorImage" :px="96" class="size-11"/> + + <div class="text-sm leading-snug"> + <p class="font-medium text-gray-900">{{ $authorName }}</p> + @if($authorRole) + <p class="text-muted">{{ $authorRole }}</p> + @endif + </div> + </div> +@endif diff --git a/resources/views/components/news/card.blade.php b/resources/views/components/news/card.blade.php new file mode 100644 index 0000000..baa3e04 --- /dev/null +++ b/resources/views/components/news/card.blade.php @@ -0,0 +1,86 @@ +@props([ + 'url', + 'title', + 'teaser' => null, + 'image' => null, + 'kicker' => null, + 'publishedAt' => null, + 'readingMinutes' => null, + 'authorName' => null, + 'authorImage' => null, + 'level' => 2, + 'lead' => false, + 'compact' => false, +]) + +@php + use App\Support\NewsImage; + + $heading = 'h'.$level; + $imageWidth = $lead ? 1280 : 640; +@endphp + +<article {{ $attributes->merge(['class' => 'group']) }}> + <a href="{{ $url }}" + class="block cursor-pointer rounded-panel transition focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-brand {{ $lead || $compact ? '' : 'flex items-start gap-8' }}"> + + {{-- Compact: the picture leads, as in the «continue reading» row. --}} + @if($compact && $src = NewsImage::src($image, $imageWidth)) + <img src="{{ $src }}" + @if($srcset = NewsImage::srcset($image, $imageWidth)) srcset="{{ $srcset }}" sizes="(min-width: 640px) 33vw, 100vw" @endif + alt="" width="1800" height="600" loading="lazy" decoding="async" + class="mb-3 aspect-[4/3] w-full rounded-panel object-cover"> + @endif + + <div class="{{ $lead || $compact ? '' : 'flex-1' }}"> + {{-- A 3:1 band collapses into a strip on a phone, so the crop opens up as + the viewport narrows — the same ladder the article hero uses. --}} + @if($lead && $src = NewsImage::src($image, $imageWidth)) + <img src="{{ $src }}" + @if($srcset = NewsImage::srcset($image, $imageWidth)) srcset="{{ $srcset }}" sizes="(min-width: 960px) 896px, 100vw" @endif + alt="" width="1800" height="600" decoding="async" + class="mb-6 aspect-[4/3] w-full object-cover sm:aspect-[16/9] lg:aspect-[3/1]"> + @endif + + <{{ $heading }} class="{{ $lead ? 'text-display' : ($compact ? 'text-subheading' : 'text-title') }} leading-snug transition group-hover:text-brand"> + {{ $title }} + </{{ $heading }}> + + @if($teaser) + <p class="mt-2 {{ $lead ? 'text-lead' : 'text-lg' }} leading-relaxed text-gray-600">{{ $teaser }}</p> + @endif + + {{-- Every piece of metadata on one line. Splitting author and date above the + title from the reading time below it read as two unrelated captions, and + the topic sitting above the title as a kicker read as a second heading — + as a chip it belongs to the caption, the same way the start page shows it. --}} + <div class="{{ $compact ? 'mt-2' : 'mt-4' }} flex flex-wrap items-center gap-x-2 gap-y-2 text-sm text-muted"> + @if($kicker) + <x-ui.badge :label="$kicker" size="xs"/> + @endif + + @if($authorName) + <x-news.avatar :image="$authorImage" :px="96" class="me-1 size-6"/> + <span class="text-gray-800">{{ $authorName }}</span> + @endif + + @if($publishedAt) + @if($authorName)<span aria-hidden="true">·</span>@endif + <time datetime="{{ $publishedAt->toDateString() }}">{{ $publishedAt->translatedFormat('j. F Y') }}</time> + @endif + + @if($readingMinutes) + @if($authorName || $publishedAt)<span aria-hidden="true">·</span>@endif + <span>{{ __(':count min read', ['count' => $readingMinutes]) }}</span> + @endif + </div> + </div> + + @if(! $lead && ! $compact && $src = NewsImage::src($image, $imageWidth)) + <img src="{{ $src }}" + @if($srcset = NewsImage::srcset($image, $imageWidth)) srcset="{{ $srcset }}" sizes="176px" @endif + alt="" width="1800" height="600" loading="lazy" decoding="async" + class="hidden aspect-[4/3] w-44 shrink-0 rounded-panel object-cover sm:block"> + @endif + </a> +</article> diff --git a/resources/views/components/news/latest.blade.php b/resources/views/components/news/latest.blade.php new file mode 100644 index 0000000..bced39b --- /dev/null +++ b/resources/views/components/news/latest.blade.php @@ -0,0 +1,33 @@ +@props(['articles' => null]) + +@php + $articles ??= collect(); + $urlFor = fn ($entry) => localized_route('news.show', ['locale' => app()->getLocale(), 'news' => $entry]); + // The same chip the news index puts on a card, so an article carries one topic + // across the site instead of a series title here and a tag list there. + $topicFor = fn ($entry) => array_filter([$entry->series?->title ?? (is_array($entry->tags) ? ($entry->tags[0] ?? null) : null)]); +@endphp + +{{-- Built from the components the start page already uses (x-layout.list + x-card.item-card), + not from the news index's card. The start page carries no imagery at all — a block with + a large lead picture read as a piece of a different site. --}} +@if($articles->isNotEmpty()) + <x-layout.section> + {{-- No «read all» link here: the next-page card at the foot of the start page + already leads to the overview, and two of them side by side read as two + different destinations. --}} + <x-h2 :title="__('News')"/> + + <x-layout.list class="mt-4"> + @foreach($articles as $entry) + <x-card.item-card + :url="$urlFor($entry)" + :title="$entry->title" + :teaser="$entry->teaser" + :tags="$topicFor($entry)" + :published-at="$entry->published_at" + :level="3"/> + @endforeach + </x-layout.list> + </x-layout.section> +@endif diff --git a/resources/views/components/news/series-nav.blade.php b/resources/views/components/news/series-nav.blade.php new file mode 100644 index 0000000..a95cb8d --- /dev/null +++ b/resources/views/components/news/series-nav.blade.php @@ -0,0 +1,73 @@ +@props(['series', 'parts', 'current', 'urlFor']) + +@php + $total = $parts->count(); + $position = $parts->search(fn ($part) => $part->is($current)); + $position = $position === false ? null : $position; + $previous = $position !== null && $position > 0 ? $parts[$position - 1] : null; + $next = $position !== null && $position < $total - 1 ? $parts[$position + 1] : null; +@endphp + +<section class="news-block border-t border-border pt-10" aria-labelledby="series-heading"> + <p class="text-sm uppercase tracking-[0.15em] text-muted"> + {{ __('Series') }} + @if($position !== null) + <span aria-hidden="true">·</span> + {{ __('Part :position of :total', ['position' => $position + 1, 'total' => $total]) }} + @endif + </p> + <x-h2 id="series-heading" :title="$series->title" class="mt-2 mb-0 text-title"/> + @if($series->description) + <p class="mt-2 max-w-2xl text-lg leading-relaxed text-gray-600">{{ $series->description }}</p> + @endif + + <div class="mt-6 flex gap-1.5" aria-hidden="true"> + @foreach($parts as $part) + <span class="h-1 flex-1 rounded-pill {{ $position !== null && $loop->index <= $position ? 'bg-brand' : 'bg-border' }}"></span> + @endforeach + </div> + + <ol class="mt-8 divide-y divide-border-soft border-y border-border-soft"> + @foreach($parts as $part) + @php($isCurrent = $part->is($current)) + <li class="flex items-center gap-4 py-4"> + <span @class([ + 'shrink-0 size-8 rounded-full grid place-items-center text-sm font-semibold', + 'bg-brand text-white' => $isCurrent, + 'border border-brand text-brand' => ! $isCurrent && $position !== null && $loop->index < $position, + 'border border-border text-muted' => ! $isCurrent && ($position === null || $loop->index > $position), + ])>{{ $loop->iteration }}</span> + + @if($isCurrent) + <span class="text-lg font-semibold text-gray-900" aria-current="true">{{ $part->title }}</span> + @else + <x-ui.link :href="$urlFor($part)" :label="$part->title" class="text-lg text-gray-700"/> + @endif + + @if($part->reading_minutes) + <span class="ml-auto text-sm text-muted">{{ __(':count min', ['count' => $part->reading_minutes]) }}</span> + @endif + </li> + @endforeach + </ol> + + @if($previous || $next) + <div class="mt-8 grid gap-4 sm:grid-cols-2"> + @if($previous) + <x-card.linkable :url="$urlFor($previous)" surface="panel-lg"> + <p class="text-sm text-muted">← {{ __('Part :position', ['position' => $position]) }}</p> + <p class="mt-1 text-subheading transition group-hover:text-brand">{{ $previous->title }}</p> + </x-card.linkable> + @else + <div></div> + @endif + + @if($next) + <x-card.linkable :url="$urlFor($next)" surface="panel-lg" class="text-right"> + <p class="text-sm text-muted">{{ __('Part :position', ['position' => $position + 2]) }} →</p> + <p class="mt-1 text-subheading transition group-hover:text-brand">{{ $next->title }}</p> + </x-card.linkable> + @endif + </div> + @endif +</section> diff --git a/resources/views/components/news/table-of-contents.blade.php b/resources/views/components/news/table-of-contents.blade.php new file mode 100644 index 0000000..423750c --- /dev/null +++ b/resources/views/components/news/table-of-contents.blade.php @@ -0,0 +1,19 @@ +@props(['headings' => []]) + +@if(count($headings) > 1) + {{-- Aligned with the reading column, not the frame: nothing on this page sticks + out past the text. --}} + <nav x-data="tableOfContents" class="news-toc" aria-labelledby="toc-label"> + <p id="toc-label" class="news-toc__label">{{ __('Contents') }}</p> + + <ul class="news-toc__list"> + @foreach($headings as $heading) + <li class="{{ $heading['level'] === 3 ? 'news-toc__sub' : '' }}"> + <a href="#{{ $heading['id'] }}" data-anchor="{{ $heading['id'] }}"> + {{ $heading['title'] }} + </a> + </li> + @endforeach + </ul> + </nav> +@endif diff --git a/resources/views/components/next-page.blade.php b/resources/views/components/next-page.blade.php index a611912..1f056c9 100644 --- a/resources/views/components/next-page.blade.php +++ b/resources/views/components/next-page.blade.php @@ -10,7 +10,7 @@ <x-layout.section> <x-h2 :title="__('components.explore.title')"/> <x-layout.grid class="mt-4"> - <x-card.nav-card :url="localized_route($next['route'])" :route="$next['route']" :label="$next['label']" :teaser="$next['teaser']"/> + <x-card.nav-card :url="localized_route($next['route'])" :label="$next['label']" :teaser="$next['teaser']"/> </x-layout.grid> </x-layout.section> @endif diff --git a/resources/views/components/opening-hours.blade.php b/resources/views/components/opening-hours.blade.php index 45a02f2..29bb9b3 100644 --- a/resources/views/components/opening-hours.blade.php +++ b/resources/views/components/opening-hours.blade.php @@ -32,21 +32,23 @@ @endphp <div {{ $attributes }}> - <x-h2 :title="__('Opening hours')"/> + {{-- The status sits next to the heading as a chip: a green dot while we are open, + a grey one while we are not — the box itself keeps the neutral logo wash. --}} + <div class="flex flex-wrap items-center gap-3"> + <x-h2 :title="__('Opening hours')"/> - {{-- The box colour carries the status: green while open, red while closed. --}} - <div @class([ - 'mt-2 rounded-panel border px-4 sm:px-6 py-2', - 'border-green-200 bg-green-50/60' => $isOpen, - 'border-red-200 bg-red-50/60' => ! $isOpen, - ])> - <span class="sr-only">{{ $isOpen ? __('Currently open') : __('Currently closed') }}</span> + <x-ui.badge class="mb-2" :variant="$isOpen ? 'success' : 'default'"> + <span @class([ + 'mr-1.5 size-1.5 shrink-0 rounded-full', + 'bg-emerald-500' => $isOpen, + 'bg-gray-400' => ! $isOpen, + ])></span> + {{ $isOpen ? __('Currently open') : __('Currently closed') }} + </x-ui.badge> + </div> - <dl @class([ - 'divide-y text-base', - 'divide-green-200/70' => $isOpen, - 'divide-red-200/70' => ! $isOpen, - ])> + <div class="rounded-panel border border-border bg-linear-to-r from-fuchsia-600/10 via-brand/10 to-blue-600/10 px-4 py-2 sm:px-6"> + <dl class="divide-y divide-border-soft text-base"> @foreach ($groups as $group) @php $isToday = $todayIso - 1 >= $group['startIdx'] && $todayIso - 1 <= $group['endIdx']; diff --git a/resources/views/components/ui/alert.blade.php b/resources/views/components/ui/alert.blade.php new file mode 100644 index 0000000..83a88f6 --- /dev/null +++ b/resources/views/components/ui/alert.blade.php @@ -0,0 +1,14 @@ +@props(['variant' => 'info']) + +@php + $variants = [ + 'info' => 'bg-gray-400/10 text-gray-800 ring-gray-400/20', + 'success' => 'bg-green-50 text-green-900 ring-green-600/20', + 'error' => 'bg-red-50 text-red-900 ring-red-600/20', + ]; +@endphp + +<div role="{{ $variant === 'error' ? 'alert' : 'status' }}" + {{ $attributes->merge(['class' => 'rounded-panel px-4 py-3 ring-1 ring-inset '.($variants[$variant] ?? $variants['info'])]) }}> + {{ $slot }} +</div> diff --git a/resources/views/components/ui/arrow-link.blade.php b/resources/views/components/ui/arrow-link.blade.php new file mode 100644 index 0000000..05a338b --- /dev/null +++ b/resources/views/components/ui/arrow-link.blade.php @@ -0,0 +1,15 @@ +@props(['href', 'label' => null, 'direction' => 'forward']) + +{{-- The «read on» link: a label with a directional arrow that slides on hover. + The arrow is decorative — the label alone has to say where the link goes. --}} +<x-ui.link :href="$href" {{ $attributes->merge(['class' => 'group inline-flex min-h-control items-center gap-1.5 text-base font-medium text-brand sm:min-h-0']) }}> + @if($direction === 'back') + <x-icon.arrow-right class="size-4 rotate-180 transition-transform group-hover:-translate-x-1"/> + @endif + + {{ $slot->isEmpty() ? $label : $slot }} + + @if($direction !== 'back') + <x-icon.arrow-right class="size-4 transition-transform group-hover:translate-x-1"/> + @endif +</x-ui.link> diff --git a/resources/views/components/ui/badge-link.blade.php b/resources/views/components/ui/badge-link.blade.php index b407723..b529e4d 100644 --- a/resources/views/components/ui/badge-link.blade.php +++ b/resources/views/components/ui/badge-link.blade.php @@ -1,16 +1,7 @@ @props(['href', 'label' => null, 'target' => '_self', 'title' => null, 'variant' => 'default']) -@php - $variantClasses = match ($variant) { - 'brand' => 'bg-brand text-white hover:bg-brand-strong', - default => 'bg-gray-400/10 text-muted hover:bg-gray-400/20 hover:text-gray-800 ring-1 ring-gray-400/20 ring-inset', - }; -@endphp - -<a href="{{ $href }}" - @if($target !== '_self') target="{{ $target }}" rel="noopener noreferrer" @endif - @if(filled($title)) title="{{ $title }}" @endif - {{ $attributes->merge(['class' => $variantClasses . ' inline-flex items-center rounded-pill px-3 py-2 sm:px-2 sm:py-1 text-sm font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-brand']) }}> - {{ $label }} +{{-- A badge that is a link. All styling lives in x-ui.badge — this exists so the + intent still reads at the call site. --}} +<x-ui.badge :href="$href" :label="$label" :target="$target" :title="$title" :variant="$variant" {{ $attributes }}> {{ $slot }} -</a> +</x-ui.badge> diff --git a/resources/views/components/ui/badge.blade.php b/resources/views/components/ui/badge.blade.php index d737db7..383f5c2 100644 --- a/resources/views/components/ui/badge.blade.php +++ b/resources/views/components/ui/badge.blade.php @@ -1,7 +1,65 @@ -@props(['label' => null, 'title' => null]) +@props([ + 'href' => null, + 'label' => null, + 'title' => null, + 'target' => '_self', + 'variant' => 'default', + 'size' => 'sm', +]) -<span @if(filled($title)) title="{{ $title }}" @endif - {{ $attributes->merge(['class' => 'inline-flex items-center rounded-pill bg-gray-400/10 px-2 py-1 text-sm font-medium text-muted ring-1 ring-gray-400/20 ring-inset']) }}> - {{ $label }} - {{ $slot }} -</span> +@php + // Every chip on the site — meta badges, tag lists, news topic filters, partner + // tiers, model links — is this component. Add a variant here, never a one-off + // pill somewhere in a view. + $variants = [ + 'default' => 'bg-gray-400/10 text-muted ring-1 ring-gray-400/20 ring-inset', + 'outline' => 'text-muted ring-1 ring-border ring-inset', + 'solid' => 'bg-gray-900 text-white', + 'brand' => 'bg-brand text-white', + // Live status — currently the opening-hours chip on the contact page. + 'success' => 'bg-emerald-500/10 text-emerald-700 ring-1 ring-emerald-600/20 ring-inset', + // The partner tier chip — the one place a gradient earns its keep. + 'metal' => 'bg-linear-to-b from-gray-100 via-white to-gray-300 text-gray-700 ring-1 ring-gray-400/40 ring-inset', + ]; + + $hovers = [ + 'default' => 'hover:bg-gray-400/20 hover:text-gray-800', + 'outline' => 'hover:text-brand hover:ring-brand', + 'solid' => 'hover:bg-gray-800', + 'brand' => 'hover:bg-brand-strong', + 'success' => '', + 'metal' => '', + ]; + + $sizes = [ + 'xs' => 'px-2 py-0.5 text-xs', + 'sm' => 'px-2.5 py-1 text-sm', + 'md' => 'px-3.5 py-1.5 text-sm', + ]; + + $classes = trim(implode(' ', array_filter([ + 'inline-flex items-center justify-center rounded-pill font-medium', + $variants[$variant] ?? $variants['default'], + $sizes[$size] ?? $sizes['sm'], + // A chip that can be clicked has to be reachable with a thumb, so it grows + // to the shared control height on a phone and stays compact from sm up. + $href ? 'min-h-control sm:min-h-0 transition cursor-pointer focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand' : null, + $href ? ($hovers[$variant] ?? '') : null, + ]))); +@endphp + +@if($href) + <a href="{{ $href }}" + @if($target !== '_self') target="{{ $target }}" rel="noopener noreferrer" @endif + @if(filled($title)) title="{{ $title }}" @endif + {{ $attributes->merge(['class' => $classes]) }}> + {{ $label }} + {{ $slot }} + </a> +@else + <span @if(filled($title)) title="{{ $title }}" @endif + {{ $attributes->merge(['class' => $classes]) }}> + {{ $label }} + {{ $slot }} + </span> +@endif diff --git a/resources/views/components/ui/button.blade.php b/resources/views/components/ui/button.blade.php index 3d6d8a4..bd381fb 100644 --- a/resources/views/components/ui/button.blade.php +++ b/resources/views/components/ui/button.blade.php @@ -2,19 +2,39 @@ 'href' => null, 'label' => null, 'variant' => 'primary', + 'size' => 'md', 'target' => '_self', 'type' => 'submit', + 'block' => false, ]) @php - $base = 'inline-flex items-center justify-center px-5 py-2.5 rounded-pill text-sm font-medium transition w-full sm:w-auto focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-brand'; + $base = 'inline-flex items-center justify-center gap-2 rounded-pill font-medium transition cursor-pointer ' + . 'focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-brand ' + . 'disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none'; $variants = [ - 'primary' => 'text-white bg-brand hover:bg-brand-strong', + 'primary' => 'bg-brand text-white hover:bg-brand-strong', 'outline' => 'bg-white border border-brand text-brand hover:bg-brand hover:text-white', + 'ghost' => 'text-brand hover:bg-surface', ]; - $classes = $base . ' ' . ($variants[$variant] ?? $variants['primary']); + // md and lg sit on the shared control height and clear the 44px touch target. + // sm is for dense desktop UI and must never be the only target on a phone. + $sizes = [ + 'sm' => 'h-control-sm px-4 text-sm', + 'md' => 'h-control px-5 text-sm', + 'lg' => 'h-control px-7 text-base', + ]; + + $classes = trim(implode(' ', array_filter([ + $base, + $variants[$variant] ?? $variants['primary'], + $sizes[$size] ?? $sizes['md'], + // Width is the caller's decision, not the component's — pass block for the + // full-width treatment a form submit wants on a phone. + $block ? 'w-full' : null, + ]))); @endphp @if($href) diff --git a/resources/views/components/ui/link.blade.php b/resources/views/components/ui/link.blade.php index 4927a22..55da09e 100644 --- a/resources/views/components/ui/link.blade.php +++ b/resources/views/components/ui/link.blade.php @@ -1,9 +1,11 @@ @props(['href', 'label' => null, 'target' => '_self', 'title' => null, 'download' => null]) +{{-- Colour, not weight, marks the hover: bolding the label on hover re-flowed the + line and nudged every link beside it in the navigation. --}} <a href="{{ $href }}" @if($target !== '_self') target="{{ $target }}" rel="noopener noreferrer" @endif @if(filled($title)) title="{{ $title }}" @endif @if(filled($download)) download="{{ $download }}" @endif - {{ $attributes->merge(['class' => 'hover:text-brand hover:font-semibold transition']) }}> + {{ $attributes->merge(['class' => 'rounded-pill transition hover:text-brand focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand']) }}> {{ $slot->isEmpty() ? $label : $slot }} </a> diff --git a/resources/views/components/ui/prose.blade.php b/resources/views/components/ui/prose.blade.php index e5c7ae8..8bea1df 100644 --- a/resources/views/components/ui/prose.blade.php +++ b/resources/views/components/ui/prose.blade.php @@ -3,7 +3,9 @@ @php $classes = match ($variant) { 'legal' => 'legal-prose prose prose-gray max-w-none text-gray-800 prose-p:my-0 prose-p:leading-relaxed prose-ul:mt-4 prose-ul:mb-0 prose-li:my-1.5 prose-li:leading-relaxed', - default => 'prose md:prose-md', + // prose-md does not exist in @tailwindcss/typography (sm|base|lg|xl|2xl); + // md:prose-lg is the step that actually matches the surrounding text-lg. + default => 'prose prose-gray max-w-none md:prose-lg', }; @endphp diff --git a/resources/views/components/ui/social-links.blade.php b/resources/views/components/ui/social-links.blade.php new file mode 100644 index 0000000..5a1f7ac --- /dev/null +++ b/resources/views/components/ui/social-links.blade.php @@ -0,0 +1,48 @@ +@props(['links' => [], 'name' => null, 'titles' => []]) + +@php + // One row of contact icons for every card that has people on it. Order comes + // from this table, not from the caller, so two cards side by side list the same + // channels in the same sequence. + $types = [ + 'linkedin' => ['icon' => 'icon.linkedin', 'label' => 'LinkedIn', 'external' => true], + 'github' => ['icon' => 'icon.github', 'label' => 'GitHub', 'external' => true], + 'website' => ['icon' => 'icon.website', 'label' => __('Website'), 'external' => true], + 'email' => ['icon' => 'icon.email', 'label' => __('Email'), 'external' => false, 'scheme' => 'mailto:'], + 'phone' => ['icon' => 'icon.phone', 'label' => __('Phone'), 'external' => false, 'scheme' => 'tel:'], + ]; + + $items = collect($types) + ->map(fn (array $type, string $key) => $type + ['value' => data_get($links, $key)]) + ->filter(fn (array $type) => filled($type['value'])); +@endphp + +@if($items->isNotEmpty()) + <div {{ $attributes->merge(['class' => 'flex items-center']) }}> + @foreach($items as $key => $type) + @php + $value = $type['value']; + $href = match ($type['scheme'] ?? null) { + 'tel:' => 'tel:'.preg_replace('/[^0-9+]/', '', $value), + 'mailto:' => 'mailto:'.$value, + default => $value, + }; + // «LinkedIn» repeated once per person is useless in a screen reader's + // link list; the name makes each one identifiable. + $label = filled($name) ? $type['label'].' — '.$name : $type['label']; + + // Mail and phone show the address itself; a caller can override any + // entry — the network cards show the bare host rather than «Website». + $tooltip = data_get($titles, $key) ?? (isset($type['scheme']) ? $value : $type['label']); + @endphp + + <a href="{{ $href }}" + @if($type['external']) target="_blank" rel="noopener noreferrer" @endif + aria-label="{{ $label }}" + title="{{ $tooltip }}" + class="grid size-control place-items-center rounded-pill text-muted transition hover:text-gray-800 focus:outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-brand sm:size-8"> + <x-dynamic-component :component="$type['icon']"/> + </a> + @endforeach + </div> +@endif diff --git a/resources/views/demo/_app_layout.blade.php b/resources/views/demo/_app_layout.blade.php index 5da6545..9cb78a1 100644 --- a/resources/views/demo/_app_layout.blade.php +++ b/resources/views/demo/_app_layout.blade.php @@ -1,8 +1,6 @@ <x-app-layout :page="$page"> - <div class="-mx-4 sm:-mx-6 lg:-mx-8 flex items-center justify-between gap-4 bg-zinc-950 text-white text-xs px-4 sm:px-6 lg:px-8 py-2 mb-6"> - <a href="{{ route('demo.flows.v2.index') }}" class="font-semibold hover:text-white/70 transition">← Alle Swiss-Grid Varianten</a> - <span class="text-white/50 truncate">{{ $variantTitle ?? '' }}</span> - </div> + <x-demo.bar :href="route('demo.flows.v2.index')" label="Alle Swiss-Grid Varianten" + :title="$variantTitle ?? ''" :inset="true"/> @yield('content') </x-app-layout> diff --git a/resources/views/demo/_layout.blade.php b/resources/views/demo/_layout.blade.php index a7117af..600597e 100644 --- a/resources/views/demo/_layout.blade.php +++ b/resources/views/demo/_layout.blade.php @@ -1,20 +1,21 @@ +{{-- The standalone shell for the layout prototypes. Deliberately not the app + layout: each variant is a full-bleed design exploration that brings its own + header, palette and <main>, so the real site chrome would only get in the way. + It is noindex and unreachable from the navigation. --}} <!DOCTYPE html> -<html lang="de" class="scroll-smooth"> +<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="scroll-smooth"> <head> <meta charset="utf-8"/> <meta name="viewport" content="width=device-width, initial-scale=1"/> <title>{{ $variantTitle ?? 'Flows layout demo' }} — Flows demo - + @vite(['resources/js/app.js']) - + @unless($hideDemoBar ?? false) -
- ← Alle 10 Layouts - {{ $variantTitle ?? '' }} -
+ @endunless @yield('content') diff --git a/resources/views/layouts/_partials/_footer.blade.php b/resources/views/layouts/_partials/_footer.blade.php index 819dbef..2419e7c 100644 --- a/resources/views/layouts/_partials/_footer.blade.php +++ b/resources/views/layouts/_partials/_footer.blade.php @@ -1,59 +1,53 @@ -