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..68c74d5 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,8 @@ -APP_NAME="Laravel Template" +APP_NAME="codebar Solutions AG" APP_ENV=local -APP_KEY=base64:F+mHMDBbavrsp/I3WYA5lDSwDJJI/0wQG4eM3csq/lo= +APP_KEY= APP_DEBUG=true -APP_URL=https://laravel-template-l11.test - -NOVA_LICENSE_KEY= +APP_URL=https://web.codebar.test LOG_CHANNEL=stack LOG_STACK=single,flare @@ -13,12 +11,12 @@ LOG_LEVEL=debug FLARE_KEY= -DB_CONNECTION=mysql +DB_CONNECTION=pgsql DB_HOST=127.0.0.1 -DB_PORT=3306 -DB_DATABASE=laravel-template -DB_USERNAME=root -DB_PASSWORD= +DB_PORT=5432 +DB_DATABASE=web-codebar +DB_USERNAME=postgres +DB_PASSWORD=postgres FPH_ENABLED=true CSP_ENABLED=true @@ -27,7 +25,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/FetchLlmUsageAction.php b/app/Actions/FetchLlmUsageAction.php index f680a99..3091c1b 100644 --- a/app/Actions/FetchLlmUsageAction.php +++ b/app/Actions/FetchLlmUsageAction.php @@ -1,5 +1,7 @@ remember('last_synced_at', function (): ?Carbon { + return $this->toDate(AiModelDailyUsage::query()->max('updated_at')); + }); + } + + 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/Actions/LocaleAction.php b/app/Actions/LocaleAction.php index 522d807..e813342 100644 --- a/app/Actions/LocaleAction.php +++ b/app/Actions/LocaleAction.php @@ -1,5 +1,7 @@ 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/StoreLlmUsageAction.php b/app/Actions/StoreLlmUsageAction.php index 1609566..a01d683 100644 --- a/app/Actions/StoreLlmUsageAction.php +++ b/app/Actions/StoreLlmUsageAction.php @@ -1,5 +1,7 @@ forLocale($locale); return Cache::rememberForever($key, function () { return Product::where('published', true)->orderBy('order')->get(); @@ -35,7 +37,7 @@ public function products(string $locale): Collection */ public function services(string $locale): Collection { - $key = Str::slug("services_published_{$locale}"); + $key = CacheKeyEnum::SERVICES_PUBLISHED->forLocale($locale); return Cache::rememberForever($key, function () { return Service::where('published', true)->orderBy('order')->get(); @@ -47,10 +49,16 @@ public function services(string $locale): Collection */ public function news(string $locale): Collection { - $key = Str::slug("news_published_{$locale}"); + $key = CacheKeyEnum::NEWS_PUBLISHED->forLocale($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(); }); } @@ -59,7 +67,7 @@ public function news(string $locale): Collection */ public function technologies(string $locale): Collection { - $key = Str::slug("technologies_published_{$locale}"); + $key = CacheKeyEnum::TECHNOLOGIES_PUBLISHED->forLocale($locale); return Cache::rememberForever($key, function () { return Technology::where('published', true)->orderBy('order')->get(); @@ -71,7 +79,7 @@ public function technologies(string $locale): Collection */ public function aiModelGroups(): Collection { - return Cache::rememberForever('ai_models_active', function () { + return Cache::rememberForever(CacheKeyEnum::AI_MODELS_ACTIVE->value, function () { return $this->groupAiModelsByCategory( AiModel::whereNull('archived_at')->orderBy('order')->get() ); @@ -83,7 +91,7 @@ public function aiModelGroups(): Collection */ public function aiModelArchive(): Collection { - return Cache::rememberForever('ai_models_archived', function () { + return Cache::rememberForever(CacheKeyEnum::AI_MODELS_ARCHIVED->value, function () { return $this->groupAiModelsByCategory( AiModel::whereNotNull('archived_at')->with('replacedBy')->orderBy('order')->get() ); @@ -110,7 +118,7 @@ private function groupAiModelsByCategory(Collection $models): Collection */ public function openSource(string $locale): Collection { - $key = Str::slug("open_source_published_{$locale}"); + $key = CacheKeyEnum::OPEN_SOURCE_PUBLISHED->forLocale($locale); return Cache::rememberForever($key, function () { return OpenSource::where('published', true)->orderByDesc('downloads')->get(); @@ -119,11 +127,14 @@ public function openSource(string $locale): Collection public function contacts(string $locale): \stdClass { - $key = Str::slug("contacts_published_{$locale}"); + $key = CacheKeyEnum::CONTACTS_PUBLISHED->forLocale($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/Checks/FailedJobsCheck.php b/app/Checks/FailedJobsCheck.php index 870c294..130b06a 100644 --- a/app/Checks/FailedJobsCheck.php +++ b/app/Checks/FailedJobsCheck.php @@ -1,5 +1,7 @@ count(); + $pending = DB::table('jobs')->count(); - $result = Result::make(); - $result->shortSummary("jobs table count: {$count}"); + $result = Result::make()->shortSummary("pending jobs: {$pending}"); - if ($count <= 0) { + if ($pending === 0) { return $result->ok(); } - $newCounts = Collection::times(self::TIME, function () { - $count = DB::table('jobs')->count(); - sleep(1); + $waitingMinutes = $this->oldestPendingJobAgeInMinutes(); - return $count; - }); + if ($waitingMinutes === null || $waitingMinutes < self::STALLED_AFTER_MINUTES) { + return $result->ok(); + } - $allMatch = $newCounts->every(fn (int $value) => $value === $count); + return $result->failed( + "The queue is not draining: {$pending} pending job(s), the oldest waiting {$waitingMinutes} minute(s)." + ); + } - $result->shortSummary("jobs table count: {$allMatch}"); + private function oldestPendingJobAgeInMinutes(): ?int + { + $availableAt = DB::table('jobs')->min('available_at'); - if ($count != $allMatch) { - return $result->ok(); + if (! is_numeric($availableAt)) { + return null; } - return $result->failed(); - + return (int) Carbon::createFromTimestamp((int) $availableAt)->diffInMinutes(absolute: true); } } diff --git a/app/Console/Commands/FetchLlmAnalyticsCommand.php b/app/Console/Commands/FetchLlmAnalyticsCommand.php index 273b18a..85a5ca0 100644 --- a/app/Console/Commands/FetchLlmAnalyticsCommand.php +++ b/app/Console/Commands/FetchLlmAnalyticsCommand.php @@ -1,5 +1,7 @@ yamlFiles(); + + if ($files === []) { + $this->components->warn('No AI model files found under '.$this->basePath().'.'); + + return self::SUCCESS; + } + + $dryRun = $this->isDryRun(); + $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 ? 'would update' : '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, '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(AiModel::query(), 'name', array_values(array_map(fn (array $data): string => $data['name'], $parsedByKey))); + + // The replaced_by pass above writes through the query builder, which fires no + // model events — so the observer that drops the cached catalogue never runs. + ContentCacheObserver::flush(new AiModel); + + $this->newLine(); + $this->components->info(sprintf('%d model(s) imported, %d skipped.', $imported, $skipped)); + + return $skipped > 0 ? self::FAILURE : self::SUCCESS; + } + + protected function defaultPath(): string + { + return 'files/ai_models'; + } + + /** + * @return array{key: string, name: string, category: AiModelCategoryEnum, order: int, provider: ?string, ram: ?string, license: ?string, role: ?array, link_label: ?string, link_url: ?string, archived_at: ?string, replaced_by: ?string}|null + */ + private function parse(string $path): ?array + { + $parsed = $this->parseYamlFile($path); + + if ($parsed === null) { + 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->localizedMap($parsed['role'] ?? null) ?: 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), + ]; + } +} diff --git a/app/Console/Commands/ImportCommand.php b/app/Console/Commands/ImportCommand.php new file mode 100644 index 0000000..12df437 --- /dev/null +++ b/app/Console/Commands/ImportCommand.php @@ -0,0 +1,307 @@ +option('path'); + + return is_string($override) && $override !== '' + ? rtrim($override, '/') + : database_path($this->defaultPath()); + } + + protected function isDryRun(): bool + { + return (bool) $this->option('dry-run'); + } + + /** + * @return array + */ + protected function yamlFiles(): array + { + $base = $this->basePath(); + + if (! is_dir($base)) { + return []; + } + + $files = array_merge(glob($base.'/*.yaml') ?: [], glob($base.'/*.yml') ?: []); + sort($files); + + return $files; + } + + /** + * @return array|null + */ + protected function parseYamlFile(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; + } + + return $this->stringKeyed($parsed); + } + + /** + * Splits a Markdown file into its YAML front matter and its body. + * + * @param bool $parseDateTime Without it, a bare `published_at: 2026-07-28` arrives + * as a Unix timestamp rather than a date. + * @return array{front: array, body: string}|null + */ + protected function parseMarkdownFile(string $path, bool $parseDateTime = false): ?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], $parseDateTime ? Yaml::PARSE_DATETIME : 0); + } catch (ParseException $exception) { + $this->components->error(basename($path).': '.$exception->getMessage()); + + return null; + } + + return [ + 'front' => is_array($front) ? $this->stringKeyed($front) : [], + 'body' => trim($parts[2]), + ]; + } + + /** + * Reads database/files/{importer}/{locale}/*.md into [key => [locale => document]]. + * + * @param array $locales + * @return array, body: string}>> + */ + protected function readLocaleDocuments(array $locales, bool $parseDateTime = false): array + { + $documents = []; + + foreach ($locales as $locale) { + $directory = $this->basePath().'/'.$locale; + + if (! is_dir($directory)) { + continue; + } + + foreach (glob($directory.'/*.md') ?: [] as $path) { + $parsed = $this->parseMarkdownFile($path, $parseDateTime); + + 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][$locale] = $parsed; + } + } + + ksort($documents); + + return $documents; + } + + /** + * Warns when a file breaks the naming convention. A mismatch is reported, not fatal — + * renaming must never break an import. + * + * @param array $front + */ + protected function checkFileName(string $path, string $key, array $front): void + { + $expected = $key.'.md'; + + if (basename($path) !== $expected) { + $this->components->warn(sprintf('%s should be named %s.', basename($path), $expected)); + } + } + + /** + * A record removed from the repository must disappear from the site too, otherwise + * the files stop being the source of truth. + * + * @template TModel of Model + * + * @param Builder $query + * @param array $keys + */ + protected function removeOrphans(Builder $query, string $column, array $keys): void + { + foreach ($query->whereNotIn($column, $keys)->get() as $orphan) { + $label = $orphan->getAttribute($column); + + $this->components->twoColumnDetail(is_scalar($label) ? (string) $label : '?', 'removed'); + $orphan->delete(); + } + } + + /** + * Reports which locales a record is missing, so a half-translated file never + * reaches the database as a record with an empty language. + * + * @param array $required + * @param array $present + * @return array + */ + protected function missingLocales(array $required, array $present): array + { + return array_values(array_diff($required, array_keys($present))); + } + + /** + * Narrows a YAML mapping to its string keys, which is what every caller assumes + * and what PHPStan needs to see. + * + * @param array $values + * @return array + */ + protected function stringKeyed(array $values): array + { + $result = []; + + foreach ($values as $key => $value) { + if (is_string($key)) { + $result[$key] = $value; + } + } + + return $result; + } + + /** + * A YAML mapping of locale => text, with blanks dropped. + * + * @return array + */ + protected 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; + } + + /** + * @return array + */ + protected function stringList(mixed $values): array + { + if (! is_array($values)) { + return []; + } + + $result = []; + + foreach ($values as $value) { + $string = $this->string($value); + + if ($string !== '') { + $result[] = $string; + } + } + + return $result; + } + + protected 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 ''; + } + + protected function nullableString(mixed $value): ?string + { + $string = $this->string($value); + + return $string === '' ? null : $string; + } + + protected function nullableInt(mixed $value): ?int + { + if (is_int($value)) { + return $value; + } + + return is_string($value) && ctype_digit($value) ? (int) $value : null; + } +} diff --git a/app/Console/Commands/ImportNetworksCommand.php b/app/Console/Commands/ImportNetworksCommand.php new file mode 100644 index 0000000..035346f --- /dev/null +++ b/app/Console/Commands/ImportNetworksCommand.php @@ -0,0 +1,151 @@ +yamlFiles(); + + if ($files === []) { + $this->components->warn('No network files found under '.$this->basePath().'.'); + + return self::SUCCESS; + } + + $dryRun = $this->isDryRun(); + $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 ? 'would update' : '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, 'imported'); + $imported++; + } + + if (! $dryRun) { + $this->removeOrphans(Network::query(), 'key', $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; + } + + protected function defaultPath(): string + { + return 'files/networks'; + } + + /** + * @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, tier_label: array, excerpt: array}|null + */ + private function parse(string $path): ?array + { + $parsed = $this->parseYamlFile($path); + + if ($parsed === null) { + return null; + } + + $key = $this->string($parsed['key'] ?? '') ?: pathinfo($path, PATHINFO_FILENAME); + // Sanitised up front, so the completeness check below sees exactly the + // locales that survive as usable strings — not keys that only look present. + $names = $this->localizedMap($parsed['name'] ?? null); + + $missing = $this->missingLocales( + array_map(fn (LocaleEnum $case): string => $case->value, LocaleEnum::cases()), + $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' => $names, + 'tier_label' => $this->localizedMap($parsed['tier_label'] ?? null), + 'excerpt' => $this->localizedMap($parsed['excerpt'] ?? null), + ]; + } +} diff --git a/app/Console/Commands/ImportNewsCommand.php b/app/Console/Commands/ImportNewsCommand.php new file mode 100644 index 0000000..601971e --- /dev/null +++ b/app/Console/Commands/ImportNewsCommand.php @@ -0,0 +1,346 @@ +readLocaleDocuments( + array_map(fn (LocaleEnum $case): string => $case->value, LocaleEnum::cases()), + parseDateTime: true, + ); + + if ($documents === []) { + $this->components->warn('No article files found under '.$this->basePath().'.'); + + return self::SUCCESS; + } + + $dryRun = $this->isDryRun(); + $only = $this->option('key'); + + $imported = 0; + $skipped = 0; + + foreach ($documents as $key => $localeDocuments) { + if (is_string($only) && $only !== '' && $only !== $key) { + continue; + } + + $missing = $this->missingLocales( + array_map(fn (LocaleEnum $case): string => $case->value, LocaleEnum::cases()), + $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 ? 'would update' : 'would create'); + $imported++; + + continue; + } + + $this->store($key, $localeDocuments, $markdown); + $this->components->twoColumnDetail($key, '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; + } + + protected function defaultPath(): string + { + return 'files/news'; + } + + /** + * 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 $front + */ + protected 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)); + } + } + + /** + * @param array, 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->stringList($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 $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, 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; + } + + /** + * @param array, body: string}> $localeDocuments + * @return array + */ + private function resolveTagIds(mixed $tags, array $localeDocuments): array + { + $ids = []; + + foreach ($this->stringList($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, 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 $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); + } + } +} diff --git a/app/Console/Commands/ImportPagesCommand.php b/app/Console/Commands/ImportPagesCommand.php new file mode 100644 index 0000000..fc7b1e2 --- /dev/null +++ b/app/Console/Commands/ImportPagesCommand.php @@ -0,0 +1,144 @@ +yamlFiles(); + + if ($files === []) { + $this->components->warn('No page files found under '.$this->basePath().'.'); + + return self::SUCCESS; + } + + $dryRun = $this->isDryRun(); + $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 ? 'would update' : 'would create'); + $imported++; + + continue; + } + + Page::updateOrCreate(['key' => $key], [ + 'robots' => $data['robots'], + 'title' => $data['title'], + 'description' => $data['description'], + 'image' => $data['image'], + ]); + + $this->components->twoColumnDetail($key, 'imported'); + $imported++; + } + + if (! $dryRun) { + $this->removeOrphans(Page::query(), 'key', $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; + } + + protected function defaultPath(): string + { + return 'files/pages'; + } + + /** + * @return array{key: string, robots: string, title: array, description: array, image: ?string}|null + */ + private function parse(string $path): ?array + { + $parsed = $this->parseYamlFile($path); + + if ($parsed === null) { + 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; + } + + // Sanitised up front, so the completeness check below sees exactly the + // locales that survive as usable strings — not keys that only look present. + $titles = $this->localizedMap($parsed['title'] ?? null); + $descriptions = $this->localizedMap($parsed['description'] ?? null); + + $locales = array_map(fn (LocaleEnum $case): string => $case->value, LocaleEnum::cases()); + + // Checked per field: a single array_diff against both at once would accept a + // page whose title is German-only and whose description is English-only. + foreach (['title' => $titles, 'description' => $descriptions] as $field => $values) { + $missing = $this->missingLocales($locales, $values); + + if ($missing !== []) { + $this->components->error(sprintf( + '%s is missing a %s for %s.', + basename($path), + $field, + implode(', ', $missing) + )); + + return null; + } + } + + return [ + 'key' => $key, + 'robots' => $robots, + 'title' => $titles, + 'description' => $descriptions, + 'image' => $this->nullableString($parsed['image'] ?? null), + ]; + } +} diff --git a/app/Console/Commands/ImportProductsCommand.php b/app/Console/Commands/ImportProductsCommand.php new file mode 100644 index 0000000..0411e99 --- /dev/null +++ b/app/Console/Commands/ImportProductsCommand.php @@ -0,0 +1,164 @@ +readLocaleDocuments( + array_map(fn (LocaleEnum $case): string => $case->value, LocaleEnum::cases()) + ); + + if ($documents === []) { + $this->components->warn('No product files found under '.$this->basePath().'.'); + + return self::SUCCESS; + } + + $dryRun = $this->isDryRun(); + $imported = 0; + $skipped = 0; + $keys = []; + + foreach ($documents as $key => $localeDocuments) { + $missing = $this->missingLocales( + array_map(fn (LocaleEnum $case): string => $case->value, LocaleEnum::cases()), + $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 ? 'would update' : 'would create'); + $imported++; + + continue; + } + + $this->store($key, $localeDocuments); + $this->components->twoColumnDetail($key, 'imported'); + $imported++; + } + + if (! $dryRun) { + $this->removeOrphans(Product::query(), 'slug', $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; + } + + protected function defaultPath(): string + { + return 'files/products'; + } + + /** + * 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, 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->stringList($primary['tags'] ?? []), + ...$translated, + ] + ); + } + + /** + * Normalises a `features`/`deployment_options` front-matter list into + * {title, description} pairs, dropping anything malformed. + * + * @return array + */ + 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; + } +} diff --git a/app/Console/Commands/ImportServicesCommand.php b/app/Console/Commands/ImportServicesCommand.php new file mode 100644 index 0000000..d710866 --- /dev/null +++ b/app/Console/Commands/ImportServicesCommand.php @@ -0,0 +1,122 @@ +readLocaleDocuments( + array_map(fn (LocaleEnum $case): string => $case->value, LocaleEnum::cases()) + ); + + if ($documents === []) { + $this->components->warn('No service files found under '.$this->basePath().'.'); + + return self::SUCCESS; + } + + $dryRun = $this->isDryRun(); + $imported = 0; + $skipped = 0; + $keys = []; + + foreach ($documents as $key => $localeDocuments) { + $missing = $this->missingLocales( + array_map(fn (LocaleEnum $case): string => $case->value, LocaleEnum::cases()), + $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 ? 'would update' : 'would create'); + $imported++; + + continue; + } + + $this->store($key, $localeDocuments); + $this->components->twoColumnDetail($key, 'imported'); + $imported++; + } + + if (! $dryRun) { + $this->removeOrphans(Service::query(), 'slug', $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; + } + + protected function defaultPath(): string + { + return 'files/services'; + } + + /** + * 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, 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->stringList($primary['tags'] ?? []), + ] + ); + } +} diff --git a/app/Console/Commands/ImportTeamCommand.php b/app/Console/Commands/ImportTeamCommand.php new file mode 100644 index 0000000..352c9cf --- /dev/null +++ b/app/Console/Commands/ImportTeamCommand.php @@ -0,0 +1,155 @@ +yamlFiles(); + + if ($files === []) { + $this->components->warn('No team files found under '.$this->basePath().'.'); + + return self::SUCCESS; + } + + $dryRun = $this->isDryRun(); + $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 ? 'would update' : '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, 'imported'); + $imported++; + } + + if (! $dryRun) { + $this->removeOrphans(Contact::query(), 'key', $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; + } + + protected function defaultPath(): string + { + return 'files/team'; + } + + /** + * @return array{key: string, name: string, published: bool, sort: int, image: string, sections: array, icons: array}|null + */ + private function parse(string $path): ?array + { + $parsed = $this->parseYamlFile($path); + + if ($parsed === null) { + 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->localizedMap($parsed['icons'] ?? null), + ]; + } + + /** + * @return array + */ + 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; + } +} diff --git a/app/Console/Commands/ImportTechnologiesCommand.php b/app/Console/Commands/ImportTechnologiesCommand.php new file mode 100644 index 0000000..21e6bc9 --- /dev/null +++ b/app/Console/Commands/ImportTechnologiesCommand.php @@ -0,0 +1,123 @@ +readLocaleDocuments( + array_map(fn (LocaleEnum $case): string => $case->value, LocaleEnum::cases()) + ); + + if ($documents === []) { + $this->components->warn('No technology files found under '.$this->basePath().'.'); + + return self::SUCCESS; + } + + $dryRun = $this->isDryRun(); + $imported = 0; + $skipped = 0; + $keys = []; + + foreach ($documents as $key => $localeDocuments) { + $missing = $this->missingLocales( + array_map(fn (LocaleEnum $case): string => $case->value, LocaleEnum::cases()), + $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 ? 'would update' : 'would create'); + $imported++; + + continue; + } + + $this->store($key, $localeDocuments); + $this->components->twoColumnDetail($key, 'imported'); + $imported++; + } + + if (! $dryRun) { + $this->removeOrphans(Technology::query(), 'slug', $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; + } + + protected function defaultPath(): string + { + return 'files/technologies'; + } + + /** + * 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, 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->stringList($primary['tags'] ?? []), + ] + ); + } +} diff --git a/app/Console/Commands/SendNetworkInvitesCommand.php b/app/Console/Commands/SendNetworkInvitesCommand.php index 751f1c6..f880366 100644 --- a/app/Console/Commands/SendNetworkInvitesCommand.php +++ b/app/Console/Commands/SendNetworkInvitesCommand.php @@ -1,5 +1,7 @@ publishedAt instanceof Carbon; + } + public function url(): string { return route($this->routeName, $this->routeParameters, true); diff --git a/app/Enums/AiModelCategoryEnum.php b/app/Enums/AiModelCategoryEnum.php index 36d0370..944182b 100644 --- a/app/Enums/AiModelCategoryEnum.php +++ b/app/Enums/AiModelCategoryEnum.php @@ -1,5 +1,7 @@ value directly. + */ enum CacheKeyEnum: string { const string VALID_FILESYSTEMS_DEFAULT = 'valid_filesystems_defaullt'; + + case CONTACTS_PUBLISHED = 'contacts_published'; + + case NEWS_PUBLISHED = 'news_published'; + + case PRODUCTS_PUBLISHED = 'products_published'; + + case SERVICES_PUBLISHED = 'services_published'; + + case TECHNOLOGIES_PUBLISHED = 'technologies_published'; + + case OPEN_SOURCE_PUBLISHED = 'open_source_published'; + + case AI_MODELS_ACTIVE = 'ai_models_active'; + + case AI_MODELS_ARCHIVED = 'ai_models_archived'; + + case SITEMAP = 'sitemap_xml'; + + public function forLocale(string $locale): string + { + return Str::slug("{$this->value}_{$locale}"); + } + + /** + * @return array + */ + public function forAllLocales(): array + { + return array_map( + fn (LocaleEnum $locale): string => $this->forLocale($locale->value), + LocaleEnum::cases() + ); + } } diff --git a/app/Enums/ContactSectionEnum.php b/app/Enums/ContactSectionEnum.php index c3fdfc2..116d1fb 100644 --- a/app/Enums/ContactSectionEnum.php +++ b/app/Enums/ContactSectionEnum.php @@ -1,5 +1,7 @@ markdown(); + return Str::of($markdown)->markdown()->toString(); } } diff --git a/app/Helpers/HelperMoney.php b/app/Helpers/HelperMoney.php index 93c4de4..fc9af9b 100755 --- a/app/Helpers/HelperMoney.php +++ b/app/Helpers/HelperMoney.php @@ -1,5 +1,7 @@ getLocale(); + + $page = (new PageAction(locale: null, routeName: 'about-us.index'))->default(); + $contacts = $viewData->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/AiIndexController.php b/app/Http/Controllers/Ai/AiIndexController.php index 3cab038..94ce290 100644 --- a/app/Http/Controllers/Ai/AiIndexController.php +++ b/app/Http/Controllers/Ai/AiIndexController.php @@ -1,5 +1,7 @@ $month, 'model' => $model, 'modelLabel' => $model === LlmUsageStatsAction::OTHER_MODEL ? $otherLabel : $model, + 'lastSyncedAt' => $stats->lastSyncedAt(), ]); } diff --git a/app/Http/Controllers/Ai/AiLlmIndexController.php b/app/Http/Controllers/Ai/AiLlmIndexController.php index 464cc96..b6df9bd 100644 --- a/app/Http/Controllers/Ai/AiLlmIndexController.php +++ b/app/Http/Controllers/Ai/AiLlmIndexController.php @@ -1,5 +1,7 @@ with([ 'page' => (new PageAction(locale: null, routeName: 'ai.llm.index'))->default(), 'groups' => $viewData->aiModelGroups(), diff --git a/app/Http/Controllers/CoWorking/CoWorkingIndexController.php b/app/Http/Controllers/CoWorking/CoWorkingIndexController.php index 812fd9b..8c7ba5a 100644 --- a/app/Http/Controllers/CoWorking/CoWorkingIndexController.php +++ b/app/Http/Controllers/CoWorking/CoWorkingIndexController.php @@ -1,5 +1,7 @@ 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/Controller.php b/app/Http/Controllers/Controller.php index e7f7c94..ded7c1f 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -1,5 +1,7 @@ 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/Jobs/JobsIndexController.php b/app/Http/Controllers/Jobs/JobsIndexController.php index 7368627..0486a74 100644 --- a/app/Http/Controllers/Jobs/JobsIndexController.php +++ b/app/Http/Controllers/Jobs/JobsIndexController.php @@ -1,5 +1,7 @@ with([ diff --git a/app/Http/Controllers/Legal/PrivacyIndexController.php b/app/Http/Controllers/Legal/PrivacyIndexController.php index 6fac7b2..ad0e723 100644 --- a/app/Http/Controllers/Legal/PrivacyIndexController.php +++ b/app/Http/Controllers/Legal/PrivacyIndexController.php @@ -1,5 +1,7 @@ validate([ - 'language' => ['required', new Enum(LocaleEnum::class)], - ]); - - if (! is_array($validated) || ! is_string($validated['language'] ?? null)) { - abort(422); - } - - $language = $validated['language']; - - $locale = (new LocaleAction($language))->setLocale(); - - $previousUrl = url()->previous(); - - $route = Route::getRoutes()->match(request()->create($previousUrl)); - $routeName = Str::after((string) $route->getName(), '.'); - $routeParameters = $route->parameters(); - - if (array_key_exists('locale', $routeParameters)) { - $routeParameters['locale'] = $locale; - } - - $localeSlug = Str::slug($locale); - - Artisan::call(ClearCommand::class); - - return redirect()->route("{$localeSlug}.{$routeName}", $routeParameters); - } -} diff --git a/app/Http/Controllers/Media/MediaIndexController.php b/app/Http/Controllers/Media/MediaIndexController.php index c8769dd..1dd095b 100644 --- a/app/Http/Controllers/Media/MediaIndexController.php +++ b/app/Http/Controllers/Media/MediaIndexController.php @@ -1,5 +1,7 @@ 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..efa07d7 100644 --- a/app/Http/Controllers/News/NewsIndexController.php +++ b/app/Http/Controllers/News/NewsIndexController.php @@ -1,28 +1,93 @@ getLocale(); + + $page = (new PageAction(locale: null, routeName: 'news.index'))->default(); + $all = $viewData->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, + 'lead' => $lead, + 'news' => $rest->values(), + 'topics' => $topics, + 'activeTopic' => $active, + 'total' => $news->count(), + 'schema' => $page instanceof PageDTO + ? 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 $news + * @return Collection + */ + 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 */ + 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(); + } + /** - * Display the user's profile form. + * @param Collection $topics */ - public function __invoke(): View|RedirectResponse + private function activeTopic(Collection $topics): ?NewsTag { - return redirect()->route(Str::slug(app()->getLocale()).'.start.index'); + $slug = request()->query('thema'); - /* $locale = app()->getLocale(); + if (! is_string($slug) || $slug === '') { + return null; + } - return view('app.news.index')->with([ - 'page' => (new PageAction(locale: null, routeName: 'news.index'))->default(), - 'news' => (new ViewDataAction)->news($locale), - ]);*/ + 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 36b1cea..fbaa5f0 100644 --- a/app/Http/Controllers/News/NewsShowController.php +++ b/app/Http/Controllers/News/NewsShowController.php @@ -1,32 +1,104 @@ 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, + 'news' => $news, + 'title' => $news->title, + 'teaser' => $news->teaser, + 'tags' => collect($news->tags), + '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), + ]); + } + /** - * Display the user's profile form. + * 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 */ - public function __invoke(string $locale, News $news): View|RedirectResponse + private function relatedArticles(News $news): Collection { - return redirect()->route(Str::slug(app()->getLocale()).'.start.index'); - - /* 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(), - ]);*/ + $curated = $news->relatedArticles()->published()->get(); + + if ($curated->count() >= self::RELATED_ARTICLES) { + return $curated->take(self::RELATED_ARTICLES); + } + + $tags = $this->tagStrings($news); + + $byTag = News::query() + ->published() + // Only the card fields — `content` holds the full article body in both + // languages, and scoring by tag overlap never looks at it. + ->select(['id', 'key', 'slug', 'title', 'teaser', 'hero_image', 'published_at', 'reading_minutes', 'tags', 'series_id', 'contact_id']) + ->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') + // Tag overlap is scored in PHP, so the candidate set has to be bounded: + // "related" never reaches past the most recent articles anyway. + ->limit(self::RELATED_CANDIDATE_POOL) + ->get() + ->sortByDesc(fn (News $candidate): int => count(array_intersect($tags, $this->tagStrings($candidate)))) + ->take(self::RELATED_ARTICLES - $curated->count()); + + return $curated->merge($byTag)->values(); + } + + /** + * @return array + */ + 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 2d8d56f..43f6900 100644 --- a/app/Http/Controllers/OpenSource/OpenSoruceShowController.php +++ b/app/Http/Controllers/OpenSource/OpenSoruceShowController.php @@ -1,8 +1,9 @@ route(Str::slug(app()->getLocale()).'.start.index'); - /* return view('app.open-source.show')->with([ - 'page' => (new PageAction(locale: $locale))->openSource(openSource: $openSource), + /* // `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; + } + + $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..c74eb24 100644 --- a/app/Http/Controllers/OpenSource/OpenSourceIndexController.php +++ b/app/Http/Controllers/OpenSource/OpenSourceIndexController.php @@ -1,9 +1,9 @@ route(Str::slug(app()->getLocale()).'.start.index'); diff --git a/app/Http/Controllers/Products/ProductsShowController.php b/app/Http/Controllers/Products/ProductsShowController.php index 21d5333..4ed7b56 100644 --- a/app/Http/Controllers/Products/ProductsShowController.php +++ b/app/Http/Controllers/Products/ProductsShowController.php @@ -1,5 +1,7 @@ route(Str::slug(app()->getLocale()).'.start.index'); diff --git a/app/Http/Controllers/Robots/RobotsController.php b/app/Http/Controllers/Robots/RobotsController.php index 43997e3..5f1acb4 100644 --- a/app/Http/Controllers/Robots/RobotsController.php +++ b/app/Http/Controllers/Robots/RobotsController.php @@ -1,5 +1,7 @@ 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..353d610 100644 --- a/app/Http/Controllers/Services/ServicesIndexController.php +++ b/app/Http/Controllers/Services/ServicesIndexController.php @@ -1,24 +1,26 @@ getLocale(); + $services = $viewData->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/Services/ServicesShowController.php b/app/Http/Controllers/Services/ServicesShowController.php index a8836b7..5223388 100644 --- a/app/Http/Controllers/Services/ServicesShowController.php +++ b/app/Http/Controllers/Services/ServicesShowController.php @@ -1,5 +1,7 @@ route(Str::slug(app()->getLocale()).'.start.index'); diff --git a/app/Http/Controllers/Sitemap/SitemapController.php b/app/Http/Controllers/Sitemap/SitemapController.php index ee054dc..275a634 100644 --- a/app/Http/Controllers/Sitemap/SitemapController.php +++ b/app/Http/Controllers/Sitemap/SitemapController.php @@ -1,12 +1,16 @@ value, ttl: now()->addHours(24), callback: function (): string { $sitemap = new SitemapBuilder; @@ -48,36 +55,68 @@ public function __invoke(): Response return response(content: $content) ->header('Content-Type', 'application/xml') - ->header('Cache-Control', 'public, max-age=3600'); // Cache for 1 hour + ->header('Cache-Control', 'public, max-age=3600'); } private function builder(SitemapBuilder $sitemap): void { $this->addDefaultRoutesToSitemap($sitemap); $this->addNetworksToSitemap($sitemap); + $this->addNewsToSitemap($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); + $this->addLocalizedSet( + $sitemap, + fn (string $locale): PageDTO => (new PageAction)->network(network: $network, locale: $locale), + ); + }); + } - $pages = collect(self::DEFAULT_LOCALES) - ->map(fn (string $locale): PageDTO => $action->network(network: $network, locale: $locale)) - ->values(); + private function addNewsToSitemap(SitemapBuilder $sitemap): void + { + News::query() + ->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 { + $this->addLocalizedSet( + $sitemap, + fn (string $locale): PageDTO => (new PageAction)->news(news: $news, locale: $locale), + ); + }); + } - $pages->each(function (PageDTO $page) use ($pages): void { - $page->referencePages = $pages; - }); + /** + * 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 ($sitemap): void { - $sitemap->addItem(page: $page); - }); - }); + $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 diff --git a/app/Http/Controllers/Start/StartIndexController.php b/app/Http/Controllers/Start/StartIndexController.php index 27cd7c6..b84b0e0 100644 --- a/app/Http/Controllers/Start/StartIndexController.php +++ b/app/Http/Controllers/Start/StartIndexController.php @@ -1,9 +1,12 @@ routeIs(Str::slug(LocaleEnum::EN->value).'.start.index') ? LocaleEnum::EN->value @@ -24,6 +24,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' => $viewData->news($locale)->take(2), ]); } } diff --git a/app/Http/Controllers/Technologies/TechnologiesIndexController.php b/app/Http/Controllers/Technologies/TechnologiesIndexController.php index 0fba774..9efd78a 100644 --- a/app/Http/Controllers/Technologies/TechnologiesIndexController.php +++ b/app/Http/Controllers/Technologies/TechnologiesIndexController.php @@ -1,5 +1,7 @@ route(Str::slug(app()->getLocale()).'.start.index'); diff --git a/app/Http/Controllers/Technologies/TechnologiesShowController.php b/app/Http/Controllers/Technologies/TechnologiesShowController.php index bd025d2..32f6c1a 100644 --- a/app/Http/Controllers/Technologies/TechnologiesShowController.php +++ b/app/Http/Controllers/Technologies/TechnologiesShowController.php @@ -1,5 +1,7 @@ route(Str::slug(app()->getLocale()).'.start.index'); diff --git a/app/Http/Middleware/PreventRequestForgery.php b/app/Http/Middleware/PreventRequestForgery.php index c6e7361..1ecc91e 100644 --- a/app/Http/Middleware/PreventRequestForgery.php +++ b/app/Http/Middleware/PreventRequestForgery.php @@ -1,5 +1,7 @@ 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/Http/Middleware/SetLanguage.php b/app/Http/Middleware/SetLanguage.php index a8609ef..f490b3d 100644 --- a/app/Http/Middleware/SetLanguage.php +++ b/app/Http/Middleware/SetLanguage.php @@ -1,5 +1,7 @@ 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\1>/s', + function (array $match) use (&$seen): string { + $id = $this->anchor(trim(strip_tags($match[2])), $seen); + + return ''.$match[2].''; + }, + $html + ); + } + + /** + * @param array $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 + */ + 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, 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 + */ + 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 $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> + */ + 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/AiModel.php b/app/Models/AiModel.php index f011603..424ac19 100644 --- a/app/Models/AiModel.php +++ b/app/Models/AiModel.php @@ -1,5 +1,7 @@ */ + protected $fillable = [ + 'category', + 'order', + 'name', + 'provider', + 'ram', + 'license', + 'role', + 'link_label', + 'link_url', + 'archived_at', + 'replaced_by_id', + ]; + protected $casts = [ 'category' => AiModelCategoryEnum::class, 'role' => 'json', diff --git a/app/Models/AiModelDailyUsage.php b/app/Models/AiModelDailyUsage.php index 921f048..36fc007 100644 --- a/app/Models/AiModelDailyUsage.php +++ b/app/Models/AiModelDailyUsage.php @@ -1,5 +1,7 @@ */ use HasFactory; + /** @var list */ + protected $fillable = [ + 'date', + 'model', + 'ai_model_id', + 'prompt_tokens', + 'completion_tokens', + 'total_tokens', + 'requests', + 'spend', + ]; + protected $casts = [ 'date' => 'date', 'spend' => 'decimal:6', diff --git a/app/Models/Contact.php b/app/Models/Contact.php index a83acd1..f13edf4 100644 --- a/app/Models/Contact.php +++ b/app/Models/Contact.php @@ -1,19 +1,31 @@ */ use HasFactory; + /** @var list */ + protected $fillable = [ + 'key', + 'published', + 'sort', + 'name', + 'sections', + 'image', + 'icons', + ]; + protected $casts = [ 'published' => 'boolean', 'sections' => 'json', @@ -28,8 +40,8 @@ protected static function booted(): void public static function clearPublishedCache(): void { - foreach (LocaleEnum::cases() as $locale) { - Cache::forget(Str::slug("contacts_published_{$locale->value}")); + foreach (CacheKeyEnum::CONTACTS_PUBLISHED->forAllLocales() as $key) { + Cache::forget($key); } } } diff --git a/app/Models/Network.php b/app/Models/Network.php index 9fdec28..57afe99 100644 --- a/app/Models/Network.php +++ b/app/Models/Network.php @@ -1,5 +1,7 @@ */ protected array $translatable = ['name', 'excerpt', 'tier_label']; + /** @var list */ + protected $fillable = [ + 'key', + 'name', + 'category', + 'status', + 'cover_disk', + 'cover_path', + 'cover_url', + 'tier_label', + 'excerpt', + 'website', + 'since_year', + 'until_year', + 'page_slug', + 'published', + 'sort', + ]; + protected $casts = [ 'category' => NetworkCategoryEnum::class, 'status' => NetworkStatusEnum::class, diff --git a/app/Models/NetworkUser.php b/app/Models/NetworkUser.php index 51c03b3..0b77d4f 100644 --- a/app/Models/NetworkUser.php +++ b/app/Models/NetworkUser.php @@ -1,5 +1,7 @@ */ + protected $fillable = [ + 'network_key', + 'name', + 'role', + 'avatar_disk', + 'avatar_path', + 'avatar_url', + 'email', + 'public_email', + 'linkedin', + 'phone', + 'published', + 'sort', + ]; + protected $casts = [ 'published' => 'boolean', ]; diff --git a/app/Models/News.php b/app/Models/News.php index 743f4ec..3068330 100644 --- a/app/Models/News.php +++ b/app/Models/News.php @@ -1,14 +1,21 @@ */ use HasFactory; @@ -17,13 +24,42 @@ class News extends Model use HasTranslations; /** @var array */ - protected array $translatable = ['title', 'teaser', 'content']; + protected array $translatable = ['title', 'teaser', 'content', 'slug', 'hero_caption', 'hero_alt']; + + /** @var list */ + protected $fillable = [ + 'key', + 'slug', + 'title', + 'teaser', + 'content', + 'hero_image', + 'hero_caption', + 'hero_alt', + 'published_at', + 'published', + 'author', + 'contact_id', + 'series_id', + 'series_position', + 'featured', + 'reading_minutes', + 'tags', + ]; 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 +69,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 + */ + 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 + */ + public function series(): BelongsTo + { + return $this->belongsTo(NewsSeries::class, 'series_id'); + } + + /** + * @return BelongsToMany + */ + 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 + */ + public function relatedArticles(): BelongsToMany + { + return $this->belongsToMany(News::class, 'news_relations', 'news_id', 'related_news_id') + ->withPivot('sort') + ->orderBy('news_relations.sort'); + } + + /** + * @param Builder $query + * @return Builder + */ + 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 + */ + public function seriesParts(): Collection + { + if ($this->series_id === null) { + return new Collection; + } + + /** @var Collection */ + 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..98bc6b4 --- /dev/null +++ b/app/Models/NewsSeries.php @@ -0,0 +1,56 @@ + */ + protected $fillable = [ + 'key', + 'title', + 'slug', + 'description', + 'published', + ]; + + protected $table = 'news_series'; + + /** @var array */ + protected array $translatable = ['title', 'slug', 'description']; + + protected $casts = [ + 'published' => 'boolean', + ]; + + /** + * @return HasMany + */ + public function articles(): HasMany + { + return $this->hasMany(News::class, 'series_id')->orderBy('series_position'); + } + + /** + * @return HasMany + */ + 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..66a5e8d --- /dev/null +++ b/app/Models/NewsTag.php @@ -0,0 +1,43 @@ + */ + protected $fillable = [ + 'key', + 'title', + 'slug', + 'description', + ]; + + use HasTranslations; + + /** @var array */ + protected array $translatable = ['title', 'slug', 'description']; + + /** + * @return BelongsToMany + */ + public function articles(): BelongsToMany + { + return $this->belongsToMany(News::class, 'news_news_tag'); + } + + public function getRouteKeyName(): string + { + return 'slug'; + } +} diff --git a/app/Models/OpenSource.php b/app/Models/OpenSource.php index 9a941b0..2f252bd 100644 --- a/app/Models/OpenSource.php +++ b/app/Models/OpenSource.php @@ -1,5 +1,7 @@ */ protected array $translatable = ['title', 'teaser', 'content']; + /** @var list */ + protected $fillable = [ + 'published', + 'title', + 'slug', + 'teaser', + 'content', + 'image', + 'tags', + 'link', + 'downloads', + 'version', + 'github_name', + 'stars', + 'forks', + 'primary_language', + ]; + protected $casts = [ 'published' => 'boolean', 'tags' => 'json', @@ -34,4 +54,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/Models/Page.php b/app/Models/Page.php index d33892d..47dde16 100644 --- a/app/Models/Page.php +++ b/app/Models/Page.php @@ -1,5 +1,7 @@ */ + protected $fillable = [ + 'key', + 'robots', + 'title', + 'description', + 'image', + ]; + use HasTranslations; /** @var array */ diff --git a/app/Models/Product.php b/app/Models/Product.php index e3aa540..ac8b506 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -1,5 +1,7 @@ */ + protected $fillable = [ + 'published', + 'order', + 'name', + 'headline', + 'teaser', + 'slug', + 'content', + 'features_heading', + 'features_intro', + 'features', + 'deployment_heading', + 'deployment_intro', + 'deployment_options', + 'cta_heading', + 'cta_body', + 'image', + 'url', + 'tags', + ]; + protected $casts = [ 'published' => 'boolean', 'tags' => 'json', diff --git a/app/Models/ProductModule.php b/app/Models/ProductModule.php index a8bc40b..113d635 100644 --- a/app/Models/ProductModule.php +++ b/app/Models/ProductModule.php @@ -1,5 +1,7 @@ */ + protected $fillable = [ + 'published', + 'locale', + 'slug', + 'tags', + 'product_id', + ]; + protected $casts = [ 'published' => 'boolean', 'locale' => LocaleEnum::class, diff --git a/app/Models/Role.php b/app/Models/Role.php index fc0fc90..b17769c 100644 --- a/app/Models/Role.php +++ b/app/Models/Role.php @@ -1,5 +1,7 @@ */ protected array $translatable = ['name', 'teaser', 'content']; + /** @var list */ + protected $fillable = [ + 'published', + 'group', + 'order', + 'name', + 'teaser', + 'slug', + 'content', + 'image', + 'url', + 'tags', + ]; + protected $casts = [ 'published' => 'boolean', 'tags' => 'json', diff --git a/app/Models/Technology.php b/app/Models/Technology.php index 5e507b4..744eac9 100644 --- a/app/Models/Technology.php +++ b/app/Models/Technology.php @@ -1,5 +1,7 @@ */ protected array $translatable = ['title', 'teaser', 'content']; + /** @var list */ + protected $fillable = [ + 'published', + 'group', + 'order', + 'title', + 'slug', + 'teaser', + 'content', + 'image', + 'tags', + 'link', + ]; + protected $casts = [ 'published' => 'boolean', 'tags' => 'json', diff --git a/app/Models/User.php b/app/Models/User.php index 2287d46..ec12915 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -1,5 +1,7 @@ */ + protected $fillable = [ + 'name', + 'email', + 'email_verified_at', + 'password', + 'locale', + ]; + protected $hidden = [ 'password', 'remember_token', @@ -37,11 +48,4 @@ class User extends Authenticatable implements MustVerifyEmailContract 'password' => 'hashed', 'locale' => LocaleEnum::class, ]; - - public function getGravatarUrl(int $size = 200): string - { - $hash = md5(strtolower(trim($this->email))); - - return "https://www.gravatar.com/avatar/$hash?s=$size&d=mp"; - } } diff --git a/app/Notifications/NetworkInviteNotification.php b/app/Notifications/NetworkInviteNotification.php index 8cf15cd..0551fbb 100644 --- a/app/Notifications/NetworkInviteNotification.php +++ b/app/Notifications/NetworkInviteNotification.php @@ -1,5 +1,7 @@ update(...)` has to call flush() itself. + */ +class ContentCacheObserver +{ + public function saved(Model $model): void + { + self::flush($model); + } + + public function deleted(Model $model): void + { + self::flush($model); + } + + public static function flush(Model $model): void + { + foreach (self::keysFor($model) as $key) { + Cache::forget($key); + } + + ResponseCache::clear(); + } + + /** + * @return array + */ + private static function keysFor(Model $model): array + { + return match (true) { + $model instanceof Service => CacheKeyEnum::SERVICES_PUBLISHED->forAllLocales(), + $model instanceof Product => CacheKeyEnum::PRODUCTS_PUBLISHED->forAllLocales(), + $model instanceof Technology => CacheKeyEnum::TECHNOLOGIES_PUBLISHED->forAllLocales(), + $model instanceof OpenSource => CacheKeyEnum::OPEN_SOURCE_PUBLISHED->forAllLocales(), + $model instanceof AiModel => [ + CacheKeyEnum::AI_MODELS_ACTIVE->value, + CacheKeyEnum::AI_MODELS_ARCHIVED->value, + ], + default => [], + }; + } +} diff --git a/app/Observers/NetworkObserver.php b/app/Observers/NetworkObserver.php index a9a3100..edad25a 100644 --- a/app/Observers/NetworkObserver.php +++ b/app/Observers/NetworkObserver.php @@ -1,5 +1,7 @@ forAllLocales() as $key) { + Cache::forget($key); + } + + ResponseCache::clear(); + } +} diff --git a/app/Observers/SitemapCacheObserver.php b/app/Observers/SitemapCacheObserver.php new file mode 100644 index 0000000..af50e9b --- /dev/null +++ b/app/Observers/SitemapCacheObserver.php @@ -0,0 +1,34 @@ +forget(); + } + + public function deleted(Model $model): void + { + $this->forget(); + } + + private function forget(): void + { + Cache::forget(CacheKeyEnum::SITEMAP->value); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 995a370..a44b0cb 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -1,19 +1,26 @@ app->isLocal()); + // Staging, not just local: production-shaped data is where a lazy load first + // shows up, and the cached listings in ViewDataAction rely on this to catch one. + Model::shouldBeStrict(! $this->app->isProduction()); $environmentCheck = EnvironmentCheck::new(); $environmentCheck->if(app()->isProduction()); diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php deleted file mode 100644 index 2fb3305..0000000 --- a/app/Providers/EventServiceProvider.php +++ /dev/null @@ -1,10 +0,0 @@ -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..3ed44a1 --- /dev/null +++ b/app/Seo/SchemaGraph.php @@ -0,0 +1,311 @@ +. + * + * 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' => ($image = NewsImage::src($this->page->image, config()->integer('seo.image_width'))) !== null + ? ['@type' => 'ImageObject', 'url' => $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..371b11a --- /dev/null +++ b/app/Seo/SchemaNodes.php @@ -0,0 +1,271 @@ +<?php + +declare(strict_types=1); + +namespace App\Seo; + +use App\DTO\ContactDTO; +use App\DTO\PageDTO; +use App\Models\News; +use App\Models\OpenSource; +use App\Models\Service; +use App\Support\NewsImage; +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' => self::articleAuthor($news, $organizationId), + 'publisher' => ['@id' => $organizationId], + 'image' => NewsImage::src($news->hero_image, config()->integer('seo.image_width')), + '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. + * + * @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..108fc7d 100644 --- a/app/Sitemap/SitemapBuilder.php +++ b/app/Sitemap/SitemapBuilder.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + namespace App\Sitemap; use App\DTO\PageDTO; @@ -15,11 +17,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/CloudinaryUrl.php b/app/Support/CloudinaryUrl.php index 43f1f62..cd9b846 100644 --- a/app/Support/CloudinaryUrl.php +++ b/app/Support/CloudinaryUrl.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + namespace App\Support; class CloudinaryUrl diff --git a/app/Support/GravatarUrl.php b/app/Support/GravatarUrl.php index dfef1c9..dea9022 100644 --- a/app/Support/GravatarUrl.php +++ b/app/Support/GravatarUrl.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + namespace App\Support; class GravatarUrl diff --git a/app/Support/LocalizedRouteParameters.php b/app/Support/LocalizedRouteParameters.php new file mode 100644 index 0000000..37fca84 --- /dev/null +++ b/app/Support/LocalizedRouteParameters.php @@ -0,0 +1,52 @@ +<?php + +declare(strict_types=1); + +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..15c04b8 --- /dev/null +++ b/app/Support/NewsImage.php @@ -0,0 +1,115 @@ +<?php + +declare(strict_types=1); + +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..9489354 100644 --- a/app/Support/PageNavigation.php +++ b/app/Support/PageNavigation.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + namespace App\Support; class PageNavigation @@ -14,6 +16,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 b62bb11..c065175 100644 --- a/app/Support/helpers.php +++ b/app/Support/helpers.php @@ -1,5 +1,8 @@ <?php +declare(strict_types=1); + +use App\Support\LocalizedRouteParameters; use Illuminate\Support\Str; if (! function_exists('localized_route')) { @@ -11,3 +14,43 @@ 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, '.'); + + /** @var array<string, mixed> $parameters */ + $parameters = $route?->parameters() ?? []; + + // 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); + } catch (Throwable) { + return route($localeSlug.'.start.index'); + } + } +} diff --git a/app/Traits/HasLocalizedRouteBinding.php b/app/Traits/HasLocalizedRouteBinding.php index bf60d6c..12393ad 100644 --- a/app/Traits/HasLocalizedRouteBinding.php +++ b/app/Traits/HasLocalizedRouteBinding.php @@ -1,21 +1,51 @@ <?php +declare(strict_types=1); + 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/Traits/HasUuid.php b/app/Traits/HasUuid.php index 79b5838..3a580b6 100755 --- a/app/Traits/HasUuid.php +++ b/app/Traits/HasUuid.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + namespace App\Traits; use Illuminate\Database\Eloquent\Builder; diff --git a/app/View/Components/AppLayout.php b/app/View/Components/AppLayout.php index 8bd4dca..4e80d64 100644 --- a/app/View/Components/AppLayout.php +++ b/app/View/Components/AppLayout.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + namespace App\View\Components; use App\Enums\LocaleEnum; @@ -9,9 +11,20 @@ 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 = [], + /** + * 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 @@ -23,6 +36,8 @@ public function render(): View 'locale' => Str::slug($locale), 'page' => $this->page, 'preconnectCloudinary' => $this->preconnectCloudinary, + 'schema' => $this->schema, + 'wide' => $this->wide, ]); } } diff --git a/bootstrap/app.php b/bootstrap/app.php index 81a9a25..83c38ec 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,10 +1,11 @@ <?php +declare(strict_types=1); + use App\Http\Middleware\PreventRequestForgery; use App\Http\Middleware\SecurityHeaders; use App\Http\Middleware\SetLanguage; use App\Providers\AppServiceProvider; -use App\Providers\EventServiceProvider; use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; @@ -17,7 +18,6 @@ return Application::configure(basePath: dirname(__DIR__)) ->withProviders([ AppServiceProvider::class, - EventServiceProvider::class, ]) ->withRouting( web: __DIR__.'/../routes/web.php', diff --git a/composer.json b/composer.json index efb3217..de8ca5f 100644 --- a/composer.json +++ b/composer.json @@ -1,102 +1,96 @@ { - "name": "laravel/laravel", - "type": "project", - "description": "The skeleton application for the Laravel framework.", - "keywords": [ - "laravel", - "framework" - ], - "license": "MIT", - "require": { - "php": "^8.5", - "codebar-ag/laravel-flysystem-cloudinary": "^13.0", - "laravel/framework": "^13.0", - "laravel/tinker": "^3.0", - "league/flysystem-aws-s3-v3": "^3.28", - "mazedlx/laravel-feature-policy": "^2.2", - "sammyjo20/lasso": "^3.5", - "spatie/laravel-csp": "^3.8", - "spatie/laravel-flash": "^1.10", - "spatie/laravel-health": "^1.27", - "spatie/laravel-honeypot": "^4.5", - "spatie/laravel-ignition": "^2.7", - "spatie/laravel-permission": "^8.0", - "spatie/laravel-responsecache": "^8.0", - "spatie/laravel-sitemap": "^8.0", - "spatie/laravel-translatable": "^6.14", - "spatie/security-advisories-health-check": "^1.2", - "symfony/http-client": "^8.1", - "symfony/postmark-mailer": "^8.1" - }, - "require-dev": { - "fakerphp/faker": "^1.23", - "larastan/larastan": "^3.10", - "laravel/pint": "^1.21", - "laravel/sail": "^1.26", - "mockery/mockery": "^1.6", - "nunomaduro/collision": "^8.1.1", - "pestphp/pest": "^4.0", - "pestphp/pest-plugin-arch": "^4.0", - "pestphp/pest-plugin-faker": "^4.0", - "pestphp/pest-plugin-laravel": "^4.0", - "pestphp/pest-plugin-type-coverage": "^4.0", - "phpstan/extension-installer": "^1.4.3", - "phpstan/phpstan": "^2.2", - "spatie/laravel-ray": "^1.39" - }, - "autoload": { - "psr-4": { - "App\\": "app/", - "Database\\Factories\\": "database/factories/", - "Database\\Seeders\\": "database/seeders/" - }, - "files": [ - "app/Support/helpers.php" - ] - }, - "autoload-dev": { - "psr-4": { - "Tests\\": "tests/" - } - }, - "scripts": { - "post-autoload-dump": [ - "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", - "@php artisan package:discover --ansi" - ], - "post-update-cmd": [ - "@php artisan vendor:publish --tag=laravel-assets --ansi --force" - ], - "post-root-package-install": [ - "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" + "name": "laravel/laravel", + "type": "project", + "description": "The skeleton application for the Laravel framework.", + "keywords": [ + "laravel", + "framework" ], - "post-create-project-cmd": [ - "@php artisan key:generate --ansi", - "@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"", - "@php artisan migrate --graceful --ansi" - ] - }, - "extra": { - "laravel": { - "dont-discover": [] - } - }, - "config": { - "optimize-autoloader": true, - "preferred-install": "dist", - "sort-packages": true, - "allow-plugins": { - "pestphp/pest-plugin": true, - "php-http/discovery": true, - "phpstan/extension-installer": true - } - }, - "minimum-stability": "dev", - "prefer-stable": true, - "repositories": [ - { - "type": "composer", - "url": "https://nova.laravel.com" - } - ] + "license": "MIT", + "require": { + "php": "^8.5", + "codebar-ag/laravel-flysystem-cloudinary": "^13.0", + "laravel/framework": "^13.0", + "laravel/tinker": "^3.0", + "league/flysystem-aws-s3-v3": "^3.28", + "mazedlx/laravel-feature-policy": "^2.2", + "sammyjo20/lasso": "^3.5", + "spatie/laravel-csp": "^3.8", + "spatie/laravel-flash": "^1.10", + "spatie/laravel-health": "^1.27", + "spatie/laravel-honeypot": "^4.5", + "spatie/laravel-permission": "^8.0", + "spatie/laravel-responsecache": "^8.0", + "spatie/laravel-sitemap": "^8.0", + "spatie/laravel-translatable": "^6.14", + "spatie/security-advisories-health-check": "^1.2", + "symfony/http-client": "^8.1", + "symfony/postmark-mailer": "^8.1" + }, + "require-dev": { + "fakerphp/faker": "^1.23", + "larastan/larastan": "^3.10", + "laravel/pint": "^1.21", + "laravel/sail": "^1.26", + "mockery/mockery": "^1.6", + "nunomaduro/collision": "^8.1.1", + "pestphp/pest": "^4.0", + "pestphp/pest-plugin-arch": "^4.0", + "pestphp/pest-plugin-faker": "^4.0", + "pestphp/pest-plugin-laravel": "^4.0", + "pestphp/pest-plugin-type-coverage": "^4.0", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.2", + "spatie/laravel-ignition": "^2.7", + "spatie/laravel-ray": "^1.39" + }, + "autoload": { + "psr-4": { + "App\\": "app/", + "Database\\Factories\\": "database/factories/", + "Database\\Seeders\\": "database/seeders/" + }, + "files": [ + "app/Support/helpers.php" + ] + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } + }, + "scripts": { + "post-autoload-dump": [ + "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", + "@php artisan package:discover --ansi" + ], + "post-update-cmd": [ + "@php artisan vendor:publish --tag=laravel-assets --ansi --force" + ], + "post-root-package-install": [ + "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" + ], + "post-create-project-cmd": [ + "@php artisan key:generate --ansi", + "@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"", + "@php artisan migrate --graceful --ansi" + ] + }, + "extra": { + "laravel": { + "dont-discover": [] + } + }, + "config": { + "optimize-autoloader": true, + "preferred-install": "dist", + "sort-packages": true, + "allow-plugins": { + "pestphp/pest-plugin": true, + "php-http/discovery": true, + "phpstan/extension-installer": true + } + }, + "minimum-stability": "dev", + "prefer-stable": true } diff --git a/composer.lock b/composer.lock index af10ba5..a2ed68a 100644 --- a/composer.lock +++ b/composer.lock @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.388.12", + "version": "3.389.2", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "9eca8f6408501a47efe5dee2ce494b93eac983a6" + "reference": "784e0fb95e752e55c4654b5800b900c78f6d3990" }, "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/784e0fb95e752e55c4654b5800b900c78f6d3990", + "reference": "784e0fb95e752e55c4654b5800b900c78f6d3990", "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.2" }, - "time": "2026-07-22T18:05:00+00:00" + "time": "2026-07-28T18:10:25+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", @@ -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", @@ -10925,11 +10927,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 +10987,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..1c49a4d 100644 --- a/config/app.php +++ b/config/app.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + return [ /* @@ -15,6 +17,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/cache.php b/config/cache.php index 8c22224..1cf9e53 100644 --- a/config/cache.php +++ b/config/cache.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + use Illuminate\Support\Str; return [ diff --git a/config/company.php b/config/company.php new file mode 100644 index 0000000..772f5dd --- /dev/null +++ b/config/company.php @@ -0,0 +1,111 @@ +<?php + +declare(strict_types=1); + +/* +|-------------------------------------------------------------------------- +| 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://www.linkedin.com/company/codebar-solutions-ag', + 'https://github.com/codebar-ag', + // 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/config/cors.php b/config/cors.php index 7e68698..e825759 100644 --- a/config/cors.php +++ b/config/cors.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + return [ /* diff --git a/config/csp.php b/config/csp.php index 81fd6b8..884e388 100644 --- a/config/csp.php +++ b/config/csp.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + use App\Security\Generator\LaravelViteNonceGenerator; use App\Security\Presets\MyCspPreset; diff --git a/config/database.php b/config/database.php index 99c87a8..bb78d59 100644 --- a/config/database.php +++ b/config/database.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + use Illuminate\Support\Str; use Pdo\Mysql; @@ -17,7 +19,7 @@ | */ - 'default' => env('DB_CONNECTION', 'sqlite'), + 'default' => env('DB_CONNECTION', 'pgsql'), /* |-------------------------------------------------------------------------- diff --git a/config/default-nova.php b/config/default-nova.php deleted file mode 100644 index e69de29..0000000 diff --git a/config/default.php b/config/default.php index a4679ea..d0c935d 100644 --- a/config/default.php +++ b/config/default.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + use App\Enums\EnvironmentEnum; use Mazedlx\FeaturePolicy\Value; diff --git a/config/feature-policy.php b/config/feature-policy.php index 6743cee..9a029be 100644 --- a/config/feature-policy.php +++ b/config/feature-policy.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + use App\Security\FeaturePolicyBasic; return [ diff --git a/config/filesystems.php b/config/filesystems.php index de97700..1818ee4 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + return [ /* diff --git a/config/flare.php b/config/flare.php index babb06e..7a2c45f 100644 --- a/config/flare.php +++ b/config/flare.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + use Spatie\FlareClient\FlareMiddleware\AddGitInformation; use Spatie\FlareClient\FlareMiddleware\CensorRequestBodyFields; use Spatie\FlareClient\FlareMiddleware\CensorRequestHeaders; diff --git a/config/flysystem-cloudinary.php b/config/flysystem-cloudinary.php index 73551b2..336aeff 100644 --- a/config/flysystem-cloudinary.php +++ b/config/flysystem-cloudinary.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + return [ /* diff --git a/config/hashing.php b/config/hashing.php index cab8bc7..ee0d1b5 100644 --- a/config/hashing.php +++ b/config/hashing.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + return [ /* diff --git a/config/health.php b/config/health.php index 63db095..dc8b561 100644 --- a/config/health.php +++ b/config/health.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + use Spatie\Health\Notifications\CheckFailedNotification; use Spatie\Health\Notifications\Notifiable; use Spatie\Health\ResultStores\CacheHealthResultStore; diff --git a/config/honeypot.php b/config/honeypot.php index 395ae12..8e1d705 100644 --- a/config/honeypot.php +++ b/config/honeypot.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + use Spatie\Honeypot\SpamProtection; use Spatie\Honeypot\SpamResponder\BlankPageResponder; diff --git a/config/logging.php b/config/logging.php index 7c435d3..cb8efc9 100644 --- a/config/logging.php +++ b/config/logging.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + use Monolog\Handler\StreamHandler; use Monolog\Processor\PsrLogMessageProcessor; diff --git a/config/mail.php b/config/mail.php index d2fe50e..b37fa05 100644 --- a/config/mail.php +++ b/config/mail.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + return [ /* diff --git a/config/nova.php b/config/nova.php deleted file mode 100644 index 92c8948..0000000 --- a/config/nova.php +++ /dev/null @@ -1,211 +0,0 @@ -<?php - -use Illuminate\Http\Middleware\CheckResponseForModifications; -use Laravel\Nova\Actions\ActionResource; -use Laravel\Nova\Http\Middleware\Authenticate; -use Laravel\Nova\Http\Middleware\Authorize; -use Laravel\Nova\Http\Middleware\HandleInertiaRequests; - -return [ - - /* - |-------------------------------------------------------------------------- - | Nova License Key - |-------------------------------------------------------------------------- - | - | The following configuration option contains your Nova license key. On - | non-local domains, Nova will verify that the Nova installation has - | a valid license associated with the application's active domain. - | - */ - - 'license_key' => env('NOVA_LICENSE_KEY'), - - /* - |-------------------------------------------------------------------------- - | Nova App Name - |-------------------------------------------------------------------------- - | - | This value is the name of your application. This value is used when the - | framework needs to display the name of the application within the UI - | or in other locations. Of course, you're free to change the value. - | - */ - - 'name' => env('NOVA_APP_NAME', env('APP_NAME')), - - /* - |-------------------------------------------------------------------------- - | Nova Domain Name - |-------------------------------------------------------------------------- - | - | This value is the "domain name" associated with your application. This - | can be used to prevent Nova's internal routes from being registered - | on subdomains which do not need access to your admin application. - | - */ - - 'domain' => env('NOVA_DOMAIN_NAME', null), - - /* - |-------------------------------------------------------------------------- - | Nova Path - |-------------------------------------------------------------------------- - | - | This is the URI path where Nova will be accessible from. Feel free to - | change this path to anything you like. Note that this URI will not - | affect Nova's internal API routes which aren't exposed to users. - | - */ - - 'path' => '/nova', - - /* - |-------------------------------------------------------------------------- - | Nova Authentication Guard - |-------------------------------------------------------------------------- - | - | This configuration option defines the authentication guard that will - | be used to protect your Nova routes. This option should match one - | of the authentication guards defined in the "auth" config file. - | - */ - - 'guard' => env('NOVA_GUARD', null), - - /* - |-------------------------------------------------------------------------- - | Nova Password Reset Broker - |-------------------------------------------------------------------------- - | - | This configuration option defines the password broker that will be - | used when passwords are reset. This option should mirror one of - | the password reset options defined in the "auth" config file. - | - */ - - 'passwords' => env('NOVA_PASSWORDS', null), - - /* - |-------------------------------------------------------------------------- - | Nova Route Middleware - |-------------------------------------------------------------------------- - | - | These middleware will be assigned to every Nova route, giving you the - | chance to add your own middleware to this stack or override any of - | the existing middleware. Or, you can just stick with this stack. - | - */ - - 'middleware' => [ - 'web', - HandleInertiaRequests::class, - 'nova:serving', - ], - - 'api_middleware' => [ - 'nova', - Authenticate::class, - // \Laravel\Nova\Http\Middleware\AuthenticateSession::class, - // \Laravel\Nova\Http\Middleware\EnsureEmailIsVerified::class, - Authorize::class, - ], - - 'asset_middleware' => [ - 'nova:api', - CheckResponseForModifications::class, - ], - - /* - |-------------------------------------------------------------------------- - | Nova Pagination Type - |-------------------------------------------------------------------------- - | - | This option defines the visual style used in Nova's resource pagination - | views. You may select between "simple", "load-more", and "links" for - | your applications. Feel free to adjust this option to your choice. - | - */ - - 'pagination' => 'simple', - - /* - |-------------------------------------------------------------------------- - | Nova Storage Disk - |-------------------------------------------------------------------------- - | - | This configuration option allows you to define the default disk that - | will be used to store files using the Image, File, and other file - | related field types. You're welcome to use any configured disk. - | - */ - - 'storage_disk' => env('NOVA_STORAGE_DISK', 'public'), - - /* - |-------------------------------------------------------------------------- - | Nova Currency - |-------------------------------------------------------------------------- - | - | This configuration option allows you to define the default currency - | used by the Currency field within Nova. You may change this to a - | valid ISO 4217 currency code to suit your application's needs. - | - */ - - 'currency' => 'USD', - - /* - |-------------------------------------------------------------------------- - | Branding - |-------------------------------------------------------------------------- - | - | These configuration values allow you to customize the branding of the - | Nova interface, including the primary color and the logo that will - | be displayed within the Nova interface. This logo value must be - | the absolute path to an SVG logo within the local filesystem. - | - */ - - // 'brand' => [ - // 'logo' => resource_path('/img/example-logo.svg'), - - // 'colors' => [ - // "400" => "24, 182, 155, 0.5", - // "500" => "24, 182, 155", - // "600" => "24, 182, 155, 0.75", - // ] - // ], - - /* - |-------------------------------------------------------------------------- - | Nova Action Resource Class - |-------------------------------------------------------------------------- - | - | This configuration option allows you to specify a custom resource class - | to use for action log entries instead of the default that ships with - | Nova, thus allowing for the addition of additional UI form fields. - | - */ - - 'actions' => [ - 'resource' => ActionResource::class, - ], - - /* - |-------------------------------------------------------------------------- - | Nova Impersonation Redirection URLs - |-------------------------------------------------------------------------- - | - | This configuration option allows you to specify a URL where Nova should - | redirect an administrator after impersonating another user and a URL - | to redirect the administrator after stopping impersonating a user. - | - */ - - 'impersonation' => [ - 'started' => '/', - 'stopped' => '/', - ], - -]; diff --git a/config/permission.php b/config/permission.php index 338a27b..22bb158 100644 --- a/config/permission.php +++ b/config/permission.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + use Spatie\Permission\Models\Permission; use Spatie\Permission\Models\Role; diff --git a/config/responsecache.php b/config/responsecache.php index 8bd6de2..91dc412 100644 --- a/config/responsecache.php +++ b/config/responsecache.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + use Spatie\ResponseCache\CacheProfiles\CacheAllSuccessfulGetRequests; use Spatie\ResponseCache\Hasher\DefaultHasher; use Spatie\ResponseCache\Replacers\CsrfTokenReplacer; diff --git a/config/seo.php b/config/seo.php index 2966398..5de0abc 100644 --- a/config/seo.php +++ b/config/seo.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + return [ 'default_image' => 'images/seo/og-codebar.png', 'image_width' => 1200, diff --git a/config/services.php b/config/services.php index 2c038b2..94987c5 100644 --- a/config/services.php +++ b/config/services.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + return [ /* @@ -9,8 +11,7 @@ | | Every service not listed here falls back to Laravel's internal default | config (vendor/laravel/framework/config/services.php) — e.g. postmark, - | ses, resend, slack. "microsoft" is added here since it isn't a Laravel - | default. + | ses, resend, slack. | */ @@ -23,12 +24,4 @@ 'token' => env('GITHUB_TOKEN'), ], - 'microsoft' => [ - 'client_id' => env('MICROSOFT_CLIENT_ID'), - 'client_secret' => env('MICROSOFT_CLIENT_SECRET'), - 'redirect' => env('MICROSOFT_REDIRECT_URI'), - 'tenant' => env('MICROSOFT_TENANT_ID'), - 'include_tenant_info' => true, - ], - ]; diff --git a/config/session.php b/config/session.php index 1d1e28b..503f5a6 100644 --- a/config/session.php +++ b/config/session.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + use Illuminate\Support\Str; return [ diff --git a/database/factories/AiModelDailyUsageFactory.php b/database/factories/AiModelDailyUsageFactory.php index 56d1b72..004abc8 100644 --- a/database/factories/AiModelDailyUsageFactory.php +++ b/database/factories/AiModelDailyUsageFactory.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + namespace Database\Factories; use App\Models\AiModelDailyUsage; diff --git a/database/factories/ContactFactory.php b/database/factories/ContactFactory.php index 04e45bf..0146dd5 100644 --- a/database/factories/ContactFactory.php +++ b/database/factories/ContactFactory.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + namespace Database\Factories; use App\Models\Contact; @@ -17,9 +19,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/NetworkFactory.php b/database/factories/NetworkFactory.php index 063e776..036c3ce 100644 --- a/database/factories/NetworkFactory.php +++ b/database/factories/NetworkFactory.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + namespace Database\Factories; use App\Enums\NetworkCategoryEnum; diff --git a/database/factories/NetworkUserFactory.php b/database/factories/NetworkUserFactory.php index ea51974..c8a7456 100644 --- a/database/factories/NetworkUserFactory.php +++ b/database/factories/NetworkUserFactory.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + namespace Database\Factories; use App\Models\NetworkUser; diff --git a/database/factories/NewsFactory.php b/database/factories/NewsFactory.php index c42cf87..3c4b98e 100644 --- a/database/factories/NewsFactory.php +++ b/database/factories/NewsFactory.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + namespace Database\Factories; use App\Models\News; @@ -21,14 +23,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/factories/OpenSourceFactory.php b/database/factories/OpenSourceFactory.php index 982ed65..c616429 100644 --- a/database/factories/OpenSourceFactory.php +++ b/database/factories/OpenSourceFactory.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + namespace Database\Factories; use App\Models\OpenSource; diff --git a/database/factories/ProductFactory.php b/database/factories/ProductFactory.php index 59299dc..eb17771 100644 --- a/database/factories/ProductFactory.php +++ b/database/factories/ProductFactory.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + namespace Database\Factories; use App\Models\Product; diff --git a/database/factories/ProductModuleFactory.php b/database/factories/ProductModuleFactory.php index 4305822..5560958 100644 --- a/database/factories/ProductModuleFactory.php +++ b/database/factories/ProductModuleFactory.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + namespace Database\Factories; use App\Models\ProductModule; diff --git a/database/factories/RoleFactory.php b/database/factories/RoleFactory.php index e68ad53..5ace7fa 100644 --- a/database/factories/RoleFactory.php +++ b/database/factories/RoleFactory.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + namespace Database\Factories; use App\Enums\GuardEnum; diff --git a/database/factories/ServiceFactory.php b/database/factories/ServiceFactory.php index 35183ba..b0b1797 100644 --- a/database/factories/ServiceFactory.php +++ b/database/factories/ServiceFactory.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + namespace Database\Factories; use App\Models\Service; diff --git a/database/factories/TechnologyFactory.php b/database/factories/TechnologyFactory.php index 4397dee..5c10998 100644 --- a/database/factories/TechnologyFactory.php +++ b/database/factories/TechnologyFactory.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + namespace Database\Factories; use App\Models\Technology; diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index c4c8087..8d4efe5 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + namespace Database\Factories; use App\Enums\LocaleEnum; 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 70% 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..02e6059 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,16 @@ +--- +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 +hero: images/news/placeholders/docuware-7-12.svg +hero_alt: Platzhaltergrafik zum DocuWare-Release 7.12 +tags: [DMS/ECM] +--- + Das aktuelle Release bringt zahlreiche Verbesserungen in den Bereichen E-Rechnungsverarbeitung, IDP-Integration, Analytics sowie bei Sicherheit und Konfiguration. @@ -35,15 +48,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..5a42be0 --- /dev/null +++ b/database/files/news/de_CH/2025-09-15-docuware-7-13-is-here.md @@ -0,0 +1,62 @@ +--- +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 +hero: images/news/placeholders/docuware-7-13.svg +hero_alt: Platzhaltergrafik zum DocuWare-Release 7.13 +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..becb1f9 --- /dev/null +++ b/database/files/news/de_CH/2026-05-12-docuware-7-14-is-here.md @@ -0,0 +1,63 @@ +--- +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 +hero: images/news/placeholders/docuware-7-14.svg +hero_alt: Platzhaltergrafik zum DocuWare-Release 7.14 +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 68% 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..13c6ade 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,16 @@ +--- +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 +hero: images/news/placeholders/docuware-7-12.svg +hero_alt: Placeholder graphic for the DocuWare 7.12 release +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 +48,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..ef52f92 --- /dev/null +++ b/database/files/news/en_CH/2025-09-15-docuware-7-13-is-here.md @@ -0,0 +1,60 @@ +--- +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 +hero: images/news/placeholders/docuware-7-13.svg +hero_alt: Placeholder graphic for the DocuWare 7.13 release +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..9ce57c4 --- /dev/null +++ b/database/files/news/en_CH/2026-05-12-docuware-7-14-is-here.md @@ -0,0 +1,63 @@ +--- +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 +hero: images/news/placeholders/docuware-7-14.svg +hero_alt: Placeholder graphic for the DocuWare 7.14 release +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/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php index 232ded6..33672c0 100644 --- a/database/migrations/0001_01_01_000000_create_users_table.php +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; diff --git a/database/migrations/0001_01_01_000001_create_cache_table.php b/database/migrations/0001_01_01_000001_create_cache_table.php index b9c106b..960e12b 100644 --- a/database/migrations/0001_01_01_000001_create_cache_table.php +++ b/database/migrations/0001_01_01_000001_create_cache_table.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; diff --git a/database/migrations/0001_01_01_000002_create_jobs_table.php b/database/migrations/0001_01_01_000002_create_jobs_table.php index 425e705..0dcb8c4 100644 --- a/database/migrations/0001_01_01_000002_create_jobs_table.php +++ b/database/migrations/0001_01_01_000002_create_jobs_table.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; diff --git a/database/migrations/0001_01_01_000010_create_auth_provider_table.php b/database/migrations/0001_01_01_000010_create_auth_provider_table.php index 2ea19f8..75389de 100644 --- a/database/migrations/0001_01_01_000010_create_auth_provider_table.php +++ b/database/migrations/0001_01_01_000010_create_auth_provider_table.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; diff --git a/database/migrations/2024_06_06_074449_create_notifications_table.php b/database/migrations/2024_06_06_074449_create_notifications_table.php index d738032..1f148f6 100644 --- a/database/migrations/2024_06_06_074449_create_notifications_table.php +++ b/database/migrations/2024_06_06_074449_create_notifications_table.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; diff --git a/database/migrations/2024_06_06_100452_create_permission_tables.php b/database/migrations/2024_06_06_100452_create_permission_tables.php index c542481..7a11a51 100644 --- a/database/migrations/2024_06_06_100452_create_permission_tables.php +++ b/database/migrations/2024_06_06_100452_create_permission_tables.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; diff --git a/database/migrations/2025_04_04_175530_create_products_table.php b/database/migrations/2025_04_04_175530_create_products_table.php index c5bc9c6..10d7af9 100644 --- a/database/migrations/2025_04_04_175530_create_products_table.php +++ b/database/migrations/2025_04_04_175530_create_products_table.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; diff --git a/database/migrations/2025_04_04_175541_create_services_table.php b/database/migrations/2025_04_04_175541_create_services_table.php index 6e666fb..e5108bb 100644 --- a/database/migrations/2025_04_04_175541_create_services_table.php +++ b/database/migrations/2025_04_04_175541_create_services_table.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; 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_04_06_164401_create_pages_table.php b/database/migrations/2025_04_06_164401_create_pages_table.php index 69a5530..a18c15f 100644 --- a/database/migrations/2025_04_06_164401_create_pages_table.php +++ b/database/migrations/2025_04_06_164401_create_pages_table.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; diff --git a/database/migrations/2025_04_16_205932_create_product_modules_table.php b/database/migrations/2025_04_16_205932_create_product_modules_table.php index 5edee5f..660d55f 100644 --- a/database/migrations/2025_04_16_205932_create_product_modules_table.php +++ b/database/migrations/2025_04_16_205932_create_product_modules_table.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; 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..be75c85 100644 --- a/database/migrations/2025_06_23_130054_create_contacts_table.php +++ b/database/migrations/2025_06_23_130054_create_contacts_table.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; @@ -13,7 +15,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/2025_06_24_063710_create_open_sources_table.php b/database/migrations/2025_06_24_063710_create_open_sources_table.php index e8a4dbb..7347069 100644 --- a/database/migrations/2025_06_24_063710_create_open_sources_table.php +++ b/database/migrations/2025_06_24_063710_create_open_sources_table.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; diff --git a/database/migrations/2025_06_24_063716_create_technologies_table.php b/database/migrations/2025_06_24_063716_create_technologies_table.php index 50aee5c..22975ff 100644 --- a/database/migrations/2025_06_24_063716_create_technologies_table.php +++ b/database/migrations/2025_06_24_063716_create_technologies_table.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; diff --git a/database/migrations/2026_07_21_134448_create_ai_models_table.php b/database/migrations/2026_07_21_134448_create_ai_models_table.php index 17429e1..99e797a 100644 --- a/database/migrations/2026_07_21_134448_create_ai_models_table.php +++ b/database/migrations/2026_07_21_134448_create_ai_models_table.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; diff --git a/database/migrations/2026_07_21_160000_create_ai_model_daily_usages_table.php b/database/migrations/2026_07_21_160000_create_ai_model_daily_usages_table.php index 988983c..2c0e8b0 100644 --- a/database/migrations/2026_07_21_160000_create_ai_model_daily_usages_table.php +++ b/database/migrations/2026_07_21_160000_create_ai_model_daily_usages_table.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; diff --git a/database/migrations/2026_07_22_100000_create_networks_table.php b/database/migrations/2026_07_22_100000_create_networks_table.php index 92c4ee3..6df0018 100644 --- a/database/migrations/2026_07_22_100000_create_networks_table.php +++ b/database/migrations/2026_07_22_100000_create_networks_table.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; diff --git a/database/migrations/2026_07_22_100010_create_network_users_table.php b/database/migrations/2026_07_22_100010_create_network_users_table.php index 7730f21..f90be9b 100644 --- a/database/migrations/2026_07_22_100010_create_network_users_table.php +++ b/database/migrations/2026_07_22_100010_create_network_users_table.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; 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..b1a48ec --- /dev/null +++ b/database/migrations/2026_07_28_150000_create_news_series_table.php @@ -0,0 +1,28 @@ +<?php + +declare(strict_types=1); + +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..addae6d --- /dev/null +++ b/database/migrations/2026_07_28_150010_create_news_table.php @@ -0,0 +1,51 @@ +<?php + +declare(strict_types=1); + +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. + // PostgreSQL only: `->>` here takes a bare key, which is not valid MySQL. + // The whole application targets pgsql (see config/database.php). + 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..a82a6e8 --- /dev/null +++ b/database/migrations/2026_07_28_150020_create_news_tags_table.php @@ -0,0 +1,27 @@ +<?php + +declare(strict_types=1); + +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..5f2a047 --- /dev/null +++ b/database/migrations/2026_07_28_150030_create_news_news_tag_table.php @@ -0,0 +1,25 @@ +<?php + +declare(strict_types=1); + +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..186a364 --- /dev/null +++ b/database/migrations/2026_07_28_150040_create_news_relations_table.php @@ -0,0 +1,26 @@ +<?php + +declare(strict_types=1); + +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/AiModelDailyUsagesTableSeeder.php b/database/seeders/AiModelDailyUsagesTableSeeder.php index 3385839..2276147 100644 --- a/database/seeders/AiModelDailyUsagesTableSeeder.php +++ b/database/seeders/AiModelDailyUsagesTableSeeder.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + namespace Database\Seeders; use App\Actions\StoreLlmUsageAction; diff --git a/database/seeders/AiModelsTableSeeder.php b/database/seeders/AiModelsTableSeeder.php index f86f539..6ff9873 100644 --- a/database/seeders/AiModelsTableSeeder.php +++ b/database/seeders/AiModelsTableSeeder.php @@ -1,46 +1,21 @@ <?php +declare(strict_types=1); + 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/Concerns/ReadsCsv.php b/database/seeders/Concerns/ReadsCsv.php index 1af639b..2abecbc 100644 --- a/database/seeders/Concerns/ReadsCsv.php +++ b/database/seeders/Concerns/ReadsCsv.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + namespace Database\Seeders\Concerns; trait ReadsCsv diff --git a/database/seeders/ContactsTableSeeder.php b/database/seeders/ContactsTableSeeder.php index c6b8c50..b78fc33 100644 --- a/database/seeders/ContactsTableSeeder.php +++ b/database/seeders/ContactsTableSeeder.php @@ -1,28 +1,24 @@ <?php +declare(strict_types=1); + 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/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 9a78d87..4219729 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + namespace Database\Seeders; use App\Enums\LocaleEnum; @@ -36,6 +38,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/NetworkUsersTableSeeder.php b/database/seeders/NetworkUsersTableSeeder.php index 975320c..9f088a3 100644 --- a/database/seeders/NetworkUsersTableSeeder.php +++ b/database/seeders/NetworkUsersTableSeeder.php @@ -1,94 +1,46 @@ <?php +declare(strict_types=1); + 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..9f4d543 100644 --- a/database/seeders/NetworksTableSeeder.php +++ b/database/seeders/NetworksTableSeeder.php @@ -1,158 +1,21 @@ <?php +declare(strict_types=1); + 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 13c6a5d..86ac4aa 100644 --- a/database/seeders/NewsTableSeeder.php +++ b/database/seeders/NewsTableSeeder.php @@ -1,77 +1,26 @@ <?php +declare(strict_types=1); + 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 { - /** - * Run the database seeds. - */ 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.', - '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'], - ); - - $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'], - ); - - } - - /** - * @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/OpenSourceTableSeeder.php b/database/seeders/OpenSourceTableSeeder.php index 77809c6..d937a3b 100644 --- a/database/seeders/OpenSourceTableSeeder.php +++ b/database/seeders/OpenSourceTableSeeder.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + namespace Database\Seeders; use App\Models\OpenSource; diff --git a/database/seeders/PagesTableSeeder.php b/database/seeders/PagesTableSeeder.php index 4a5b6cb..5cb8a65 100644 --- a/database/seeders/PagesTableSeeder.php +++ b/database/seeders/PagesTableSeeder.php @@ -1,132 +1,24 @@ <?php +declare(strict_types=1); + 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', '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.'], - ], - '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', '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.'], - ], - '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', '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.'], - ], - ]; - - 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..0638569 100644 --- a/database/seeders/ProductsTableSeeder.php +++ b/database/seeders/ProductsTableSeeder.php @@ -1,121 +1,21 @@ <?php +declare(strict_types=1); + 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/RolesAndPermissionsSeeder.php b/database/seeders/RolesAndPermissionsSeeder.php index 3535c2a..9f381d9 100644 --- a/database/seeders/RolesAndPermissionsSeeder.php +++ b/database/seeders/RolesAndPermissionsSeeder.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + namespace Database\Seeders; use App\Enums\GuardEnum; diff --git a/database/seeders/SeoImageCleanupSeeder.php b/database/seeders/SeoImageCleanupSeeder.php index 50c38f7..36135b0 100644 --- a/database/seeders/SeoImageCleanupSeeder.php +++ b/database/seeders/SeoImageCleanupSeeder.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + namespace Database\Seeders; use App\Models\Page; @@ -27,7 +29,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..bdd7ca6 100644 --- a/database/seeders/ServicesTableSeeder.php +++ b/database/seeders/ServicesTableSeeder.php @@ -1,220 +1,23 @@ <?php +declare(strict_types=1); + 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..7d54b82 100644 --- a/database/seeders/TechnologiesTableSeeder.php +++ b/database/seeders/TechnologiesTableSeeder.php @@ -1,37 +1,21 @@ <?php +declare(strict_types=1); + 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/UserSeeder.php b/database/seeders/UserSeeder.php index a58a460..b0e7bda 100644 --- a/database/seeders/UserSeeder.php +++ b/database/seeders/UserSeeder.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + namespace Database\Seeders; use App\Enums\RoleEnum; 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 3b5945a..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;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 -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 -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 -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 fc87acd..87f713a 100644 --- a/lang/de_CH/components.php +++ b/lang/de_CH/components.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + return [ 'intro' => [ 'title' => 'Innovative Ideen zum Leben erwecken', @@ -27,6 +29,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 +39,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 +56,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,6 +89,7 @@ 'total' => 'Total', ], 'empty' => 'Noch keine Nutzungsdaten vorhanden.', + 'last_synced' => 'Zuletzt aktualisiert am :datetime.', 'back' => 'Zurück zu unseren LLMs', ], 'ai_llm' => [ diff --git a/lang/de_CH/errors.php b/lang/de_CH/errors.php index f129bd9..eb9430c 100644 --- a/lang/de_CH/errors.php +++ b/lang/de_CH/errors.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + return [ 'title_client' => 'Anfrage fehlgeschlagen', 'title_server' => 'Etwas ist schiefgelaufen', diff --git a/lang/de_CH/validation.php b/lang/de_CH/validation.php index 452d96c..70945c5 100644 --- a/lang/de_CH/validation.php +++ b/lang/de_CH/validation.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + return [ /* diff --git a/lang/en_CH.json b/lang/en_CH.json index 54020f4..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 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 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 language body": "In the event of discrepancies between the German and English versions of these GTC, the German version prevails." + "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 d662f79..e13cd4e 100644 --- a/lang/en_CH/components.php +++ b/lang/en_CH/components.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + return [ 'intro' => [ 'title' => 'Bringing innovative ideas to life', @@ -27,6 +29,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 +39,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 +56,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,6 +89,7 @@ 'total' => 'Total', ], 'empty' => 'No usage data available yet.', + 'last_synced' => 'Last updated on :datetime.', 'back' => 'Back to our LLMs', ], 'ai_llm' => [ diff --git a/lang/en_CH/errors.php b/lang/en_CH/errors.php index 2385a44..1aab83e 100644 --- a/lang/en_CH/errors.php +++ b/lang/en_CH/errors.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + return [ // Error page headings (4xx vs 5xx) 'title_client' => 'Request failed', diff --git a/lang/en_CH/validation.php b/lang/en_CH/validation.php index 6224316..09466f2 100644 --- a/lang/en_CH/validation.php +++ b/lang/en_CH/validation.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + return [ /* diff --git a/package-lock.json b/package-lock.json index 3cfcda7..e25f0a7 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.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.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.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", @@ -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/phpstan.neon.dist b/phpstan.neon.dist index 4769541..9e57c74 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -10,11 +10,19 @@ parameters: level: 10 ignoreErrors: + # SimpleMessage::salutation() is documented as taking a string, but the + # parameter is untyped and the notification template renders it through + # {{ }}, which honours Htmlable — the framework's own fallback branch + # emits <br> the same way. Passing HtmlString is how a salutation gets + # line breaks; the vendor docblock is simply narrower than the behaviour. + - + identifier: argument.type + message: '#salutation\(\) expects string, Illuminate\\Support\\HtmlString given#' + paths: + - app/Notifications/* excludePaths: # Vendor-published migration (spatie/laravel-permission) — not our code. - database/migrations/2024_06_06_100452_create_permission_tables.php - # Nova is not installed; this config references Laravel\Nova classes. - - config/nova.php # Pest's architecture-testing DSL is not statically analysable. - tests/Core/ArchitectureTest.php diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..4b4f940 --- /dev/null +++ b/pint.json @@ -0,0 +1,6 @@ +{ + "preset": "laravel", + "rules": { + "declare_strict_types": true + } +} 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 0000000..a3d7ce4 Binary files /dev/null and b/public/fonts/poppins/poppins-400-normal-latin-ext.woff2 differ 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 0000000..ed45aa4 Binary files /dev/null and b/public/fonts/poppins/poppins-400-normal-latin.woff2 differ 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 0000000..78d89a5 Binary files /dev/null and b/public/fonts/poppins/poppins-600-normal-latin-ext.woff2 differ 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 0000000..e721556 Binary files /dev/null and b/public/fonts/poppins/poppins-600-normal-latin.woff2 differ 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 0000000..05e439e Binary files /dev/null and b/public/fonts/poppins/poppins-700-normal-latin-ext.woff2 differ 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 0000000..568b553 Binary files /dev/null and b/public/fonts/poppins/poppins-700-normal-latin.woff2 differ diff --git a/public/images/news/placeholders/docuware-7-12.svg b/public/images/news/placeholders/docuware-7-12.svg new file mode 100644 index 0000000..1b2e451 --- /dev/null +++ b/public/images/news/placeholders/docuware-7-12.svg @@ -0,0 +1,62 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" width="1600" height="900" role="img" aria-hidden="true"> + <title>DocuWare 7.12 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + DOCUWARE + 7.12 + + diff --git a/public/images/news/placeholders/docuware-7-13.svg b/public/images/news/placeholders/docuware-7-13.svg new file mode 100644 index 0000000..e7e1504 --- /dev/null +++ b/public/images/news/placeholders/docuware-7-13.svg @@ -0,0 +1,62 @@ + diff --git a/public/images/news/placeholders/docuware-7-14.svg b/public/images/news/placeholders/docuware-7-14.svg new file mode 100644 index 0000000..dcca212 --- /dev/null +++ b/public/images/news/placeholders/docuware-7-14.svg @@ -0,0 +1,62 @@ + diff --git a/public/index.php b/public/index.php index 947d989..d55a3b2 100644 --- a/public/index.php +++ b/public/index.php @@ -1,5 +1,7 @@ = 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 46177ef..eff2756 100644 --- a/resources/views/app/about-us/index.blade.php +++ b/resources/views/app/about-us/index.blade.php @@ -1,6 +1,14 @@ - + + + + +

