diff --git a/frontend/app/api/questions/[category]/route.ts b/frontend/app/api/questions/[category]/route.ts index db59c3e7..3cac95ba 100644 --- a/frontend/app/api/questions/[category]/route.ts +++ b/frontend/app/api/questions/[category]/route.ts @@ -1,72 +1,78 @@ import { NextResponse } from 'next/server'; import { db } from '@/db'; -import { questions, categories } from '@/db/schema'; +import { questions } from '@/db/schema'; import { eq, sql, and, ilike } from 'drizzle-orm'; const DEFAULT_PAGE = 1; -const DEFAULT_LIMIT = 10; +const DEFAULT_LIMIT = 20; +const DEFAULT_LOCALE = 'uk'; export async function GET( req: Request, ctx: { params: Promise<{ category: string }> } ) { - const { category } = await ctx.params; - const { searchParams } = new URL(req.url); + try { + const { category } = await ctx.params; + const { searchParams } = new URL(req.url); - const page = Math.max( - 1, - parseInt(searchParams.get('page') || String(DEFAULT_PAGE), 10) - ); - const limit = Math.max( - 1, - Math.min( + const page = Math.max(1, Number(searchParams.get('page') ?? DEFAULT_PAGE)); + const limit = Math.min( 50, - parseInt(searchParams.get('limit') || String(DEFAULT_LIMIT), 10) - ) - ); - const search = searchParams.get('search')?.trim() || ''; - const offset = (page - 1) * limit; + Math.max(1, Number(searchParams.get('limit') ?? DEFAULT_LIMIT)) + ); + const offset = (page - 1) * limit; - const cat = await db - .select() - .from(categories) - .where(eq(categories.name, category)) - .limit(1); + const locale = + searchParams.get('locale') || + req.headers.get('x-locale') || + DEFAULT_LOCALE; - if (!cat.length) { - return NextResponse.json({ - items: [], - total: 0, - page, - totalPages: 0, - }); - } + const search = searchParams.get('search')?.trim(); + + const baseCondition = and( + eq(questions.categorySlug, category.toLowerCase()), + eq(questions.locale, locale) + ); - const baseCondition = eq(questions.categoryId, cat[0].id); - const whereCondition = search - ? and(baseCondition, ilike(questions.question, `%${search}%`)) - : baseCondition; + const whereCondition = search + ? and(baseCondition, ilike(questions.question, `%${search}%`)) + : baseCondition; - const [countResult] = await db - .select({ count: sql`count(*)` }) - .from(questions) - .where(whereCondition); + const [{ count }] = await db + .select({ count: sql`count(*)` }) + .from(questions) + .where(whereCondition); - const total = Number(countResult.count); - const totalPages = Math.ceil(total / limit); + const total = Number(count); + const totalPages = Math.ceil(total / limit); - const items = await db - .select() - .from(questions) - .where(whereCondition) - .orderBy(questions.id) - .limit(limit) - .offset(offset); + const items = await db + .select() + .from(questions) + .where(whereCondition) + .orderBy(questions.sortOrder) + .limit(limit) + .offset(offset); - return NextResponse.json({ - items, - total, - page, - totalPages, - }); + return NextResponse.json({ + items, + total, + page, + totalPages, + locale, + }); + } catch (error) { + console.error('[GET /api/questions/:category]', error); + + return NextResponse.json( + { + items: [], + total: 0, + page: 1, + totalPages: 0, + locale: DEFAULT_LOCALE, + }, + { status: 500 } + ); + } } diff --git a/frontend/components/q&a/TabsSection.tsx b/frontend/components/q&a/TabsSection.tsx index bd5a251a..7d1fc716 100644 --- a/frontend/components/q&a/TabsSection.tsx +++ b/frontend/components/q&a/TabsSection.tsx @@ -1,10 +1,11 @@ 'use client'; import { useState, useEffect, useCallback, useRef } from 'react'; -import { useSearchParams } from 'next/navigation'; +import { useSearchParams, useParams } from 'next/navigation'; import { useRouter } from '@/i18n/routing'; import { useTranslations } from 'next-intl'; import { Search, X } from 'lucide-react'; + import AccordionList from '@/components/q&a/AccordionList'; import { Pagination } from '@/components/q&a/Pagination'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; @@ -24,8 +25,11 @@ export default function TabsSection() { const t = useTranslations('qa'); const router = useRouter(); const searchParams = useSearchParams(); + const params = useParams(); + + const locale = params.locale as string; - const pageFromUrl = parseInt(searchParams.get('page') || '1', 10); + const pageFromUrl = Number(searchParams.get('page') || 1); const categoryFromUrl = searchParams.get('category') || DEFAULT_CATEGORY; const searchFromUrl = searchParams.get('search') || ''; @@ -40,21 +44,19 @@ export default function TabsSection() { const [isLoading, setIsLoading] = useState(true); const debounceRef = useRef(null); + const mountedRef = useRef(false); const updateUrl = useCallback( (category: string, page: number, search: string) => { const params = new URLSearchParams(); - if (category !== DEFAULT_CATEGORY) { - params.set('category', category); - } - if (page > 1) { - params.set('page', String(page)); - } - if (search) { - params.set('search', search); - } + + if (category !== DEFAULT_CATEGORY) params.set('category', category); + if (page > 1) params.set('page', String(page)); + if (search) params.set('search', search); + const queryString = params.toString(); - router.push(`/q&a${queryString ? `?${queryString}` : ''}`, { + + router.replace(`/q&a${queryString ? `?${queryString}` : ''}`, { scroll: false, }); }, @@ -62,10 +64,13 @@ export default function TabsSection() { ); useEffect(() => { - if (debounceRef.current) { - clearTimeout(debounceRef.current); + if (!mountedRef.current) { + mountedRef.current = true; + return; } + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { setDebouncedSearch(searchQuery); setCurrentPage(1); @@ -73,23 +78,25 @@ export default function TabsSection() { }, DEBOUNCE_MS); return () => { - if (debounceRef.current) { - clearTimeout(debounceRef.current); - } + if (debounceRef.current) clearTimeout(debounceRef.current); }; - }, [searchQuery]); + }, [searchQuery, active, updateUrl]); useEffect(() => { async function load() { setIsLoading(true); + try { const searchParam = debouncedSearch ? `&search=${encodeURIComponent(debouncedSearch)}` : ''; + const res = await fetch( - `/api/questions/${active}?page=${currentPage}&limit=10${searchParam}` + `/api/questions/${active}?page=${currentPage}&limit=10&locale=${locale}${searchParam}` ); + const data: PaginatedResponse = await res.json(); + setItems(data.items); setTotalPages(data.totalPages); } catch (error) { @@ -100,8 +107,9 @@ export default function TabsSection() { setIsLoading(false); } } + load(); - }, [active, currentPage, debouncedSearch]); + }, [active, currentPage, debouncedSearch, locale]); const handleCategoryChange = (category: string) => { setActive(category); @@ -117,46 +125,35 @@ export default function TabsSection() { window.scrollTo({ top: 0, behavior: 'smooth' }); }; - const handleSearchChange = (e: React.ChangeEvent) => { - setSearchQuery(e.target.value); - }; - - const handleClearSearch = () => { - setSearchQuery(''); - setDebouncedSearch(''); - setCurrentPage(1); - updateUrl(active, 1, ''); - }; - return (
-
- -
+ + setSearchQuery(e.target.value)} placeholder={t('searchPlaceholder')} - className="block w-full pl-10 pr-10 py-3 border border-gray-300 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors" + className="w-full pl-10 pr-10 py-3 border rounded-lg" /> + {searchQuery && ( )}
- + {categoryNames.map(c => ( @@ -169,12 +166,12 @@ export default function TabsSection() { {isLoading ? (
-
+
- ) : items.length > 0 ? ( + ) : items.length ? ( ) : ( -

+

{debouncedSearch ? t('noResults', { query: debouncedSearch }) : t('noQuestions')} diff --git a/frontend/db/schema/categories.ts b/frontend/db/schema/categories.ts index b2390ac0..47f977c6 100644 --- a/frontend/db/schema/categories.ts +++ b/frontend/db/schema/categories.ts @@ -1,27 +1,14 @@ -import { pgTable, uuid, text, jsonb } from 'drizzle-orm/pg-core'; -import { relations } from 'drizzle-orm'; - -export const categories = pgTable('categories', { - id: uuid('id').defaultRandom().primaryKey(), - name: text('name').notNull().unique(), -}); - -export const questions = pgTable('questions', { - id: uuid('id').defaultRandom().primaryKey(), - question: text('question').notNull(), - answerBlocks: jsonb('answer_blocks').notNull(), - categoryId: uuid('category_id') - .notNull() - .references(() => categories.id, { onDelete: 'cascade' }), -}); - -export const categoriesRelations = relations(categories, ({ many }) => ({ - questions: many(questions), -})); - -export const questionsRelations = relations(questions, ({ one }) => ({ - category: one(categories, { - fields: [questions.categoryId], - references: [categories.id], - }), -})); +import { pgTable, uuid, varchar, text, unique } from 'drizzle-orm/pg-core'; + +export const categories = pgTable( + 'categories', + { + id: uuid('id').defaultRandom().primaryKey(), + slug: varchar('slug', { length: 50 }).notNull(), + locale: varchar('locale', { length: 5 }).notNull(), + title: text('title').notNull(), + }, + table => [ + unique('categories_slug_locale_unique').on(table.slug, table.locale), + ] +); diff --git a/frontend/db/schema/index.ts b/frontend/db/schema/index.ts index 17abe767..18f6cdb8 100644 --- a/frontend/db/schema/index.ts +++ b/frontend/db/schema/index.ts @@ -1,5 +1,6 @@ export * from './categories'; +export * from './questions'; export * from './quiz'; export * from './users'; export * from './points'; -export * from "./shop"; \ No newline at end of file +export * from './shop'; diff --git a/frontend/db/schema/questions.ts b/frontend/db/schema/questions.ts new file mode 100644 index 00000000..fe3609de --- /dev/null +++ b/frontend/db/schema/questions.ts @@ -0,0 +1,28 @@ +import { + pgTable, + uuid, + varchar, + text, + jsonb, + integer, + unique, +} from 'drizzle-orm/pg-core'; + +export const questions = pgTable( + 'questions', + { + id: uuid('id').defaultRandom().primaryKey(), + categorySlug: varchar('category_slug', { length: 50 }).notNull(), + locale: varchar('locale', { length: 5 }).notNull(), + question: text('question').notNull(), + answerBlocks: jsonb('answer_blocks').notNull(), + sortOrder: integer('sort_order').default(0), + }, + table => [ + unique('questions_category_locale_order_unique').on( + table.categorySlug, + table.locale, + table.sortOrder + ), + ] +); diff --git a/frontend/db/seed-categories.ts b/frontend/db/seed-categories.ts index 566ec395..4779fba6 100644 --- a/frontend/db/seed-categories.ts +++ b/frontend/db/seed-categories.ts @@ -1,15 +1,28 @@ import 'dotenv/config'; - import { db } from './index'; import { categories } from './schema'; import { categoryNames } from '../data/category'; +import { slugify } from '../utils/slugify'; + +const LOCALES = ['en', 'pl', 'uk'] as const; + +async function seedCategories() { + const rows = categoryNames.flatMap(name => { + const slug = slugify(name); + + return LOCALES.map(locale => ({ + slug, + locale, + title: name, + })); + }); -async function categoriesList() { - for (const name of categoryNames) { - await db.insert(categories).values({ name }); - } + await db.insert(categories).values(rows).onConflictDoNothing(); - console.log('Categories seeded!'); + console.log('✅ Categories seeded!'); } -categoriesList(); +seedCategories().catch(err => { + console.error('❌ Seed failed...', err); + process.exit(1); +}); diff --git a/frontend/db/seed-questions.ts b/frontend/db/seed-questions.ts new file mode 100644 index 00000000..a0aeb030 --- /dev/null +++ b/frontend/db/seed-questions.ts @@ -0,0 +1,44 @@ +import 'dotenv/config'; +import { db } from './index'; +import { questions } from './schema'; +import rawData from '../parse/questions.json'; + +type RawQuestion = { + category: string; + order?: number; + translations: Record< + string, + { + question: string; + answerBlocks: unknown; + } + >; +}; + +const data = Array.isArray(rawData) ? (rawData as RawQuestion[]) : []; + +async function seedQuestions() { + if (!data.length) { + console.log('No questions to seed — skipping.'); + return; + } + + const rows = data.flatMap(q => + Object.entries(q.translations).map(([locale, content]) => ({ + categorySlug: q.category, + locale, + sortOrder: q.order ?? 0, + question: content.question, + answerBlocks: content.answerBlocks, + })) + ); + + await db.insert(questions).values(rows).onConflictDoNothing(); + + console.log('✅ Questions seeded!'); +} + +seedQuestions().catch(err => { + console.error('❌ Seed failed...', err); + process.exit(1); +}); diff --git a/frontend/db/seed.ts b/frontend/db/seed.ts deleted file mode 100644 index fcf80804..00000000 --- a/frontend/db/seed.ts +++ /dev/null @@ -1,52 +0,0 @@ -import 'dotenv/config'; - -import { db } from './index'; -import { categories, questions } from './schema'; -import { eq } from 'drizzle-orm'; -// ! Reset sequense to 1 -// import { sql } from 'drizzle-orm'; - -import rawData from '../parse/questions.json'; - -type QuestionSeed = { - category: string; - question: string; - answerBlocks: unknown; -}; - -const data = (Array.isArray(rawData) ? rawData : []) as QuestionSeed[]; - -async function main() { - // ! Reset sequense to 1 - // await db.delete(questions); - // await db.execute(sql`ALTER SEQUENCE questions_id_seq RESTART WITH 1`); - // console.log('Table cleared, sequence reset to 1'); - - if (!data.length) { - console.log('No questions to seed — skipping.'); - return; - } - - for (const q of data) { - const category = await db - .select() - .from(categories) - .where(eq(categories.name, q.category)) - .limit(1); - - if (!category.length) { - console.log('Category not found:', q.category); - continue; - } - - await db.insert(questions).values({ - question: q.question, - answerBlocks: q.answerBlocks, - categoryId: category[0].id, - }); - } - - console.log('Questions seeded!'); -} - -main(); diff --git a/frontend/drizzle/schema.ts b/frontend/drizzle/schema.ts index 49fa45a2..a9d29eba 100644 --- a/frontend/drizzle/schema.ts +++ b/frontend/drizzle/schema.ts @@ -1,187 +1,295 @@ -import { pgTable, unique, serial, text, foreignKey, jsonb, integer, index, uuid, boolean, varchar, timestamp, numeric, primaryKey } from "drizzle-orm/pg-core" -import { sql } from "drizzle-orm" +import { + pgTable, + unique, + serial, + text, + foreignKey, + jsonb, + integer, + index, + uuid, + boolean, + varchar, + timestamp, + numeric, + primaryKey, +} from 'drizzle-orm/pg-core'; +import { sql } from 'drizzle-orm'; +export const categories = pgTable( + 'categories', + { + id: uuid('id').defaultRandom().primaryKey(), + slug: varchar('slug', { length: 50 }).notNull(), + locale: varchar('locale', { length: 5 }).notNull(), + title: text('title').notNull(), + }, + table => [ + unique('categories_slug_locale_unique').on(table.slug, table.locale), + ] +); +export const questions = pgTable( + 'questions', + { + id: uuid('id').defaultRandom().primaryKey().notNull(), + question: text('question').notNull(), + answerBlocks: jsonb('answer_blocks').notNull(), + categoryId: uuid('category_id').notNull(), + }, + table => [ + foreignKey({ + columns: [table.categoryId], + foreignColumns: [categories.id], + name: 'questions_category_id_categories_id_fk', + }).onDelete('cascade'), + ] +); -export const categories = pgTable("categories", { - id: serial().primaryKey().notNull(), - name: text().notNull(), -}, (table) => [ - unique("categories_name_unique").on(table.name), -]); +export const quizAnswers = pgTable( + 'quiz_answers', + { + id: uuid().defaultRandom().primaryKey().notNull(), + quizQuestionId: uuid('quiz_question_id').notNull(), + displayOrder: integer('display_order').notNull(), + isCorrect: boolean('is_correct').default(false).notNull(), + }, + table => [ + index('quiz_answers_question_display_order_idx').using( + 'btree', + table.quizQuestionId.asc().nullsLast().op('int4_ops'), + table.displayOrder.asc().nullsLast().op('int4_ops') + ), + foreignKey({ + columns: [table.quizQuestionId], + foreignColumns: [quizQuestions.id], + name: 'quiz_answers_quiz_question_id_quiz_questions_id_fk', + }).onDelete('cascade'), + ] +); -export const questions = pgTable("questions", { - id: serial().primaryKey().notNull(), - question: text().notNull(), - answerBlocks: jsonb("answer_blocks").notNull(), - categoryId: integer("category_id").notNull(), -}, (table) => [ - foreignKey({ - columns: [table.categoryId], - foreignColumns: [categories.id], - name: "questions_category_id_categories_id_fk" - }).onDelete("cascade"), -]); +export const quizQuestions = pgTable( + 'quiz_questions', + { + id: uuid().defaultRandom().primaryKey().notNull(), + quizId: uuid('quiz_id').notNull(), + displayOrder: integer('display_order').notNull(), + sourceQuestionId: uuid('source_question_id'), + difficulty: varchar({ length: 20 }).default('medium'), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull(), + }, + table => [ + index('quiz_questions_quiz_display_order_idx').using( + 'btree', + table.quizId.asc().nullsLast().op('int4_ops'), + table.displayOrder.asc().nullsLast().op('int4_ops') + ), + foreignKey({ + columns: [table.quizId], + foreignColumns: [quizzes.id], + name: 'quiz_questions_quiz_id_quizzes_id_fk', + }).onDelete('cascade'), + ] +); -export const quizAnswers = pgTable("quiz_answers", { - id: uuid().defaultRandom().primaryKey().notNull(), - quizQuestionId: uuid("quiz_question_id").notNull(), - displayOrder: integer("display_order").notNull(), - isCorrect: boolean("is_correct").default(false).notNull(), -}, (table) => [ - index("quiz_answers_question_display_order_idx").using("btree", table.quizQuestionId.asc().nullsLast().op("int4_ops"), table.displayOrder.asc().nullsLast().op("int4_ops")), - foreignKey({ - columns: [table.quizQuestionId], - foreignColumns: [quizQuestions.id], - name: "quiz_answers_quiz_question_id_quiz_questions_id_fk" - }).onDelete("cascade"), -]); +export const quizAttemptAnswers = pgTable( + 'quiz_attempt_answers', + { + id: uuid().defaultRandom().primaryKey().notNull(), + attemptId: uuid('attempt_id').notNull(), + quizQuestionId: uuid('quiz_question_id').notNull(), + selectedAnswerId: uuid('selected_answer_id'), + isCorrect: boolean('is_correct').notNull(), + answeredAt: timestamp('answered_at', { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull(), + }, + table => [ + index('quiz_attempt_answers_attempt_idx').using( + 'btree', + table.attemptId.asc().nullsLast().op('uuid_ops') + ), + foreignKey({ + columns: [table.attemptId], + foreignColumns: [quizAttempts.id], + name: 'quiz_attempt_answers_attempt_id_quiz_attempts_id_fk', + }).onDelete('cascade'), + foreignKey({ + columns: [table.quizQuestionId], + foreignColumns: [quizQuestions.id], + name: 'quiz_attempt_answers_quiz_question_id_quiz_questions_id_fk', + }).onDelete('cascade'), + foreignKey({ + columns: [table.selectedAnswerId], + foreignColumns: [quizAnswers.id], + name: 'quiz_attempt_answers_selected_answer_id_quiz_answers_id_fk', + }).onDelete('set null'), + ] +); -export const quizQuestions = pgTable("quiz_questions", { - id: uuid().defaultRandom().primaryKey().notNull(), - quizId: uuid("quiz_id").notNull(), - displayOrder: integer("display_order").notNull(), - sourceQuestionId: uuid("source_question_id"), - difficulty: varchar({ length: 20 }).default('medium'), - createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), -}, (table) => [ - index("quiz_questions_quiz_display_order_idx").using("btree", table.quizId.asc().nullsLast().op("int4_ops"), table.displayOrder.asc().nullsLast().op("int4_ops")), - foreignKey({ - columns: [table.quizId], - foreignColumns: [quizzes.id], - name: "quiz_questions_quiz_id_quizzes_id_fk" - }).onDelete("cascade"), -]); +export const quizzes = pgTable( + 'quizzes', + { + id: uuid().defaultRandom().primaryKey().notNull(), + topicId: uuid('topic_id').notNull(), + slug: varchar({ length: 100 }).notNull(), + displayOrder: integer('display_order').default(0).notNull(), + questionsCount: integer('questions_count').default(10).notNull(), + timeLimitSeconds: integer('time_limit_seconds'), + isActive: boolean('is_active').default(true).notNull(), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull(), + }, + table => [ + unique('quizzes_topic_id_slug_unique').on(table.topicId, table.slug), + ] +); -export const quizAttemptAnswers = pgTable("quiz_attempt_answers", { - id: uuid().defaultRandom().primaryKey().notNull(), - attemptId: uuid("attempt_id").notNull(), - quizQuestionId: uuid("quiz_question_id").notNull(), - selectedAnswerId: uuid("selected_answer_id"), - isCorrect: boolean("is_correct").notNull(), - answeredAt: timestamp("answered_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), -}, (table) => [ - index("quiz_attempt_answers_attempt_idx").using("btree", table.attemptId.asc().nullsLast().op("uuid_ops")), - foreignKey({ - columns: [table.attemptId], - foreignColumns: [quizAttempts.id], - name: "quiz_attempt_answers_attempt_id_quiz_attempts_id_fk" - }).onDelete("cascade"), - foreignKey({ - columns: [table.quizQuestionId], - foreignColumns: [quizQuestions.id], - name: "quiz_attempt_answers_quiz_question_id_quiz_questions_id_fk" - }).onDelete("cascade"), - foreignKey({ - columns: [table.selectedAnswerId], - foreignColumns: [quizAnswers.id], - name: "quiz_attempt_answers_selected_answer_id_quiz_answers_id_fk" - }).onDelete("set null"), -]); +export const quizAttempts = pgTable( + 'quiz_attempts', + { + id: uuid().defaultRandom().primaryKey().notNull(), + userId: text('user_id').notNull(), + quizId: uuid('quiz_id').notNull(), + score: integer().notNull(), + totalQuestions: integer('total_questions').notNull(), + percentage: numeric({ precision: 5, scale: 2 }).notNull(), + timeSpentSeconds: integer('time_spent_seconds'), + integrityScore: integer('integrity_score').default(100), + metadata: jsonb().default({}), + startedAt: timestamp('started_at', { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull(), + completedAt: timestamp('completed_at', { + withTimezone: true, + mode: 'string', + }) + .defaultNow() + .notNull(), + }, + table => [ + index('quiz_attempts_quiz_integrity_score_idx').using( + 'btree', + table.quizId.asc().nullsLast().op('uuid_ops'), + table.integrityScore.asc().nullsLast().op('int4_ops') + ), + index('quiz_attempts_quiz_percentage_completed_at_idx').using( + 'btree', + table.quizId.asc().nullsLast().op('numeric_ops'), + table.percentage.asc().nullsLast().op('numeric_ops'), + table.completedAt.asc().nullsLast().op('uuid_ops') + ), + index('quiz_attempts_user_completed_at_idx').using( + 'btree', + table.userId.asc().nullsLast().op('text_ops'), + table.completedAt.asc().nullsLast().op('text_ops') + ), + foreignKey({ + columns: [table.quizId], + foreignColumns: [quizzes.id], + name: 'quiz_attempts_quiz_id_quizzes_id_fk', + }).onDelete('cascade'), + ] +); -export const quizzes = pgTable("quizzes", { - id: uuid().defaultRandom().primaryKey().notNull(), - topicId: uuid("topic_id").notNull(), - slug: varchar({ length: 100 }).notNull(), - displayOrder: integer("display_order").default(0).notNull(), - questionsCount: integer("questions_count").default(10).notNull(), - timeLimitSeconds: integer("time_limit_seconds"), - isActive: boolean("is_active").default(true).notNull(), - createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), -}, (table) => [ - unique("quizzes_topic_id_slug_unique").on(table.topicId, table.slug), -]); +export const user = pgTable('user', { + id: text('id').primaryKey().notNull(), + name: text(), + email: text().notNull(), + emailVerified: timestamp('email_verified', { mode: 'string' }), + image: text(), + role: text().default('user'), + points: integer().default(0), + preferredLocale: varchar('preferred_locale', { length: 5 }).default('en'), + createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(), +}); -export const quizAttempts = pgTable("quiz_attempts", { - id: uuid().defaultRandom().primaryKey().notNull(), - userId: text("user_id").notNull(), - quizId: uuid("quiz_id").notNull(), - score: integer().notNull(), - totalQuestions: integer("total_questions").notNull(), - percentage: numeric({ precision: 5, scale: 2 }).notNull(), - timeSpentSeconds: integer("time_spent_seconds"), - integrityScore: integer("integrity_score").default(100), - metadata: jsonb().default({}), - startedAt: timestamp("started_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), - completedAt: timestamp("completed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), -}, (table) => [ - index("quiz_attempts_quiz_integrity_score_idx").using("btree", table.quizId.asc().nullsLast().op("uuid_ops"), table.integrityScore.asc().nullsLast().op("int4_ops")), - index("quiz_attempts_quiz_percentage_completed_at_idx").using("btree", table.quizId.asc().nullsLast().op("numeric_ops"), table.percentage.asc().nullsLast().op("numeric_ops"), table.completedAt.asc().nullsLast().op("uuid_ops")), - index("quiz_attempts_user_completed_at_idx").using("btree", table.userId.asc().nullsLast().op("text_ops"), table.completedAt.asc().nullsLast().op("text_ops")), - foreignKey({ - columns: [table.quizId], - foreignColumns: [quizzes.id], - name: "quiz_attempts_quiz_id_quizzes_id_fk" - }).onDelete("cascade"), -]); +export const pointTransactions = pgTable( + 'point_transactions', + { + id: uuid().defaultRandom().primaryKey().notNull(), + userId: text('user_id').notNull(), + points: integer().default(0).notNull(), + source: varchar({ length: 50 }).default('quiz').notNull(), + sourceId: uuid('source_id'), + metadata: jsonb().default({}), + createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull(), + }, + table => [ + foreignKey({ + columns: [table.userId], + foreignColumns: [user.id], + name: 'point_transactions_user_id_user_id_fk', + }).onDelete('cascade'), + ] +); -export const user = pgTable("user", { - id: text().primaryKey().notNull(), - name: text(), - email: text().notNull(), - emailVerified: timestamp({ mode: 'string' }), - image: text(), - role: text().default('user'), - points: integer().default(0), - createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(), -}, (table) => [ - unique("user_email_unique").on(table.email), -]); +export const quizAnswerTranslations = pgTable( + 'quiz_answer_translations', + { + quizAnswerId: uuid('quiz_answer_id').notNull(), + locale: varchar({ length: 5 }).notNull(), + answerText: text('answer_text').notNull(), + }, + table => [ + foreignKey({ + columns: [table.quizAnswerId], + foreignColumns: [quizAnswers.id], + name: 'quiz_answer_translations_quiz_answer_id_quiz_answers_id_fk', + }).onDelete('cascade'), + primaryKey({ + columns: [table.quizAnswerId, table.locale], + name: 'quiz_answer_translations_quiz_answer_id_locale_pk', + }), + ] +); -export const pointTransactions = pgTable("point_transactions", { - id: uuid().defaultRandom().primaryKey().notNull(), - userId: text("user_id").notNull(), - points: integer().default(0).notNull(), - source: varchar({ length: 50 }).default('quiz').notNull(), - sourceId: uuid("source_id"), - metadata: jsonb().default({}), - createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), -}, (table) => [ - foreignKey({ - columns: [table.userId], - foreignColumns: [user.id], - name: "point_transactions_user_id_user_id_fk" - }).onDelete("cascade"), -]); +export const quizQuestionContent = pgTable( + 'quiz_question_content', + { + quizQuestionId: uuid('quiz_question_id').notNull(), + locale: varchar({ length: 5 }).notNull(), + questionText: text('question_text').notNull(), + explanation: jsonb(), + }, + table => [ + foreignKey({ + columns: [table.quizQuestionId], + foreignColumns: [quizQuestions.id], + name: 'quiz_question_content_quiz_question_id_quiz_questions_id_fk', + }).onDelete('cascade'), + primaryKey({ + columns: [table.quizQuestionId, table.locale], + name: 'quiz_question_content_quiz_question_id_locale_pk', + }), + ] +); -export const quizAnswerTranslations = pgTable("quiz_answer_translations", { - quizAnswerId: uuid("quiz_answer_id").notNull(), - locale: varchar({ length: 5 }).notNull(), - answerText: text("answer_text").notNull(), -}, (table) => [ - foreignKey({ - columns: [table.quizAnswerId], - foreignColumns: [quizAnswers.id], - name: "quiz_answer_translations_quiz_answer_id_quiz_answers_id_fk" - }).onDelete("cascade"), - primaryKey({ columns: [table.quizAnswerId, table.locale], name: "quiz_answer_translations_quiz_answer_id_locale_pk"}), -]); - -export const quizQuestionContent = pgTable("quiz_question_content", { - quizQuestionId: uuid("quiz_question_id").notNull(), - locale: varchar({ length: 5 }).notNull(), - questionText: text("question_text").notNull(), - explanation: jsonb(), -}, (table) => [ - foreignKey({ - columns: [table.quizQuestionId], - foreignColumns: [quizQuestions.id], - name: "quiz_question_content_quiz_question_id_quiz_questions_id_fk" - }).onDelete("cascade"), - primaryKey({ columns: [table.quizQuestionId, table.locale], name: "quiz_question_content_quiz_question_id_locale_pk"}), -]); - -export const quizTranslations = pgTable("quiz_translations", { - quizId: uuid("quiz_id").notNull(), - locale: varchar({ length: 5 }).notNull(), - title: varchar({ length: 200 }).notNull(), - description: text(), -}, (table) => [ - foreignKey({ - columns: [table.quizId], - foreignColumns: [quizzes.id], - name: "quiz_translations_quiz_id_quizzes_id_fk" - }).onDelete("cascade"), - primaryKey({ columns: [table.quizId, table.locale], name: "quiz_translations_quiz_id_locale_pk"}), -]); +export const quizTranslations = pgTable( + 'quiz_translations', + { + quizId: uuid('quiz_id').notNull(), + locale: varchar({ length: 5 }).notNull(), + title: varchar({ length: 200 }).notNull(), + description: text(), + }, + table => [ + foreignKey({ + columns: [table.quizId], + foreignColumns: [quizzes.id], + name: 'quiz_translations_quiz_id_quizzes_id_fk', + }).onDelete('cascade'), + primaryKey({ + columns: [table.quizId, table.locale], + name: 'quiz_translations_quiz_id_locale_pk', + }), + ] +); diff --git a/frontend/package.json b/frontend/package.json index 75b3a97c..ac5c03ea 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,7 +8,7 @@ "start": "next start", "lint": "eslint", "seed:categories": "tsx db/seed-categories.ts", - "seed": "tsx db/seed.ts", + "seed:questions": "tsx db/seed-questions.ts", "seed:users": "tsx db/seed-users.ts", "parse": "tsx parse/parse.ts", "db:generate": "drizzle-kit generate --config drizzle.config.ts", diff --git a/frontend/parse/questions.json b/frontend/parse/questions.json index 2d4f8f30..aff06f66 100644 --- a/frontend/parse/questions.json +++ b/frontend/parse/questions.json @@ -1,17933 +1,311 @@ [ { - "question": "1. Що означає абревіатура HTML і яке її призначення?", "category": "html", - "answerBlocks": [ - { - "type": "bulletList", - "children": [ + "order": 1, + "translations": { + "uk": { + "question": "1. Що означає абревіатура HTML і яке її призначення?", + "answerBlocks": [ { - "type": "listItem", + "type": "bulletList", "children": [ { - "text": "HTML — HyperText Markup Language, мова розмітки для структурування вмісту вебсторінок (текст, зображення, посилання тощо)." - } - ] - }, - { - "type": "listItem", - "children": [ + "type": "listItem", + "children": [ + { + "text": "HTML — HyperText Markup Language, мова розмітки для структурування вмісту вебсторінок (текст, зображення, посилання тощо)." + } + ] + }, { - "text": "Призначення — описувати структуру та семантику контенту, який браузер відображає користувачу." + "type": "listItem", + "children": [ + { + "text": "Призначення — описувати структуру та семантику контенту, який браузер відображає користувачу." + } + ] } ] } ] - } - ] - }, - { - "question": "2. Чим HTML відрізняється від CSS та JavaScript?", - "category": "html", - "answerBlocks": [ - { - "type": "bulletList", - "children": [ - { - "type": "listItem", - "children": [ - { - "text": "HTML", - "bold": true - }, - { - "text": " — описує структуру веб-сторінки (що є: заголовки, абзаци, зображення, посилання)." - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "CSS", - "bold": true - }, - { - "text": " — відповідає за оформлення і стиль (як виглядає: кольори, шрифти, розташування)." - } - ] - }, + }, + "en": { + "question": "1. What does the HTML abbreviation stand for and what is its purpose?", + "answerBlocks": [ { - "type": "listItem", + "type": "bulletList", "children": [ { - "text": "JavaScript", - "bold": true + "type": "listItem", + "children": [ + { + "text": "HTML — HyperText Markup Language, a markup language used to structure web page content (text, images, links, etc.)." + } + ] }, { - "text": " — додає динаміку та інтерактивність (що робить: обробка подій, зміна DOM, анімації, логіка)." + "type": "listItem", + "children": [ + { + "text": "Its purpose is to describe the structure and semantics of content that browsers display to users." + } + ] } ] } ] }, - { - "type": "paragraph", - "children": [ - { - "text": "Разом вони формують основу фронтенд-розробки: HTML — кістяк, CSS — одяг, JavaScript — поведінка." - } - ] - } - ] - }, - { - "question": "3. Що таке HTML теги?", - "category": "html", - "answerBlocks": [ - { - "type": "bulletList", - "children": [ - { - "type": "listItem", - "children": [ - { - "text": "HTML теги — це інструкції (команди), які визначають структуру та зміст веб-сторінки." - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "Теги пишуться у кутових дужках " - }, - { - "text": "< >", - "code": true - }, - { - "text": "." - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "Більшість тегів мають відкриваючу та закриваючу частини:" - }, - { - "type": "code", - "language": "html", - "content": "

Текст абзацу

\r" - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "Деякі теги самозакриваючі:" - }, - { - "type": "code", - "language": "html", - "content": "
\r\n \"Зображення\"\r" - } - ] - }, + "pl": { + "question": "1. Co oznacza skrót HTML i jakie jest jego przeznaczenie?", + "answerBlocks": [ { - "type": "listItem", + "type": "bulletList", "children": [ { - "text": "Теги можуть мати атрибути для додаткової інформації:" + "type": "listItem", + "children": [ + { + "text": "HTML — HyperText Markup Language, język znaczników służący do strukturyzowania zawartości stron internetowych (tekst, obrazy, linki itp.)." + } + ] }, { - "type": "code", - "language": "html", - "content": " Посилання\r" + "type": "listItem", + "children": [ + { + "text": "Jego celem jest opis struktury i semantyki treści wyświetlanej użytkownikowi przez przeglądarkę." + } + ] } ] } ] } - ] + } }, { - "question": "4. Що таке HTML елементи?", "category": "html", - "answerBlocks": [ - { - "type": "paragraph", - "children": [ - { - "text": "HTML елемент — це повна конструкція, що складається з відкриваючого тега, контенту та закриваючого тега." - } - ] - }, - { - "type": "heading", - "level": 4, - "children": [ - { - "text": "Структура елемента:" - } - ] - }, - { - "type": "code", - "language": "html", - "content": "контент\r" - }, - { - "type": "heading", - "level": 4, - "children": [ - { - "text": "Приклади:" - } - ] - }, - { - "type": "bulletList", - "children": [ + "order": 2, + "translations": { + "uk": { + "question": "2. Чим HTML відрізняється від CSS та JavaScript?", + "answerBlocks": [ { - "type": "listItem", + "type": "bulletList", "children": [ { - "text": "

Заголовок

", - "code": true + "type": "listItem", + "children": [ + { "text": "HTML", "bold": true }, + { + "text": " — описує структуру веб-сторінки (що є: заголовки, абзаци, зображення, посилання)." + } + ] }, { - "text": " — елемент заголовка" - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "

Абзац тексту

", - "code": true + "type": "listItem", + "children": [ + { "text": "CSS", "bold": true }, + { + "text": " — відповідає за оформлення і стиль (як виглядає: кольори, шрифти, розташування)." + } + ] }, { - "text": " — елемент абзацу" + "type": "listItem", + "children": [ + { "text": "JavaScript", "bold": true }, + { + "text": " — додає динаміку та інтерактивність (події, DOM, логіка)." + } + ] } ] }, { - "type": "listItem", + "type": "paragraph", "children": [ { - "text": "\"Фото\"", - "code": true - }, - { - "text": " — елемент зображення (самозакриваючий)" + "text": "Разом вони формують основу фронтенд-розробки: HTML — кістяк, CSS — одяг, JavaScript — поведінка." } ] } ] }, - { - "type": "paragraph", - "children": [ - { - "text": "Елементи можуть містити інші елементи (вкладеність) і мати атрибути для додаткових налаштувань." - } - ] - } - ] - }, - { - "question": "5. Опишіть базову структуру HTML-сторінки?", - "category": "html", - "answerBlocks": [ - { - "type": "paragraph", - "children": [ - { - "text": "Основна структура HTML-документа:", - "bold": true - } - ] - }, - { - "type": "code", - "language": "html", - "content": "\r\n\r\n\r\n \r\n \r\n Назва сторінки\r\n \r\n \r\n \r\n \r\n\r" - }, - { - "type": "paragraph", - "children": [ - { - "text": "Складові:", - "bold": true - } - ] - }, - { - "type": "numberedList", - "children": [ - { - "type": "listItem", - "children": [ - { - "text": "", - "code": true - }, - { - "text": " — вказує, що документ у HTML5." - } - ] - }, + "en": { + "question": "2. How does HTML differ from CSS and JavaScript?", + "answerBlocks": [ { - "type": "listItem", + "type": "bulletList", "children": [ { - "text": "", - "code": true + "type": "listItem", + "children": [ + { "text": "HTML", "bold": true }, + { + "text": " — defines the structure of a web page (headings, paragraphs, images, links)." + } + ] }, { - "text": " — кореневий елемент." - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "", - "code": true + "type": "listItem", + "children": [ + { "text": "CSS", "bold": true }, + { + "text": " — controls presentation and styling (colors, fonts, layout)." + } + ] }, { - "text": " — метадані (кодова сторінка, заголовок, стилі, скрипти)." + "type": "listItem", + "children": [ + { "text": "JavaScript", "bold": true }, + { + "text": " — adds interactivity and dynamic behavior (events, DOM manipulation, logic)." + } + ] } ] }, { - "type": "listItem", + "type": "paragraph", "children": [ { - "text": "", - "code": true - }, - { - "text": " — видимий вміст сторінки." + "text": "Together they form the foundation of frontend development: HTML is the structure, CSS is the appearance, JavaScript is the behavior." } ] } ] - } - ] - }, - { - "question": "6. Що таке DOCTYPE і навіщо його використовують?", - "category": "html", - "answerBlocks": [ - { - "type": "paragraph", - "children": [ - { - "text": "DOCTYPE", - "code": true - }, - { - "text": " (Document Type Declaration) — це декларація типу документа, яка повідомляє браузеру, яку версію HTML використовує сторінка." - } - ] }, - { - "type": "heading", - "level": 4, - "children": [ + "pl": { + "question": "2. Czym różni się HTML od CSS i JavaScript?", + "answerBlocks": [ { - "text": "Синтаксис для HTML5:" - } - ] - }, - { - "type": "code", - "language": "html", - "content": "\r" - }, - { - "type": "heading", - "level": 4, - "children": [ - { - "text": "Призначення:" - } - ] - }, - { - "type": "bulletList", - "children": [ - { - "type": "listItem", + "type": "bulletList", "children": [ { - "text": "Запобігає режиму сумісності (quirks mode) браузера" - } - ] - }, - { - "type": "listItem", - "children": [ + "type": "listItem", + "children": [ + { "text": "HTML", "bold": true }, + { + "text": " — opisuje strukturę strony internetowej (nagłówki, akapity, obrazy, linki)." + } + ] + }, { - "text": "Забезпечує коректне відображення сторінки згідно зі стандартами" - } - ] - }, - { - "type": "listItem", - "children": [ + "type": "listItem", + "children": [ + { "text": "CSS", "bold": true }, + { + "text": " — odpowiada za wygląd i styl (kolory, czcionki, układ)." + } + ] + }, { - "text": "Допомагає валідаторам перевіряти правильність коду" + "type": "listItem", + "children": [ + { "text": "JavaScript", "bold": true }, + { + "text": " — dodaje dynamikę i interaktywność (zdarzenia, manipulacja DOM, logika)." + } + ] } ] }, { - "type": "listItem", + "type": "paragraph", "children": [ { - "text": "Має стояти першим рядком HTML-документа" + "text": "Razem tworzą podstawę frontendu: HTML to struktura, CSS to wygląd, JavaScript to zachowanie." } ] } ] } - ] + } }, { - "question": "7. Яка поточна версія HTML?", "category": "html", - "answerBlocks": [ - { - "type": "bulletList", - "children": [ + "order": 3, + "translations": { + "uk": { + "question": "3. Що таке HTML теги?", + "answerBlocks": [ { - "type": "listItem", + "type": "bulletList", "children": [ { - "text": "Поточна версія — " - }, - { - "text": "HTML5", - "bold": true + "type": "listItem", + "children": [ + { + "text": "HTML теги — це інструкції, які визначають структуру та зміст веб-сторінки." + } + ] }, { - "text": "." + "type": "listItem", + "children": [ + { "text": "Теги пишуться у кутових дужках " }, + { "text": "< >", "code": true }, + { "text": "." } + ] } ] - }, + } + ] + }, + "en": { + "question": "3. What are HTML tags?", + "answerBlocks": [ { - "type": "listItem", + "type": "bulletList", "children": [ { - "text": "HTML5 є " - }, - { - "text": "Living Standard", - "code": true + "type": "listItem", + "children": [ + { + "text": "HTML tags are instructions that define the structure and content of a web page." + } + ] }, { - "text": " (живий стандарт), що означає постійне оновлення без формальних нових версій." - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "Підтримується всіма сучасними браузерами." - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "Включає нові семантичні елементи, API, покращену підтримку мультимедіа та багато інших функцій." - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "Розробляється консорціумом WHATWG (Web Hypertext Application Technology Working Group)." + "type": "listItem", + "children": [ + { "text": "Tags are written inside angle brackets " }, + { "text": "< >", "code": true }, + { "text": "." } + ] } ] } ] - } - ] - }, - { - "question": "8. Що роблять атрибути DOCTYPE та html lang?", - "category": "html", - "answerBlocks": [ - { - "type": "bulletList", - "children": [ + }, + "pl": { + "question": "3. Czym są tagi HTML?", + "answerBlocks": [ { - "type": "listItem", + "type": "bulletList", "children": [ { - "text": "", - "code": true + "type": "listItem", + "children": [ + { + "text": "Tagi HTML to instrukcje określające strukturę i zawartość strony internetowej." + } + ] }, { - "text": " — повідомляє браузеру, що документ написаний у стандарті HTML5, щоб уникнути режиму сумісності." - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "lang", - "code": true - }, - { - "text": " у " - }, - { - "text": "", - "code": true - }, - { - "text": " — вказує мову вмісту сторінки для пошукових систем, читачів екрану та інших сервісів (наприклад, lang=\"uk\")." - } - ] - } - ] - }, - { - "type": "heading", - "level": 4, - "children": [ - { - "text": "Приклад:" - } - ] - }, - { - "type": "code", - "language": "html", - "content": "\r\n\r\n \r\n\r" - }, - { - "type": "paragraph", - "children": [ - { - "text": "Ці атрибути покращують доступність, SEO та коректність відображення сторінки." - } - ] - } - ] - }, - { - "question": "9. Що описується в тегі head?", - "category": "html", - "answerBlocks": [ - { - "type": "paragraph", - "children": [ - { - "text": "Тег " - }, - { - "text": "", - "code": true - }, - { - "text": " містить метаінформацію про документ:" - } - ] - }, - { - "type": "numberedList", - "children": [ - { - "type": "listItem", - "children": [ - { - "text": "Назва сторінки", - "bold": true - }, - { - "text": " — " - }, - { - "text": "", - "code": true - }, - { - "text": "." - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "Мета-теги", - "bold": true - }, - { - "text": " — " - }, - { - "text": "<meta>", - "code": true - }, - { - "text": " (характеристики документа, ключові слова, опис)." - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "Підключення стилів", - "bold": true - }, - { - "text": " — " - }, - { - "text": "<link>", - "code": true - }, - { - "text": " (CSS-файли)." - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "Скрипти", - "bold": true - }, - { - "text": " — " - }, - { - "text": "<script>", - "code": true - }, - { - "text": " (підключення JavaScript)." - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "Фавікон", - "bold": true - }, - { - "text": " — " - }, - { - "text": "<link>", - "code": true - }, - { - "text": " для іконки сайту." - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "Інші налаштування", - "bold": true - }, - { - "text": " — наприклад, " - }, - { - "text": "<base>", - "code": true - }, - { - "text": " для базового URL." - } - ] - } - ] - }, - { - "type": "heading", - "level": 4, - "children": [ - { - "text": "Приклад:" - } - ] - }, - { - "type": "code", - "language": "html", - "content": "<head>\r\n <meta charset=\"UTF-8\" />\r\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\r\n <title>Назва сторінки\r\n \r\n \r\n\r" - } - ] - }, - { - "question": "10. Яка різниця між тегами head та body?", - "category": "html", - "answerBlocks": [ - { - "type": "bulletList", - "children": [ - { - "type": "listItem", - "children": [ - { - "text": "", - "code": true - }, - { - "text": " — містить метадані про документ (назва, кодування, стилі, скрипти, SEO-теги), не відображається безпосередньо на сторінці." - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "", - "code": true - }, - { - "text": " — містить видимий контент для користувача (текст, зображення, кнопки, відео тощо)." - } - ] - } - ] - }, - { - "type": "heading", - "level": 4, - "children": [ - { - "text": "Ключові відмінності:" - } - ] - }, - { - "type": "paragraph", - "children": [ - { - "text": "``:", - "bold": true - } - ] - }, - { - "type": "bulletList", - "children": [ - { - "type": "listItem", - "children": [ - { - "text": "Невидимий для користувача" - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "Містить службову інформацію" - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "Налаштування сторінки" - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "SEO-дані" - } - ] - } - ] - }, - { - "type": "paragraph", - "children": [ - { - "text": "``:", - "bold": true - } - ] - }, - { - "type": "bulletList", - "children": [ - { - "type": "listItem", - "children": [ - { - "text": "Видимий контент" - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "Те, що бачить користувач" - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "Інтерактивні елементи" - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "Основний вміст сторінки" - } - ] - } - ] - } - ] - }, - { - "question": "11. Що таке пробільний простір в HTML?", - "category": "html", - "answerBlocks": [ - { - "type": "paragraph", - "children": [ - { - "text": "Пробільний простір в HTML — це пробіли, табуляції та переноси рядків між елементами." - } - ] - }, - { - "type": "heading", - "level": 4, - "children": [ - { - "text": "Поведінка браузера:" - } - ] - }, - { - "type": "bulletList", - "children": [ - { - "type": "listItem", - "children": [ - { - "text": "Браузер автоматично " - }, - { - "text": "згортає", - "bold": true - }, - { - "text": " (схлопує) декілька пробілів в один" - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "Кілька переносів рядків замінюються одним пробілом" - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "Провідні та кінцеві пробіли ігноруються" - } - ] - } - ] - }, - { - "type": "heading", - "level": 4, - "children": [ - { - "text": "Приклад:" - } - ] - }, - { - "type": "code", - "language": "html", - "content": "

Цей текст має багато пробілів

\r" - }, - { - "type": "paragraph", - "children": [ - { - "text": "Відображається як: " - }, - { - "text": "Цей текст має багато пробілів", - "code": true - } - ] - }, - { - "type": "paragraph", - "children": [ - { - "text": "Для збереження пробілів використовуйте CSS " - }, - { - "text": "white-space: pre", - "code": true - }, - { - "text": " або тег " - }, - { - "text": "
",
-            "code": true
-          }
-        ]
-      }
-    ]
-  },
-  {
-    "question": "12. Що таке порожні елементи в HTML?",
-    "category": "html",
-    "answerBlocks": [
-      {
-        "type": "paragraph",
-        "children": [
-          {
-            "text": "Порожні елементи (void elements) — це HTML теги, які не мають контенту і закриваючого тега."
-          }
-        ]
-      },
-      {
-        "type": "heading",
-        "level": 4,
-        "children": [
-          {
-            "text": "Основні порожні елементи:"
-          }
-        ]
-      },
-      {
-        "type": "bulletList",
-        "children": [
-          {
-            "type": "listItem",
-            "children": [
-              {
-                "text": "
", - "code": true - }, - { - "text": " — розрив рядка" - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "
", - "code": true - }, - { - "text": " — горизонтальна лінія" - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "", - "code": true - }, - { - "text": " — зображення" - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "", - "code": true - }, - { - "text": " — поле вводу" - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "", - "code": true - }, - { - "text": " — метадані" - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "", - "code": true - }, - { - "text": " — посилання на ресурс" - } - ] - } - ] - }, - { - "type": "heading", - "level": 4, - "children": [ - { - "text": "Синтаксис:" - } - ] - }, - { - "type": "code", - "language": "html", - "content": "\r\n\"Фото\"\r\n
\r\n\r\n\r\n\r\n\"Фото\"\r\n
\r" - } - ] - }, - { - "question": "13. У чому різниця між елементами div і span?", - "category": "html", - "answerBlocks": [ - { - "type": "heading", - "level": 4, - "children": [ - { - "text": "
", - "code": true - }, - { - "text": " (блоковий елемент):" - } - ] - }, - { - "type": "bulletList", - "children": [ - { - "type": "listItem", - "children": [ - { - "text": "Займає всю доступну ширину" - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "Починається з нового рядка" - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "Має верхні та нижні відступи" - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "Використовується для великих блоків контенту" - } - ] - } - ] - }, - { - "type": "heading", - "level": 4, - "children": [ - { - "text": "", - "code": true - }, - { - "text": " (вбудований елемент):" - } - ] - }, - { - "type": "bulletList", - "children": [ - { - "type": "listItem", - "children": [ - { - "text": "Займає тільки необхідну ширину" - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "Не переносить на новий рядок" - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "Не має вертикальних відступів" - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "Використовується для маленьких фрагментів тексту" - } - ] - } - ] - }, - { - "type": "heading", - "level": 4, - "children": [ - { - "text": "Приклад:" - } - ] - }, - { - "type": "code", - "language": "html", - "content": "
Це блоковий елемент
\r\n
Він займає всю ширину
\r\n\r\n

Це вбудований елемент всередині абзацу.

\r" - } - ] - }, - { - "question": "14. Яка різниця між тегами b та strong?", - "category": "html", - "answerBlocks": [ - { - "type": "bulletList", - "children": [ - { - "type": "listItem", - "children": [ - { - "text": "", - "code": true - }, - { - "text": " — просто робить текст візуально жирним, без додаткового смислового значення." - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "", - "code": true - }, - { - "text": " — робить текст жирним і додає семантичний акцент (важливість), що враховується пошуковими системами та скрінрідерами." - } - ] - } - ] - }, - { - "type": "heading", - "level": 4, - "children": [ - { - "text": "Приклади:" - } - ] - }, - { - "type": "code", - "language": "html", - "content": "

Цей текст жирний тільки візуально.

\r\n

Цей текст важливий за змістом.

\r" - }, - { - "type": "paragraph", - "children": [ - { - "text": "Рекомендація:", - "bold": true - }, - { - "text": " Використовуйте " - }, - { - "text": "", - "code": true - }, - { - "text": " для смислово важливого тексту, " - }, - { - "text": "", - "code": true - }, - { - "text": " — тільки для стилізації без семантичного значення." - } - ] - } - ] - }, - { - "question": "15. Коли слід використовувати em замість i, і навпаки?", - "category": "html", - "answerBlocks": [ - { - "type": "bulletList", - "children": [ - { - "type": "listItem", - "children": [ - { - "text": "", - "code": true - }, - { - "text": " — виділяє текст курсивом з семантичним наголосом (інтонаційне чи логічне підкреслення важливості)." - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "", - "code": true - }, - { - "text": " — відображає текст курсивом без зміни смислу (наприклад, іноземні слова, технічні терміни, назви)." - } - ] - } - ] - }, - { - "type": "heading", - "level": 4, - "children": [ - { - "text": "Приклади:" - } - ] - }, - { - "type": "code", - "language": "html", - "content": "\r\n

Я дуже хочу це зробити!

\r\n\r\n\r\n

Фільм Титанік вийшов в 1997 році.

\r\n

Слово bonjour означає \"привіт\" французькою.

\r" - }, - { - "type": "paragraph", - "children": [ - { - "text": "Якщо потрібно передати смисловий акцент — використовуємо " - }, - { - "text": "", - "code": true - }, - { - "text": ". Якщо тільки стиль відображення — " - }, - { - "text": "", - "code": true - }, - { - "text": "." - } - ] - } - ] - }, - { - "question": "16. Яке призначення тегів small, s та mark?", - "category": "html", - "answerBlocks": [ - { - "type": "bulletList", - "children": [ - { - "type": "listItem", - "children": [ - { - "text": "", - "code": true - }, - { - "text": " — робить текст меншим і семантично позначає додаткову або другорядну інформацію (примітки, дисклеймери)." - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "", - "code": true - }, - { - "text": " — відображає текст закресленим, коли він втратив актуальність, але його варто залишити для перегляду." - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "", - "code": true - }, - { - "text": " — виділяє текст жовтим фоном для підсвічування важливого або знайденого фрагмента." - } - ] - } - ] - }, - { - "type": "heading", - "level": 4, - "children": [ - { - "text": "Приклади:" - } - ] - }, - { - "type": "code", - "language": "html", - "content": "

Ціна: 100 грн 80 грн

\r\n

Важливо: Термін дії до 31 грудня.

\r\n

Основний текст. © 2023 Всі права захищені.

\r" - } - ] - }, - { - "question": "17. Як створити абзац або розрив рядка в HTML?", - "category": "html", - "answerBlocks": [ - { - "type": "paragraph", - "children": [ - { - "text": "Абзац:", - "bold": true - } - ] - }, - { - "type": "code", - "language": "html", - "content": "

Текст абзацу

\r" - }, - { - "type": "paragraph", - "children": [ - { - "text": "створює блок з відступами зверху і знизу." - } - ] - }, - { - "type": "paragraph", - "children": [ - { - "text": "Розрив рядка:", - "bold": true - } - ] - }, - { - "type": "code", - "language": "html", - "content": "Текст першого рядка
Текст другого рядка\r" - }, - { - "type": "paragraph", - "children": [ - { - "text": "переносить текст без створення нового абзацу." - } - ] - }, - { - "type": "heading", - "level": 4, - "children": [ - { - "text": "Коли використовувати:" - } - ] - }, - { - "type": "bulletList", - "children": [ - { - "type": "listItem", - "children": [ - { - "text": "

", - "code": true - }, - { - "text": " — для окремих абзаців тексту" - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "
", - "code": true - }, - { - "text": " — для примусового переносу в межах одного блоку (адреси, вірші)" - } - ] - } - ] - } - ] - }, - { - "question": "18. Для чого використовуються теги sub та sup?", - "category": "html", - "answerBlocks": [ - { - "type": "bulletList", - "children": [ - { - "type": "listItem", - "children": [ - { - "text": "", - "code": true - }, - { - "text": " — підрядковий текст (нижче базової лінії)" - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "", - "code": true - }, - { - "text": " — надрядковий текст (вище базової лінії)" - } - ] - } - ] - }, - { - "type": "heading", - "level": 4, - "children": [ - { - "text": "Використання:" - } - ] - }, - { - "type": "paragraph", - "children": [ - { - "text": "Математичні формули:", - "bold": true - } - ] - }, - { - "type": "code", - "language": "html", - "content": "

H2O - формула води

\r\n

E = mc2 - формула Ейнштейна

\r" - }, - { - "type": "paragraph", - "children": [ - { - "text": "Хімічні формули:", - "bold": true - } - ] - }, - { - "type": "code", - "language": "html", - "content": "

CO2 - вуглекислий газ

\r" - }, - { - "type": "paragraph", - "children": [ - { - "text": "Сноски:", - "bold": true - } - ] - }, - { - "type": "code", - "language": "html", - "content": "

Цей факт1 потребує підтвердження.

\r" - } - ] - }, - { - "question": "19. Розкажіть про тег samp?", - "category": "html", - "answerBlocks": [ - { - "type": "paragraph", - "children": [ - { - "text": "Тег " - }, - { - "text": "", - "code": true - }, - { - "text": " використовується для позначення тексту, який є результатом виконання комп'ютерної програми, наприклад, повідомлень про помилки або вихідних даних. Він відображається звичайним шрифтом, але зазвичай використовується для стилістичних цілей." - } - ] - }, - { - "type": "paragraph", - "children": [ - { - "text": "Приклад використання тегу " - }, - { - "text": "", - "code": true - }, - { - "text": ":" - } - ] - }, - { - "type": "code", - "language": "html", - "content": "

Результат виконання програми: Помилка: Невірний ввід

\r\n

Командний рядок повернув: Hello World!

\r" - }, - { - "type": "paragraph", - "children": [ - { - "text": "Пов'язані теги:", - "bold": true - } - ] - }, - { - "type": "bulletList", - "children": [ - { - "type": "listItem", - "children": [ - { - "text": "", - "code": true - }, - { - "text": " — для фрагментів коду" - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "", - "code": true - }, - { - "text": " — для клавіш клавіатури" - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "", - "code": true - }, - { - "text": " — для змінних" - } - ] - } - ] - } - ] - }, - { - "question": "20. Які глобальні атрибути є в HTML?", - "category": "html", - "answerBlocks": [ - { - "type": "paragraph", - "children": [ - { - "text": "Глобальні атрибути можна використовувати з будь-яким HTML елементом:" - } - ] - }, - { - "type": "heading", - "level": 4, - "children": [ - { - "text": "Основні глобальні атрибути:" - } - ] - }, - { - "type": "bulletList", - "children": [ - { - "type": "listItem", - "children": [ - { - "text": "id", - "code": true - }, - { - "text": " — унікальний ідентифікатор елемента" - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "class", - "code": true - }, - { - "text": " — CSS класи для стилізації" - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "style", - "code": true - }, - { - "text": " — вбудовані стилі CSS" - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "title", - "code": true - }, - { - "text": " — підказка при наведенні" - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "lang", - "code": true - }, - { - "text": " — мова контенту" - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "dir", - "code": true - }, - { - "text": " — напрям тексту (ltr/rtl)" - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "tabindex", - "code": true - }, - { - "text": " — порядок фокусування" - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "hidden", - "code": true - }, - { - "text": " — приховує елемент" - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "data-*", - "code": true - }, - { - "text": " — користувацькі атрибути" - } - ] - } - ] - }, - { - "type": "heading", - "level": 4, - "children": [ - { - "text": "Атрибути подій:" - } - ] - }, - { - "type": "bulletList", - "children": [ - { - "type": "listItem", - "children": [ - { - "text": "onclick", - "code": true - }, - { - "text": ", " - }, - { - "text": "onmouseover", - "code": true - }, - { - "text": ", " - }, - { - "text": "onload", - "code": true - }, - { - "text": " тощо" - } - ] - } - ] - }, - { - "type": "heading", - "level": 4, - "children": [ - { - "text": "Приклад:" - } - ] - }, - { - "type": "code", - "language": "html", - "content": "\r\n Контент...\r\n
\r" - } - ] - }, - { - "question": "21. Як використовують атрибути class та id? Яка різниця між класами та ідентифікаторами?", - "category": "html", - "answerBlocks": [ - { - "type": "heading", - "level": 4, - "children": [ - { - "text": "Атрибут " - }, - { - "text": "id", - "code": true - }, - { - "text": ":" - } - ] - }, - { - "type": "bulletList", - "children": [ - { - "type": "listItem", - "children": [ - { - "text": "Унікальний ідентифікатор елемента на сторінці" - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "Може бути тільки один елемент з конкретним id" - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "Використовується для JavaScript, якірних посилань, CSS стилізації" - } - ] - } - ] - }, - { - "type": "heading", - "level": 4, - "children": [ - { - "text": "Атрибут " - }, - { - "text": "class", - "code": true - }, - { - "text": ":" - } - ] - }, - { - "type": "bulletList", - "children": [ - { - "type": "listItem", - "children": [ - { - "text": "Групує елементи за спільними характеристиками" - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "Один клас можна застосувати до багатьох елементів" - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "Один елемент може мати кілька класів" - } - ] - } - ] - }, - { - "type": "heading", - "level": 4, - "children": [ - { - "text": "Приклади:" - } - ] - }, - { - "type": "code", - "language": "html", - "content": "\r\n
Заголовок сайту
\r\n
Навігація
\r\n\r\n\r\n

Важливий текст

\r\n

Великий важливий текст

\r\nЩе важливий текст\r" - }, - { - "type": "heading", - "level": 4, - "children": [ - { - "text": "CSS селектори:" - } - ] - }, - { - "type": "code", - "language": "css", - "content": "#header {\r\n color: blue;\r\n} /* ID селектор */\r\n.highlight {\r\n color: red;\r\n} /* Class селектор */\r" - }, - { - "type": "paragraph", - "children": [ - { - "text": "Рекомендація:", - "bold": true - }, - { - "text": " Використовуйте " - }, - { - "text": "id", - "code": true - }, - { - "text": " для унікальних елементів і якірних посилань, " - }, - { - "text": "class", - "code": true - }, - { - "text": " — для стилізації та групування." - } - ] - } - ] - }, - { - "question": "22. Яке призначення атрибута alt в HTML?", - "category": "html", - "answerBlocks": [ - { - "type": "bulletList", - "children": [ - { - "type": "listItem", - "children": [ - { - "text": "alt", - "code": true - }, - { - "text": " — альтернативний текст для зображення." - } - ] - } - ] - }, - { - "type": "heading", - "level": 4, - "children": [ - { - "text": "Призначення:" - } - ] - }, - { - "type": "bulletList", - "children": [ - { - "type": "listItem", - "children": [ - { - "text": "Показується, якщо зображення не завантажилось." - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "Допомагає скрінрідерам робити сайт доступним." - } - ] - }, - { - "type": "listItem", - "children": [ - { - "text": "Використовується для SEO." - } - ] - } - ] - }, - { - "type": "heading", - "level": 4, - "children": [ - { - "text": "Приклади:" - } - ] - }, - { - "type": "code", - "language": "html", - "content": "\r\n\"Графік\r\n\r\n\r\n\"\"\r\n\r\n\r\n\r\n \"Повернутися\r\n\r" - }, - { - "type": "paragraph", - "children": [ - { - "text": "Важливо:", - "bold": true - }, - { - "text": " Для декоративних зображень використовуйте порожній " - }, - { - "text": "alt=\"\"", - "code": true - }, - { - "text": ", щоб скрін-рідери їх ігнорували." - } - ] - } - ] - }, - { - "question": "23. Навіщо використовується тег label?", - "category": "html", - "answerBlocks": [ - { - "type": "paragraph", - "children": [ - { - "text": "