76 - Lock feed language to channel language in onboarding wizard

This commit is contained in:
myrmidex 2026-03-07 10:43:48 +01:00
parent 2a0653981c
commit 35e4260c87
5 changed files with 199 additions and 61 deletions

View file

@ -47,6 +47,7 @@ class Onboarding extends Component
// State // State
public array $formErrors = []; public array $formErrors = [];
public bool $isLoading = false; public bool $isLoading = false;
private ?int $previousChannelLanguageId = null;
protected LemmyAuthService $lemmyAuthService; protected LemmyAuthService $lemmyAuthService;
@ -104,6 +105,11 @@ public function nextStep(): void
{ {
$this->step++; $this->step++;
$this->formErrors = []; $this->formErrors = [];
// When entering feed step, inherit language from channel
if ($this->step === 4 && $this->channelLanguageId) {
$this->feedLanguageId = $this->channelLanguageId;
}
} }
public function previousStep(): void public function previousStep(): void
@ -210,24 +216,37 @@ public function createFeed(): void
$this->formErrors = []; $this->formErrors = [];
$this->isLoading = true; $this->isLoading = true;
// Get available provider codes for validation
$availableProviders = collect($this->getProvidersForLanguage())->pluck('code')->implode(',');
$this->validate([ $this->validate([
'feedName' => 'required|string|max:255', 'feedName' => 'required|string|max:255',
'feedProvider' => 'required|in:belga,vrt', 'feedProvider' => "required|in:{$availableProviders}",
'feedLanguageId' => 'required|exists:languages,id', 'feedLanguageId' => 'required|exists:languages,id',
'feedDescription' => 'nullable|string|max:1000', 'feedDescription' => 'nullable|string|max:1000',
]); ]);
try { try {
// Map provider to URL // Get language short code
$url = $this->feedProvider === 'vrt' $language = Language::find($this->feedLanguageId);
? 'https://www.vrt.be/vrtnws/en/' $langCode = $language->short_code;
: 'https://www.belganewsagency.eu/';
// Look up URL from config
$url = config("feed.providers.{$this->feedProvider}.languages.{$langCode}.url");
if (!$url) {
$this->formErrors['general'] = 'Invalid provider and language combination.';
$this->isLoading = false;
return;
}
$providerConfig = config("feed.providers.{$this->feedProvider}");
Feed::firstOrCreate( Feed::firstOrCreate(
['url' => $url], ['url' => $url],
[ [
'name' => $this->feedName, 'name' => $this->feedName,
'type' => 'website', 'type' => $providerConfig['type'] ?? 'website',
'provider' => $this->feedProvider, 'provider' => $this->feedProvider,
'language_id' => $this->feedLanguageId, 'language_id' => $this->feedLanguageId,
'description' => $this->feedDescription ?: null, 'description' => $this->feedDescription ?: null,
@ -255,6 +274,16 @@ public function createChannel(): void
'channelDescription' => 'nullable|string|max:1000', 'channelDescription' => 'nullable|string|max:1000',
]); ]);
// If language changed, reset feed form
if ($this->previousChannelLanguageId !== null && $this->previousChannelLanguageId !== $this->channelLanguageId) {
$this->feedName = '';
$this->feedProvider = '';
$this->feedDescription = '';
$this->routeFeedId = null;
$this->routeChannelId = null;
}
$this->previousChannelLanguageId = $this->channelLanguageId;
try { try {
$platformInstance = PlatformInstance::findOrFail($this->platformInstanceId); $platformInstance = PlatformInstance::findOrFail($this->platformInstanceId);
@ -340,23 +369,97 @@ public function completeOnboarding(): void
$this->redirect(route('dashboard')); $this->redirect(route('dashboard'));
} }
/**
* Get language codes that have at least one active provider.
*/
public function getAvailableLanguageCodes(): array
{
$providers = config('feed.providers', []);
$languageCodes = [];
foreach ($providers as $provider) {
if (!($provider['is_active'] ?? false)) {
continue;
}
foreach (array_keys($provider['languages'] ?? []) as $code) {
$languageCodes[$code] = true;
}
}
return array_keys($languageCodes);
}
/**
* Get providers available for the current channel language.
*/
public function getProvidersForLanguage(): array
{
if (!$this->channelLanguageId) {
return [];
}
$language = Language::find($this->channelLanguageId);
if (!$language) {
return [];
}
$langCode = $language->short_code;
$providers = config('feed.providers', []);
$available = [];
foreach ($providers as $key => $provider) {
if (!($provider['is_active'] ?? false)) {
continue;
}
if (isset($provider['languages'][$langCode])) {
$available[] = [
'code' => $provider['code'],
'name' => $provider['name'],
'description' => $provider['description'] ?? '',
];
}
}
return $available;
}
/**
* Get the current channel language model.
*/
public function getChannelLanguage(): ?Language
{
if (!$this->channelLanguageId) {
return null;
}
return Language::find($this->channelLanguageId);
}
public function render() public function render()
{ {
$languages = Language::where('is_active', true)->orderBy('name')->get(); // For channel step: only show languages that have providers
$platformInstances = PlatformInstance::where('is_active', true)->orderBy('name')->get(); $availableCodes = $this->getAvailableLanguageCodes();
$feeds = Feed::where('is_active', true)->orderBy('name')->get(); $wizardLanguages = Language::where('is_active', true)
$channels = PlatformChannel::where('is_active', true)->orderBy('name')->get(); ->whereIn('short_code', $availableCodes)
->orderBy('name')
->get();
$feedProviders = collect(config('feed.providers', [])) $platformInstances = PlatformInstance::where('is_active', true)->orderBy('name')->get();
->filter(fn($provider) => $provider['is_active'] ?? false) $feeds = Feed::with('language')->where('is_active', true)->orderBy('name')->get();
->values(); $channels = PlatformChannel::with('language')->where('is_active', true)->orderBy('name')->get();
// For feed step: only show providers for the channel's language
$feedProviders = collect($this->getProvidersForLanguage());
// Get channel language for display
$channelLanguage = $this->getChannelLanguage();
return view('livewire.onboarding', [ return view('livewire.onboarding', [
'languages' => $languages, 'wizardLanguages' => $wizardLanguages,
'platformInstances' => $platformInstances, 'platformInstances' => $platformInstances,
'feeds' => $feeds, 'feeds' => $feeds,
'channels' => $channels, 'channels' => $channels,
'feedProviders' => $feedProviders, 'feedProviders' => $feedProviders,
'channelLanguage' => $channelLanguage,
])->layout('layouts.onboarding'); ])->layout('layouts.onboarding');
} }
} }

