Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion apps/frontend/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,19 @@ export default defineNuxtConfig({
i18n: {
restructureDir: "./src/i18n",
defaultLocale: "en",
locales: [{ code: "en", name: "English", file: "en.json" }],
// no_prefix: locale lives in a cookie, no /vi/ path — keeps the
// root /[shortCode] redirect route untouched.
strategy: "no_prefix",
locales: [
{ code: "en", name: "English", file: "en.json" },
{ code: "vi", name: "Tiếng Việt", file: "vi.json" },
],
// Detect browser language on first visit, then persist the choice.
detectBrowserLanguage: {
useCookie: true,
cookieKey: "i18n_redirected",
redirectOn: "root",
},
},
// Runtime
runtimeConfig: {
Expand Down
5 changes: 3 additions & 2 deletions apps/frontend/src/app/components/Footer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ const socialLinks: SocialLink[] = [
},
];

const footerItems: FooterColumn[] = [
// Recompute labels on locale change — setLocale() does not re-run setup.
const footerItems = computed<FooterColumn[]>(() => [
{
title: $t("footer.column.about"),
items: [
Expand Down Expand Up @@ -63,7 +64,7 @@ const footerItems: FooterColumn[] = [
},
],
},
];
]);
</script>

<template>
Expand Down
27 changes: 10 additions & 17 deletions apps/frontend/src/app/components/Header.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,18 @@ type NavigationItem = {
kbd: string;
};

const items: NavigationItem[] = [
{
name: $t("nav.url_shortener"),
path: "/",
kbd: "s",
},
{
name: $t("nav.qr_generator"),
path: "/qr",
kbd: "q",
},
{
name: $t("nav.settings"),
path: "/settings",
kbd: ",",
},
const navigation = [
{ key: "nav.url_shortener", path: "/", kbd: "s" },
{ key: "nav.qr_generator", path: "/qr", kbd: "q" },
{ key: "nav.settings", path: "/settings", kbd: "," },
];

items.forEach((item) => {
// Recompute labels on locale change — setLocale() does not re-run setup.
const items = computed<NavigationItem[]>(() =>
navigation.map(({ key, path, kbd }) => ({ name: $t(key), path, kbd })),
);

navigation.forEach((item) => {
onKeyStroke(item.kbd, (e) => {
if (!isEditableElement(e.target)) {
e.preventDefault();
Expand Down
20 changes: 10 additions & 10 deletions apps/frontend/src/app/components/form/ShortenForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,23 +22,23 @@ const state = ref<FormData>({

const { r$ } = useRegle(state, {
longUrl: {
required: withMessage(required, $t("form.url.required")),
url: withMessage(url, $t("form.url.invalid")),
required: withMessage(required, () => $t("form.url.required")),
url: withMessage(url, () => $t("form.url.invalid")),
},
alias: {
string: withMessage(string, $t("form.alias.invalid")),
minLength: withMessage(minLength(7), $t("form.alias.min")),
maxLength: withMessage(maxLength(255), $t("form.alias.max")),
string: withMessage(string, () => $t("form.alias.invalid")),
minLength: withMessage(minLength(7), () => $t("form.alias.min")),
maxLength: withMessage(maxLength(255), () => $t("form.alias.max")),
},
password: {
string: withMessage(string, $t("form.password.invalid")),
minLength: withMessage(minLength(3), $t("form.password.min")),
maxLength: withMessage(maxLength(255), $t("form.password.max")),
string: withMessage(string, () => $t("form.password.invalid")),
minLength: withMessage(minLength(3), () => $t("form.password.min")),
maxLength: withMessage(maxLength(255), () => $t("form.password.max")),
},
expireTime: {
required,
number: withMessage(number, $t("form.expire_time.invalid")),
minValue: withMessage(minValue(0), $t("form.expire_time.min")),
number: withMessage(number, () => $t("form.expire_time.invalid")),
minValue: withMessage(minValue(0), () => $t("form.expire_time.min")),
},
expireTimeUnit: {
required,
Expand Down
2 changes: 1 addition & 1 deletion apps/frontend/src/app/components/form/UnlockForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const state = ref<FormData>({

const { r$ } = useRegle(state, {
password: {
required: withMessage(required, $t("form.password.empty")),
required: withMessage(required, () => $t("form.password.empty")),
},
});

Expand Down
48 changes: 48 additions & 0 deletions apps/frontend/src/app/components/setting/LanguageSetting.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<script setup lang="ts">
type LocaleCode = "en" | "vi";

type Language = {
code: LocaleCode;
// Displayed in its own language so it reads correctly in any locale.
name: string;
};

const languages: Language[] = [
{
code: "en",
name: "English",
},
{
code: "vi",
name: "Tiếng Việt",
},
];

const { locale, setLocale } = useI18n();

function switchLanguage(code: LocaleCode) {
setLocale(code);
}
</script>

<template>
<SettingBase name="language">
<template #body>
<div
class="flex items-center justify-center w-full border border-border rounded-lg p-1 gap-2">
<button
:class="[
'w-full p-1 rounded-lg hover:cursor-pointer',
locale === language.code
? 'bg-gray-1000 text-gray-100 hover:(bg-gray-1000/90)'
: 'hover:(bg-gray-200)',
]"
v-for="language in languages"
:key="language.code"
@click="switchLanguage(language.code)">
{{ language.name }}
</button>
</div>
</template>
</SettingBase>
</template>
5 changes: 3 additions & 2 deletions apps/frontend/src/app/components/setting/ThemeSetting.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ type Theme = {
name: string;
};

const themes: Theme[] = [
// Recompute labels on locale change — setLocale() does not re-run setup.
const themes = computed<Theme[]>(() => [
{
id: "system",
name: $t("settings.theme.auto"),
Expand All @@ -17,7 +18,7 @@ const themes: Theme[] = [
id: "dark",
name: $t("settings.theme.dark"),
},
];
]);

const colorMode = useColorMode();

Expand Down
1 change: 1 addition & 0 deletions apps/frontend/src/app/pages/settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ useSeoMeta({
</div>
<div class="flex flex-col gap-12">
<ThemeSetting />
<LanguageSetting />
</div>
</div>
</template>
141 changes: 141 additions & 0 deletions apps/frontend/src/i18n/locales/vi.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
{
"site": {
"url_shortener": {
"title": "rút gọn url - solitar",
"og_title": "Rút Gọn URL - Solitar",
"description": "Tạo url ngắn cho website và trang mạng xã hội của bạn trong vài giây"
},
"qr_generator": {
"title": "tạo mã qr - solitar",
"og_title": "Trình Tạo Mã QR - Solitar",
"description": "Tạo mã qr cho url của bạn trong vài giây, lưu và chia sẻ ngay lập tức"
},
"settings": {
"title": "cài đặt - solitar",
"og_title": "Cài Đặt - Solitar",
"description": "Tùy chỉnh trải nghiệm solitar của bạn"
}
},

"nav": {
"url_shortener": "rút gọn url",
"qr_generator": "tạo mã qr",
"settings": "cài đặt"
},

"footer": {
"column": {
"about": "giới thiệu",
"resource": "tài nguyên",
"legal": "pháp lý"
},
"item": {
"changelog": "nhật ký thay đổi",
"status": "trạng thái",
"translate": "dịch thuật",
"api": "api",
"terms_of_use": "điều khoản sử dụng",
"privacy_policy": "chính sách bảo mật",
"report": "báo cáo"
}
},

"feature": {
"title": "Solitar có những gì?",
"subtitle": "Mọi thứ bạn cần khi làm việc với url",
"item": {
"speed": {
"title": "Nhanh Chóng Mặt",
"description": "Chuyển hướng tức thì, không độ trễ. Không trang trung gian hay quảng cáo chèn ngang."
},
"analytics": {
"title": "Phân Tích Tôn Trọng Quyền Riêng Tư",
"description": "Theo dõi lượt nhấp và nguồn truy cập mà không thu thập dữ liệu cá nhân người dùng."
},
"api": {
"title": "Ưu Tiên Lập Trình Viên",
"description": "REST API mạnh mẽ để tự động tạo liên kết ngay trong ứng dụng của bạn."
},
"expiration": {
"title": "Hết Hạn Linh Hoạt",
"description": "Đặt liên kết tồn tại vĩnh viễn hoặc tự hủy sau một khoảng thời gian nhất định."
},
"security": {
"title": "Xác Minh Bảo Mật",
"description": "Cảnh báo người dùng khi truy cập các trang web không tuân thủ tiêu chuẩn bảo mật."
},
"open_source": {
"title": "Mã Nguồn Mở",
"description": "Logic chuyển hướng hoàn toàn minh bạch. Kiểm tra mã nguồn, tự lưu trữ."
}
}
},

"form": {
"password": {
"empty": "Mật khẩu không được để trống",
"invalid": "Mật khẩu phải là chuỗi ký tự",
"min": "Mật khẩu phải có ít nhất 3 ký tự",
"max": "Mật khẩu không được vượt quá 255 ký tự"
},
"url": {
"required": "URL là bắt buộc",
"invalid": "URL không hợp lệ"
},
"alias": {
"invalid": "Bí danh phải là chuỗi ký tự",
"min": "Bí danh phải có ít nhất 7 ký tự",
"max": "Bí danh không được vượt quá 255 ký tự"
},
"expire_time": {
"invalid": "Thời gian hết hạn phải là số",
"min": "Thời gian hết hạn phải lớn hơn 0"
}
},

"settings": {
"theme": {
"auto": "Tự động",
"light": "Sáng",
"dark": "Tối"
}
},

"counter": "{count} liên kết đã được tạo và truy cập {clicks} lần",

"hero": {
"title": "Công cụ tất-cả-trong-một mà bạn cần.",
"subtitle_1": "Solitar là nền tảng mã nguồn mở, mạnh mẽ, dùng hằng ngày, giúp bạn rút gọn liên kết dài và tạo mã qr tức thì.",
"subtitle_2": "Solitar mang đến chuyển hướng tức thì không quảng cáo chèn ngang, tự động hóa quy trình với REST API và đặt thời hạn linh hoạt cho liên kết của bạn."
},

"not_secure_warning": {
"title": "Kết nối của bạn không an toàn",
"description": "Liên kết bạn sắp truy cập không dùng HTTPS, nghĩa là kết nối của bạn không được mã hóa. Dữ liệu của bạn có thể bị bên thứ ba chặn lấy.",
"enforce_https": "Bắt buộc HTTPS",
"accept_risk": "Chấp nhận rủi ro & tiếp tục"
},

"unlock": {
"title": "URL đã bị khóa",
"description": "Liên kết bạn sắp truy cập đã bị người tạo khóa. Vui lòng nhập mật khẩu để mở khóa"
},

"button": {
"download": "tải xuống",
"copy_url": "sao chép url",
"close": "đóng",
"unlock": "mở khóa",
"return": "quay lại"
},

"error": {
"url_not_found": "Không tìm thấy URL",
"url_expired": "URL này không còn khả dụng",
"url_disabled": "URL này đã bị vô hiệu hóa do vi phạm điều khoản",
"short_code_conflict": "Bí danh này đã tồn tại.",
"password_protected": "Vui lòng nhập mật khẩu hợp lệ để mở khóa URL này",
"incorrect_password": "Mật khẩu được cung cấp không chính xác.",
"default": "Đã xảy ra lỗi"
}
}
2 changes: 1 addition & 1 deletion compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ services:
- "127.0.0.1:26258:26258"
command: start-single-node --insecure --http-addr=0.0.0.0:26258
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost:26258/health?ready=1" ]
test: ["CMD", "curl", "-f", "http://localhost:26258/health?ready=1"]
interval: 3s
timeout: 5s
retries: 3
Expand Down
Loading