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>