Merge branch 'main' of github.com:/VERT-sh/VERT

This commit is contained in:
not-nullptr 2025-11-13 17:57:56 +00:00
commit 69ba2ff7ed
28 changed files with 624 additions and 78 deletions

View File

@ -7,11 +7,15 @@ on:
paths:
- "src/**"
- "static/**"
- "Dockerfile"
- ".dockerignore"
pull_request:
branches: ["main"]
paths:
- "src/**"
- "static/**"
- "Dockerfile"
- ".dockerignore"
workflow_dispatch:
jobs:

View File

@ -8,7 +8,7 @@ services:
PUB_HOSTNAME: ${PUB_HOSTNAME:-localhost:5173}
PUB_PLAUSIBLE_URL: ${PUB_PLAUSIBLE_URL:-}
PUB_ENV: ${PUB_ENV:-production}
PUB_DISABLE_ALL_EXTERNAL_REQUESTS: ${DISABLE_ALL_EXTERNAL_REQUESTS:-false}
PUB_DISABLE_ALL_EXTERNAL_REQUESTS: ${PUB_DISABLE_ALL_EXTERNAL_REQUESTS:-false}
PUB_VERTD_URL: ${PUB_VERTD_URL:-}
PUB_DONATION_URL: ${PUB_DONATION_URL:-https://donations.vert.sh}
PUB_STRIPE_KEY: ${PUB_STRIPE_KEY:-pk_live_51RDVmAGSxPVad6bQwzVNnbc28nlmzA30krLWk1fefCMpUPiSRPkavMMbGqa8A3lUaOCMlsUEVy2CWDYg0ip3aPpL00ZJlsMkf2}

View File

@ -52,6 +52,7 @@
"extracted": "Extracted {extract_count} files from {filename}. {ignore_count} items were ignored.",
"extract_error": "Error extracting {filename}: {error}"
},
"large_file_warning": "Due to browser / device limitations, video to audio conversion is disabled for this file as it is larger than {limit}GB. We recommend using Firefox or Safari for files of this size since they have less limitations.",
"external_warning": {
"title": "External server warning",
"text": "If you choose to convert into a video format, those files will be uploaded to an external server to be converted. Do you want to continue?",
@ -189,7 +190,15 @@
"total_size": "Total Size",
"files_cached_label": "Files Cached",
"cache_cleared": "Cache cleared successfully!",
"cache_clear_error": "Failed to clear cache."
"cache_clear_error": "Failed to clear cache.",
"site_data_title": "Site data management",
"site_data_description": "Clear all site data including settings and cached files, resetting VERT to its default state and reloading the page.",
"clear_all_data": "Clear all site data",
"clear_all_data_confirm_title": "Clear all site data?",
"clear_all_data_confirm": "This will reset all settings & cache, then reload the page. This action cannot be undone.",
"clear_all_data_cancel": "Cancel",
"all_data_cleared": "All site data cleared! Reloading page...",
"all_data_clear_error": "Failed to clear all site data."
},
"language": {
"title": "Language",
@ -224,7 +233,9 @@
"thank_you": "Thank you for your donation!",
"payment_failed": "Payment failed: {message}{period} You have not been charged.",
"donation_error": "An error occurred while processing your donation. Please try again later.",
"payment_error": "Error fetching payment details. Please try again later."
"payment_error": "Error fetching payment details. Please try again later.",
"donation_notice_official": "Your donations here go to the official VERT instance (vert.sh), and helps to support the development of the project.",
"donation_notice_unofficial": "Your donations here go to the operator of this VERT instance. If you wish to support the official VERT developers, please visit [official_link]vert.sh[/official_link] instead."
},
"credits": {
"title": "Credits",
@ -256,19 +267,24 @@
"ffmpeg": "Error loading FFmpeg, some features may not work as expected.",
"pandoc": "Error loading Pandoc worker, document conversion may not work as expected.",
"no_audio": "No audio stream found.",
"invalid_rate": "Invalid sample rate specified: {rate}Hz"
"invalid_rate": "Invalid sample rate specified: {rate}Hz",
"file_too_large": "This file exceeds the {limit}GB browser / device limit. Try Firefox or Safari to convert this large file, which typically have higher limits."
}
},
"privacy": {
"title": "Privacy Policy",
"summary": {
"title": "Summary",
"description": "VERT's privacy policy is very simple: we do not collect or store any data on you at all. We don't use cookies or trackers, analytics are completely private, and all conversions (except videos) happen locally on your browser. Videos are deleted after being downloaded, or an hour, unless explicitly given permission by you to be stored; it will only be used for the purpose of troubleshooting. VERT self-hosts a Coolify instance for hosting the website and vertd (for video conversion), and a Plausible instance for completely anonymous and aggregated analytics.<br/><br/>Note this may only apply to the official VERT instance at [vert_link]vert.sh[/vert_link]; third-party instances may handle your data differently."
"description": "VERT's privacy policy is very simple: we do not collect or store any data on you at all. We don't use cookies or trackers, analytics are completely private, and all conversions (except videos) happen locally on your browser. Videos are deleted after being downloaded, or an hour, unless explicitly given permission by you to be stored; it will only be used for the purpose of troubleshooting. VERT self-hosts a Coolify instance for hosting the website and vertd (for video conversion), and a Plausible instance for completely anonymous and aggregated analytics. We use Stripe to process donations, which may collect some data used for fraud prevention.<br/><br/>Note this may only apply to the official VERT instance at [vert_link]vert.sh[/vert_link]; third-party instances may handle your data differently."
},
"conversions": {
"title": "Conversions",
"description": "Most conversions (images, documents, audio) happen entirely locally on your device using WebAssembly versions of the relevant tools (e.g. ImageMagick, Pandoc, FFmpeg). This means your files never leave your device and we will never have access to them.<br/><br/>Video conversions are performed on our servers because they require more processing power and cannot be done very quickly on the browser yet. Videos you convert with VERT are deleted after being downloaded, or after one hour, unless you explicitly give permission for us to store them longer purely for troubleshooting purposes."
},
"donations": {
"title": "Donations",
"description": "We use Stripe on the [about_link]about[/about_link] page to collect donations. Stripe may collect certain information about the payment and device for fraud prevention as described in [stripe_link]their documentation on advanced fraud detection[/stripe_link]. External network requests to Stripe are deferred, and are only made after you click the button to pay."
},
"conversion_errors": {
"title": "Conversion Errors",
"description": "When a video conversion fails, we may collect some anonymous data to help us diagnose the issue. This data may include:",
@ -291,6 +307,6 @@
"title": "Contact",
"description": "For questions, email us at: [email_link]hello@vert.sh[/email_link]. If you are using a third-party instance of VERT, please contact the hoster of that instance instead."
},
"last_updated": "Last updated: 2025-10-19"
"last_updated": "Last updated: 2025-10-29"
}
}

