feat: end to end api type safety with redis rate limiting (#573)

* feat: support hono rpc

* add wrangler file

* update name of wrangler file

* add wrangler as a dependency

* add compatibility_flags

* trigger ci

* core: add health check to docker file

* expose port 80

* update custom healthcheck

* feat: implement openapi

* install dependencies in root

* debug yml

* update yml

* update port

* update dockerfile with envs

* completely move away from apiClient

* rename backend to api

* refactor: update server descriptions and comment out local spec saving

* update lock file

* use api.classroomio.com

* add support for redis

* fix: add missing line break in WelcomeModal.svelte

* refactor: remove wrangler configuration and update Vite config for API integration
This commit is contained in:
Rotimi Best
2025-09-21 19:35:41 +01:00
committed by GitHub
parent a6880f1b54
commit 6d68351a46
102 changed files with 2938 additions and 1633 deletions
@@ -80,7 +80,7 @@ export const addMathPlugin = (tinymce: any) => {
onSubmit: async function (w) {
let content = output.exportAs('html');
// The generated content always points to https://latex.codecogs.com/svg.image, this means we have to always rely on their server to serve formulas. We have a dedicated endpoint for generating formulas on our backend service: GET `/katex`. We will need to manually update the URL in the generate HTML to point to our server:
// The generated content always points to https://latex.codecogs.com/svg.image, this means we have to always rely on their server to serve formulas. We have a dedicated endpoint for generating formulas on our api service: GET `/katex`. We will need to manually update the URL in the generate HTML to point to our server:
// https://latex.codecogs.com/svg.image
// 1. Get only the URL in the img tag generated by codecogs library
@@ -1,19 +0,0 @@
import axios from 'axios';
import { env } from '$env/dynamic/public';
import { getAccessToken } from '$lib/utils/functions/supabase';
export const apiClient = axios.create({
baseURL: `${env.PUBLIC_SERVER_URL}`,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
}
});
apiClient.interceptors.request.use(async (config) => {
const token = await getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
@@ -0,0 +1,80 @@
# API Client
## Basic Usage
```typescript
import { apiClient, safeRequest, classroomio } from '$lib/utils/services/api';
// GET request
const result = await safeRequest(async () => {
return apiClient.request('/api/users', { method: 'GET' });
});
// POST request
const result = await safeRequest(async () => {
return apiClient.request('/api/users', {
method: 'POST',
body: { name: 'John', email: '[email protected]' }
});
});
// Handle response
if (result.success) {
console.log('Data:', result.data);
} else {
console.error('Error:', result.error.message);
}
```
## Error Handling
```typescript
if (result.success) {
// Success
console.log(result.data);
} else {
// Check error type
if (result.error.error_code === 'auth') {
window.location.href = '/login';
} else if (result.error.error_code === 'client') {
showError('Please check your input');
} else if (result.error.error_code === 'server') {
showError('Server error, please try again');
}
}
```
## Custom Fetch
```typescript
import { createApiClient } from '$lib/utils/services/api';
const customClient = createApiClient({
customFetch: (input, init) => {
// Your custom fetch logic
return fetch(input, init);
}
});
const result = await safeRequest(async () => {
return customClient.request('/api/data', { method: 'GET' });
});
```
## RPC Usage
```typescript
import { classroomio } from '$lib/utils/services/api';
// Use with Hono RPC
const data = await classroomio.users.$get();
const user = await classroomio.users.$post({ json: { name: 'John' } });
```
## Error Codes
- `'auth'` - 401 Unauthorized
- `'client'` - 400-499 Client errors
- `'server'` - 500-599 Server errors
- `'timeout'` - 408 Request timeout
- `'network'` - 0 Network issues
@@ -0,0 +1,24 @@
import type { ApiClientConfig } from './types';
import { env } from '$env/dynamic/public';
// Default configuration
export const DEFAULT_CONFIG: Required<ApiClientConfig> = {
baseURL: env.PUBLIC_SERVER_URL || '',
timeout: 30000, // 30 seconds
retries: 1,
retryDelay: 1000, // 1 second
customFetch: fetch,
onAuthError: async () => {
// Default: redirect to login or refresh token
console.warn('Authentication error occurred. Consider redirecting to login.');
},
onNetworkError: async (error: Error) => {
console.error('Network error:', error);
},
onResponse: async (response: Response) => {
// Default: log response for debugging
if (process.env.NODE_ENV === 'development') {
console.log(`API Response: ${response.status} ${response.statusText}`);
}
}
};
@@ -0,0 +1,212 @@
import { type ApiClientConfig, ApiError, type RequestConfig } from './types';
import { DEFAULT_CONFIG } from './constants';
import { delay } from './utils';
import { env } from '$env/dynamic/public';
import { getAccessToken } from '$lib/utils/functions/supabase';
import { hcWithType } from '@cio/api/rpc-types';
class ApiClient {
private config: Required<ApiClientConfig>;
constructor(config: ApiClientConfig = {}) {
this.config = { ...DEFAULT_CONFIG, ...config };
}
private async makeRequest(
input: RequestInfo | URL,
requestConfig: RequestConfig = {}
): Promise<Response> {
console.log('makeRequest', input, requestConfig);
const {
timeout = this.config.timeout,
retries = this.config.retries,
skipAuth = false,
...fetchConfig
} = requestConfig;
const url = typeof input === 'string' ? input : input.toString();
const fullUrl = url.startsWith('http') ? url : `${this.config.baseURL}${url}`;
// Prepare headers
const headers = new Headers(fetchConfig.headers);
// Add default headers
if (!headers.has('Accept')) {
headers.set('Accept', 'application/json');
}
// Handle body serialization and content-type
let requestBody = fetchConfig.body;
if (
fetchConfig.body &&
typeof fetchConfig.body === 'object' &&
!(fetchConfig.body instanceof FormData)
) {
// Auto-stringify objects (except FormData)
requestBody = JSON.stringify(fetchConfig.body);
if (!headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
} else if (fetchConfig.body && !headers.has('Content-Type')) {
// Set content-type for other body types if not already set
if (typeof fetchConfig.body === 'string') {
headers.set('Content-Type', 'text/plain');
}
}
// Add authentication if not skipped
if (!skipAuth) {
try {
const token = await getAccessToken();
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
} catch (error) {
console.warn('Failed to get access token:', error);
// Continue without auth if token retrieval fails
}
}
// Create abort controller for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
const requestInit: RequestInit = {
...fetchConfig,
headers,
body: requestBody,
signal: controller.signal
};
let lastError: Error | null = null;
// Retry logic with exponential backoff
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const response = await this.config.customFetch(fullUrl, requestInit);
clearTimeout(timeoutId);
await this.config.onResponse(response);
if (response.ok) {
return response;
}
if (response.status === 401) {
await this.config.onAuthError();
throw new ApiError(
'Authentication failed',
response.status,
response.statusText,
response
);
}
if (response.status >= 400 && response.status < 500) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new ApiError(
`Client error: ${errorText}`,
response.status,
response.statusText,
response
);
}
if (response.status >= 500) {
const error = new ApiError(
`Server error: ${response.statusText}`,
response.status,
response.statusText,
response
);
if (attempt === retries) {
throw error;
}
lastError = error;
await delay(this.config.retryDelay * Math.pow(2, attempt));
continue;
}
// Other status codes
throw new ApiError(
`Unexpected status: ${response.statusText}`,
response.status,
response.statusText,
response
);
} catch (error) {
clearTimeout(timeoutId);
// Handle abort (timeout)
if (error instanceof Error && error.name === 'AbortError') {
throw new ApiError('Request timeout', 408, 'Request Timeout');
}
// Handle network errors - retryable
if (error instanceof TypeError && error.message.includes('fetch')) {
const networkError = new ApiError('Network error', 0, 'Network Error');
if (attempt === retries) {
await this.config.onNetworkError(networkError);
throw networkError;
}
lastError = networkError;
await delay(this.config.retryDelay * Math.pow(2, attempt));
continue;
}
// Re-throw non-retryable errors
throw error;
}
}
// If we get here, all retries failed
throw lastError || new ApiError('Request failed after all retries');
}
// Generic request method - handles all HTTP methods
async request(input: RequestInfo | URL, config: RequestConfig = {}): Promise<Response> {
return this.makeRequest(input, config);
}
// Update configuration
updateConfig(newConfig: Partial<ApiClientConfig>): void {
this.config = { ...this.config, ...newConfig };
}
}
// Create default instance
export const apiClient = new ApiClient();
// RPC client using the new fetch wrapper
export const classroomio = hcWithType(env.PUBLIC_SERVER_URL, {
fetch: async (input: RequestInfo | URL, requestInit?: RequestInit) => {
return apiClient.request(input, requestInit);
}
});
// Utility functions for common use cases
export const createApiClient = (config: ApiClientConfig) => new ApiClient(config);
// Re-export utility functions for convenience
export {
handleJsonResponse,
handleTextResponse,
isApiError,
isNetworkError,
isAuthError,
isClientError,
isServerError,
isTimeoutError,
safeRequest,
safeRequestText,
safeRequestRaw,
type Result
} from './utils';
export type { InferResponseType, InferRequestType } from '@cio/api/rpc-types';
@@ -0,0 +1,46 @@
// Types for better type safety
export interface ApiClientConfig {
baseURL?: string;
timeout?: number;
retries?: number;
retryDelay?: number;
customFetch?: typeof fetch;
onAuthError?: () => Promise<void> | void;
onNetworkError?: (error: Error) => Promise<void> | void;
onResponse?: (response: Response) => Promise<void> | void;
}
export interface RequestConfig extends RequestInit {
timeout?: number;
retries?: number;
skipAuth?: boolean;
}
export class ApiError extends Error {
public readonly error_code: string;
constructor(
message: string,
public status?: number,
public statusText?: string,
public response?: Response
) {
super(message);
this.name = 'ApiError';
// Determine error code based on status
if (status === 401) {
this.error_code = 'auth';
} else if (status && status >= 400 && status < 500) {
this.error_code = 'client';
} else if (status && status >= 500) {
this.error_code = 'server';
} else if (status === 408) {
this.error_code = 'timeout';
} else if (status === 0 || !status) {
this.error_code = 'network';
} else {
this.error_code = 'unknown';
}
}
}
@@ -0,0 +1,125 @@
import { ApiError } from './types';
// Utility function for exponential backoff
export const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// Helper function to handle JSON responses
export const handleJsonResponse = async <T = any>(response: Response): Promise<T> => {
if (!response.ok) {
throw new ApiError(
`HTTP ${response.status}: ${response.statusText}`,
response.status,
response.statusText,
response
);
}
try {
return await response.json();
} catch (error) {
throw new ApiError(
'Failed to parse JSON response',
response.status,
response.statusText,
response
);
}
};
// Helper function to handle text responses
export const handleTextResponse = async (response: Response): Promise<string> => {
if (!response.ok) {
throw new ApiError(
`HTTP ${response.status}: ${response.statusText}`,
response.status,
response.statusText,
response
);
}
return response.text();
};
// Error handling utilities
export const isApiError = (error: unknown): error is ApiError => {
return error instanceof ApiError;
};
export const isNetworkError = (error: unknown): boolean => {
return isApiError(error) && error.status === 0;
};
export const isAuthError = (error: unknown): boolean => {
return isApiError(error) && error.status === 401;
};
export const isClientError = (error: unknown): boolean => {
return isApiError(error) && (error.status ?? 0) >= 400 && (error.status ?? 0) < 500;
};
export const isServerError = (error: unknown): boolean => {
return isApiError(error) && (error.status ?? 0) >= 500;
};
export const isTimeoutError = (error: unknown): boolean => {
return isApiError(error) && error.status === 408;
};
// Safe request wrapper that returns Result pattern
export type Result<T, E = ApiError> = { success: true; data: T } | { success: false; error: E };
// Generic safe request wrapper
export const safeRequest = async <T = any>(
requestFn: () => Promise<Response>
): Promise<Result<T>> => {
try {
const response = await requestFn();
const data = await handleJsonResponse<T>(response);
return { success: true, data };
} catch (error) {
if (isApiError(error)) {
return { success: false, error };
}
return {
success: false,
error: new ApiError('Unexpected error', 0, 'Unknown Error')
};
}
};
// Safe request wrapper for text responses
export const safeRequestText = async (
requestFn: () => Promise<Response>
): Promise<Result<string>> => {
try {
const response = await requestFn();
const data = await handleTextResponse(response);
return { success: true, data };
} catch (error) {
if (isApiError(error)) {
return { success: false, error };
}
return {
success: false,
error: new ApiError('Unexpected error', 0, 'Unknown Error')
};
}
};
// Safe request wrapper that returns the raw response
export const safeRequestRaw = async (
requestFn: () => Promise<Response>
): Promise<Result<Response>> => {
try {
const response = await requestFn();
return { success: true, data: response };
} catch (error) {
if (isApiError(error)) {
return { success: false, error };
}
return {
success: false,
error: new ApiError('Unexpected error', 0, 'Unknown Error')
};
}
};
@@ -11,9 +11,9 @@ import type {
} from '$lib/utils/types';
import type { PostgrestError, PostgrestSingleResponse } from '@supabase/supabase-js';
import { GenericUploader } from './presign';
import { QUESTION_TYPE } from '$lib/components/Question/constants';
import { STATUS } from '$lib/utils/constants/course';
import { apiClient } from '$lib/utils/services/api';
import { get } from 'svelte/store';
import { isOrgAdmin } from '$lib/utils/store/org';
import { isUUID } from '$lib/utils/functions/isUUID';
@@ -53,12 +53,10 @@ export async function fetchProfileCourseProgress(
data: ProfileCourseProgress[] | null;
error: PostgrestError | null;
}> {
const { data, error } = await supabase
.rpc('get_course_progress', {
course_id_arg: courseId,
profile_id_arg: profileId
})
.returns<ProfileCourseProgress[]>();
const { data, error } = await supabase.rpc('get_course_progress', {
course_id_arg: courseId,
profile_id_arg: profileId
});
return { data, error };
}
@@ -286,23 +284,30 @@ export async function fetchLesson(lessonId: Lesson['id']) {
.single();
if (data) {
const keys = data.videos.filter((video) => video.type === 'upload').map((video) => video.key);
const videoKeys =
data.videos?.filter((video) => video.type === 'upload')?.map((video) => video.key) || [];
if (!keys.length) {
return { data, error };
}
const docKeys = data.documents?.map((doc) => doc.key) || [];
try {
const response = await apiClient.post('/course/presign/download', { keys });
// Get presigned URLs for videos and documents
const genericUploader = new GenericUploader('generic');
const urls = await genericUploader.getAllDownloadPresignedUrl(videoKeys, docKeys);
data.videos = data.videos.map((video) => {
if (video.type === 'upload') {
video.link = response.data.urls[video.key];
if (urls.videos[video.key]) {
video.link = urls.videos[video.key];
}
return video;
});
data.documents = data.documents.map((doc) => {
doc.link = urls.documents[doc.key];
return doc;
});
} catch (error) {
console.error('Error retrieving videos:', error);
console.error('Error retrieving presigned assets (videos and documents):', error);
}
}
@@ -0,0 +1,134 @@
import { type Writable, get } from 'svelte/store';
import {
lessonDocUpload,
lessonVideoUpload
} from '$lib/components/Course/components/Lesson/store/lessons';
import axios from 'axios';
import { classroomio } from '$lib/utils/services/api';
export type UploadType = 'document' | 'video' | 'generic';
export class GenericUploader {
public abortController: AbortController | null = null;
private uploadType: UploadType;
private uploadStore: Writable<any>;
constructor(uploadType: UploadType) {
this.uploadType = uploadType;
this.uploadStore = uploadType === 'document' ? lessonDocUpload : lessonVideoUpload;
this.abortController = new AbortController();
}
async getDownloadPresignedUrl(keys: string[], type = this.uploadType) {
const endpoint =
type === 'document'
? classroomio.course.presign.document.download
: classroomio.course.presign.video.download;
const response = await endpoint.$post({
json: {
keys
}
});
return response.json();
}
async getAllDownloadPresignedUrl(videoKeys: string[], docKeys: string[]) {
const urls = {
videos: {},
documents: {}
};
try {
if (videoKeys.length) {
const videoResponse = await this.getDownloadPresignedUrl(videoKeys, 'video');
urls.videos = videoResponse?.urls || {};
}
if (docKeys.length) {
const docResponse = await this.getDownloadPresignedUrl(docKeys, 'document');
urls.documents = docResponse?.urls || {};
}
} catch (error) {
console.error('Error getting download presigned url:', error);
}
return urls;
}
async getPresignedUrl(file: File) {
const endpoint =
this.uploadType === 'document'
? classroomio.course.presign.document.upload
: classroomio.course.presign.video.upload;
const response = await endpoint.$post({
json: {
fileName: file?.name,
fileType: file?.type
}
});
return response.json();
}
async uploadFile(params: { url: string; file: File }) {
await axios.put(params.url, params.file, {
headers: {
'Content-Type': params.file.type
},
maxContentLength: Infinity,
maxBodyLength: Infinity,
signal: this.abortController?.signal,
onUploadProgress: (progressEvent) => {
if (get(this.uploadStore).isCancelled) {
this.abortController?.abort();
return;
}
const progress = Math.round((progressEvent.loaded * 100) / (progressEvent.total || 1));
this.uploadStore.update((state) => ({
...state,
uploadProgress: progress
}));
}
});
}
initUpload() {
this.uploadStore.update((state) => ({
...state,
isUploading: true,
uploadProgress: 0,
error: null,
isCancelled: false
}));
this.abortController = new AbortController();
}
cancelUpload() {
this.uploadStore.update((store) => ({
...store,
isCancelled: true,
isUploading: false
}));
this.abortController?.abort();
this.abortController = null;
}
}
export class DocumentUploader extends GenericUploader {
constructor() {
super('document');
}
}
export class VideoUploader extends GenericUploader {
constructor() {
super('video');
}
}
@@ -789,7 +789,8 @@
"loading_pdf": "PDF laden ...",
"failed_to_load_pdf": "Das PDF -Dokument konnte nicht geladen werden",
"failed_to_render_pdf": "Die PDF -Seite versäumte es, PDF -Seite zu rendern",
"use_arrow_keys": "Verwenden Sie Pfeiltasten, um zu navigieren, +/-, um zu zoomen, oder klicken Sie auf die obigen Steuerelemente. Mit der rechten Maustaste ist deaktiviert."
"use_arrow_keys": "Verwenden Sie Pfeiltasten, um zu navigieren, +/-, um zu zoomen, oder klicken Sie auf die obigen Steuerelemente. Mit der rechten Maustaste ist deaktiviert.",
"upgrade": "Aktualisieren Sie Ihren Plan, um Dokumente hochzuladen"
}
}
},
@@ -1409,7 +1410,8 @@
},
"generic": {
"locked": "Gesperrt",
"unlocked": "Entsperrt"
"unlocked": "Entsperrt",
"loading": "Laden"
},
"specialization": {
"course_tag": "Spezialisierung"
@@ -693,6 +693,7 @@
"upload_error_message": "Unsupported file format or upload failed",
"upload_error_try_again": "Please try again with a valid document file",
"try_again": "Try Again",
"upgrade": "Upgrade your plan to upload documents",
"cancel_upload": "Cancel Upload",
"upload_cancelled": "Upload cancelled by user",
@@ -1521,7 +1522,8 @@
"generic": {
"locked": "Locked",
"unlocked": "Unlocked"
"unlocked": "Unlocked",
"loading": "Loading"
},
"validations": {
@@ -789,7 +789,8 @@
"loading_pdf": "Cargando PDF ...",
"failed_to_load_pdf": "No se pudo cargar el documento PDF",
"failed_to_render_pdf": "No se pudo renderizar la página de PDF",
"use_arrow_keys": "Use las teclas de flecha para navegar, +/- para hacer zoom o hacer clic en los controles anteriores. El clic derecho está deshabilitado."
"use_arrow_keys": "Use las teclas de flecha para navegar, +/- para hacer zoom o hacer clic en los controles anteriores. El clic derecho está deshabilitado.",
"upgrade": "Actualice su plan para cargar documentos"
}
}
},
@@ -1409,7 +1410,8 @@
},
"generic": {
"locked": "bloqueado",
"unlocked": "desbloqueado"
"unlocked": "desbloqueado",
"loading": "Cargando"
},
"specialization": {
"course_tag": "Especialización"
@@ -789,7 +789,8 @@
"loading_pdf": "Chargement du PDF ...",
"failed_to_load_pdf": "Échec du chargement du document PDF",
"failed_to_render_pdf": "Échec de la page PDF",
"use_arrow_keys": "Utilisez des touches flèches pour naviguer, +/- pour zoomer ou cliquez sur les commandes ci-dessus. Le clic droit est désactivé."
"use_arrow_keys": "Utilisez des touches flèches pour naviguer, +/- pour zoomer ou cliquez sur les commandes ci-dessus. Le clic droit est désactivé.",
"upgrade": "Mettez à niveau votre plan pour télécharger des documents"
}
}
},
@@ -1409,7 +1410,8 @@
},
"generic": {
"locked": "Fermé à clé",
"unlocked": "Débloqué"
"unlocked": "Débloqué",
"loading": "Chargement"
},
"specialization": {
"course_tag": "Spécialisation"
@@ -789,7 +789,8 @@
"loading_pdf": "लोडिंग पीडीएफ ...",
"failed_to_load_pdf": "पीडीएफ दस्तावेज़ लोड करने में विफल",
"failed_to_render_pdf": "पीडीएफ पेज को प्रस्तुत करने में विफल",
"use_arrow_keys": "ज़ूम करने के लिए नेविगेट करने के लिए तीर कुंजियों का उपयोग करें, या ऊपर दिए गए नियंत्रणों पर क्लिक करें। राइट-क्लिक अक्षम है।"
"use_arrow_keys": "ज़ूम करने के लिए नेविगेट करने के लिए तीर कुंजियों का उपयोग करें, या ऊपर दिए गए नियंत्रणों पर क्लिक करें। राइट-क्लिक अक्षम है।",
"upgrade": "दस्तावेज़ अपलोड करने के लिए अपनी योजना को अपग्रेड करें"
}
}
},
@@ -1409,7 +1410,8 @@
},
"generic": {
"locked": "बंद",
"unlocked": "अनलॉक किया"
"unlocked": "अनलॉक किया",
"loading": "लोड करना"
},
"specialization": {
"course_tag": "विशेषज्ञता"
@@ -99,7 +99,8 @@
},
"generic": {
"locked": "Zamknięty",
"unlocked": "Odblokowany"
"unlocked": "Odblokowany",
"loading": "Załadunek"
},
"content": {
"heading": "Internacjonalizacja w SvelTekit",
@@ -957,7 +958,8 @@
"loading_pdf": "Ładowanie PDF ...",
"failed_to_load_pdf": "Nie udało się załadować dokumentu PDF",
"failed_to_render_pdf": "Nie udało się renderować strony PDF",
"use_arrow_keys": "Użyj klawiszy strzałek do nawigacji, +/-, aby powiększyć lub kliknąć powyższe elementy sterujące. Kliknięcie prawym przyciskiem myszy jest wyłączone."
"use_arrow_keys": "Użyj klawiszy strzałek do nawigacji, +/-, aby powiększyć lub kliknąć powyższe elementy sterujące. Kliknięcie prawym przyciskiem myszy jest wyłączone.",
"upgrade": "Uaktualnij swój plan przesyłania dokumentów"
}
}
},
@@ -789,7 +789,8 @@
"loading_pdf": "Carregando PDF ...",
"failed_to_load_pdf": "Falha ao carregar o documento PDF",
"failed_to_render_pdf": "Falhou em renderizar a página em PDF",
"use_arrow_keys": "Use as teclas de seta para navegar, +/- zoom ou clique nos controles acima. Clique com o botão direito do mouse está desativado."
"use_arrow_keys": "Use as teclas de seta para navegar, +/- zoom ou clique nos controles acima. Clique com o botão direito do mouse está desativado.",
"upgrade": "Atualize seu plano para fazer upload de documentos"
}
}
},
@@ -1409,7 +1410,8 @@
},
"generic": {
"locked": "Bloqueado",
"unlocked": "Desbloqueado"
"unlocked": "Desbloqueado",
"loading": "Carregando"
},
"specialization": {
"course_tag": "Especialização"
@@ -789,7 +789,8 @@
"loading_pdf": "Загрузка PDF ...",
"failed_to_load_pdf": "Не удалось загрузить документ PDF",
"failed_to_render_pdf": "Не удалось отобразить страницу PDF",
"use_arrow_keys": "Используйте клавиши со стрелками, чтобы перемещаться, +/- до масштабирования или нажмите кнопку элементов управления выше. Щелкнуть правой кнопкой мыши отключено."
"use_arrow_keys": "Используйте клавиши со стрелками, чтобы перемещаться, +/- до масштабирования или нажмите кнопку элементов управления выше. Щелкнуть правой кнопкой мыши отключено.",
"upgrade": "Обновите свой план для загрузки документов"
}
}
},
@@ -1409,7 +1410,8 @@
},
"generic": {
"locked": "Заблокировано",
"unlocked": "Разблокировано"
"unlocked": "Разблокировано",
"loading": "Загрузка"
},
"specialization": {
"course_tag": "Специализация"
@@ -789,7 +789,8 @@
"loading_pdf": "Tải PDF ...",
"failed_to_load_pdf": "Không tải tài liệu PDF",
"failed_to_render_pdf": "Không thể hiển thị trang PDF",
"use_arrow_keys": "Sử dụng các phím mũi tên để điều hướng, +/- đến phóng to hoặc nhấp vào các điều khiển ở trên. Nhấp chuột phải bị vô hiệu hóa."
"use_arrow_keys": "Sử dụng các phím mũi tên để điều hướng, +/- đến phóng to hoặc nhấp vào các điều khiển ở trên. Nhấp chuột phải bị vô hiệu hóa.",
"upgrade": "Nâng cấp kế hoạch của bạn để tải lên tài liệu"
}
}
},
@@ -1409,7 +1410,8 @@
},
"generic": {
"locked": "Đã khóa",
"unlocked": "Đã mở khóa"
"unlocked": "Đã mở khóa",
"loading": "Đang tải"
},
"specialization": {
"course_tag": "Chuyên môn"
+1 -1
View File
@@ -295,7 +295,7 @@ export type LessonDocument = {
name: string;
link: string;
size?: number;
key?: string;
key: string;
};
export interface LessonPage {