Compare commits
1 Commits
main
...
codex/add-
| Author | SHA1 | Date |
|---|---|---|
|
|
52f33ee276 |
|
|
@ -2,8 +2,8 @@ import { css } from '@styled/css'
|
|||
import { OperatorIcon } from './OperatorIcon'
|
||||
|
||||
export interface OperatorSectionProps {
|
||||
operator: 'addition' | 'subtraction' | 'mixed' | undefined
|
||||
onChange: (operator: 'addition' | 'subtraction' | 'mixed') => void
|
||||
operator: 'addition' | 'subtraction' | 'mixed' | 'fractions' | undefined
|
||||
onChange: (operator: 'addition' | 'subtraction' | 'mixed' | 'fractions') => void
|
||||
isDark?: boolean
|
||||
}
|
||||
|
||||
|
|
@ -187,6 +187,67 @@ export function OperatorSection({ operator, onChange, isDark = false }: Operator
|
|||
</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Fractions Toggle */}
|
||||
<label
|
||||
data-action="toggle-fractions"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '3',
|
||||
cursor: 'pointer',
|
||||
px: '3',
|
||||
py: '2.5',
|
||||
rounded: 'lg',
|
||||
border: '2px solid',
|
||||
transition: 'all 0.2s',
|
||||
bg:
|
||||
operator === 'fractions'
|
||||
? isDark
|
||||
? 'brand.900'
|
||||
: 'brand.50'
|
||||
: isDark
|
||||
? 'gray.700'
|
||||
: 'white',
|
||||
borderColor: operator === 'fractions' ? 'brand.500' : isDark ? 'gray.600' : 'gray.300',
|
||||
_hover: {
|
||||
borderColor: 'brand.400',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
checked={operator === 'fractions'}
|
||||
onChange={() => onChange('fractions')}
|
||||
className={css({
|
||||
width: '5',
|
||||
height: '5',
|
||||
cursor: 'pointer',
|
||||
accentColor: 'brand.600',
|
||||
flexShrink: 0,
|
||||
})}
|
||||
/>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
})}
|
||||
>
|
||||
<OperatorIcon operator="addition" isDark={isDark} />
|
||||
<span
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'semibold',
|
||||
color: isDark ? 'gray.200' : 'gray.700',
|
||||
})}
|
||||
>
|
||||
Mixed Denominator Fractions
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p
|
||||
|
|
@ -196,7 +257,9 @@ export function OperatorSection({ operator, onChange, isDark = false }: Operator
|
|||
lineHeight: '1.5',
|
||||
})}
|
||||
>
|
||||
{additionChecked && subtractionChecked
|
||||
{operator === 'fractions'
|
||||
? 'All problems will add fractions with different denominators'
|
||||
: additionChecked && subtractionChecked
|
||||
? 'Problems will randomly use addition or subtraction'
|
||||
: subtractionChecked
|
||||
? 'All problems will be subtraction'
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import type { DisplayRules } from '../../displayRules'
|
|||
interface ProblemPreviewProps {
|
||||
displayRules: DisplayRules
|
||||
resolvedDisplayRules?: DisplayRules
|
||||
operator?: 'addition' | 'subtraction' | 'mixed'
|
||||
operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions'
|
||||
digitRange?: { min: number; max: number }
|
||||
className?: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ import { ProblemPreview } from './ProblemPreview'
|
|||
export interface Tab {
|
||||
id: string
|
||||
label: string
|
||||
icon: string | ((operator?: 'addition' | 'subtraction' | 'mixed') => string) | 'preview'
|
||||
icon:
|
||||
| string
|
||||
| ((operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions') => string)
|
||||
| 'preview'
|
||||
subtitle?: (props: {
|
||||
mode?: 'custom' | 'manual' | 'mastery'
|
||||
difficultyProfile?: string
|
||||
|
|
@ -20,7 +23,7 @@ export interface Tab {
|
|||
pages?: number
|
||||
displayRules?: DisplayRules
|
||||
resolvedDisplayRules?: DisplayRules
|
||||
operator?: 'addition' | 'subtraction' | 'mixed'
|
||||
operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions'
|
||||
}) => string | null
|
||||
}
|
||||
|
||||
|
|
@ -31,6 +34,7 @@ export const TABS: Tab[] = [
|
|||
icon: (operator) => {
|
||||
if (operator === 'mixed') return '±'
|
||||
if (operator === 'subtraction') return '−'
|
||||
if (operator === 'fractions') return '⅟'
|
||||
return '+'
|
||||
},
|
||||
},
|
||||
|
|
@ -115,7 +119,7 @@ export const TABS: Tab[] = [
|
|||
interface TabNavigationProps {
|
||||
activeTab: string
|
||||
onChange: (tabId: string) => void
|
||||
operator?: 'addition' | 'subtraction' | 'mixed'
|
||||
operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions'
|
||||
mode?: 'custom' | 'manual' | 'mastery'
|
||||
difficultyProfile?: string
|
||||
interpolate?: boolean
|
||||
|
|
|
|||
|
|
@ -343,7 +343,7 @@ const additionConfigV4BaseSchema = z.object({
|
|||
}),
|
||||
|
||||
// V4: Operator selection (addition, subtraction, or mixed)
|
||||
operator: z.enum(['addition', 'subtraction', 'mixed']).default('addition'),
|
||||
operator: z.enum(['addition', 'subtraction', 'mixed', 'fractions']).default('addition'),
|
||||
|
||||
// Regrouping probabilities (shared between modes)
|
||||
pAnyStart: z.number().min(0).max(1),
|
||||
|
|
|
|||
|
|
@ -243,7 +243,7 @@ function describeScaffoldingChange(
|
|||
fromRules: DisplayRules,
|
||||
toRules: DisplayRules,
|
||||
direction: 'added' | 'reduced',
|
||||
operator?: 'addition' | 'subtraction' | 'mixed'
|
||||
operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions'
|
||||
): string {
|
||||
const changes: string[] = []
|
||||
|
||||
|
|
@ -786,7 +786,7 @@ export function makeHarder(
|
|||
displayRules: DisplayRules
|
||||
},
|
||||
mode: DifficultyMode = 'both',
|
||||
operator?: 'addition' | 'subtraction' | 'mixed'
|
||||
operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions'
|
||||
): {
|
||||
pAnyStart: number
|
||||
pAllStart: number
|
||||
|
|
@ -989,7 +989,7 @@ export function makeEasier(
|
|||
displayRules: DisplayRules
|
||||
},
|
||||
mode: DifficultyMode = 'both',
|
||||
operator?: 'addition' | 'subtraction' | 'mixed'
|
||||
operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions'
|
||||
): {
|
||||
pAnyStart: number
|
||||
pAllStart: number
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type { WorksheetFormState } from '@/app/create/worksheets/types'
|
|||
import {
|
||||
generateMasteryMixedProblems,
|
||||
generateMixedProblems,
|
||||
generateFractionProblems,
|
||||
generateProblems,
|
||||
generateSubtractionProblems,
|
||||
} from './problemGenerator'
|
||||
|
|
@ -86,7 +87,9 @@ export async function generateWorksheetPreview(
|
|||
let problems
|
||||
|
||||
// Special handling for mastery + mixed mode
|
||||
if (mode === 'mastery' && operator === 'mixed') {
|
||||
if (operator === 'fractions') {
|
||||
problems = generateFractionProblems(validatedConfig.total, validatedConfig.seed ?? Date.now())
|
||||
} else if (mode === 'mastery' && operator === 'mixed') {
|
||||
// Query both skill configs
|
||||
const addSkillId = config.currentAdditionSkillId
|
||||
const subSkillId = config.currentSubtractionSkillId
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import type {
|
||||
AdditionProblem,
|
||||
FractionProblem,
|
||||
ProblemCategory,
|
||||
SubtractionProblem,
|
||||
WorksheetProblem,
|
||||
|
|
@ -1269,3 +1270,49 @@ export function generateMixedProblems(
|
|||
|
||||
return problems
|
||||
}
|
||||
|
||||
export function generateFractionProblems(
|
||||
total: number,
|
||||
seed: number,
|
||||
options: {
|
||||
minNumerator?: number
|
||||
maxNumerator?: number
|
||||
minDenominator?: number
|
||||
maxDenominator?: number
|
||||
} = {}
|
||||
): FractionProblem[] {
|
||||
const {
|
||||
minNumerator = 1,
|
||||
maxNumerator = 9,
|
||||
minDenominator = 2,
|
||||
maxDenominator = 12,
|
||||
} = options
|
||||
|
||||
const rand = createPRNG(seed)
|
||||
const problems: FractionProblem[] = []
|
||||
|
||||
const randomFraction = () => {
|
||||
const denominator = randint(minDenominator, maxDenominator, rand)
|
||||
const numerator = randint(minNumerator, Math.max(minNumerator, denominator - 1), rand)
|
||||
return { numerator, denominator }
|
||||
}
|
||||
|
||||
for (let i = 0; i < total; i++) {
|
||||
let a = randomFraction()
|
||||
let b = randomFraction()
|
||||
|
||||
while (a.denominator === b.denominator) {
|
||||
b = randomFraction()
|
||||
}
|
||||
|
||||
problems.push({
|
||||
aNumerator: a.numerator,
|
||||
aDenominator: a.denominator,
|
||||
bNumerator: b.numerator,
|
||||
bDenominator: b.denominator,
|
||||
operator: 'fractions',
|
||||
})
|
||||
}
|
||||
|
||||
return problems
|
||||
}
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ export type WorksheetFormState = Partial<Omit<AdditionConfigV4Custom, 'version'>
|
|||
/**
|
||||
* Worksheet operator type
|
||||
*/
|
||||
export type WorksheetOperator = 'addition' | 'subtraction' | 'mixed'
|
||||
export type WorksheetOperator = 'addition' | 'subtraction' | 'mixed' | 'fractions'
|
||||
|
||||
/**
|
||||
* A single addition problem
|
||||
|
|
@ -136,10 +136,21 @@ export interface SubtractionProblem {
|
|||
operator: 'sub'
|
||||
}
|
||||
|
||||
/**
|
||||
* Fraction addition problem (mixed denominators)
|
||||
*/
|
||||
export interface FractionProblem {
|
||||
aNumerator: number
|
||||
aDenominator: number
|
||||
bNumerator: number
|
||||
bDenominator: number
|
||||
operator: 'fractions'
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified problem type (addition or subtraction)
|
||||
*/
|
||||
export type WorksheetProblem = AdditionProblem | SubtractionProblem
|
||||
export type WorksheetProblem = AdditionProblem | SubtractionProblem | FractionProblem
|
||||
|
||||
/**
|
||||
* Validation result
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Typst document generator for addition worksheets
|
||||
|
||||
import type { WorksheetConfig, WorksheetProblem } from '@/app/create/worksheets/types'
|
||||
import type { FractionProblem, WorksheetConfig, WorksheetProblem } from '@/app/create/worksheets/types'
|
||||
import { resolveDisplayForProblem } from './displayRules'
|
||||
import { analyzeProblem, analyzeSubtractionProblem } from './problemAnalysis'
|
||||
import { generateQRCodeSVG } from './qrCodeGenerator'
|
||||
|
|
@ -24,6 +24,13 @@ function generateWorksheetDescription(config: WorksheetConfig): {
|
|||
title: string
|
||||
scaffolding: string
|
||||
} {
|
||||
if (config.operator === 'fractions') {
|
||||
return {
|
||||
title: 'Fraction addition (mixed denominators)',
|
||||
scaffolding: 'No scaffolding elements required',
|
||||
}
|
||||
}
|
||||
|
||||
// Line 1: Digit range + operator + regrouping percentage
|
||||
const parts: string[] = []
|
||||
|
||||
|
|
@ -116,6 +123,26 @@ function chunkProblems(problems: WorksheetProblem[], pageSize: number): Workshee
|
|||
return pages
|
||||
}
|
||||
|
||||
function gcd(a: number, b: number): number {
|
||||
return b === 0 ? Math.abs(a) : gcd(b, a % b)
|
||||
}
|
||||
|
||||
function lcm(a: number, b: number): number {
|
||||
return Math.abs((a * b) / gcd(a, b))
|
||||
}
|
||||
|
||||
function addAndSimplifyFractions(problem: FractionProblem) {
|
||||
const commonDenominator = lcm(problem.aDenominator, problem.bDenominator)
|
||||
const numerator =
|
||||
problem.aNumerator * (commonDenominator / problem.aDenominator) +
|
||||
problem.bNumerator * (commonDenominator / problem.bDenominator)
|
||||
const divisor = gcd(numerator, commonDenominator)
|
||||
return {
|
||||
numerator: numerator / divisor,
|
||||
denominator: commonDenominator / divisor,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate maximum number of digits in any problem on this page
|
||||
* Returns max digits across all operands (handles both addition and subtraction)
|
||||
|
|
@ -128,12 +155,21 @@ function calculateMaxDigits(problems: WorksheetProblem[]): number {
|
|||
const digitsB = problem.b.toString().length
|
||||
const maxProblemDigits = Math.max(digitsA, digitsB)
|
||||
maxDigits = Math.max(maxDigits, maxProblemDigits)
|
||||
} else {
|
||||
} else if (problem.operator === 'sub') {
|
||||
// Subtraction
|
||||
const digitsMinuend = problem.minuend.toString().length
|
||||
const digitsSubtrahend = problem.subtrahend.toString().length
|
||||
const maxProblemDigits = Math.max(digitsMinuend, digitsSubtrahend)
|
||||
maxDigits = Math.max(maxDigits, maxProblemDigits)
|
||||
} else {
|
||||
const fractionProblem = problem as FractionProblem
|
||||
const maxProblemDigits = Math.max(
|
||||
fractionProblem.aNumerator.toString().length,
|
||||
fractionProblem.aDenominator.toString().length,
|
||||
fractionProblem.bNumerator.toString().length,
|
||||
fractionProblem.bDenominator.toString().length
|
||||
)
|
||||
maxDigits = Math.max(maxDigits, maxProblemDigits)
|
||||
}
|
||||
}
|
||||
return maxDigits
|
||||
|
|
@ -596,6 +632,12 @@ export async function generateTypstSource(
|
|||
shareUrl?: string,
|
||||
domain?: string
|
||||
): Promise<string[]> {
|
||||
const hasFractionProblems = problems.some((p) => (p as WorksheetProblem).operator === 'fractions')
|
||||
|
||||
if (hasFractionProblems) {
|
||||
return await generateFractionTypstSource(config, problems as FractionProblem[], shareUrl, domain)
|
||||
}
|
||||
|
||||
// Use the problemsPerPage directly from config (primary state)
|
||||
const problemsPerPage = config.problemsPerPage
|
||||
const rowsPerPage = problemsPerPage / config.cols
|
||||
|
|
@ -651,3 +693,133 @@ export async function generateTypstSource(
|
|||
|
||||
return worksheetPages
|
||||
}
|
||||
|
||||
function generateFractionPageTypst(
|
||||
config: WorksheetConfig,
|
||||
pageProblems: FractionProblem[],
|
||||
problemOffset: number,
|
||||
qrCodeSvg?: string,
|
||||
shareCode?: string,
|
||||
domain?: string
|
||||
): string {
|
||||
const margin = 0.4
|
||||
const problemData = pageProblems
|
||||
.map((p, idx) => {
|
||||
const answer = addAndSimplifyFractions(p)
|
||||
return ` (number: ${problemOffset + idx + 1}, aNum: ${p.aNumerator}, aDen: ${p.aDenominator}, bNum: ${p.bNumerator}, bDen: ${p.bDenominator}, ansNum: ${answer.numerator}, ansDen: ${answer.denominator}),`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
return `#let fraction-problems = (
|
||||
${problemData}
|
||||
)
|
||||
|
||||
#page(
|
||||
paper: (${config.page.wIn}in, ${config.page.hIn}in),
|
||||
margin: (${margin}in, ${margin}in),
|
||||
header: align(center)[
|
||||
#text(size: 14pt, weight: 'bold')[${config.name}]
|
||||
#v(6pt)
|
||||
#text(size: 10pt)[Fraction Addition (Mixed Denominators)]
|
||||
],
|
||||
footer: align(center)[
|
||||
#text(size: 8pt)[${domain || 'abaci.one'}]
|
||||
],
|
||||
)[
|
||||
#grid(columns: ${config.cols}, gutter: 0.3in)[
|
||||
..for p in fraction-problems {
|
||||
box(stroke: 0.5pt, inset: 10pt)[
|
||||
#stack(dir: ttb, spacing: 6pt)[
|
||||
#align(center)[
|
||||
#text(size: 13pt)[#frac(p.aNum, p.aDen) + #frac(p.bNum, p.bDen) = \h(30pt)]
|
||||
]
|
||||
#align(right)[#text(size: 8pt)[#p.number]]
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
${
|
||||
qrCodeSvg
|
||||
? `#place(bottom + left, dx: 0.1in, dy: -0.05in)[#stack(dir: ttb, spacing: 2pt, align(center)[#image(bytes("${qrCodeSvg.replace(/"/g, '\\"').replace(/\n/g, '')}"), format: "svg", width: 0.63in, height: 0.63in)], align(center)[#text(size: 7pt, font: "Courier New")[${shareCode || 'PREVIEW'}]])]`
|
||||
: ''
|
||||
}
|
||||
]
|
||||
`
|
||||
}
|
||||
|
||||
function generateFractionAnswerKey(
|
||||
config: WorksheetConfig,
|
||||
problems: FractionProblem[],
|
||||
qrCodeSvg?: string,
|
||||
shareCode?: string
|
||||
): string[] {
|
||||
const margin = 0.4
|
||||
const answerLines = problems
|
||||
.map((p, idx) => {
|
||||
const answer = addAndSimplifyFractions(p)
|
||||
return ` #text(size: 11pt)[${idx + 1}. #frac(${p.aNumerator}, ${p.aDenominator}) + #frac(${p.bNumerator}, ${p.bDenominator}) = #frac(${answer.numerator}, ${answer.denominator})]`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
const qrBlock =
|
||||
qrCodeSvg && shareCode
|
||||
? `#place(bottom + left, dx: 0.1in, dy: -0.05in)[#stack(dir: ttb, spacing: 2pt, align(center)[#image(bytes("${qrCodeSvg.replace(/"/g, '\\"').replace(/\n/g, '')}"), format: "svg", width: 0.63in, height: 0.63in)], align(center)[#text(size: 7pt, font: "Courier New")[${shareCode}])])]`
|
||||
: ''
|
||||
|
||||
return [
|
||||
`#page(
|
||||
paper: (${config.page.wIn}in, ${config.page.hIn}in),
|
||||
margin: (${margin}in, ${margin}in),
|
||||
header: align(center)[#text(size: 14pt, weight: 'bold')[Answer Key - Fractions]],
|
||||
)[
|
||||
#stack(dir: ttb, spacing: 6pt)[
|
||||
${answerLines}
|
||||
]
|
||||
${qrBlock}
|
||||
]
|
||||
`,
|
||||
]
|
||||
}
|
||||
|
||||
async function generateFractionTypstSource(
|
||||
config: WorksheetConfig,
|
||||
problems: FractionProblem[],
|
||||
shareUrl?: string,
|
||||
domain?: string
|
||||
): Promise<string[]> {
|
||||
const problemsPerPage = config.problemsPerPage
|
||||
const pages = chunkProblems(problems, problemsPerPage)
|
||||
|
||||
let qrCodeSvg: string | undefined
|
||||
let shareCode: string | undefined
|
||||
if (config.includeQRCode && shareUrl) {
|
||||
qrCodeSvg = await generateQRCodeSVG(shareUrl, 200)
|
||||
shareCode = extractShareCode(shareUrl)
|
||||
}
|
||||
|
||||
let brandDomain = domain
|
||||
if (!brandDomain && shareUrl) {
|
||||
try {
|
||||
brandDomain = new URL(shareUrl).hostname
|
||||
} catch {
|
||||
// Ignore invalid URL and fall back to default
|
||||
}
|
||||
}
|
||||
|
||||
const fractionPages = pages.map((page, pageIndex) =>
|
||||
generateFractionPageTypst(
|
||||
config,
|
||||
page,
|
||||
pageIndex * problemsPerPage,
|
||||
qrCodeSvg,
|
||||
shareCode,
|
||||
brandDomain
|
||||
)
|
||||
)
|
||||
|
||||
if (config.includeAnswerKey) {
|
||||
return [...fractionPages, ...generateFractionAnswerKey(config, problems, qrCodeSvg, shareCode)]
|
||||
}
|
||||
|
||||
return fractionPages
|
||||
}
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ export function validateProblemSpace(
|
|||
pages: number,
|
||||
digitRange: { min: number; max: number },
|
||||
pAnyStart: number,
|
||||
operator: 'addition' | 'subtraction' | 'mixed'
|
||||
operator: 'addition' | 'subtraction' | 'mixed' | 'fractions'
|
||||
): ProblemSpaceValidation {
|
||||
const requestedProblems = problemsPerPage * pages
|
||||
const warnings: string[] = []
|
||||
|
|
@ -123,12 +123,15 @@ export function validateProblemSpace(
|
|||
const addSpace = estimateUniqueProblemSpace(digitRange, pAnyStart, 'addition')
|
||||
const subSpace = estimateUniqueProblemSpace(digitRange, pAnyStart, 'subtraction')
|
||||
estimatedSpace = addSpace + subSpace
|
||||
} else if (operator === 'fractions') {
|
||||
// Fractions have extremely large combinatorial space; treat as effectively infinite
|
||||
estimatedSpace = Number.POSITIVE_INFINITY
|
||||
} else {
|
||||
estimatedSpace = estimateUniqueProblemSpace(digitRange, pAnyStart, operator)
|
||||
}
|
||||
|
||||
// Calculate duplicate risk
|
||||
const ratio = requestedProblems / estimatedSpace
|
||||
const ratio = estimatedSpace === Number.POSITIVE_INFINITY ? 0 : requestedProblems / estimatedSpace
|
||||
let duplicateRisk: 'none' | 'low' | 'medium' | 'high' | 'extreme'
|
||||
|
||||
if (ratio < 0.3) {
|
||||
|
|
|
|||
|
|
@ -167,8 +167,8 @@ export function validateWorksheetConfig(formState: WorksheetFormState): Validati
|
|||
|
||||
// V4: Validate operator (addition, subtraction, or mixed)
|
||||
const operator = formState.operator ?? 'addition'
|
||||
if (!['addition', 'subtraction', 'mixed'].includes(operator)) {
|
||||
errors.push('Operator must be "addition", "subtraction", or "mixed"')
|
||||
if (!['addition', 'subtraction', 'mixed', 'fractions'].includes(operator)) {
|
||||
errors.push('Operator must be "addition", "subtraction", "fractions", or "mixed"')
|
||||
}
|
||||
|
||||
// Validate seed (must be positive integer)
|
||||
|
|
|
|||
Loading…
Reference in New Issue