feat: add internationalization and spanish locale

This commit is contained in:
2026-01-14 19:23:44 +01:00
parent 80261ad627
commit 613c5b24da
13 changed files with 1526 additions and 61 deletions

View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '~/components/ui/select'
import { Languages } from 'lucide-vue-next'
const { locale, locales, setLocale } = useI18n()
const { t } = useI18n()
const switchLocalePath = useSwitchLocalePath()
const availableLocales = computed(() => locales.value)
const currentLocale = computed({
get: () => locale.value,
set: (value) => {
const path = switchLocalePath(value)
navigateTo(path)
},
})
</script>
<template>
<Select v-model="currentLocale">
<SelectTrigger class="w-[160px]">
<Languages class="mr-2 h-4 w-4" />
<SelectValue :placeholder="t('selectLanguage')" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem
v-for="loc in availableLocales"
:key="loc.code"
:value="loc.code"
>
{{ loc.name }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</template>

View File

@@ -2,9 +2,9 @@
<Dialog :open="open" @update:open="$emit('update:open', $event)">
<DialogContent class="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Preset</DialogTitle>
<DialogTitle>{{ t('editPreset') }}</DialogTitle>
<DialogDescription>
Make changes to your preset here. Click save when you're done.
{{ t('editPresetDescription') }}
</DialogDescription>
</DialogHeader>
<PresetForm
@@ -28,6 +28,8 @@ import {
} from "@/components/ui/dialog";
import PresetForm from "~/components/app/PresetForm.vue";
const { t } = useI18n();
const props = defineProps<{
open: boolean;
presetUser: any;

View File

@@ -4,7 +4,7 @@
<FieldGroup>
<form.Field v-slot="{ field }" name="name">
<Field :data-invalid="isInvalid(field)">
<FieldLabel :for="`${formId}-name`"> Preset name </FieldLabel>
<FieldLabel :for="`${formId}-name`"> {{ t('presetName') }} </FieldLabel>
<Input
:id="`${formId}-name`"
:name="field.name"
@@ -24,7 +24,7 @@
</FieldGroup>
<form.Field v-slot="{ field }" name="iceServers">
<Field :data-invalid="isInvalid(field)">
<FieldLabel>Ice Servers (JSON)</FieldLabel>
<FieldLabel>{{ t('iceServersJson') }}</FieldLabel>
<div
class="h-96 w-full border rounded-md overflow-hidden focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px] transition"
>
@@ -47,10 +47,10 @@
<Field orientation="horizontal">
<FieldContent>
<FieldLabel :for="`${formId}-default`">
Set as default preset
{{ t('setAsDefaultPreset') }}
</FieldLabel>
<FieldDescription>
This preset will be selected by default on the preset selector.
{{ t('setAsDefaultPresetDescription') }}
</FieldDescription>
</FieldContent>
<Switch
@@ -62,7 +62,7 @@
</Field>
</form.Field>
<Field orientation="horizontal">
<Button type="submit" :form="formId">Save</Button>
<Button type="submit" :form="formId">{{ t('save') }}</Button>
</Field>
</form>
</div>
@@ -84,6 +84,8 @@ import { Input } from "~/components/ui/input";
import { Switch } from "~/components/ui/switch";
import { schema } from "~/lib/schema/new-preset";
const { t } = useI18n();
const props = defineProps<{
initialValues?: {
name: string;
@@ -153,14 +155,14 @@ const form = useForm({
if (request.success) {
toast.success(
props.isEdit
? "Preset updated successfully!"
: "Preset created successfully!",
? t('presetUpdatedSuccessfully')
: t('presetCreatedSuccessfully'),
);
emit("success");
}
} catch (e) {
toast.error(
props.isEdit ? "Failed to update preset." : "Failed to create preset.",
props.isEdit ? t('failedToUpdatePreset') : t('failedToCreatePreset'),
);
}
},

View File

@@ -11,6 +11,7 @@ import { Plus } from "lucide-vue-next";
import type { ApiResponse, PresetUser } from "~/lib/types/PresetGetResponse";
import { useStreamerStore } from "~/state/streamer";
const { t } = useI18n();
const router = useRouter();
const selectedValue = ref("");
const presets = ref<PresetUser[]>([]);
@@ -70,7 +71,7 @@ watch(selectedValue, (newValue) => {
<Select v-model="selectedValue" :disabled="loading">
<SelectTrigger class="w-[180px]">
<SelectValue
:placeholder="loading ? 'Loading presets...' : 'Select a preset'"
:placeholder="loading ? t('loadingPresets') : t('selectAPreset')"
/>
</SelectTrigger>
<SelectContent>
@@ -78,7 +79,7 @@ watch(selectedValue, (newValue) => {
v-if="presets.length === 0 && !loading"
class="px-2 py-1.5 text-sm text-muted-foreground"
>
No presets available
{{ t('noPresetsAvailable') }}
</div>
<div v-else-if="!loading">
@@ -90,7 +91,7 @@ watch(selectedValue, (newValue) => {
<span
v-if="preset.isDefault"
class="ml-2 text-xs text-muted-foreground"
>(default)</span
>({{ t('default') }})</span
>
</SelectItem>
</div>
@@ -100,7 +101,7 @@ watch(selectedValue, (newValue) => {
<SelectItem value="create-new">
<div class="font-bold flex gap-2 items-center">
<Plus class="size-4" />
Create New Preset
{{ t('createNewPreset') }}
</div>
</SelectItem>
</SelectContent>

View File

@@ -1,8 +1,11 @@
<script setup lang="ts">
import SignInDialog from "~/components/app/SignInDialog.vue";
import ThemeDropdown from "~/components/ui/ThemeDropdown.vue";
import LanguageSwitcher from "~/components/LanguageSwitcher.vue";
import "vue-sonner/style.css";
import { Toaster } from "@/components/ui/sonner";
const { t } = useI18n();
</script>
<template>
@@ -18,14 +21,14 @@ import { Toaster } from "@/components/ui/sonner";
class="text-sm font-medium hover:text-primary transition-colors"
active-class="text-primary"
>
Home
{{ t('home') }}
</NuxtLink>
<NuxtLink
to="/stream"
class="text-sm font-medium hover:text-primary transition-colors"
active-class="text-primary"
>
Stream
{{ t('stream') }}
</NuxtLink>
<ClientOnly>
<SignedIn>
@@ -34,13 +37,14 @@ import { Toaster } from "@/components/ui/sonner";
class="text-sm font-medium hover:text-primary transition-colors"
active-class="text-primary"
>
Presets
{{ t('presets') }}
</NuxtLink>
</SignedIn>
</ClientOnly>
</nav>
</div>
<div class="flex space-x-4">
<div class="flex items-center space-x-4">
<LanguageSwitcher />
<ThemeDropdown />
<ClientOnly>
<SignedOut>

View File

@@ -1,6 +1,6 @@
<template>
<div
class="flex flex-col md:flex-row items-center justify-center gap-6 mt-10 px-4 min-h-[80vh]"
class="flex flex-col md:flex-row items-center justify-center gap-6 md:gap-20 mt-10 px-4 min-h-[80vh]"
>
<div
v-if="!isConnected"
@@ -9,7 +9,7 @@
<div class="text-center space-y-2">
<h1 class="text-4xl font-bold tracking-tight">helium</h1>
<p class="text-muted-foreground text-lg">
effortless screensharing powered by webrtc
{{ $t("effortlessScreensharing") }}
</p>
</div>
@@ -17,7 +17,7 @@
<NuxtLink to="/stream">
<Button variant="link" class="text-muted-foreground hover:text-primary">
host instead?
{{ $t("hostInstead") }}
</Button>
</NuxtLink>
</div>
@@ -36,9 +36,11 @@
class="absolute inset-0 flex items-center justify-center z-10 p-4 text-center"
>
<div v-if="viewerStore.isDisconnected" class="space-y-4">
<p class="text-sm font-medium text-muted-foreground">stream ended</p>
<p class="text-sm font-medium text-muted-foreground">
{{ $t("streamEnded") }}
</p>
<Button @click="handleReset" variant="outline">
Enter another code
{{ $t("enterAnotherCode") }}
</Button>
</div>
<div
@@ -53,7 +55,7 @@
</p>
</div>
<p v-else class="text-muted-foreground/50 text-sm">
enter code to join stream
{{ $t("enterCodeToJoinStream") }}
</p>
</div>
@@ -73,7 +75,9 @@
<div
v-if="isConnected"
class="absolute top-0 left-0 right-0 p-4 flex justify-between items-start transition-opacity bg-gradient-to-b from-black/50 to-transparent"
:class="[controlsVisible ? 'opacity-100' : 'opacity-0 hover:opacity-100']"
:class="[
controlsVisible ? 'opacity-100' : 'opacity-0 hover:opacity-100',
]"
@click="resetControlsTimeout"
@touchstart="showControls"
>
@@ -84,7 +88,7 @@
@click="cleanupViewing"
>
<LogOut class="w-5 h-5" />
Disconnect
{{ $t("disconnect") }}
</Button>
<Button
@@ -94,7 +98,7 @@
@click="toggleFullscreen"
>
<Maximize class="w-5 h-5" />
Fullscreen
{{ $t("fullscreen") }}
</Button>
</div>
</div>
@@ -319,7 +323,7 @@ function resetControlsTimeout() {
if (controlsHideTimeout) {
clearTimeout(controlsHideTimeout);
}
controlsHideTimeout = setTimeout(() => {
controlsVisible.value = false;
}, 3000);
@@ -344,4 +348,3 @@ onMounted(() => {
});
});
</script>

View File

@@ -1,10 +1,10 @@
<template>
<div class="px-4">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold">Presets</h1>
<h1 class="text-3xl font-bold">{{ t('presets') }}</h1>
<Button @click="navigateTo('/presets/new')">
<Plus class="mr-2 h-4 w-4" />
Create New Preset
{{ t('createNewPreset') }}
</Button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
@@ -14,19 +14,16 @@
<CardTitle class="flex items-center justify-between">
<span>{{ presetUser.preset.name }}</span>
<Badge v-if="presetUser.isDefault" variant="secondary"
>Default</Badge
>{{ t('default') }}</Badge
>
</CardTitle>
<CardDescription
>Created by {{ presetUser.preset.createdBy }}</CardDescription
>{{ t('createdBy') }} {{ presetUser.preset.createdBy }}</CardDescription
>
</CardHeader>
<CardContent class="grow">
<div class="text-sm text-muted-foreground truncate">
{{ presetUser.preset.iceServers.length }} ICE Server{{
presetUser.preset.iceServers.length === 1 ? "" : "s"
}}
configured
{{ presetUser.preset.iceServers.length }} {{ presetUser.preset.iceServers.length === 1 ? t('iceServerConfigured') : t('iceServersConfigured') }} {{ t('configured') }}
</div>
</CardContent>
<CardFooter class="flex justify-between gap-2 ml-auto">
@@ -91,6 +88,7 @@ import EditPresetDialog from "~/components/app/EditPresetDialog.vue";
import { Edit, Share2, Trash, Plus } from "lucide-vue-next";
import type { ApiResponse } from "~/lib/types/PresetGetResponse";
const { t } = useI18n();
const { user } = useUser();
const { data, refresh } = await useFetch<ApiResponse>("/api/presets", {
cache: "no-cache",
@@ -120,34 +118,34 @@ function editPreset(presetUser: any) {
}
async function deletePreset(id: string) {
if (!confirm("Are you sure you want to delete this preset?")) return;
if (!confirm(t('deletePresetConfirm'))) return;
try {
await $fetch(`/api/presets/${id}`, {
method: "DELETE",
});
toast.success("Preset deleted successfully");
toast.success(t('presetDeletedSuccessfully'));
refresh();
} catch (error) {
toast.error("Failed to delete preset");
toast.error(t('failedToDeletePreset'));
}
}
async function handleShare(preset: any) {
if (!confirm("Do you want to share this preset?")) return;
if (!confirm(t('sharePresetConfirm'))) return;
try {
const response = await $fetch(`/api/presets/${preset.id}/share`, {
method: "POST",
});
if (!response.success) {
toast.error("Failed to generate shareable link");
toast.error(t('failedToGenerateShareableLink'));
return;
}
const shareableLink = `${window.location.origin}/presets/shared/${preset.id}`;
navigator.clipboard.writeText(shareableLink);
toast.success("Link copied to clipboard");
toast.success(t('linkCopiedToClipboard'));
} catch (error) {
toast.error("Failed to share preset");
toast.error(t('failedToSharePreset'));
}
}
</script>

View File

@@ -1,7 +1,7 @@
<template>
<div class="flex flex-col items-center justify-center gap-6 mt-10 px-4">
<div class="flex space-x-4 items-center">
<Button @click="startScreenShare"> screenshare </Button>
<Button @click="startScreenShare"> {{ $t('screenshare') }} </Button>
<PresetSelect />
</div>
<p v-if="streamerStore.code" class="font-mono">{{ streamerStore.code }}</p>

45
i18n/locales/en.json Normal file
View File

@@ -0,0 +1,45 @@
{
"welcome": "Welcome",
"language": "Language",
"selectLanguage": "Select language",
"home": "Home",
"about": "About",
"contact": "Contact",
"stream": "Stream",
"presets": "Presets",
"effortlessScreensharing": "effortless screensharing powered by webrtc",
"hostInstead": "stream instead?",
"enterCodeToJoinStream": "enter code to join stream",
"streamEnded": "stream ended",
"enterAnotherCode": "Enter another code",
"disconnect": "Disconnect",
"fullscreen": "Fullscreen",
"screenshare": "screenshare",
"loadingPresets": "Loading presets...",
"selectAPreset": "Select a preset",
"noPresetsAvailable": "No presets available",
"default": "default",
"createNewPreset": "Create New Preset",
"presetName": "Preset name",
"iceServersJson": "Ice Servers (JSON)",
"setAsDefaultPreset": "Set as default preset",
"setAsDefaultPresetDescription": "This preset will be selected by default on the preset selector.",
"save": "Save",
"editPreset": "Edit Preset",
"editPresetDescription": "Make changes to your preset here. Click save when you're done.",
"presetCreatedSuccessfully": "Preset created successfully!",
"presetUpdatedSuccessfully": "Preset updated successfully!",
"failedToCreatePreset": "Failed to create preset.",
"failedToUpdatePreset": "Failed to update preset.",
"createdBy": "Created by",
"iceServerConfigured": "ICE Server",
"iceServersConfigured": "ICE Servers",
"configured": "configured",
"deletePresetConfirm": "Are you sure you want to delete this preset?",
"presetDeletedSuccessfully": "Preset deleted successfully",
"failedToDeletePreset": "Failed to delete preset",
"sharePresetConfirm": "Do you want to share this preset?",
"failedToGenerateShareableLink": "Failed to generate shareable link",
"linkCopiedToClipboard": "Link copied to clipboard",
"failedToSharePreset": "Failed to share preset"
}

45
i18n/locales/es.json Normal file
View File

@@ -0,0 +1,45 @@
{
"welcome": "Bienvenido",
"language": "Idioma",
"selectLanguage": "Seleccionar idioma",
"home": "Inicio",
"about": "Acerca de",
"contact": "Contacto",
"stream": "Transmisión",
"presets": "Ajustes predefinidos",
"effortlessScreensharing": "comparte pantalla sin complicaciones",
"hostInstead": "¿prefieres compartir pantalla?",
"enterCodeToJoinStream": "introduce un código para unirse a la transmisión",
"streamEnded": "transmisión finalizada",
"enterAnotherCode": "introduce otro código",
"disconnect": "Desconectar",
"fullscreen": "Pantalla completa",
"screenshare": "compartir pantalla",
"loadingPresets": "Cargando ajustes...",
"selectAPreset": "Seleccionar un ajuste",
"noPresetsAvailable": "No hay ajustes disponibles",
"default": "predeterminado",
"createNewPreset": "Crear nuevo ajuste",
"presetName": "Nombre del ajuste",
"iceServersJson": "Servidores ICE (JSON)",
"setAsDefaultPreset": "Establecer como ajuste predeterminado",
"setAsDefaultPresetDescription": "Este ajuste se seleccionará de forma predeterminada en el selector de ajustes predefinidos.",
"save": "Guardar",
"editPreset": "Editar ajuste",
"editPresetDescription": "Realiza cambios en su ajuste aquí. Haga clic en guardar cuando haya terminado.",
"presetCreatedSuccessfully": "¡Ajuste creado correctamente!",
"presetUpdatedSuccessfully": "¡Ajuste actualizado correctamente!",
"failedToCreatePreset": "Error al crear el ajuste.",
"failedToUpdatePreset": "Error al actualizar el ajuste.",
"createdBy": "Creado por",
"iceServerConfigured": "Servidor ICE",
"iceServersConfigured": "Servidores ICE",
"configured": "configurado",
"deletePresetConfirm": "¿Está seguro de que desea eliminar este ajuste?",
"presetDeletedSuccessfully": "Ajuste eliminado correctamente",
"failedToDeletePreset": "Error al eliminar el ajuste",
"sharePresetConfirm": "¿Desea compartir este ajuste?",
"failedToGenerateShareableLink": "Error al generar el enlace para compartir",
"linkCopiedToClipboard": "Enlace copiado al portapapeles",
"failedToSharePreset": "Error al compartir el ajuste"
}

View File

@@ -7,9 +7,6 @@ export default defineNuxtConfig({
css: ["~/assets/css/tailwind.css"],
vite: {
plugins: [tailwindcss()],
server: {
allowedHosts: ["urods-79-145-156-36.a.free.pinggy.link"],
},
},
modules: [
"shadcn-nuxt",
@@ -18,7 +15,32 @@ export default defineNuxtConfig({
"nuxt-cron",
"@clerk/nuxt",
"nuxt-monaco-editor",
"@nuxtjs/i18n",
],
i18n: {
locales: [
{
code: "en",
language: "en-US",
name: "English",
file: "en.json",
},
{
code: "es",
language: "es-ES",
name: "Español",
file: "es.json",
},
],
defaultLocale: "en",
langDir: "locales",
strategy: "prefix_except_default",
detectBrowserLanguage: {
useCookie: true,
cookieKey: "i18n_locale",
redirectOn: "root",
},
},
colorMode: {
classSuffix: "",
},

View File

@@ -14,6 +14,7 @@
"dependencies": {
"@clerk/nuxt": "^1.13.10",
"@clerk/themes": "^2.4.46",
"@nuxtjs/i18n": "^10.2.1",
"@pinia/nuxt": "0.11.2",
"@tailwindcss/vite": "^4.1.16",
"@tanstack/vue-form": "^1.27.7",

1322
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff