Changed channel categories from DB-backed Category records to a backed PHP enum

This commit is contained in:
2026-05-18 06:55:03 +03:30
parent 295a408965
commit 59e4ae60da
20 changed files with 221 additions and 130 deletions

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Enums;
enum ChannelCategory: string
{
case Gaming = 'gaming';
case Music = 'music';
case TalkShows = 'talk-shows';
case Education = 'education';
case Creative = 'creative';
public function label(): string
{
return match ($this) {
self::Gaming => __('Gaming'),
self::Music => __('Music'),
self::TalkShows => __('Talk Shows'),
self::Education => __('Education'),
self::Creative => __('Creative'),
};
}
/**
* @return array{value: string, label: string}
*/
public function toOption(): array
{
return [
'value' => $this->value,
'label' => $this->label(),
];
}
/**
* @return list<array{value: string, label: string}>
*/
public static function options(): array
{
return array_map(
fn (self $category): array => $category->toOption(),
self::cases(),
);
}
}

View File

@@ -22,7 +22,7 @@ class ChannelController extends Controller
): Response { ): Response {
abort_if($channel->isSuspended(), 404); abort_if($channel->isSuspended(), 404);
$channel->load(['category', 'liveBroadcast', 'user']); $channel->load(['liveBroadcast', 'user']);
$channel->loadCount('follows'); $channel->loadCount('follows');
$currentBroadcast = $channel->liveBroadcast; $currentBroadcast = $channel->liveBroadcast;
@@ -39,11 +39,7 @@ class ChannelController extends Controller
'is_live' => $channel->is_live, 'is_live' => $channel->is_live,
'viewer_count' => $channel->is_live ? $viewerCounts->current($channel) : 0, 'viewer_count' => $channel->is_live ? $viewerCounts->current($channel) : 0,
'followers_count' => $channel->follows_count, 'followers_count' => $channel->follows_count,
'category' => $channel->category ? [ 'category' => $channel->category?->toOption(),
'id' => $channel->category->id,
'name' => $channel->category->name,
'slug' => $channel->category->slug,
] : null,
'owner' => [ 'owner' => [
'id' => $channel->user->id, 'id' => $channel->user->id,
'name' => $channel->user->name, 'name' => $channel->user->name,

View File

@@ -2,8 +2,8 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Enums\ChannelCategory;
use App\Models\Broadcast; use App\Models\Broadcast;
use App\Models\Category;
use App\Models\Channel; use App\Models\Channel;
use App\Services\Streaming\StreamKeyManager; use App\Services\Streaming\StreamKeyManager;
use App\Services\Streaming\ViewerCountStore; use App\Services\Streaming\ViewerCountStore;
@@ -21,7 +21,7 @@ class CreatorDashboardController extends Controller
{ {
$channel = $request->user() $channel = $request->user()
->channel() ->channel()
->with(['category', 'liveBroadcast', 'streamKey']) ->with(['liveBroadcast', 'streamKey'])
->first(); ->first();
return Inertia::render('dashboard', [ return Inertia::render('dashboard', [
@@ -34,14 +34,7 @@ class CreatorDashboardController extends Controller
? $streamKeys->tokenizedPath($channel, (string) session('plain_stream_key')) ? $streamKeys->tokenizedPath($channel, (string) session('plain_stream_key'))
: null, : null,
], ],
'categories' => Category::query() 'categories' => ChannelCategory::options(),
->orderBy('name')
->get(['id', 'name', 'slug'])
->map(fn (Category $category) => [
'id' => $category->id,
'name' => $category->name,
'slug' => $category->slug,
]),
'recentBroadcasts' => $channel 'recentBroadcasts' => $channel
? $channel->broadcasts()->with('vod')->latest()->limit(10)->get()->map(fn (Broadcast $broadcast) => [ ? $channel->broadcasts()->with('vod')->latest()->limit(10)->get()->map(fn (Broadcast $broadcast) => [
'id' => $broadcast->id, 'id' => $broadcast->id,
@@ -65,7 +58,7 @@ class CreatorDashboardController extends Controller
'display_name' => ['required', 'string', 'max:80'], 'display_name' => ['required', 'string', 'max:80'],
'slug' => ['required', 'string', 'min:3', 'max:40', 'alpha_dash:ascii', 'lowercase', 'unique:channels,slug'], 'slug' => ['required', 'string', 'min:3', 'max:40', 'alpha_dash:ascii', 'lowercase', 'unique:channels,slug'],
'description' => ['nullable', 'string', 'max:1000'], 'description' => ['nullable', 'string', 'max:1000'],
'category_id' => ['nullable', 'exists:categories,id'], 'category' => ['nullable', Rule::enum(ChannelCategory::class)],
]); ]);
$channel = $request->user()->channel()->create([ $channel = $request->user()->channel()->create([
@@ -92,7 +85,7 @@ class CreatorDashboardController extends Controller
'display_name' => ['required', 'string', 'max:80'], 'display_name' => ['required', 'string', 'max:80'],
'slug' => ['required', 'string', 'min:3', 'max:40', 'alpha_dash:ascii', 'lowercase', Rule::unique('channels', 'slug')->ignore($channel)], 'slug' => ['required', 'string', 'min:3', 'max:40', 'alpha_dash:ascii', 'lowercase', Rule::unique('channels', 'slug')->ignore($channel)],
'description' => ['nullable', 'string', 'max:1000'], 'description' => ['nullable', 'string', 'max:1000'],
'category_id' => ['nullable', 'exists:categories,id'], 'category' => ['nullable', Rule::enum(ChannelCategory::class)],
'thumbnail' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:4096'], 'thumbnail' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:4096'],
]); ]);
@@ -187,7 +180,7 @@ class CreatorDashboardController extends Controller
'is_live' => $channel->is_live, 'is_live' => $channel->is_live,
'viewer_count' => $channel->viewer_count, 'viewer_count' => $channel->viewer_count,
'stream_key_last_used_at' => $channel->streamKey?->last_used_at?->toIso8601String(), 'stream_key_last_used_at' => $channel->streamKey?->last_used_at?->toIso8601String(),
'category_id' => $channel->category_id, 'category' => $channel->category?->value,
'live_broadcast' => $channel->liveBroadcast ? [ 'live_broadcast' => $channel->liveBroadcast ? [
'id' => $channel->liveBroadcast->id, 'id' => $channel->liveBroadcast->id,
'title' => $channel->liveBroadcast->title, 'title' => $channel->liveBroadcast->title,

View File

@@ -2,7 +2,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Category; use App\Enums\ChannelCategory;
use App\Models\Channel; use App\Models\Channel;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@@ -17,7 +17,7 @@ class HomeController extends Controller
$liveChannels = Channel::query() $liveChannels = Channel::query()
->where('is_live', true) ->where('is_live', true)
->whereNull('suspended_at') ->whereNull('suspended_at')
->with(['category', 'liveBroadcast', 'user']) ->with(['liveBroadcast', 'user'])
->withCount('follows') ->withCount('follows')
->orderByDesc('viewer_count') ->orderByDesc('viewer_count')
->latest() ->latest()
@@ -28,14 +28,7 @@ class HomeController extends Controller
return Inertia::render('welcome', [ return Inertia::render('welcome', [
'canRegister' => Features::enabled(Features::registration()), 'canRegister' => Features::enabled(Features::registration()),
'liveChannels' => $liveChannels, 'liveChannels' => $liveChannels,
'categories' => Category::query() 'categories' => ChannelCategory::options(),
->orderBy('name')
->get(['id', 'name', 'slug'])
->map(fn (Category $category) => [
'id' => $category->id,
'name' => $category->name,
'slug' => $category->slug,
]),
]); ]);
} }
@@ -52,11 +45,7 @@ class HomeController extends Controller
'banner_url' => $this->mediaUrl($channel->banner_path), 'banner_url' => $this->mediaUrl($channel->banner_path),
'viewer_count' => $channel->viewer_count, 'viewer_count' => $channel->viewer_count,
'followers_count' => $channel->follows_count ?? 0, 'followers_count' => $channel->follows_count ?? 0,
'category' => $channel->category ? [ 'category' => $channel->category?->toOption(),
'id' => $channel->category->id,
'name' => $channel->category->name,
'slug' => $channel->category->slug,
] : null,
'broadcast' => $channel->liveBroadcast ? [ 'broadcast' => $channel->liveBroadcast ? [
'id' => $channel->liveBroadcast->id, 'id' => $channel->liveBroadcast->id,
'title' => $channel->liveBroadcast->title, 'title' => $channel->liveBroadcast->title,

View File

@@ -1,21 +0,0 @@
<?php
namespace App\Models;
use Database\Factories\CategoryFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['name', 'slug', 'description'])]
class Category extends Model
{
/** @use HasFactory<CategoryFactory> */
use HasFactory;
public function channels(): HasMany
{
return $this->hasMany(Channel::class);
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Enums\ChannelCategory;
use Database\Factories\ChannelFactory; use Database\Factories\ChannelFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -12,7 +13,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
#[Fillable([ #[Fillable([
'user_id', 'user_id',
'category_id', 'category',
'live_broadcast_id', 'live_broadcast_id',
'slug', 'slug',
'display_name', 'display_name',
@@ -32,6 +33,7 @@ class Channel extends Model
protected function casts(): array protected function casts(): array
{ {
return [ return [
'category' => ChannelCategory::class,
'is_live' => 'boolean', 'is_live' => 'boolean',
'viewer_count' => 'integer', 'viewer_count' => 'integer',
'suspended_at' => 'datetime', 'suspended_at' => 'datetime',
@@ -48,11 +50,6 @@ class Channel extends Model
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
public function streamKey(): HasOne public function streamKey(): HasOne
{ {
return $this->hasOne(StreamKey::class); return $this->hasOne(StreamKey::class);

View File

@@ -1,20 +0,0 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
class CategoryFactory extends Factory
{
public function definition(): array
{
$name = fake()->unique()->words(2, true);
return [
'name' => Str::headline($name),
'slug' => Str::slug($name),
'description' => fake()->optional()->sentence(),
];
}
}

View File

@@ -2,7 +2,7 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\Category; use App\Enums\ChannelCategory;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@@ -15,7 +15,7 @@ class ChannelFactory extends Factory
return [ return [
'user_id' => User::factory(), 'user_id' => User::factory(),
'category_id' => Category::factory(), 'category' => fake()->randomElement(ChannelCategory::cases())->value,
'slug' => Str::slug($name), 'slug' => Str::slug($name),
'display_name' => Str::headline($name), 'display_name' => Str::headline($name),
'description' => fake()->sentence(), 'description' => fake()->sentence(),

View File

@@ -0,0 +1,76 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('channels', function (Blueprint $table) {
$table->string('category')->nullable()->after('user_id')->index();
});
$allowedCategories = ['gaming', 'music', 'talk-shows', 'education', 'creative'];
DB::table('categories')
->select(['id', 'slug'])
->orderBy('id')
->get()
->each(function (object $category) use ($allowedCategories): void {
if (! in_array($category->slug, $allowedCategories, true)) {
return;
}
DB::table('channels')
->where('category_id', $category->id)
->update(['category' => $category->slug]);
});
Schema::table('channels', function (Blueprint $table) {
$table->dropConstrainedForeignId('category_id');
});
Schema::dropIfExists('categories');
}
public function down(): void
{
Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->string('name')->unique();
$table->string('slug')->unique();
$table->text('description')->nullable();
$table->timestamps();
});
DB::table('categories')->insert([
['name' => 'Gaming', 'slug' => 'gaming', 'created_at' => now(), 'updated_at' => now()],
['name' => 'Music', 'slug' => 'music', 'created_at' => now(), 'updated_at' => now()],
['name' => 'Talk Shows', 'slug' => 'talk-shows', 'created_at' => now(), 'updated_at' => now()],
['name' => 'Education', 'slug' => 'education', 'created_at' => now(), 'updated_at' => now()],
['name' => 'Creative', 'slug' => 'creative', 'created_at' => now(), 'updated_at' => now()],
]);
Schema::table('channels', function (Blueprint $table) {
$table->foreignId('category_id')->nullable()->after('user_id')->constrained()->nullOnDelete();
});
DB::table('categories')
->select(['id', 'slug'])
->orderBy('id')
->get()
->each(function (object $category): void {
DB::table('channels')
->where('category', $category->slug)
->update(['category_id' => $category->id]);
});
Schema::table('channels', function (Blueprint $table) {
$table->dropIndex(['category']);
$table->dropColumn('category');
});
}
};

View File

@@ -2,7 +2,7 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Models\Category; use App\Enums\ChannelCategory;
use App\Models\PlatformSetting; use App\Models\PlatformSetting;
use App\Models\User; use App\Models\User;
use App\Services\Streaming\StreamKeyManager; use App\Services\Streaming\StreamKeyManager;
@@ -15,17 +15,6 @@ class DatabaseSeeder extends Seeder
*/ */
public function run(): void public function run(): void
{ {
collect([
['name' => 'Gaming', 'slug' => 'gaming'],
['name' => 'Music', 'slug' => 'music'],
['name' => 'Talk Shows', 'slug' => 'talk-shows'],
['name' => 'Education', 'slug' => 'education'],
['name' => 'Creative', 'slug' => 'creative'],
])->each(fn (array $category) => Category::query()->firstOrCreate(
['slug' => $category['slug']],
['name' => $category['name']],
));
PlatformSetting::current(); PlatformSetting::current();
$admin = User::query()->firstOrNew(['email' => 'admin@nyone.net']); $admin = User::query()->firstOrNew(['email' => 'admin@nyone.net']);
@@ -47,7 +36,7 @@ class DatabaseSeeder extends Seeder
])->save(); ])->save();
$channel = $official->channel()->updateOrCreate([], [ $channel = $official->channel()->updateOrCreate([], [
'category_id' => Category::query()->where('slug', 'creative')->value('id'), 'category' => ChannelCategory::Creative,
'slug' => 'nyone', 'slug' => 'nyone',
'display_name' => 'Nyone Official', 'display_name' => 'Nyone Official',
'description' => 'Official updates and live sessions from Nyone.', 'description' => 'Official updates and live sessions from Nyone.',

View File

@@ -82,6 +82,7 @@
"Create an account": "إنشاء حساب", "Create an account": "إنشاء حساب",
"Create channel": "إنشاء قناة", "Create channel": "إنشاء قناة",
"Create your channel": "أنشئ قناتك", "Create your channel": "أنشئ قناتك",
"Creative": "الإبداع",
"Creator Studio": "استوديو المنشئ", "Creator Studio": "استوديو المنشئ",
"Creator access grants stay in the moderation console.": "تبقى أذونات وصول المنشئ في وحدة الإشراف.", "Creator access grants stay in the moderation console.": "تبقى أذونات وصول المنشئ في وحدة الإشراف.",
"Creator grants": "أذونات المنشئ", "Creator grants": "أذونات المنشئ",
@@ -101,6 +102,7 @@
"Don't have an account?": "ليس لديك حساب؟", "Don't have an account?": "ليس لديك حساب؟",
"Each account can own one public channel in this version.": "يمكن لكل حساب امتلاك قناة عامة واحدة في هذا الإصدار.", "Each account can own one public channel in this version.": "يمكن لكل حساب امتلاك قناة عامة واحدة في هذا الإصدار.",
"Each recovery code can be used once to access your account and will be removed after use. If you need more, click": "يمكن استخدام كل رمز استرداد مرة واحدة للوصول إلى حسابك وسيتم حذفه بعد الاستخدام. إذا احتجت إلى المزيد، فانقر", "Each recovery code can be used once to access your account and will be removed after use. If you need more, click": "يمكن استخدام كل رمز استرداد مرة واحدة للوصول إلى حسابك وسيتم حذفه بعد الاستخدام. إذا احتجت إلى المزيد، فانقر",
"Education": "التعليم",
"Email": "البريد الإلكتروني", "Email": "البريد الإلكتروني",
"Email address": "عنوان البريد الإلكتروني", "Email address": "عنوان البريد الإلكتروني",
"Email password reset link": "إرسال رابط إعادة تعيين كلمة المرور", "Email password reset link": "إرسال رابط إعادة تعيين كلمة المرور",
@@ -123,6 +125,7 @@
"Forgot password": "نسيت كلمة المرور", "Forgot password": "نسيت كلمة المرور",
"Forgot password?": "هل نسيت كلمة المرور؟", "Forgot password?": "هل نسيت كلمة المرور؟",
"Full name": "الاسم الكامل", "Full name": "الاسم الكامل",
"Gaming": "الألعاب",
"Grant": "منح", "Grant": "منح",
"Hide password": "إخفاء كلمة المرور", "Hide password": "إخفاء كلمة المرور",
"Hide recovery codes": "إخفاء رموز الاسترداد", "Hide recovery codes": "إخفاء رموز الاسترداد",
@@ -152,6 +155,7 @@
"Message sent to admin.": "تم إرسال الرسالة إلى المسؤول.", "Message sent to admin.": "تم إرسال الرسالة إلى المسؤول.",
"Moderation console": "وحدة الإشراف", "Moderation console": "وحدة الإشراف",
"More": "المزيد", "More": "المزيد",
"Music": "الموسيقى",
"NEEDS APPROVAL": "يتطلب موافقة", "NEEDS APPROVAL": "يتطلب موافقة",
"NEEDS CHANNEL GRANT": "يتطلب إذن قناة", "NEEDS CHANNEL GRANT": "يتطلب إذن قناة",
"Name": "الاسم", "Name": "الاسم",
@@ -268,6 +272,7 @@
"Suspend": "تعليق", "Suspend": "تعليق",
"Suspend channel?": "تعليق القناة؟", "Suspend channel?": "تعليق القناة؟",
"System": "النظام", "System": "النظام",
"Talk Shows": "برامج الحوار",
"The channel will be hidden from public browsing and watch pages.": "سيتم إخفاء القناة من التصفح العام وصفحات المشاهدة.", "The channel will be hidden from public browsing and watch pages.": "سيتم إخفاء القناة من التصفح العام وصفحات المشاهدة.",
"The channel will be visible again if the owning account is active.": "ستظهر القناة مرة أخرى إذا كان حساب المالك نشطًا.", "The channel will be visible again if the owning account is active.": "ستظهر القناة مرة أخرى إذا كان حساب المالك نشطًا.",
"The live floor is quiet": "ساحة البث هادئة", "The live floor is quiet": "ساحة البث هادئة",

View File

@@ -82,6 +82,7 @@
"Create an account": "Create an account", "Create an account": "Create an account",
"Create channel": "Create channel", "Create channel": "Create channel",
"Create your channel": "Create your channel", "Create your channel": "Create your channel",
"Creative": "Creative",
"Creator Studio": "Creator Studio", "Creator Studio": "Creator Studio",
"Creator access grants stay in the moderation console.": "Creator access grants stay in the moderation console.", "Creator access grants stay in the moderation console.": "Creator access grants stay in the moderation console.",
"Creator grants": "Creator grants", "Creator grants": "Creator grants",
@@ -101,6 +102,7 @@
"Don't have an account?": "Don't have an account?", "Don't have an account?": "Don't have an account?",
"Each account can own one public channel in this version.": "Each account can own one public channel in this version.", "Each account can own one public channel in this version.": "Each account can own one public channel in this version.",
"Each recovery code can be used once to access your account and will be removed after use. If you need more, click": "Each recovery code can be used once to access your account and will be removed after use. If you need more, click", "Each recovery code can be used once to access your account and will be removed after use. If you need more, click": "Each recovery code can be used once to access your account and will be removed after use. If you need more, click",
"Education": "Education",
"Email": "Email", "Email": "Email",
"Email address": "Email address", "Email address": "Email address",
"Email password reset link": "Email password reset link", "Email password reset link": "Email password reset link",
@@ -123,6 +125,7 @@
"Forgot password": "Forgot password", "Forgot password": "Forgot password",
"Forgot password?": "Forgot password?", "Forgot password?": "Forgot password?",
"Full name": "Full name", "Full name": "Full name",
"Gaming": "Gaming",
"Grant": "Grant", "Grant": "Grant",
"Hide password": "Hide password", "Hide password": "Hide password",
"Hide recovery codes": "Hide recovery codes", "Hide recovery codes": "Hide recovery codes",
@@ -152,6 +155,7 @@
"Message sent to admin.": "Message sent to admin.", "Message sent to admin.": "Message sent to admin.",
"Moderation console": "Moderation console", "Moderation console": "Moderation console",
"More": "More", "More": "More",
"Music": "Music",
"NEEDS APPROVAL": "NEEDS APPROVAL", "NEEDS APPROVAL": "NEEDS APPROVAL",
"NEEDS CHANNEL GRANT": "NEEDS CHANNEL GRANT", "NEEDS CHANNEL GRANT": "NEEDS CHANNEL GRANT",
"Name": "Name", "Name": "Name",
@@ -268,6 +272,7 @@
"Suspend": "Suspend", "Suspend": "Suspend",
"Suspend channel?": "Suspend channel?", "Suspend channel?": "Suspend channel?",
"System": "System", "System": "System",
"Talk Shows": "Talk Shows",
"The channel will be hidden from public browsing and watch pages.": "The channel will be hidden from public browsing and watch pages.", "The channel will be hidden from public browsing and watch pages.": "The channel will be hidden from public browsing and watch pages.",
"The channel will be visible again if the owning account is active.": "The channel will be visible again if the owning account is active.", "The channel will be visible again if the owning account is active.": "The channel will be visible again if the owning account is active.",
"The live floor is quiet": "The live floor is quiet", "The live floor is quiet": "The live floor is quiet",

View File

@@ -82,6 +82,7 @@
"Create an account": "Crear una cuenta", "Create an account": "Crear una cuenta",
"Create channel": "Crear canal", "Create channel": "Crear canal",
"Create your channel": "Crea tu canal", "Create your channel": "Crea tu canal",
"Creative": "Creatividad",
"Creator Studio": "Estudio de creador", "Creator Studio": "Estudio de creador",
"Creator access grants stay in the moderation console.": "Los permisos de acceso de creador permanecen en la consola de moderación.", "Creator access grants stay in the moderation console.": "Los permisos de acceso de creador permanecen en la consola de moderación.",
"Creator grants": "Permisos de creador", "Creator grants": "Permisos de creador",
@@ -101,6 +102,7 @@
"Don't have an account?": "¿No tienes una cuenta?", "Don't have an account?": "¿No tienes una cuenta?",
"Each account can own one public channel in this version.": "Cada cuenta puede tener un canal público en esta versión.", "Each account can own one public channel in this version.": "Cada cuenta puede tener un canal público en esta versión.",
"Each recovery code can be used once to access your account and will be removed after use. If you need more, click": "Cada código de recuperación puede usarse una vez para acceder a tu cuenta y se eliminará después de usarlo. Si necesitas más, haz clic", "Each recovery code can be used once to access your account and will be removed after use. If you need more, click": "Cada código de recuperación puede usarse una vez para acceder a tu cuenta y se eliminará después de usarlo. Si necesitas más, haz clic",
"Education": "Educación",
"Email": "Correo electrónico", "Email": "Correo electrónico",
"Email address": "Dirección de correo electrónico", "Email address": "Dirección de correo electrónico",
"Email password reset link": "Enviar enlace para restablecer contraseña", "Email password reset link": "Enviar enlace para restablecer contraseña",
@@ -123,6 +125,7 @@
"Forgot password": "Olvidé mi contraseña", "Forgot password": "Olvidé mi contraseña",
"Forgot password?": "¿Olvidaste tu contraseña?", "Forgot password?": "¿Olvidaste tu contraseña?",
"Full name": "Nombre completo", "Full name": "Nombre completo",
"Gaming": "Videojuegos",
"Grant": "Conceder", "Grant": "Conceder",
"Hide password": "Ocultar contraseña", "Hide password": "Ocultar contraseña",
"Hide recovery codes": "Ocultar códigos de recuperación", "Hide recovery codes": "Ocultar códigos de recuperación",
@@ -152,6 +155,7 @@
"Message sent to admin.": "Mensaje enviado al administrador.", "Message sent to admin.": "Mensaje enviado al administrador.",
"Moderation console": "Consola de moderación", "Moderation console": "Consola de moderación",
"More": "Más", "More": "Más",
"Music": "Música",
"NEEDS APPROVAL": "REQUIERE APROBACIÓN", "NEEDS APPROVAL": "REQUIERE APROBACIÓN",
"NEEDS CHANNEL GRANT": "REQUIERE PERMISO DE CANAL", "NEEDS CHANNEL GRANT": "REQUIERE PERMISO DE CANAL",
"Name": "Nombre", "Name": "Nombre",
@@ -268,6 +272,7 @@
"Suspend": "Suspender", "Suspend": "Suspender",
"Suspend channel?": "¿Suspender canal?", "Suspend channel?": "¿Suspender canal?",
"System": "Sistema", "System": "Sistema",
"Talk Shows": "Programas de conversación",
"The channel will be hidden from public browsing and watch pages.": "El canal se ocultará de la navegación pública y de las páginas de visualización.", "The channel will be hidden from public browsing and watch pages.": "El canal se ocultará de la navegación pública y de las páginas de visualización.",
"The channel will be visible again if the owning account is active.": "El canal volverá a estar visible si la cuenta propietaria está activa.", "The channel will be visible again if the owning account is active.": "El canal volverá a estar visible si la cuenta propietaria está activa.",
"The live floor is quiet": "No hay actividad en vivo", "The live floor is quiet": "No hay actividad en vivo",

View File

@@ -82,6 +82,7 @@
"Create an account": "ایجاد یک حساب", "Create an account": "ایجاد یک حساب",
"Create channel": "ایجاد کانال", "Create channel": "ایجاد کانال",
"Create your channel": "کانال خود را ایجاد کنید", "Create your channel": "کانال خود را ایجاد کنید",
"Creative": "خلاقیت",
"Creator Studio": "استودیوی سازنده", "Creator Studio": "استودیوی سازنده",
"Creator access grants stay in the moderation console.": "مجوزهای دسترسی سازنده در کنسول مدیریت باقی می‌مانند.", "Creator access grants stay in the moderation console.": "مجوزهای دسترسی سازنده در کنسول مدیریت باقی می‌مانند.",
"Creator grants": "مجوزهای سازنده", "Creator grants": "مجوزهای سازنده",
@@ -101,6 +102,7 @@
"Don't have an account?": "حساب ندارید؟", "Don't have an account?": "حساب ندارید؟",
"Each account can own one public channel in this version.": "در این نسخه هر حساب می‌تواند یک کانال عمومی داشته باشد.", "Each account can own one public channel in this version.": "در این نسخه هر حساب می‌تواند یک کانال عمومی داشته باشد.",
"Each recovery code can be used once to access your account and will be removed after use. If you need more, click": "هر کد بازیابی فقط یک‌بار برای دسترسی به حساب شما قابل استفاده است و پس از استفاده حذف می‌شود. اگر به کدهای بیشتری نیاز دارید، کلیک کنید", "Each recovery code can be used once to access your account and will be removed after use. If you need more, click": "هر کد بازیابی فقط یک‌بار برای دسترسی به حساب شما قابل استفاده است و پس از استفاده حذف می‌شود. اگر به کدهای بیشتری نیاز دارید، کلیک کنید",
"Education": "آموزش",
"Email": "ایمیل", "Email": "ایمیل",
"Email address": "آدرس ایمیل", "Email address": "آدرس ایمیل",
"Email password reset link": "ارسال لینک بازنشانی رمز عبور", "Email password reset link": "ارسال لینک بازنشانی رمز عبور",
@@ -123,6 +125,7 @@
"Forgot password": "فراموشی رمز عبور", "Forgot password": "فراموشی رمز عبور",
"Forgot password?": "رمز عبور را فراموش کرده‌اید؟", "Forgot password?": "رمز عبور را فراموش کرده‌اید؟",
"Full name": "نام کامل", "Full name": "نام کامل",
"Gaming": "بازی",
"Grant": "اعطا", "Grant": "اعطا",
"Hide password": "پنهان کردن رمز عبور", "Hide password": "پنهان کردن رمز عبور",
"Hide recovery codes": "پنهان کردن کدهای بازیابی", "Hide recovery codes": "پنهان کردن کدهای بازیابی",
@@ -152,6 +155,7 @@
"Message sent to admin.": "پیام برای مدیر ارسال شد.", "Message sent to admin.": "پیام برای مدیر ارسال شد.",
"Moderation console": "کنسول مدیریت", "Moderation console": "کنسول مدیریت",
"More": "بیشتر", "More": "بیشتر",
"Music": "موسیقی",
"NEEDS APPROVAL": "نیازمند تأیید", "NEEDS APPROVAL": "نیازمند تأیید",
"NEEDS CHANNEL GRANT": "نیازمند مجوز کانال", "NEEDS CHANNEL GRANT": "نیازمند مجوز کانال",
"Name": "نام", "Name": "نام",
@@ -268,6 +272,7 @@
"Suspend": "تعلیق", "Suspend": "تعلیق",
"Suspend channel?": "کانال تعلیق شود؟", "Suspend channel?": "کانال تعلیق شود؟",
"System": "سیستم", "System": "سیستم",
"Talk Shows": "گفت‌وگو",
"The channel will be hidden from public browsing and watch pages.": "کانال از مرور عمومی و صفحات تماشا پنهان خواهد شد.", "The channel will be hidden from public browsing and watch pages.": "کانال از مرور عمومی و صفحات تماشا پنهان خواهد شد.",
"The channel will be visible again if the owning account is active.": "اگر حساب مالک فعال باشد، کانال دوباره قابل مشاهده خواهد شد.", "The channel will be visible again if the owning account is active.": "اگر حساب مالک فعال باشد، کانال دوباره قابل مشاهده خواهد شد.",
"The live floor is quiet": "فضای زنده آرام است", "The live floor is quiet": "فضای زنده آرام است",

View File

@@ -150,7 +150,7 @@ export default function ChannelShow({
<LiveState channel={channel} /> <LiveState channel={channel} />
{channel.category && ( {channel.category && (
<span className="rounded-md border px-2 py-1 text-xs"> <span className="rounded-md border px-2 py-1 text-xs">
{channel.category.name} {channel.category.label}
</span> </span>
)} )}
</div> </div>

View File

@@ -38,7 +38,7 @@ import {
update as updateChannelRoute, update as updateChannelRoute,
} from '@/routes/creator/channel'; } from '@/routes/creator/channel';
import { rotate as rotateStreamKeyRoute } from '@/routes/creator/stream-key'; import { rotate as rotateStreamKeyRoute } from '@/routes/creator/stream-key';
import type { BroadcastSummary, Category } from '@/types'; import type { BroadcastSummary, ChannelCategory } from '@/types';
type CreatorChannel = { type CreatorChannel = {
id: number; id: number;
@@ -51,7 +51,7 @@ type CreatorChannel = {
is_live: boolean; is_live: boolean;
viewer_count: number; viewer_count: number;
stream_key_last_used_at: string | null; stream_key_last_used_at: string | null;
category_id: number | null; category: string | null;
live_broadcast: BroadcastSummary | null; live_broadcast: BroadcastSummary | null;
}; };
@@ -63,7 +63,7 @@ type Props = {
ingestServer: string; ingestServer: string;
tokenizedPath: string | null; tokenizedPath: string | null;
}; };
categories: Category[]; categories: ChannelCategory[];
recentBroadcasts: BroadcastSummary[]; recentBroadcasts: BroadcastSummary[];
}; };
@@ -90,14 +90,14 @@ export default function Dashboard({
display_name: '', display_name: '',
slug: '', slug: '',
description: '', description: '',
category_id: '', category: '',
}); });
const channelForm = useForm({ const channelForm = useForm({
_method: 'PATCH', _method: 'PATCH',
display_name: channel?.display_name ?? '', display_name: channel?.display_name ?? '',
slug: channel?.slug ?? '', slug: channel?.slug ?? '',
description: channel?.description ?? '', description: channel?.description ?? '',
category_id: channel?.category_id ? String(channel.category_id) : '', category: channel?.category ?? '',
thumbnail: null as File | null, thumbnail: null as File | null,
}); });
const broadcastForm = useForm({ const broadcastForm = useForm({
@@ -235,13 +235,16 @@ export default function Dashboard({
placeholder={t('nyone-live')} placeholder={t('nyone-live')}
/> />
</Field> </Field>
<Field label="Category"> <Field
label="Category"
error={createForm.errors.category}
>
<CategorySelect <CategorySelect
categories={categories} categories={categories}
value={createForm.data.category_id} value={createForm.data.category}
onChange={(value) => onChange={(value) =>
createForm.setData( createForm.setData(
'category_id', 'category',
value, value,
) )
} }
@@ -440,13 +443,16 @@ export default function Dashboard({
} }
/> />
</Field> </Field>
<Field label="Category"> <Field
label="Category"
error={channelForm.errors.category}
>
<CategorySelect <CategorySelect
categories={categories} categories={categories}
value={channelForm.data.category_id} value={channelForm.data.category}
onChange={(value) => onChange={(value) =>
channelForm.setData( channelForm.setData(
'category_id', 'category',
value, value,
) )
} }
@@ -763,7 +769,7 @@ function CategorySelect({
value, value,
onChange, onChange,
}: { }: {
categories: Category[]; categories: ChannelCategory[];
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
}) { }) {
@@ -777,8 +783,8 @@ function CategorySelect({
> >
<option value="">{t('No category')}</option> <option value="">{t('No category')}</option>
{categories.map((category) => ( {categories.map((category) => (
<option key={category.id} value={category.id}> <option key={category.value} value={category.value}>
{category.name} {category.label}
</option> </option>
))} ))}
</select> </select>

View File

@@ -19,12 +19,12 @@ import { cn } from '@/lib/utils';
import { dashboard, home, login, register } from '@/routes'; import { dashboard, home, login, register } from '@/routes';
import { show as showChannel } from '@/routes/channels'; import { show as showChannel } from '@/routes/channels';
import { index as supportIndex } from '@/routes/support'; import { index as supportIndex } from '@/routes/support';
import type { Category, ChannelCard } from '@/types'; import type { ChannelCard, ChannelCategory } from '@/types';
type Props = { type Props = {
canRegister: boolean; canRegister: boolean;
liveChannels: ChannelCard[]; liveChannels: ChannelCard[];
categories: Category[]; categories: ChannelCategory[];
}; };
export default function Welcome({ export default function Welcome({
@@ -43,7 +43,7 @@ export default function Welcome({
return liveChannels.filter((channel) => { return liveChannels.filter((channel) => {
const matchesCategory = const matchesCategory =
category === 'all' || channel.category?.slug === category; category === 'all' || channel.category?.value === category;
const matchesQuery = const matchesQuery =
normalizedQuery === '' || normalizedQuery === '' ||
channel.display_name.toLowerCase().includes(normalizedQuery) || channel.display_name.toLowerCase().includes(normalizedQuery) ||
@@ -175,7 +175,7 @@ export default function Welcome({
</span> </span>
<span className="min-w-0 break-words"> <span className="min-w-0 break-words">
{featuredChannel.category {featuredChannel.category
?.name ?? ?.label ??
t('Uncategorized')} t('Uncategorized')}
</span> </span>
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
@@ -292,8 +292,11 @@ export default function Welcome({
{t('All categories')} {t('All categories')}
</option> </option>
{categories.map((item) => ( {categories.map((item) => (
<option key={item.id} value={item.slug}> <option
{item.name} key={item.value}
value={item.value}
>
{item.label}
</option> </option>
))} ))}
</select> </select>
@@ -356,7 +359,7 @@ function StreamCard({ channel }: { channel: ChannelCard }) {
</div> </div>
<div className="flex items-center justify-between gap-3 text-sm text-muted-foreground"> <div className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
<span className="truncate"> <span className="truncate">
{channel.category?.name ?? t('Uncategorized')} {channel.category?.label ?? t('Uncategorized')}
</span> </span>
<span className="shrink-0"> <span className="shrink-0">
{t(':count viewers', { {t(':count viewers', {

View File

@@ -1,7 +1,6 @@
export type Category = { export type ChannelCategory = {
id: number; value: string;
name: string; label: string;
slug: string;
}; };
export type ChannelCard = { export type ChannelCard = {
@@ -15,7 +14,7 @@ export type ChannelCard = {
banner_url: string | null; banner_url: string | null;
viewer_count: number; viewer_count: number;
followers_count: number; followers_count: number;
category: Category | null; category: ChannelCategory | null;
broadcast: { broadcast: {
id: number; id: number;
title: string; title: string;
@@ -38,7 +37,7 @@ export type ChannelDetail = {
is_live: boolean; is_live: boolean;
viewer_count: number; viewer_count: number;
followers_count: number; followers_count: number;
category: Category | null; category: ChannelCategory | null;
owner: { owner: {
id: number; id: number;
name: string; name: string;

View File

@@ -2,7 +2,7 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Models\Category; use App\Enums\ChannelCategory;
use App\Models\Channel; use App\Models\Channel;
use App\Models\PlatformSetting; use App\Models\PlatformSetting;
use App\Models\StreamKey; use App\Models\StreamKey;
@@ -17,13 +17,12 @@ class CreatorChannelTest extends TestCase
public function test_user_with_creator_access_can_create_one_channel_and_receives_one_time_stream_key(): void public function test_user_with_creator_access_can_create_one_channel_and_receives_one_time_stream_key(): void
{ {
$user = User::factory()->create(['can_create_channel' => true]); $user = User::factory()->create(['can_create_channel' => true]);
$category = Category::factory()->create();
$response = $this->actingAs($user)->post(route('creator.channel.store'), [ $response = $this->actingAs($user)->post(route('creator.channel.store'), [
'display_name' => 'Nyone Live', 'display_name' => 'Nyone Live',
'slug' => 'nyone-live', 'slug' => 'nyone-live',
'description' => 'Live builds and demos.', 'description' => 'Live builds and demos.',
'category_id' => $category->id, 'category' => ChannelCategory::Creative->value,
]); ]);
$response $response
@@ -34,7 +33,7 @@ class CreatorChannelTest extends TestCase
'user_id' => $user->id, 'user_id' => $user->id,
'slug' => 'nyone-live', 'slug' => 'nyone-live',
'display_name' => 'Nyone Live', 'display_name' => 'Nyone Live',
'category_id' => $category->id, 'category' => ChannelCategory::Creative->value,
]); ]);
$this->assertDatabaseCount(StreamKey::class, 1); $this->assertDatabaseCount(StreamKey::class, 1);

View File

@@ -2,6 +2,7 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Enums\ChannelCategory;
use App\Models\Broadcast; use App\Models\Broadcast;
use App\Models\Channel; use App\Models\Channel;
use App\Models\User; use App\Models\User;
@@ -64,6 +65,25 @@ class DashboardTest extends TestCase
); );
} }
public function test_home_page_exposes_translated_channel_category_labels(): void
{
config(['app.locale' => 'es']);
Channel::factory()->create([
'is_live' => true,
'category' => ChannelCategory::Music,
]);
$this->get(route('home'))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('welcome')
->where('liveChannels.0.category.value', ChannelCategory::Music->value)
->where('liveChannels.0.category.label', __('Music', [], 'es'))
->where('categories', fn ($categories) => $categories->firstWhere('value', ChannelCategory::Music->value)['label'] === __('Music', [], 'es')),
);
}
public function test_creator_dashboard_exposes_channel_display_media_props(): void public function test_creator_dashboard_exposes_channel_display_media_props(): void
{ {
$user = User::factory()->create(); $user = User::factory()->create();
@@ -101,7 +121,7 @@ class DashboardTest extends TestCase
'display_name' => $channel->display_name, 'display_name' => $channel->display_name,
'slug' => $channel->slug, 'slug' => $channel->slug,
'description' => $channel->description, 'description' => $channel->description,
'category_id' => $channel->category_id, 'category' => $channel->category?->value,
'thumbnail' => UploadedFile::fake()->image('thumbnail.jpg', 1280, 720), 'thumbnail' => UploadedFile::fake()->image('thumbnail.jpg', 1280, 720),
]) ])
->assertRedirect() ->assertRedirect()
@@ -131,7 +151,7 @@ class DashboardTest extends TestCase
'display_name' => $channel->display_name, 'display_name' => $channel->display_name,
'slug' => $channel->slug, 'slug' => $channel->slug,
'description' => $channel->description, 'description' => $channel->description,
'category_id' => $channel->category_id, 'category' => $channel->category?->value,
'thumbnail' => UploadedFile::fake()->create('thumbnail.txt', 8, 'text/plain'), 'thumbnail' => UploadedFile::fake()->create('thumbnail.txt', 8, 'text/plain'),
]) ])
->assertRedirect(route('dashboard')) ->assertRedirect(route('dashboard'))