View file

@ -19,6 +19,10 @@
'description' => 'Belgian public broadcaster news', 'description' => 'Belgian public broadcaster news',
'type' => 'website', 'type' => 'website',
'is_active' => true, 'is_active' => true,
'languages' => [
'en' => ['url' => 'https://www.vrt.be/vrtnws/en/'],
'nl' => ['url' => 'https://www.vrt.be/vrtnws/nl/'],
],
'parsers' => [ 'parsers' => [
'homepage' => \App\Services\Parsers\VrtHomepageParserAdapter::class, 'homepage' => \App\Services\Parsers\VrtHomepageParserAdapter::class,
'article' => \App\Services\Parsers\VrtArticleParser::class, 'article' => \App\Services\Parsers\VrtArticleParser::class,
@ -27,10 +31,13 @@
], ],
'belga' => [ 'belga' => [
'code' => 'belga', 'code' => 'belga',
'name' => 'Belga News Agency', 'name' => 'Belga News Agency',
'description' => 'Belgian national news agency', 'description' => 'Belgian national news agency',
'type' => 'rss', 'type' => 'rss',
'is_active' => true, 'is_active' => true,
'languages' => [
'en' => ['url' => 'https://www.belganewsagency.eu/'],
],
'parsers' => [ 'parsers' => [
'homepage' => \App\Services\Parsers\BelgaHomepageParserAdapter::class, 'homepage' => \App\Services\Parsers\BelgaHomepageParserAdapter::class,
'article' => \App\Services\Parsers\BelgaArticleParser::class, 'article' => \App\Services\Parsers\BelgaArticleParser::class,

View file

@ -12,30 +12,43 @@
*/ */
'supported' => [ 'supported' => [
'en' => [ 'en' => ['short_code' => 'en', 'name' => 'English', 'native_name' => 'English', 'is_active' => true],
'short_code' => 'en', 'nl' => ['short_code' => 'nl', 'name' => 'Dutch', 'native_name' => 'Nederlands', 'is_active' => true],
'name' => 'English', 'fr' => ['short_code' => 'fr', 'name' => 'French', 'native_name' => 'Français', 'is_active' => true],
'native_name' => 'English', 'de' => ['short_code' => 'de', 'name' => 'German', 'native_name' => 'Deutsch', 'is_active' => true],
'is_active' => true, 'es' => ['short_code' => 'es', 'name' => 'Spanish', 'native_name' => 'Español', 'is_active' => true],
], 'it' => ['short_code' => 'it', 'name' => 'Italian', 'native_name' => 'Italiano', 'is_active' => true],
'nl' => [ 'pt' => ['short_code' => 'pt', 'name' => 'Portuguese', 'native_name' => 'Português', 'is_active' => true],
'short_code' => 'nl', 'pl' => ['short_code' => 'pl', 'name' => 'Polish', 'native_name' => 'Polski', 'is_active' => true],
'name' => 'Dutch', 'ru' => ['short_code' => 'ru', 'name' => 'Russian', 'native_name' => 'Русский', 'is_active' => true],
'native_name' => 'Nederlands', 'uk' => ['short_code' => 'uk', 'name' => 'Ukrainian', 'native_name' => 'Українська', 'is_active' => true],
'is_active' => true, 'cs' => ['short_code' => 'cs', 'name' => 'Czech', 'native_name' => 'Čeština', 'is_active' => true],
], 'sk' => ['short_code' => 'sk', 'name' => 'Slovak', 'native_name' => 'Slovenčina', 'is_active' => true],
'fr' => [ 'hu' => ['short_code' => 'hu', 'name' => 'Hungarian', 'native_name' => 'Magyar', 'is_active' => true],
'short_code' => 'fr', 'ro' => ['short_code' => 'ro', 'name' => 'Romanian', 'native_name' => 'Română', 'is_active' => true],
'name' => 'French', 'bg' => ['short_code' => 'bg', 'name' => 'Bulgarian', 'native_name' => 'Български', 'is_active' => true],
'native_name' => 'Français', 'hr' => ['short_code' => 'hr', 'name' => 'Croatian', 'native_name' => 'Hrvatski', 'is_active' => true],
'is_active' => true, 'sl' => ['short_code' => 'sl', 'name' => 'Slovenian', 'native_name' => 'Slovenščina', 'is_active' => true],
], 'sr' => ['short_code' => 'sr', 'name' => 'Serbian', 'native_name' => 'Српски', 'is_active' => true],
'de' => [ 'el' => ['short_code' => 'el', 'name' => 'Greek', 'native_name' => 'Ελληνικά', 'is_active' => true],
'short_code' => 'de', 'tr' => ['short_code' => 'tr', 'name' => 'Turkish', 'native_name' => 'Türkçe', 'is_active' => true],
'name' => 'German', 'da' => ['short_code' => 'da', 'name' => 'Danish', 'native_name' => 'Dansk', 'is_active' => true],
'native_name' => 'Deutsch', 'sv' => ['short_code' => 'sv', 'name' => 'Swedish', 'native_name' => 'Svenska', 'is_active' => true],
'is_active' => true, 'no' => ['short_code' => 'no', 'name' => 'Norwegian', 'native_name' => 'Norsk', 'is_active' => true],
], 'fi' => ['short_code' => 'fi', 'name' => 'Finnish', 'native_name' => 'Suomi', 'is_active' => true],
'et' => ['short_code' => 'et', 'name' => 'Estonian', 'native_name' => 'Eesti', 'is_active' => true],
'lv' => ['short_code' => 'lv', 'name' => 'Latvian', 'native_name' => 'Latviešu', 'is_active' => true],
'lt' => ['short_code' => 'lt', 'name' => 'Lithuanian', 'native_name' => 'Lietuvių', 'is_active' => true],
'ja' => ['short_code' => 'ja', 'name' => 'Japanese', 'native_name' => '日本語', 'is_active' => true],
'zh' => ['short_code' => 'zh', 'name' => 'Chinese', 'native_name' => '中文', 'is_active' => true],
'ko' => ['short_code' => 'ko', 'name' => 'Korean', 'native_name' => '한국어', 'is_active' => true],
'ar' => ['short_code' => 'ar', 'name' => 'Arabic', 'native_name' => 'العربية', 'is_active' => true],
'he' => ['short_code' => 'he', 'name' => 'Hebrew', 'native_name' => 'עברית', 'is_active' => true],
'hi' => ['short_code' => 'hi', 'name' => 'Hindi', 'native_name' => 'हिन्दी', 'is_active' => true],
'th' => ['short_code' => 'th', 'name' => 'Thai', 'native_name' => 'ไทย', 'is_active' => true],
'vi' => ['short_code' => 'vi', 'name' => 'Vietnamese', 'native_name' => 'Tiếng Việt', 'is_active' => true],
'id' => ['short_code' => 'id', 'name' => 'Indonesian', 'native_name' => 'Bahasa Indonesia', 'is_active' => true],
'ms' => ['short_code' => 'ms', 'name' => 'Malay', 'native_name' => 'Bahasa Melayu', 'is_active' => true],
], ],
/* /*

View file

@ -241,7 +241,7 @@ class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none foc
required required
> >
<option value="">Select language</option> <option value="">Select language</option>
@foreach ($languages as $language) @foreach ($wizardLanguages as $language)
<option value="{{ $language->id }}">{{ $language->name }}</option> <option value="{{ $language->id }}">{{ $language->name }}</option>
@endforeach @endforeach
</select> </select>
@ -316,6 +316,25 @@ class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none foc
@error('feedName') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror @error('feedName') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror
</div> </div>
<div>
<label for="feedLanguageId" class="block text-sm font-medium text-gray-700 mb-2 flex items-center">
Language
<span class="ml-2 text-gray-400 cursor-help" title="Language matches your channel. Additional languages can be configured from the dashboard after setup.">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</span>
</label>
<select
id="feedLanguageId"
disabled
class="w-full px-3 py-2 border border-gray-300 rounded-md bg-gray-100 text-gray-600 cursor-not-allowed"
>
<option>{{ $channelLanguage?->name ?? 'Unknown' }}</option>
</select>
<p class="text-sm text-gray-500 mt-1">Inherited from your channel</p>
</div>
<div> <div>
<label for="feedProvider" class="block text-sm font-medium text-gray-700 mb-2"> <label for="feedProvider" class="block text-sm font-medium text-gray-700 mb-2">
News Provider News Provider
@ -331,27 +350,12 @@ class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none foc
<option value="{{ $provider['code'] }}">{{ $provider['name'] }}</option> <option value="{{ $provider['code'] }}">{{ $provider['name'] }}</option>
@endforeach @endforeach
</select> </select>
@if ($feedProviders->isEmpty())
<p class="text-sm text-amber-600 mt-1">No providers available for this language.</p>
@endif
@error('feedProvider') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror @error('feedProvider') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror
</div> </div>
<div>
<label for="feedLanguageId" class="block text-sm font-medium text-gray-700 mb-2">
Language
</label>
<select
id="feedLanguageId"
wire:model="feedLanguageId"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
>
<option value="">Select language</option>
@foreach ($languages as $language)
<option value="{{ $language->id }}">{{ $language->name }}</option>
@endforeach
</select>
@error('feedLanguageId') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror
</div>
<div> <div>
<label for="feedDescription" class="block text-sm font-medium text-gray-700 mb-2"> <label for="feedDescription" class="block text-sm font-medium text-gray-700 mb-2">
Description (Optional) Description (Optional)
@ -372,7 +376,7 @@ class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none foc
</button> </button>
<button <button
type="submit" type="submit"
@disabled($isLoading) @disabled($isLoading || $feedProviders->isEmpty())
class="bg-blue-600 text-white py-2 px-6 rounded-md hover:bg-blue-700 transition duration-200 disabled:opacity-50" class="bg-blue-600 text-white py-2 px-6 rounded-md hover:bg-blue-700 transition duration-200 disabled:opacity-50"
> >
{{ $isLoading ? 'Creating...' : 'Continue' }} {{ $isLoading ? 'Creating...' : 'Continue' }}
@ -418,7 +422,7 @@ class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none foc
> >
<option value="">Select a feed</option> <option value="">Select a feed</option>
@foreach ($feeds as $feed) @foreach ($feeds as $feed)
<option value="{{ $feed->id }}">{{ $feed->name }}</option> <option value="{{ $feed->id }}">{{ $feed->name }} ({{ $feed->language?->short_code ?? '?' }})</option>
@endforeach @endforeach
</select> </select>
@error('routeFeedId') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror @error('routeFeedId') <p class="text-red-600 text-sm mt-1">{{ $message }}</p> @enderror
@ -436,7 +440,7 @@ class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none foc
> >
<option value="">Select a channel</option> <option value="">Select a channel</option>
@foreach ($channels as $channel) @foreach ($channels as $channel)
<option value="{{ $channel->id }}">{{ $channel->display_name ?? $channel->name }}</option> <option value="{{ $channel->id }}">{{ $channel->display_name ?? $channel->name }} ({{ $channel->language?->short_code ?? '?' }})</option>
@endforeach @endforeach
</select> </select>
@if ($channels->isEmpty()) @if ($channels->isEmpty())

View file

@ -65,6 +65,16 @@ pkgs.mkShell {
podman-compose -f $COMPOSE_FILE restart "$@" podman-compose -f $COMPOSE_FILE restart "$@"
} }
dev-rebuild() {
echo "Rebuilding services (down -v + up)..."
podman-compose -f $COMPOSE_FILE down -v
PODMAN_USERNS=keep-id podman-compose -f $COMPOSE_FILE up -d "$@"
echo ""
podman-compose -f $COMPOSE_FILE ps
echo ""
echo "App available at: http://localhost:8000"
}
dev-logs() { dev-logs() {
podman-compose -f $COMPOSE_FILE logs -f app "$@" podman-compose -f $COMPOSE_FILE logs -f app "$@"
} }
@ -123,6 +133,7 @@ pkgs.mkShell {
echo "Commands:" echo "Commands:"
echo " dev-up [services] Start all or specific services" echo " dev-up [services] Start all or specific services"
echo " dev-down [-v] Stop services (-v removes volumes)" echo " dev-down [-v] Stop services (-v removes volumes)"
echo " dev-rebuild Fresh start (down -v + up)"
echo " dev-restart Restart services" echo " dev-restart Restart services"
echo " dev-logs Tail app logs" echo " dev-logs Tail app logs"
echo " dev-logs-db Tail database logs" echo " dev-logs-db Tail database logs"