295
messages/ko.json Normal file
View File

@ -0,0 +1,295 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"navbar": {
"upload": "업로드",
"convert": "변환",
"settings": "설정",
"about": "정보",
"toggle_theme": "테마 전환"
},
"footer": {
"copyright": "© {year} VERT.",
"source_code": "소스 코드",
"discord_server": "Discord 서버",
"privacy_policy": "개인정보 처리방침"
},
"upload": {
"title": "이 파일 변환기,\n 마음에 드실 거예요.",
"subtitle": "모든 이미지, 오디오, 문서 처리는 사용자의 기기에서 이루어집니다. 동영상은 매우 빠른 VERT 전용 서버에서 변환됩니다. 광고나 파일 크기 제한이 전혀 없는 완전한 오픈 소스입니다.",
"uploader": {
"text": "드래그하거나 클릭해서 {action}",
"convert": "변환하기"
},
"cards": {
"title": "VERT가 지원하는 포맷들",
"images": "이미지",
"audio": "오디오",
"documents": "문서",
"video": "동영상",
"video_server_processing": "서버 지원",
"local_supported": "로컬 지원",
"status": {
"text": "<b>상태:</b> {status}",
"ready": "준비됨",
"not_ready": "준비되지 않음",
"not_initialized": "준비 안됨",
"downloading": "다운로드중...",
"initializing": "준비중...",
"unknown": "알 수 없음"
},
"supported_formats": "지원 포맷:"
},
"tooltip": {
"partial_support": "이 형식은 {direction}으로만 변환할 수 있습니다.",
"direction_input": "입력 (from)",
"direction_output": "출력 (to)",
"video_server_processing": "동영상은 기본적으로 처리를 위해 서버로 업로드됩니다. 로컬로 처리하도록 설정하는 방법은 여기에서 확인하세요."
}
},
"convert": {
"zip_file": {
"extracting": "ZIP파일 감지됨: {filename}",
"extracted": "{filename}압축 파일에서 {extract_count}개의 파일을 풀었습니다. {ignore_count}개 항목은 무시되었습니다.",
"extract_error": "{filename}압축 파일 풀던 중 오류 발생: {error}"
},
"external_warning": {
"title": "외부 서버 경고",
"text": "동영상 형식으로 변환을 선택하면 해당 파일은 변환을 위해 지정한 외부 서버로 업로드됩니다. 계속하시겠습니까?",
"yes": "계속",
"no": "아니오"
},
"panel": {
"convert_all": "모두 변환",
"download_all": ".zip으로 다운로드",
"remove_all": "모든 파일 삭제",
"set_all_to": "모두 다음으로 설정",
"na": "N/A"
},
"dropdown": {
"audio": "오디오",
"video": "비디오",
"doc": "문서",
"image": "이미지",
"placeholder": "포맷 검색"
},
"tooltips": {
"unknown_file": "알 수 없는 파일 포맷",
"audio_file": "오디오 파일",
"video_file": "비디오 파일",
"document_file": "문서 파일",
"image_file": "이미지 파일",
"convert_file": "파일 변환하기",
"download_file": "파일 다운로드"
},
"errors": {
"cant_convert": "이 파일을 변환할 수 없습니다.",
"vertd_server": "뭐 하는거임? vertd 서버부터 실행하셈",
"vertd_generic_view": "오류 세부정보 보기",
"vertd_generic_body": "비디오 변환 중 오류가 발생했습니다. 이 비디오를 개발자에게 전송해서 이 버그를 수정하는 데 도움을 주시겠습니까? 오직 비디오 파일만 전송됩니다. 익명으로 처리되며, 다른 개인 정보는 포함되지 않습니다.",
"vertd_generic_title": "비디오 변환 오류",
"vertd_generic_yes": "비디오 전송",
"vertd_generic_no": "전송 안 함",
"vertd_failed_to_keep": "영상을 서버에 저장하는데 실패했습니다: {error}",
"vertd_details": "오류 세부정보 보기",
"vertd_details_body": "제출을 누르면, 검토를 위해 항상 보고되는 오류 로그와 함께 <b>동영상도 첨부</b>됩니다. 아래 정보는 우리가 자동으로 받는 로그입니다:",
"vertd_details_footer": "이 정보는 문제 해결 목적으로만 사용되며 절대 공유되지 않습니다. 자세한 내용은 [privacy_link]개인정보 처리방침[/privacy_link]을 확인하세요.",
"vertd_details_job_id": "<b>작업 ID:</b> {jobId}",
"vertd_details_from": "<b>원본 포맷:</b> {from}",
"vertd_details_to": "<b>변환 포맷:</b> {to}",
"vertd_details_error_message": "<b>오류 메시지:</b> [view_link]오류 로그 보기[/view_link]",
"vertd_details_close": "닫기",
"unsupported_format": "이미지, 비디오, 오디오 및 문서 파일만 지원됩니다.",
"format_output_only": "이 포맷은 현재 입력으로 사용할 수 없으며 (변환된)출력으로만 사용할 수 있습니다.",
"vertd_not_found": "비디오 변환을 시작할 vertd 인스턴스를 찾을 수 없습니다. 인스턴스 URL이 올바르게 설정되었는지 확인해주세요.",
"worker_downloading": "현재 {type} 변환기를 준비하고 있습니다. 잠시 기다려 주십시오.",
"worker_error": "현재 {type} 변환기 준비 중 오류가 발생했습니다. 나중에 다시 시도해 주십시오.",
"worker_timeout": "{type} 변환기를 준비하는데 예상보다 오래 걸리고 있습니다. 잠시 더 기다리거나 페이지를 새로고침해 주세요.",
"audio": "오디오",
"doc": "문서",
"image": "이미지"
}
},
"settings": {
"title": "설정",
"errors": {
"save_failed": "현재 설정을 저장하는데 실패했습니다"
},
"appearance": {
"title": "테마",
"brightness_theme": "테마 변경",
"brightness_description": "걍 알아서",
"light": "라이트 모드",
"dark": "다크 모드",
"effect_settings": "이펙트(효과) 설정",
"effect_description": "동적인 애니메이션이나 이펙트, 아님 정적인거?",
"enable": "켜기",
"disable": "끄기"
},
"conversion": {
"title": "변환",
"advanced_settings": "고급 설정",
"filename_format": "파일 이름 형식",
"filename_description": "다운로드할 파일의 이름을 설정합니다. <b>파일 확장자(포맷)는 포함되지 않습니다.</b> 다음 템플릿을 형식에 넣을 수 있으며, 관련 정보로 대체됩니다: <b>%name%</b> 원본 파일 이름, <b>%extension%</b> 원본 파일 확장자, <b>%date%</b> 파일이 변환된 날짜 문자열.",
"placeholder": "VERT_%name%",
"default_format": "기본 변환 형식",
"default_format_description": "파일 유형의 파일을 업로드할 때 선택되는 기본 형식을 변경합니다.",
"default_format_image": "이미지",
"default_format_video": "비디오",
"default_format_audio": "오디오",
"default_format_document": "문서",
"metadata": "파일 메타데이터",
"metadata_description": "원본 파일의 메타데이터(EXIF, 노래 정보 등)가 변환된 파일에 유지되는지 선택할 수 있습니다.",
"keep": "유지",
"remove": "제거",
"quality": "변환 품질",
"quality_description": "변환된 파일의 기본 출력 품질을 변경합니다(카테고리 내에서). 더 높은 값은 더 긴 변환 시간과 파일 크기를 초래할 수 있습니다.",
"quality_video": "변환된 비디오 파일의 기본 출력 품질을 변경합니다. 높은 값은 더 긴시간과 파일 크기를 초래할 수 있습니다.",
"quality_audio": "오디오 (kbps)",
"quality_images": "이미지 (%)",
"rate": "샘플링 주파수 (Hz)"
},
"vertd": {
"title": "비디오 변환 서버",
"status": "상태:",
"loading": "로딩중...",
"available": "사용 가능, 커밋 ID {commitId}",
"unavailable": "사용 불가 (URL를 다시 확인해주세요.)",
"description": "<code>vertd</code> 프로젝트는 FFmpeg를 위한 서버 래퍼입니다. 이를 통해 VERT의 웹 인터페이스를 통해 비디오를 변환할 수 있으며, GPU를 활용하여 가능한 한 빠르게 작업을 수행할 수 있습니다.",
"hosting_info": "편의를 위해 공개 인스턴스를 호스팅하지만, PC나 서버에서 직접 호스팅하는 것도 매우 쉽습니다. 서버 바이너리를 [vertd_link]여기[/vertd_link]에서 다운로드할 수 있습니다. 이 설정 프로세스는 앞으로 더 쉬워질 것이므로 기대해 주세요!",
"instance": "인스턴스",
"url_placeholder": "예시: http://localhost:24153",
"conversion_speed": "변환 속도",
"speed_description": "이는 속도와 품질 사이의 균형을 설명합니다. 속도를 높일수록 품질은 낮아지지만 작업 속도는 더 빨라집니다.",
"speeds": {
"very_slow": "매우 느림",
"slower": "느림",
"slow": "조금 느림",
"medium": "보통",
"fast": "빠름",
"ultra_fast": "매우 빠름"
},
"auto_instance": "자동 (권장됨)",
"eu_instance": "Falkenstein, Germany",
"us_instance": "Washington, USA",
"custom_instance": "사용자 지정"
},
"privacy": {
"title": "개인정보 및 데이터",
"plausible_title": "Plausible analytics",
"plausible_description": "우리는 개인정보 보호에 초점을 둔 분석 도구인 [plausible_link]Plausible[/plausible_link]를 사용해 완전히 익명화된 통계를 수집합니다. 모든 데이터는 익명화되어 집계되며, 식별 가능한 정보는 전송되거나 보관되지 않습니다. 분석 결과는 [analytics_link]여기[/analytics_link]에서 확인할 수 있고, 아래에서 수집을 거부(opt-out)할 수 있습니다",
"opt_in": "수락",
"opt_out": "거부",
"cache_title": "캐시 정리",
"cache_description": "브라우저에 변환기 파일을 캐시하여 매번 다시 다운로드할 필요가 없도록 하여 최적화와 데이터 사용량을 줄입니다.",
"refresh_cache": "캐시 새로고침",
"clear_cache": "캐시 지우기",
"files_cached": "{size} ({count} files)",
"loading_cache": "로딩중...",
"total_size": "총 크기",
"files_cached_label": "캐시된 파일",
"cache_cleared": "캐시를 성공적으로 지웠습니다!",
"cache_clear_error": "캐시를 지우는 중 오류가 발생했습니다"
},
"language": {
"title": "언어",
"description": "선호하시는 언어를 선택하세요."
}
},
"about": {
"title": "정보",
"why": {
"title": "왜 VERT인가?",
"description": "<b>파일 변환기들은 항상 저희 기대치에 충족하지 못했습니다.</b> 못생긴 UI에, 광고로 떡칠하고, 그리고 가장 중요한 것은 느리다는겁니다. 그래서 저희가 이 모든 문제를 한 번에 해결할 대안을 직접 만들기로 했습니다. 기존 변환기들의 단점을 해결한 것은 물론이고, 그 이상의 기능도 제공하죠<br/><br/>동영상을 제외한 모든 파일은 사용자의 기기에서 바로 변환됩니다. 즉, 서버로 파일을 보냈다가 다시 받는 시간이 전혀 필요 없고, 저희가 여러분의 파일을 엿볼 일도 전혀 없다는 뜻입니다.<br/><br/>예외적으로 동영상 파일은 초고속 RTX 4000 Ada 서버로 업로드됩니다. 변환하지 않으면 영상은 서버에 1시간 동안 유지됩니다. 변환한 경우에도 영상은 서버에 최대 1시간 또는 다운로드될 때까지 보관되며, 그 후 서버에서 삭제됩니다."
},
"sponsors": {
"title": "후원자",
"description": "지원하고 싶으신가요? [discord_link]Discord[/discord_link] 서버의 개발자에게 문의하시거나, 다음 이메일로 보내주세요:",
"email_copied": "클립보드에 이메일 주소가 복사되었습니다!"
},
"resources": {
"title": "Resources",
"discord": "Discord",
"source": "소스 코드",
"email": "이메일"
},
"donate": {
"title": "VERT에 기부하기",
"description": "여러분의 후원으로 VERT를 지속적으로 유지하고 개발할 수 있습니다.",
"one_time": "일회성",
"monthly": "매월",
"custom": "사용자 지정",
"pay_now": "지금 결제하기",
"donate_amount": "${amount} USD 후원하기",
"thank_you": "후원해주셔서 감사합니다!",
"payment_failed": "결제 실패: {message}{period} 요금이 청구되지 않았습니다.",
"donation_error": "결제 처리 중 오류가 발생했습니다. 나중에 다시 시도해 주세요.",
"payment_error": "결제 세부정보를 가져오는 중 오류가 발생했습니다. 나중에 다시 시도해 주세요."
},
"credits": {
"title": "Credits",
"contact_team": "개발팀에 연락하시려면 \"Resources\" 카드에 있는 이메일로 연락해 주세요.",
"notable_contributors": "주요 기여자",
"notable_description": "VERT에 크게 기여해 주신 분들께 정말 감사드립니다.",
"github_contributors": "GitHub 기여자",
"github_description": "도와주신 모든 분들께 진심으로 감사드립니다! [github_link]기여하기[/github_link]",
"no_contributors": "아직 기여한 사람이 없는 것 같습니다... [contribute_link]첫 번째 기여자가 되어보세요![/contribute_link]",
"libraries": "라이브러리들",
"libraries_description": "수년 동안 훌륭한 라이브러리를 유지해 주신 FFmpeg (오디오, 비디오), ImageMagick (이미지) 및 Pandoc (문서)에 진심으로 감사드립니다. VERT는 위 라이브러리들을 사용하여 변환을 제공합니다.",
"roles": {
"lead_developer": "총괄 개발자; 변환 백엔드, UI 구현",
"developer": "개발자; UI 구현",
"designer": "디자이너; UX, 브랜딩, 마케팅",
"docker_ci": "Docker 및 CI 지원 유지",
"former_cofounder": "전 공동 창립자 및 디자이너"
}
},
"errors": {
"github_contributors": "Error, Github 기여자 불러오기 실패"
}
},
"workers": {
"errors": {
"general": "{file}파일을 변환하는데 오류 발생: {message}",
"cancel": "{file}파일 변환 취소 중 오류 발생: {message}",
"magick": "Magick 작업에서 오류 발생, 이미지 변환이 예상대로 작동하지 않을 수 있습니다.",
"ffmpeg": "FFmpeg 로드 중 오류 발생, 일부 기능이 예상대로 작동하지 않을 수 있습니다.",
"pandoc": "Pandoc 작업 로드 중 오류 발생, 문서 변환이 예상대로 작동하지 않을 수 있습니다.",
"no_audio": "오디오 스트림을 찾을 수 없습니다.",
"invalid_rate": "지정된 샘플 레이트가 유효하지 않습니다: {rate}Hz"
}
},
"privacy": {
"title": "개인정보 처리방침",
"summary": {
"title": "요약",
"description": "VERT의 개인정보 처리방침은 매우 간단합니다: 우리는 귀하에 대한 데이터를 수집하거나 보관하지 않습니다. 우리는 쿠키나 유저를 추적하지 않으며,, 모든 변환(비디오 제외)은 귀하의 브라우저에서 로컬로 수행됩니다. 비디오는 다운로드 후 또는 1시간 후에 삭제되며, 귀하가 명시적으로 보관을 허용한 경우에만 문제 해결을 위해 사용됩니다. VERT는 웹사이트 호스팅을 위한 Coolify 인스턴스와 비디오 변환을 위한 vertd, 완전히 익명화되고 집계된 분석을 위한 Plausible 인스턴스를 자체 호스팅합니다.<br/><br/>이는 [vert_link]vert.sh[/vert_link]의 공식 VERT 인스턴스에만 적용될 수 있습니다. 타사 인스턴스는 귀하의 데이터를 다르게 처리할 수 있습니다."
},
"conversions": {
"title": "변환",
"description": "대부분의 변환(이미지, 문서, 오디오)은 관련 도구의 WebAssembly 버전(예: ImageMagick, Pandoc, FFmpeg)을 사용하여 여러분의 기기에서 로컬로 수행됩니다. 즉, 파일이 기기를 떠나지 않으며 우리가 파일에 접근할 일은 없습니다.<br/><br/>동영상 변환은 더 높은 연산 성능이 필요하고 아직 브라우저에서 충분히 빠르게 처리하기 어려워 서버에서 수행됩니다. VERT로 변환한 동영상은 다운로드 후 또는 1시간이 지나면 삭제되며, 문제 해결만을 위해 더 오래 보관하도록 명시적으로 허용한 경우에만 예외적으로 보관됩니다."
},
"conversion_errors": {
"title": "변환 오류",
"description": "비디오 변환이 실패할 경우, 문제 진단을 위해 일부 익명 데이터를 수집할 수 있습니다. 이 데이터에는 다음이 포함될 수 있습니다:",
"list_job_id": "작업 ID (익명화된 파일 이름)",
"list_format_from": "변환 전 포맷",
"list_format_to": "변환 후 포맷",
"list_stderr": "작업의 FFmpeg stderr 출력 (오류 메시지)",
"list_video": "실제 비디오 파일 (명시적 권한이 부여된 경우)",
"footer": "이 정보는 오직 변환 문제를 진단하기 위해서만 사용됩니다. 실제 비디오 파일은 귀하가 수락한 경우에만 수집되며, 그 경우에도 오직 문제 해결을 위해서만 사용됩니다."
},
"analytics": {
"title": "분석",
"description": "저희는 완전히 익명화되고 집계된 분석을 위해 Plausible을 자체 호스팅합니다. Plausible는 쿠키를 사용하지 않으며 모든 주요 개인정보 보호 규정(GDPR/CCPA/PECR)을 준수합니다. \"개인정보 및 데이터\" 섹션에서 [settings_link]설정[/settings_link]을 통해 분석을 선택 해제할 수 있으며, Plausible의 개인정보 보호 관행에 대한 자세한 내용은 [plausible_link]여기[/plausible_link]에서 확인할 수 있습니다."
},
"local_storage": {
"title": "Local Storage",
"description": "브라우저의 로컬 스토리지를 사용해 설정을 저장하고, 반복적인 GitHub API 요청을 줄이기 위해 \"정보\" 섹션의 GitHub 기여자 목록을 브라우저의 세션 스토리지에 임시로 저장합니다. 어떤 개인 데이터도 저장되거나 전송되지 않습니다.<br/><br/>사용되는 변환 도구(FFmpeg, ImageMagick, Pandoc)의 WebAssembly 버전도 사용자가 처음 웹사이트를 방문할 때 브라우저에 로컬로 저장되므로, 매번 다시 다운로드할 필요가 없습니다. 어떤 개인 정보나 데이터도 저장되거나 전송되지 않습니다. 이 데이터는 언제든지 [settings_link]설정[/settings_link]의 \"개인정보 및 데이터\" 섹션에서 확인하거나 삭제할 수 있습니다."
},
"contact": {
"title": "문의하기",
"description": "질문이 있으시면 다음 이메일로 문의해 주세요: [email_link]hello@vert.sh[/email_link]. 서드파티 VERT 인스턴스를 사용 중인 경우 해당 인스턴스의 호스트에게 문의해 주세요."
},
"last_updated": "Last updated: 2025-10-19"
}
}