{{ __('components.team.working_body') }}

+

{{ __('components.team.learning_body') }}

+
+
+ @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 @@ @if ($llmSummary['requests'] > 0) - - - - + @endif
- - {{ __('components.ai.to_models') }} - + @if ($llmSummary['requests'] > 0) - - {{ __('components.ai.to_analytics') }} - + @endif
+ + + + +

{{ __('components.ai.local_body') }}

+

{{ __('components.ai.usage_body') }}

+
+
diff --git a/resources/views/app/ai/llm/analytics.blade.php b/resources/views/app/ai/llm/analytics.blade.php index 218dca6..6c4e508 100644 --- a/resources/views/app/ai/llm/analytics.blade.php +++ b/resources/views/app/ai/llm/analytics.blade.php @@ -1,5 +1,9 @@ - +
@endif + @if ($lastSyncedAt) + +

+ {{ __('components.ai_llm_analytics.last_synced', [ + 'datetime' => \App\Helpers\Facades\HelperDate::formatDateTime($lastSyncedAt), + ]) }} +

+
+ @endif + @if ($periods->isEmpty())

{{ __('components.ai_llm_analytics.empty') }}

@@ -103,8 +117,7 @@ class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> @endif - - {{ __('components.ai_llm_analytics.back') }} - + 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 @@ - + @foreach ($groups as $group) @@ -48,21 +51,10 @@ - - - - +
- - {{ __('components.ai.to_analytics') }} - +
@endif diff --git a/resources/views/app/contact/index.blade.php b/resources/views/app/contact/index.blade.php index 0a14373..e0ca2ef 100644 --- a/resources/views/app/contact/index.blade.php +++ b/resources/views/app/contact/index.blade.php @@ -1,14 +1,15 @@ - + - +
- +
-
@@ -16,18 +17,18 @@ class="block"/> - - + @foreach($locations as $location) + + @endforeach diff --git a/resources/views/app/legal/imprint/index.blade.php b/resources/views/app/legal/imprint/index.blade.php index e15eb86..c58ac73 100644 --- a/resources/views/app/legal/imprint/index.blade.php +++ b/resources/views/app/legal/imprint/index.blade.php @@ -4,19 +4,20 @@ - +
- +
- +
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
+ + + + +

{{ __('Media usage allowed') }}

+

{{ __('Media usage forbidden') }}

+
+
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 @@ @foreach($networks as $network) -
-