View File

@ -232,7 +232,7 @@
"magick": "Magick işlemi sırasında hata oluştu, görsel dönüştürme işlemi beklendiği gibi çalışmayabilir.",
"ffmpeg": "ffmpeg yüklenirken hata oluştu, bazı özellikler çalışmayabilir.",
"no_audio": "Ses akışı bulunamadı.",
"invalid_rate": "Geçersiz örnekleme hızı: {hız}Hz"
"invalid_rate": "Geçersiz örnekleme hızı: {rate}Hz"
}
}
}

View File

@ -47,6 +47,12 @@
}
},
"convert": {
"zip_file": {
"extracting": "检测到 ZIP 压缩包:{filename}",
"extracted": "从 {filename} 中提取了 {extract_count} 个文件。{ignore_count} 个项目被忽略。",
"extract_error": "提取 {filename} 时出错:{error}"
},
"large_file_warning": "由于浏览器/设备限制,此文件大于 {limit}GB视频转音频功能已禁用。我们建议使用 Firefox 或 Safari 处理此大小的文件,因为它们的限制较少。",
"external_warning": {
"title": "外部服务器警告",
"text": "如果你选择转换为视频格式,这些文件将被上传到外部服务器进行转换。是否继续?",
@ -183,7 +189,15 @@
"total_size": "总大小",
"files_cached_label": "已缓存文件",
"cache_cleared": "缓存已成功清除!",
"cache_clear_error": "清除缓存失败。"
"cache_clear_error": "清除缓存失败。",
"site_data_title": "网站数据管理",
"site_data_description": "清除所有网站数据,包括设置和缓存文件,将 VERT 重置为默认状态并重新加载页面。",
"clear_all_data": "清除所有网站数据",
"clear_all_data_confirm_title": "清除所有网站数据?",
"clear_all_data_confirm": "这将重置所有设置和缓存,然后重新加载页面。此操作无法撤消。",
"clear_all_data_cancel": "取消",
"all_data_cleared": "所有网站数据已清除!正在重新加载页面...",
"all_data_clear_error": "清除所有网站数据失败。"
},
"language": {
"title": "语言",
@ -248,8 +262,10 @@
"cancel": "取消转换 {file} 时出错:{message}",
"magick": "Magick worker 出错,图片转换可能无法正常工作。",
"ffmpeg": "加载 ffmpeg 时出错,某些功能可能无法工作。",
"pandoc": "加载 Pandoc worker 时出错,文档转换可能无法正常工作。",
"no_audio": "未找到音频流。",
"invalid_rate": "指定的采样率无效:{rate}Hz"
"invalid_rate": "指定的采样率无效:{rate}Hz",
"file_too_large": "此文件超过 {limit}GB 浏览器/设备限制。请尝试使用 Firefox 或 Safari 转换此大文件,它们通常具有更高的限制。"
}
},
"privacy": {

View File

@ -47,6 +47,12 @@
}
},
"convert": {
"zip_file": {
"extracting": "偵測到 ZIP 壓縮檔:{filename}",
"extracted": "從 {filename} 中提取了 {extract_count} 個檔案。{ignore_count} 個項目被忽略。",
"extract_error": "提取 {filename} 時出錯:{error}"
},
"large_file_warning": "由於瀏覽器/裝置限制,此檔案大於 {limit}GB影片轉音訊功能已停用。我們建議使用 Firefox 或 Safari 處理此大小的檔案,因為它們的限制較少。",
"external_warning": {
"title": "外部伺服器警告",
"text": "如果你選擇轉換為影片格式,這些檔案將被上傳到外部伺服器進行轉換。是否繼續?",
@ -183,7 +189,15 @@
"total_size": "總大小",
"files_cached_label": "已快取檔案",
"cache_cleared": "快取已成功清除!",
"cache_clear_error": "清除快取失敗。"
"cache_clear_error": "清除快取失敗。",
"site_data_title": "網站資料管理",
"site_data_description": "清除所有網站資料,包括設定和快取檔案,將 VERT 重置為預設狀態並重新載入頁面。",
"clear_all_data": "清除所有網站資料",
"clear_all_data_confirm_title": "清除所有網站資料?",
"clear_all_data_confirm": "這將重置所有設定和快取,然後重新載入頁面。此操作無法復原。",
"clear_all_data_cancel": "取消",
"all_data_cleared": "所有網站資料已清除!正在重新載入頁面...",
"all_data_clear_error": "清除所有網站資料失敗。"
},
"language": {
"title": "語言",
@ -248,8 +262,10 @@
"cancel": "取消轉換 {file} 時出錯:{message}",
"magick": "Magick worker 出錯,圖片轉換可能無法正常運作。",
"ffmpeg": "載入 ffmpeg 時出錯,某些功能可能無法運作。",
"pandoc": "載入 Pandoc worker 時出錯,文件轉換可能無法正常運作。",
"no_audio": "未找到音訊串流。",
"invalid_rate": "指定的取樣率無效:{rate}Hz"
"invalid_rate": "指定的取樣率無效:{rate}Hz",
"file_too_large": "此檔案超過 {limit}GB 瀏覽器/裝置限制。請嘗試使用 Firefox 或 Safari 轉換此大型檔案,它們通常具有較高的限制。"
}
},
"privacy": {

View File

@ -1,15 +0,0 @@
server {
listen 80;
server_name vert;
root /usr/share/nginx/html;
index index.html;
client_max_body_size 10M;
location / {
try_files $uri $uri/ /index.html;
}
error_page 404 /index.html;
}

View File

@ -4,7 +4,7 @@
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"build": "paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide && vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",

View File

@ -1,7 +1,7 @@
{
"$schema": "https://inlang.com/schema/project-settings",
"baseLocale": "en",
"locales": ["en", "es", "fr", "de", "it", "hr", "tr", "ja", "el", "id", "zh-Hans", "zh-Hant"],
"locales": ["en", "es", "fr", "de", "it", "hr", "tr", "ja", "ko", "el", "id", "zh-Hans", "zh-Hant"],
"modules": [
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js",
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js"

View File

@ -7,6 +7,7 @@
import { ChevronDown, SearchIcon } from "lucide-svelte";
import { onMount } from "svelte";
import { quintOut } from "svelte/easing";
import type { VertFile } from "$lib/types";
type Props = {
categories: Categories;
@ -15,6 +16,7 @@
onselect?: (option: string) => void;
disabled?: boolean;
dropdownSize?: "default" | "large" | "small";
file?: VertFile;
};
let {
@ -24,6 +26,7 @@
onselect,
disabled,
dropdownSize = "default",
file,
}: Props = $props();
let open = $state(false);
let dropdown = $state<HTMLDivElement>();
@ -67,6 +70,15 @@
);
if (from === ".gif") finalCategories.push("video");
// filter out categories that can't handle large files (due to browser/device limitations)
if (file && file.isLarge()) {
// if file is large video, disable audio conversion
if (rootCategory === "video")
finalCategories = finalCategories.filter(
(cat) => cat !== "audio",
);
}
return finalCategories;
});

View File

@ -17,7 +17,7 @@
></div>
{/if}
<style>
<style lang="postcss">
.dragoverlay {
animation: dragoverlay-animation 3s infinite linear;
}

View File

@ -9,7 +9,7 @@
let { children, text, className, position = "top" }: Props = $props();
let showTooltip = $state(false);
let timeout: number = 0;
let timeout: NodeJS.Timeout | null = null;
let triggerElement: HTMLElement;
let tooltipElement = $state<HTMLElement>();
let tooltipPosition = $state({ x: 0, y: 0 });
@ -51,7 +51,7 @@
function hide() {
showTooltip = false;
clearTimeout(timeout);
if (timeout) clearTimeout(timeout);
}
$effect(() => {
@ -94,7 +94,7 @@
</span>
{/if}
<style>
<style lang="postcss">
.tooltip {
--border-size: 1px;
@apply fixed bg-panel-alt text-foreground border border-stone-400 dynadark:border-white drop-shadow-lg text-xs rounded-full pointer-events-none z-[999] max-w-xs break-words whitespace-normal;

View File

@ -15,3 +15,5 @@ export const CONTACT_EMAIL = "hello@vert.sh";
// i'm not entirely sure this should be in consts.ts, but it is technically a constant as .env is static for VERT
export const DISABLE_ALL_EXTERNAL_REQUESTS =
PUB_DISABLE_ALL_EXTERNAL_REQUESTS === "true";
export const GB = 1024 * 1024 * 1024;

View File

@ -38,7 +38,7 @@ export class Converter {
public status: WorkerStatus = $state("not-ready");
public readonly reportsProgress: boolean = false;
private timeoutId?: number;
private timeoutId?: NodeJS.Timeout;
constructor(public readonly timeout: number = 10) {
this.startTimeout();

View File

@ -8,25 +8,22 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { page } from "$app/state";
import {
PUB_DONATION_URL,
PUB_HOSTNAME,
PUB_STRIPE_KEY,
} from "$env/static/public";
import { PUB_DONATION_URL, PUB_STRIPE_KEY } from "$env/static/public";
const OFFICIAL_DONATION_URL = "https://donations.vert.sh";
const OFFICIAL_STRIPE_KEY =
"pk_live_51RDVmAGSxPVad6bQwzVNnbc28nlmzA30krLWk1fefCMpUPiSRPkavMMbGqa8A3lUaOCMlsUEVy2CWDYg0ip3aPpL00ZJlsMkf2";
const isOfficial =
PUB_DONATION_URL === OFFICIAL_DONATION_URL &&
PUB_STRIPE_KEY === OFFICIAL_STRIPE_KEY;
// import { PUB_STRIPE_KEY, PUB_DONATION_API } from "$env/static/public";
import { fade } from "$lib/animation";
import FancyInput from "$lib/components/functional/FancyInput.svelte";
import Panel from "$lib/components/visual/Panel.svelte";
import { effects } from "$lib/store/index.svelte";
import {
loadStripe,
type Stripe,
type StripeElements,
} from "@stripe/stripe-js";
import { effects, link, sanitize } from "$lib/store/index.svelte";
import { loadStripe } from "@stripe/stripe-js/pure";
import { type Stripe, type StripeElements } from "@stripe/stripe-js";
import clsx from "clsx";
import {
CalendarHeartIcon,
@ -39,6 +36,7 @@
import { quintOut } from "svelte/easing";
import { m } from "$lib/paraglide/messages";
import { ToastManager } from "$lib/toast/index.svelte";
import { log } from "$lib/logger";
let amount = $state(1);
let customAmount = $state("");
@ -59,6 +57,9 @@
const paymentClick = async () => {
if (paymentState !== "prepay") return;
if (!stripe) stripe = await loadStripe(PUB_STRIPE_KEY);
paymentState = "fetching";
const res = await fetch(`${PUB_DONATION_URL}/billing`, {
method: "POST",
@ -89,7 +90,17 @@
const transition = "cubic-bezier(0.23, 1, 0.320, 1)";
onMount(async () => {
stripe = await loadStripe(PUB_STRIPE_KEY);
if (!isOfficial) {
log(
["about", "donate"],
"donations are being sent to an unofficial VERT instance - PUB_DONATION_URL and/or PUB_STRIPE_KEY have been changed.",
);
} else {
log(
["about", "donate"],
"donations are being sent to the official VERT instance.",
);
}
});
const donate = async () => {
@ -328,4 +339,20 @@
</div>
</div>
</div>
<p class="text-sm font-normal text-muted">
{#if isOfficial}
{m["about.donate.donation_notice_official"]()}
{:else}
{@html sanitize(
link(
"official_link",
m["about.donate.donation_notice_unofficial"](),
"https://vert.sh",
true,
"",
),
)}
{/if}
</p>
</Panel>

View File

@ -9,7 +9,7 @@
import { ToastManager } from "$lib/toast/index.svelte";
let copied = false;
let timeoutId: number | undefined;
let timeoutId: NodeJS.Timeout | null = null;
function copyToClipboard() {
try {
@ -73,7 +73,7 @@
</div>
</Panel>
<style>
<style lang="postcss">
#email {
@apply font-mono bg-gray-200 rounded-md px-1 text-inherit no-underline dynadark:bg-panel-alt dynadark:text-white;
}

View File

@ -16,6 +16,7 @@
import { error } from "$lib/logger";
import { ToastManager } from "$lib/toast/index.svelte";
import { DISABLE_ALL_EXTERNAL_REQUESTS } from "$lib/consts";
import { addDialog } from "$lib/store/DialogProvider";
const { settings = $bindable() }: { settings: ISettings } = $props();
@ -66,6 +67,57 @@
}
}
async function clearAllData() {
if (isLoadingCache) return;
addDialog(
m["settings.privacy.clear_all_data_confirm_title"](),
m["settings.privacy.clear_all_data_confirm"](),
[
{
text: m["settings.privacy.clear_all_data_cancel"](),
action: () => {},
},
{
text: m["settings.privacy.clear_all_data"](),
action: async () => {
isLoadingCache = true;
try {
await swManager.clearCache();
localStorage.clear();
sessionStorage.clear();
ToastManager.add({
type: "success",
message:
m["settings.privacy.all_data_cleared"](),
});
setTimeout(() => {
window.location.href = "/";
}, 1500);
} catch (err) {
error(
["privacy", "data"],
`Failed to clear all data: ${err}`,
);
ToastManager.add({
type: "error",
message:
m[
"settings.privacy.all_data_clear_error"
](),
});
} finally {
isLoadingCache = false;
}
},
},
],
"warning",
);
}
onMount(() => {
loadCacheInfo();
});
@ -195,6 +247,28 @@
</button>
</div>
</div>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<p class="text-base font-bold">
{m["settings.privacy.site_data_title"]()}
</p>
<p class="text-sm text-muted font-normal">
{m["settings.privacy.site_data_description"]()}
</p>
</div>
<button
onclick={clearAllData}
class="btn {$effects
? ''
: '!scale-100'} w-full p-4 rounded-lg text-black dynadark:text-white flex items-center justify-center"
disabled={isLoadingCache}
>
<Trash2Icon size="24" class="inline-block mr-2" />
{m["settings.privacy.clear_all_data"]()}
</button>
</div>
</div>
</div></Panel
>

View File

@ -11,6 +11,7 @@ import { m } from "$lib/paraglide/messages";
import sanitizeHtml from "sanitize-html";
import { unzip } from "fflate";
import { ToastManager } from "$lib/toast/index.svelte";
import { GB } from "$lib/consts";
class Files {
public files = $state<VertFile[]>([]);
@ -259,7 +260,20 @@ class Files {
this.files.push(vf);
this._addThumbnail(vf);
const isVideo = converter.name === "vertd";
const convName = converter.name;
if (file.size > MAX_ARRAY_BUFFER_SIZE && convName === "vertd") {
ToastManager.add({
type: "warning",
message: m["convert.large_file_warning"]({
limit: (MAX_ARRAY_BUFFER_SIZE / GB).toFixed(2),
}),
durations: {
stay: 10000,
},
});
}
const isVideo = convName === "vertd";
const acceptedExternalWarning =
localStorage.getItem("acceptedExternalWarning") === "true";
if (isVideo && !acceptedExternalWarning && !this._warningShown) {
@ -418,6 +432,7 @@ export const availableLocales = {
id: "Bahasa Indonesia",
tr: "Türkçe",
ja: "日本語",
ko: "한국어",
el: "Ελληνικά",
"zh-Hans": "简体中文",
"zh-Hant": "繁體中文",
@ -480,3 +495,66 @@ export function sanitize(
allowedSchemes: ["http", "https", "mailto", "blob"],
});
}
/**
* Binary search for a max value without knowing the exact value, only that it
* can be under or over It dose not test every number but instead looks for
* 1,2,4,8,16,32,64,128,96,95 to figure out that you thought about #96 from
* 0-infinity
*
* @example findFirstPositive(x => matchMedia(`(max-resolution: ${x}dpi)`).matches)
* @author Jimmy Wärting
* @see {@link https://stackoverflow.com/a/72124984/1008999}
* @param {function} f The function to run the test on (should return truthy or falsy values)
* @param {bigint} [b=1] Where to start looking from
* @param {function} d privately used to calculate the next value to test
* @returns {bigint} Integer
*/
function findFirstPositive(
f: (x: bigint) => number,
b = 1n,
d = (e: bigint, g: bigint, c?: bigint): bigint =>
g < e
? -1n
: 0 < f((c = (e + g) >> 1n))
? c == e || 0 >= f(c - 1n)
? c
: d(e, c - 1n)
: d(c + 1n, g),
): bigint {
for (; 0 >= f(b); b <<= 1n);
return d(b >> 1n, b) - 1n;
}
export const getMaxArrayBufferSize = (): number => {
if (typeof window === "undefined") return 2 * GB; // default for SSR
// check cache first
const cached = localStorage.getItem("maxArrayBufferSize");
if (cached) {
const parsed = Number(cached);
log(
["converters"],
`using cached max ArrayBuffer size: ${parsed} bytes`,
);
if (!isNaN(parsed) && parsed > 0) return parsed;
}
// detect max size using binary search
const maxSize = findFirstPositive((x) => {
try {
new ArrayBuffer(Number(x));
return 0; // false = can allocate
} catch {
return 1; // true = cannot allocate
}
});
const result = Number(maxSize);
localStorage.setItem("maxArrayBufferSize", result.toString());
log(["converters"], `detected max ArrayBuffer size: ${result} bytes`);
return result;
};
export const MAX_ARRAY_BUFFER_SIZE = getMaxArrayBufferSize();

View File

@ -1,5 +1,4 @@
import type { Component } from "svelte";
import { writable } from "svelte/store";
export type ToastType = "success" | "error" | "info" | "warning";

View File

@ -3,6 +3,7 @@ import type { Converter } from "$lib/converters/converter.svelte";
import { m } from "$lib/paraglide/messages";
import { ToastManager } from "$lib/toast/index.svelte";
import type { Component } from "svelte";
import { MAX_ARRAY_BUFFER_SIZE } from "$lib/store/index.svelte";
export class VertFile {
public id: string = Math.random().toString(36).slice(2, 8);
@ -62,6 +63,17 @@ export class VertFile {
return converter;
}
public isLarge(): boolean {
return this.file.size > MAX_ARRAY_BUFFER_SIZE;
}
public supportsStreaming(): boolean {
// only vertd (video/gif -> video/gif) supports streaming
// rest of converters need entire file in memory, limited by ArrayBuffer limits
const converter = this.findConverter();
return converter?.name === "vertd";
}
constructor(file: File, to: string, blobUrl?: string) {
const ext = file.name.split(".").pop();
const newFile = new File(

View File

@ -228,7 +228,7 @@ async function pandoc(
);
if (folders.length > 0) {
const file = new File(
[out_file.data],
[new Uint8Array(Array.from(out_file.data))],
`${in_name.split(".").slice(0, -1).join(".")}${out_ext}`,
);
const filteredMap = new Map<string, PandocFsEntry>();
@ -279,7 +279,7 @@ const pandocToFiles = (entries: PandocEntries, parent = ""): File[] => {
const nestedFiles = pandocToFiles(entry.entries, fullPath);
flattened.push(...nestedFiles);
} else {
const file = new File([entry.data], fullPath);
const file = new File([new Uint8Array(Array.from(entry.data))], fullPath);
flattened.push(file);
}
}

View File

@ -186,13 +186,6 @@
`<slot />` or `{@render ...}` tag missing — inner content will not be rendered
-->
<Layout.PageContent {children} />
<div style="display:none">
{#each locales as locale}
<a href={localizeHref(page.url.pathname, { locale })}
>{locale}</a
>
{/each}
</div>
<Layout.Toasts />
<Layout.Dialogs />

View File

@ -293,7 +293,7 @@
</div>
</div>
<style>
<style lang="postcss">
.file-category-card {
@apply bg-panel rounded-2xl p-5 shadow-panel relative;
}

View File

@ -8,9 +8,8 @@
import avatarJovannMC from "$lib/assets/avatars/jovannmc.jpg";
import avatarRealmy from "$lib/assets/avatars/realmy.jpg";
import avatarAzurejelly from "$lib/assets/avatars/azurejelly.jpg";
import { PUB_DONATION_URL, PUB_STRIPE_KEY } from "$env/static/public";
import { DISABLE_ALL_EXTERNAL_REQUESTS, GITHUB_API_URL } from "$lib/consts";
import { dev } from "$app/environment";
import { page } from "$app/state";
import { m } from "$lib/paraglide/messages";
import { ToastManager } from "$lib/toast/index.svelte";
// import { dev } from "$app/environment";
@ -138,9 +137,9 @@
}
});
const donationsEnabled =
(dev || page.url.origin.endsWith("//vert.sh")) &&
!DISABLE_ALL_EXTERNAL_REQUESTS;
const donationsEnabled = PUB_STRIPE_KEY
&& PUB_DONATION_URL
&& !DISABLE_ALL_EXTERNAL_REQUESTS;
</script>
<div class="flex flex-col h-full items-center">

View File

@ -5,7 +5,7 @@
import Panel from "$lib/components/visual/Panel.svelte";
import ProgressBar from "$lib/components/visual/ProgressBar.svelte";
import Tooltip from "$lib/components/visual/Tooltip.svelte";
import { categories, converters, byNative } from "$lib/converters";
import { categories, converters } from "$lib/converters";
import {
effects,
files,
@ -29,6 +29,8 @@
} from "lucide-svelte";
import { m } from "$lib/paraglide/messages";
import { Settings } from "$lib/sections/settings/index.svelte";
import { MAX_ARRAY_BUFFER_SIZE } from "$lib/store/index.svelte";
import { GB } from "$lib/consts";
let processedFileIds = $state(new Set<string>());
@ -220,6 +222,7 @@
{@const formatInfo = currentConverter.supportedFormats.find(
(f) => f.name === file.from,
)}
{@const isLarge = file.isLarge()}
{#if formatInfo && !formatInfo.fromSupported}
<div
class="h-full flex flex-col text-center justify-center text-failure"
@ -231,6 +234,19 @@
{m["convert.errors.format_output_only"]()}
</p>
</div>
{:else if isLarge && !file.supportsStreaming()}
<div
class="h-full flex flex-col text-center justify-center text-failure"
>
<p class="font-body font-bold">
{m["convert.errors.cant_convert"]()}
</p>
<p class="font-normal">
{m["workers.errors.file_too_large"]({
limit: (MAX_ARRAY_BUFFER_SIZE / GB).toFixed(2),
})}
</p>
</div>
{:else if currentConverter.status === "downloading"}
<div
class="h-full flex flex-col text-center justify-center text-failure"
@ -347,6 +363,7 @@
bind:selected={file.to}
onselect={(option) =>
handleSelect(option, file)}
{file}
/>
<div
class="w-full flex items-center justify-between"

View File

@ -31,6 +31,18 @@
{@html sanitize(m["privacy.conversions.description"]())}
</p>
<h2 class="text-2xl mb-3">{m["privacy.donations.title"]()}</h2>
<p class="mb-4">
{@html sanitize(
link(
["about_link", "stripe_link"],
m["privacy.donations.description"](),
["/about", "https://stripe.com/docs/disputes/prevention/advanced-fraud-detection"],
[false, true],
),
)}
</p>
<h2 class="text-2xl mb-3">
{m["privacy.conversion_errors.title"]()}
</h2>

View File

@ -1,15 +1,4 @@
User-agent: *
Allow: /
# language urls (doesn't actually change language when visiting)
# should prob be auto-generated?
Disallow: /es/
Disallow: /fr/
Disallow: /de/
Disallow: /it/
Disallow: /hr/
Disallow: /tr/
Disallow: /ja/
Disallow: /el/
Sitemap: https://vert.sh/sitemap.xml