starting to have a bracket system that is previewable
This commit is contained in:
parent
62127cc5e4
commit
82ecf80068
82 changed files with 3461 additions and 637 deletions
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
68
.idea/codeStyles/Project.xml
generated
Normal file
68
.idea/codeStyles/Project.xml
generated
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
|
<code_scheme name="Project" version="173">
|
||||||
|
<HTMLCodeStyleSettings>
|
||||||
|
<option name="HTML_SPACE_INSIDE_EMPTY_TAG" value="true" />
|
||||||
|
</HTMLCodeStyleSettings>
|
||||||
|
<JSCodeStyleSettings version="0">
|
||||||
|
<option name="FORCE_SEMICOLON_STYLE" value="true" />
|
||||||
|
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
|
||||||
|
<option name="USE_DOUBLE_QUOTES" value="false" />
|
||||||
|
<option name="FORCE_QUOTE_STYlE" value="true" />
|
||||||
|
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
|
||||||
|
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
|
||||||
|
<option name="SPACES_WITHIN_IMPORTS" value="true" />
|
||||||
|
</JSCodeStyleSettings>
|
||||||
|
<TypeScriptCodeStyleSettings version="0">
|
||||||
|
<option name="FORCE_SEMICOLON_STYLE" value="true" />
|
||||||
|
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
|
||||||
|
<option name="USE_DOUBLE_QUOTES" value="false" />
|
||||||
|
<option name="FORCE_QUOTE_STYlE" value="true" />
|
||||||
|
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
|
||||||
|
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
|
||||||
|
<option name="SPACES_WITHIN_IMPORTS" value="true" />
|
||||||
|
</TypeScriptCodeStyleSettings>
|
||||||
|
<VueCodeStyleSettings>
|
||||||
|
<option name="INTERPOLATION_NEW_LINE_AFTER_START_DELIMITER" value="false" />
|
||||||
|
<option name="INTERPOLATION_NEW_LINE_BEFORE_END_DELIMITER" value="false" />
|
||||||
|
</VueCodeStyleSettings>
|
||||||
|
<codeStyleSettings language="HTML">
|
||||||
|
<option name="SOFT_MARGINS" value="100" />
|
||||||
|
<indentOptions>
|
||||||
|
<option name="INDENT_SIZE" value="2" />
|
||||||
|
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
||||||
|
<option name="TAB_SIZE" value="2" />
|
||||||
|
<option name="USE_TAB_CHARACTER" value="true" />
|
||||||
|
</indentOptions>
|
||||||
|
</codeStyleSettings>
|
||||||
|
<codeStyleSettings language="JAVA">
|
||||||
|
<indentOptions>
|
||||||
|
<option name="INDENT_SIZE" value="2" />
|
||||||
|
</indentOptions>
|
||||||
|
</codeStyleSettings>
|
||||||
|
<codeStyleSettings language="JavaScript">
|
||||||
|
<option name="SOFT_MARGINS" value="100" />
|
||||||
|
<indentOptions>
|
||||||
|
<option name="INDENT_SIZE" value="2" />
|
||||||
|
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
||||||
|
<option name="TAB_SIZE" value="2" />
|
||||||
|
<option name="USE_TAB_CHARACTER" value="true" />
|
||||||
|
</indentOptions>
|
||||||
|
</codeStyleSettings>
|
||||||
|
<codeStyleSettings language="TypeScript">
|
||||||
|
<option name="SOFT_MARGINS" value="100" />
|
||||||
|
<indentOptions>
|
||||||
|
<option name="INDENT_SIZE" value="2" />
|
||||||
|
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
||||||
|
<option name="TAB_SIZE" value="2" />
|
||||||
|
<option name="USE_TAB_CHARACTER" value="true" />
|
||||||
|
</indentOptions>
|
||||||
|
</codeStyleSettings>
|
||||||
|
<codeStyleSettings language="Vue">
|
||||||
|
<option name="SOFT_MARGINS" value="100" />
|
||||||
|
<indentOptions>
|
||||||
|
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
||||||
|
<option name="USE_TAB_CHARACTER" value="true" />
|
||||||
|
</indentOptions>
|
||||||
|
</codeStyleSettings>
|
||||||
|
</code_scheme>
|
||||||
|
</component>
|
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
|
<state>
|
||||||
|
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||||
|
</state>
|
||||||
|
</component>
|
7
.idea/discord.xml
generated
Normal file
7
.idea/discord.xml
generated
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DiscordProjectSettings">
|
||||||
|
<option name="show" value="PROJECT_FILES" />
|
||||||
|
<option name="description" value="" />
|
||||||
|
</component>
|
||||||
|
</project>
|
12
.idea/flbxcup.iml
generated
Normal file
12
.idea/flbxcup.iml
generated
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="WEB_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
12
.idea/material_theme_project_new.xml
generated
Normal file
12
.idea/material_theme_project_new.xml
generated
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="MaterialThemeProjectNewConfig">
|
||||||
|
<option name="metadata">
|
||||||
|
<MTProjectMetadataState>
|
||||||
|
<option name="migrated" value="true" />
|
||||||
|
<option name="pristineConfig" value="false" />
|
||||||
|
<option name="userId" value="-daa4d1a:1934e52663e:-7ffa" />
|
||||||
|
</MTProjectMetadataState>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/flbxcup.iml" filepath="$PROJECT_DIR$/.idea/flbxcup.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
87
bun.lock
87
bun.lock
|
@ -11,7 +11,7 @@
|
||||||
"drizzle-orm": "^0.40.0",
|
"drizzle-orm": "^0.40.0",
|
||||||
"drizzle-zod": "^0.8.2",
|
"drizzle-zod": "^0.8.2",
|
||||||
"lucide-svelte": "^0.525.0",
|
"lucide-svelte": "^0.525.0",
|
||||||
"postgres": "^3.4.5",
|
"pg": "^8.16.3",
|
||||||
"svelte-i18n": "^4.0.1",
|
"svelte-i18n": "^4.0.1",
|
||||||
"svelvet": "^11.0.5",
|
"svelvet": "^11.0.5",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
|
@ -27,6 +27,7 @@
|
||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
|
"@types/pg": "^8.15.4",
|
||||||
"bits-ui": "^2.8.6",
|
"bits-ui": "^2.8.6",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"drizzle-kit": "^0.30.2",
|
"drizzle-kit": "^0.30.2",
|
||||||
|
@ -59,6 +60,8 @@
|
||||||
|
|
||||||
"@babel/runtime": ["@babel/runtime@7.27.6", "", {}, "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q=="],
|
"@babel/runtime": ["@babel/runtime@7.27.6", "", {}, "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q=="],
|
||||||
|
|
||||||
|
"@bufbuild/protobuf": ["@bufbuild/protobuf@2.6.0", "", {}, "sha512-6cuonJVNOIL7lTj5zgo/Rc2bKAo4/GvN+rKCrUj7GdEHRzCk8zKOfFwUsL9nAVk5rSIsRmlgcpLzTRysopEeeg=="],
|
||||||
|
|
||||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
|
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
|
||||||
|
|
||||||
"@emnapi/core": ["@emnapi/core@1.4.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.2", "tslib": "^2.4.0" } }, "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g=="],
|
"@emnapi/core": ["@emnapi/core@1.4.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.2", "tslib": "^2.4.0" } }, "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g=="],
|
||||||
|
@ -363,6 +366,8 @@
|
||||||
|
|
||||||
"@types/node": ["@types/node@22.16.0", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-B2egV9wALML1JCpv3VQoQ+yesQKAmNMBIAY7OteVrikcOcAkWm+dGL6qpeCktPjAv6N1JLnhbNiqS35UpFyBsQ=="],
|
"@types/node": ["@types/node@22.16.0", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-B2egV9wALML1JCpv3VQoQ+yesQKAmNMBIAY7OteVrikcOcAkWm+dGL6qpeCktPjAv6N1JLnhbNiqS35UpFyBsQ=="],
|
||||||
|
|
||||||
|
"@types/pg": ["@types/pg@8.15.4", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-I6UNVBAoYbvuWkkU3oosC8yxqH21f4/Jc4DK71JLG3dT2mdlGe1z+ep/LQGXaKaOgcvUrsQoPRqfgtMcvZiJhg=="],
|
||||||
|
|
||||||
"@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="],
|
"@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="],
|
||||||
|
|
||||||
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
|
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
|
||||||
|
@ -391,6 +396,8 @@
|
||||||
|
|
||||||
"bits-ui": ["bits-ui@2.8.10", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", "runed": "^0.29.1", "svelte-toolbelt": "^0.9.3", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-MOobkqapDZNrpcNmeL2g664xFmH4tZBOKBTxFmsQYMZQuybSZHQnPXy+AjM5XZEXRmCFx5+XRmo6+fC3vHh1hQ=="],
|
"bits-ui": ["bits-ui@2.8.10", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", "runed": "^0.29.1", "svelte-toolbelt": "^0.9.3", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-MOobkqapDZNrpcNmeL2g664xFmH4tZBOKBTxFmsQYMZQuybSZHQnPXy+AjM5XZEXRmCFx5+XRmo6+fC3vHh1hQ=="],
|
||||||
|
|
||||||
|
"buffer-builder": ["buffer-builder@0.2.0", "", {}, "sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg=="],
|
||||||
|
|
||||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.2.18", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw=="],
|
"bun-types": ["bun-types@1.2.18", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw=="],
|
||||||
|
@ -407,6 +414,8 @@
|
||||||
|
|
||||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||||
|
|
||||||
|
"colorjs.io": ["colorjs.io@0.5.2", "", {}, "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw=="],
|
||||||
|
|
||||||
"commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="],
|
"commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="],
|
||||||
|
|
||||||
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
|
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
|
||||||
|
@ -489,8 +498,12 @@
|
||||||
|
|
||||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||||
|
|
||||||
|
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||||
|
|
||||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||||
|
|
||||||
|
"immutable": ["immutable@5.1.3", "", {}, "sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg=="],
|
||||||
|
|
||||||
"inline-style-parser": ["inline-style-parser@0.2.4", "", {}, "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q=="],
|
"inline-style-parser": ["inline-style-parser@0.2.4", "", {}, "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q=="],
|
||||||
|
|
||||||
"intl-messageformat": ["intl-messageformat@10.7.16", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.4", "@formatjs/fast-memoize": "2.2.7", "@formatjs/icu-messageformat-parser": "2.11.2", "tslib": "^2.8.0" } }, "sha512-UmdmHUmp5CIKKjSoE10la5yfU+AYJAaiYLsodbjL4lji83JNvgOQUjGaGhGrpFCb0Uh7sl7qfP1IyILa8Z40ug=="],
|
"intl-messageformat": ["intl-messageformat@10.7.16", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.4", "@formatjs/fast-memoize": "2.2.7", "@formatjs/icu-messageformat-parser": "2.11.2", "tslib": "^2.8.0" } }, "sha512-UmdmHUmp5CIKKjSoE10la5yfU+AYJAaiYLsodbjL4lji83JNvgOQUjGaGhGrpFCb0Uh7sl7qfP1IyILa8Z40ug=="],
|
||||||
|
@ -581,6 +594,22 @@
|
||||||
|
|
||||||
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||||
|
|
||||||
|
"pg": ["pg@8.16.3", "", { "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", "pg-protocol": "^1.10.3", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.2.7" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw=="],
|
||||||
|
|
||||||
|
"pg-cloudflare": ["pg-cloudflare@1.2.7", "", {}, "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg=="],
|
||||||
|
|
||||||
|
"pg-connection-string": ["pg-connection-string@2.9.1", "", {}, "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w=="],
|
||||||
|
|
||||||
|
"pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="],
|
||||||
|
|
||||||
|
"pg-pool": ["pg-pool@3.10.1", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg=="],
|
||||||
|
|
||||||
|
"pg-protocol": ["pg-protocol@1.10.3", "", {}, "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ=="],
|
||||||
|
|
||||||
|
"pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="],
|
||||||
|
|
||||||
|
"pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="],
|
||||||
|
|
||||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
"picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
|
"picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
|
||||||
|
@ -591,6 +620,14 @@
|
||||||
|
|
||||||
"postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="],
|
"postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="],
|
||||||
|
|
||||||
|
"postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
|
||||||
|
|
||||||
|
"postgres-bytea": ["postgres-bytea@1.0.0", "", {}, "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w=="],
|
||||||
|
|
||||||
|
"postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="],
|
||||||
|
|
||||||
|
"postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
|
||||||
|
|
||||||
"prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="],
|
"prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="],
|
||||||
|
|
||||||
"prettier-plugin-svelte": ["prettier-plugin-svelte@3.4.0", "", { "peerDependencies": { "prettier": "^3.0.0", "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" } }, "sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ=="],
|
"prettier-plugin-svelte": ["prettier-plugin-svelte@3.4.0", "", { "peerDependencies": { "prettier": "^3.0.0", "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" } }, "sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ=="],
|
||||||
|
@ -613,8 +650,44 @@
|
||||||
|
|
||||||
"runed": ["runed@0.29.1", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-RGQEB8ZiWv4OvzBJhbMj2hMgRM8QrEptzTrDr7TDfkHaRePKjiUka4vJ9QHGY+8s87KymNvFoZAxFdQ4jtZNcA=="],
|
"runed": ["runed@0.29.1", "", { "dependencies": { "esm-env": "^1.0.0" }, "peerDependencies": { "svelte": "^5.7.0" } }, "sha512-RGQEB8ZiWv4OvzBJhbMj2hMgRM8QrEptzTrDr7TDfkHaRePKjiUka4vJ9QHGY+8s87KymNvFoZAxFdQ4jtZNcA=="],
|
||||||
|
|
||||||
|
"rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
|
||||||
|
|
||||||
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
|
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
|
||||||
|
|
||||||
|
"sass-embedded": ["sass-embedded@1.89.2", "", { "dependencies": { "@bufbuild/protobuf": "^2.5.0", "buffer-builder": "^0.2.0", "colorjs.io": "^0.5.0", "immutable": "^5.0.2", "rxjs": "^7.4.0", "supports-color": "^8.1.1", "sync-child-process": "^1.0.2", "varint": "^6.0.0" }, "optionalDependencies": { "sass-embedded-android-arm": "1.89.2", "sass-embedded-android-arm64": "1.89.2", "sass-embedded-android-riscv64": "1.89.2", "sass-embedded-android-x64": "1.89.2", "sass-embedded-darwin-arm64": "1.89.2", "sass-embedded-darwin-x64": "1.89.2", "sass-embedded-linux-arm": "1.89.2", "sass-embedded-linux-arm64": "1.89.2", "sass-embedded-linux-musl-arm": "1.89.2", "sass-embedded-linux-musl-arm64": "1.89.2", "sass-embedded-linux-musl-riscv64": "1.89.2", "sass-embedded-linux-musl-x64": "1.89.2", "sass-embedded-linux-riscv64": "1.89.2", "sass-embedded-linux-x64": "1.89.2", "sass-embedded-win32-arm64": "1.89.2", "sass-embedded-win32-x64": "1.89.2" }, "bin": { "sass": "dist/bin/sass.js" } }, "sha512-Ack2K8rc57kCFcYlf3HXpZEJFNUX8xd8DILldksREmYXQkRHI879yy8q4mRDJgrojkySMZqmmmW1NxrFxMsYaA=="],
|
||||||
|
|
||||||
|
"sass-embedded-android-arm": ["sass-embedded-android-arm@1.89.2", "", { "os": "android", "cpu": "arm" }, "sha512-oHAPTboBHRZlDBhyRB6dvDKh4KvFs+DZibDHXbkSI6dBZxMTT+Yb2ivocHnctVGucKTLQeT7+OM5DjWHyynL/A=="],
|
||||||
|
|
||||||
|
"sass-embedded-android-arm64": ["sass-embedded-android-arm64@1.89.2", "", { "os": "android", "cpu": "arm64" }, "sha512-+pq7a7AUpItNyPu61sRlP6G2A8pSPpyazASb+8AK2pVlFayCSPAEgpwpCE9A2/Xj86xJZeMizzKUHxM2CBCUxA=="],
|
||||||
|
|
||||||
|
"sass-embedded-android-riscv64": ["sass-embedded-android-riscv64@1.89.2", "", { "os": "android", "cpu": "none" }, "sha512-HfJJWp/S6XSYvlGAqNdakeEMPOdhBkj2s2lN6SHnON54rahKem+z9pUbCriUJfM65Z90lakdGuOfidY61R9TYg=="],
|
||||||
|
|
||||||
|
"sass-embedded-android-x64": ["sass-embedded-android-x64@1.89.2", "", { "os": "android", "cpu": "x64" }, "sha512-BGPzq53VH5z5HN8de6jfMqJjnRe1E6sfnCWFd4pK+CAiuM7iw5Fx6BQZu3ikfI1l2GY0y6pRXzsVLdp/j4EKEA=="],
|
||||||
|
|
||||||
|
"sass-embedded-darwin-arm64": ["sass-embedded-darwin-arm64@1.89.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UCm3RL/tzMpG7DsubARsvGUNXC5pgfQvP+RRFJo9XPIi6elopY5B6H4m9dRYDpHA+scjVthdiDwkPYr9+S/KGw=="],
|
||||||
|
|
||||||
|
"sass-embedded-darwin-x64": ["sass-embedded-darwin-x64@1.89.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-D9WxtDY5VYtMApXRuhQK9VkPHB8R79NIIR6xxVlN2MIdEid/TZWi1MHNweieETXhWGrKhRKglwnHxxyKdJYMnA=="],
|
||||||
|
|
||||||
|
"sass-embedded-linux-arm": ["sass-embedded-linux-arm@1.89.2", "", { "os": "linux", "cpu": "arm" }, "sha512-leP0t5U4r95dc90o8TCWfxNXwMAsQhpWxTkdtySDpngoqtTy3miMd7EYNYd1znI0FN1CBaUvbdCMbnbPwygDlA=="],
|
||||||
|
|
||||||
|
"sass-embedded-linux-arm64": ["sass-embedded-linux-arm64@1.89.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-2N4WW5LLsbtrWUJ7iTpjvhajGIbmDR18ZzYRywHdMLpfdPApuHPMDF5CYzHbS+LLx2UAx7CFKBnj5LLjY6eFgQ=="],
|
||||||
|
|
||||||
|
"sass-embedded-linux-musl-arm": ["sass-embedded-linux-musl-arm@1.89.2", "", { "os": "linux", "cpu": "arm" }, "sha512-Z6gG2FiVEEdxYHRi2sS5VIYBmp17351bWtOCUZ/thBM66+e70yiN6Eyqjz80DjL8haRUegNQgy9ZJqsLAAmr9g=="],
|
||||||
|
|
||||||
|
"sass-embedded-linux-musl-arm64": ["sass-embedded-linux-musl-arm64@1.89.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-nTyuaBX6U1A/cG7WJh0pKD1gY8hbg1m2SnzsyoFG+exQ0lBX/lwTLHq3nyhF+0atv7YYhYKbmfz+sjPP8CZ9lw=="],
|
||||||
|
|
||||||
|
"sass-embedded-linux-musl-riscv64": ["sass-embedded-linux-musl-riscv64@1.89.2", "", { "os": "linux", "cpu": "none" }, "sha512-N6oul+qALO0SwGY8JW7H/Vs0oZIMrRMBM4GqX3AjM/6y8JsJRxkAwnfd0fDyK+aICMFarDqQonQNIx99gdTZqw=="],
|
||||||
|
|
||||||
|
"sass-embedded-linux-musl-x64": ["sass-embedded-linux-musl-x64@1.89.2", "", { "os": "linux", "cpu": "x64" }, "sha512-K+FmWcdj/uyP8GiG9foxOCPfb5OAZG0uSVq80DKgVSC0U44AdGjvAvVZkrgFEcZ6cCqlNC2JfYmslB5iqdL7tg=="],
|
||||||
|
|
||||||
|
"sass-embedded-linux-riscv64": ["sass-embedded-linux-riscv64@1.89.2", "", { "os": "linux", "cpu": "none" }, "sha512-g9nTbnD/3yhOaskeqeBQETbtfDQWRgsjHok6bn7DdAuwBsyrR3JlSFyqKc46pn9Xxd9SQQZU8AzM4IR+sY0A0w=="],
|
||||||
|
|
||||||
|
"sass-embedded-linux-x64": ["sass-embedded-linux-x64@1.89.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Ax7dKvzncyQzIl4r7012KCMBvJzOz4uwSNoyoM5IV6y5I1f5hEwI25+U4WfuTqdkv42taCMgpjZbh9ERr6JVMQ=="],
|
||||||
|
|
||||||
|
"sass-embedded-win32-arm64": ["sass-embedded-win32-arm64@1.89.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-j96iJni50ZUsfD6tRxDQE2QSYQ2WrfHxeiyAXf41Kw0V4w5KYR/Sf6rCZQLMTUOHnD16qTMVpQi20LQSqf4WGg=="],
|
||||||
|
|
||||||
|
"sass-embedded-win32-x64": ["sass-embedded-win32-x64@1.89.2", "", { "os": "win32", "cpu": "x64" }, "sha512-cS2j5ljdkQsb4PaORiClaVYynE9OAPZG/XjbOMxpQmjRIf7UroY4PEIH+Waf+y47PfXFX9SyxhYuw2NIKGbEng=="],
|
||||||
|
|
||||||
"semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
"semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||||
|
|
||||||
"set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="],
|
"set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="],
|
||||||
|
@ -629,10 +702,14 @@
|
||||||
|
|
||||||
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
||||||
|
|
||||||
|
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
|
||||||
|
|
||||||
"style-to-object": ["style-to-object@1.0.9", "", { "dependencies": { "inline-style-parser": "0.2.4" } }, "sha512-G4qppLgKu/k6FwRpHiGiKPaPTFcG3g4wNVX/Qsfu+RqQM30E7Tyu/TEgxcL9PNLF5pdRLwQdE3YKKf+KF2Dzlw=="],
|
"style-to-object": ["style-to-object@1.0.9", "", { "dependencies": { "inline-style-parser": "0.2.4" } }, "sha512-G4qppLgKu/k6FwRpHiGiKPaPTFcG3g4wNVX/Qsfu+RqQM30E7Tyu/TEgxcL9PNLF5pdRLwQdE3YKKf+KF2Dzlw=="],
|
||||||
|
|
||||||
"superstruct": ["superstruct@2.0.2", "", {}, "sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A=="],
|
"superstruct": ["superstruct@2.0.2", "", {}, "sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A=="],
|
||||||
|
|
||||||
|
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
|
||||||
|
|
||||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||||
|
|
||||||
"svelte": ["svelte@5.35.2", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "esm-env": "^1.2.1", "esrap": "^2.1.0", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-uW/rRXYrhZ7Dh4UQNZ0t+oVGL1dEM+95GavCO8afAk1IY2cPq9BcZv9C3um5aLIya2y8lIeLPxLII9ASGg9Dzw=="],
|
"svelte": ["svelte@5.35.2", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "esm-env": "^1.2.1", "esrap": "^2.1.0", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-uW/rRXYrhZ7Dh4UQNZ0t+oVGL1dEM+95GavCO8afAk1IY2cPq9BcZv9C3um5aLIya2y8lIeLPxLII9ASGg9Dzw=="],
|
||||||
|
@ -649,6 +726,10 @@
|
||||||
|
|
||||||
"svelvet": ["svelvet@11.0.5", "", { "dependencies": { "svelvet": "^10.0.2", "uuid": "^11.0.5" }, "peerDependencies": { "svelte": ">=3.59.2 || ^4.0.0" } }, "sha512-wGDGh3bRKK06stu613DC+r4ujE7sWkFTAp4bfW6Impc2A89Ix0M/PzZAyeW229FMS5TF2GRDugHgpk0q0D8k2g=="],
|
"svelvet": ["svelvet@11.0.5", "", { "dependencies": { "svelvet": "^10.0.2", "uuid": "^11.0.5" }, "peerDependencies": { "svelte": ">=3.59.2 || ^4.0.0" } }, "sha512-wGDGh3bRKK06stu613DC+r4ujE7sWkFTAp4bfW6Impc2A89Ix0M/PzZAyeW229FMS5TF2GRDugHgpk0q0D8k2g=="],
|
||||||
|
|
||||||
|
"sync-child-process": ["sync-child-process@1.0.2", "", { "dependencies": { "sync-message-port": "^1.0.0" } }, "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA=="],
|
||||||
|
|
||||||
|
"sync-message-port": ["sync-message-port@1.1.3", "", {}, "sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg=="],
|
||||||
|
|
||||||
"tabbable": ["tabbable@6.2.0", "", {}, "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="],
|
"tabbable": ["tabbable@6.2.0", "", {}, "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="],
|
||||||
|
|
||||||
"tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="],
|
"tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="],
|
||||||
|
@ -697,6 +778,8 @@
|
||||||
|
|
||||||
"validator": ["validator@13.15.15", "", {}, "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A=="],
|
"validator": ["validator@13.15.15", "", {}, "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A=="],
|
||||||
|
|
||||||
|
"varint": ["varint@6.0.0", "", {}, "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg=="],
|
||||||
|
|
||||||
"vite": ["rolldown-vite@7.0.4", "", { "dependencies": { "@oxc-project/runtime": "0.75.0", "fdir": "^6.4.6", "lightningcss": "^1.30.1", "picomatch": "^4.0.2", "postcss": "^8.5.6", "rolldown": "1.0.0-beta.23", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "esbuild": "^0.25.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-AcFt2mBWuwH3svDHcz8V5+K8Es1TuZOBDdJh6+ySkGSuNS5sEpRJqnopupeMfB8SHCAXVA6Wp75OQmTBZc+TgQ=="],
|
"vite": ["rolldown-vite@7.0.4", "", { "dependencies": { "@oxc-project/runtime": "0.75.0", "fdir": "^6.4.6", "lightningcss": "^1.30.1", "picomatch": "^4.0.2", "postcss": "^8.5.6", "rolldown": "1.0.0-beta.23", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "esbuild": "^0.25.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-AcFt2mBWuwH3svDHcz8V5+K8Es1TuZOBDdJh6+ySkGSuNS5sEpRJqnopupeMfB8SHCAXVA6Wp75OQmTBZc+TgQ=="],
|
||||||
|
|
||||||
"vite-plugin-devtools-json": ["vite-plugin-devtools-json@0.2.1", "", { "dependencies": { "uuid": "^11.1.0" }, "peerDependencies": { "vite": "^2.7.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-5aiNvf/iLTuLR1dUqoI5CLLGgeK2hd6u+tA+RIp7GUZDyAcM6ECaUEWOOtGpidbcxbkKq++KtmSqA3jhMbPwMA=="],
|
"vite-plugin-devtools-json": ["vite-plugin-devtools-json@0.2.1", "", { "dependencies": { "uuid": "^11.1.0" }, "peerDependencies": { "vite": "^2.7.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-5aiNvf/iLTuLR1dUqoI5CLLGgeK2hd6u+tA+RIp7GUZDyAcM6ECaUEWOOtGpidbcxbkKq++KtmSqA3jhMbPwMA=="],
|
||||||
|
@ -705,6 +788,8 @@
|
||||||
|
|
||||||
"which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
|
"which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
|
||||||
|
|
||||||
|
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
||||||
|
|
||||||
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
||||||
|
|
||||||
"yup": ["yup@1.6.1", "", { "dependencies": { "property-expr": "^2.0.5", "tiny-case": "^1.0.3", "toposort": "^2.0.2", "type-fest": "^2.19.0" } }, "sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA=="],
|
"yup": ["yup@1.6.1", "", { "dependencies": { "property-expr": "^2.0.5", "tiny-case": "^1.0.3", "toposort": "^2.0.2", "type-fest": "^2.19.0" } }, "sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA=="],
|
||||||
|
|
|
@ -27,6 +27,7 @@
|
||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
|
"@types/pg": "^8.15.4",
|
||||||
"bits-ui": "^2.8.6",
|
"bits-ui": "^2.8.6",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"drizzle-kit": "^0.30.2",
|
"drizzle-kit": "^0.30.2",
|
||||||
|
@ -56,7 +57,7 @@
|
||||||
"drizzle-orm": "^0.40.0",
|
"drizzle-orm": "^0.40.0",
|
||||||
"drizzle-zod": "^0.8.2",
|
"drizzle-zod": "^0.8.2",
|
||||||
"lucide-svelte": "^0.525.0",
|
"lucide-svelte": "^0.525.0",
|
||||||
"postgres": "^3.4.5",
|
"pg": "^8.16.3",
|
||||||
"svelte-i18n": "^4.0.1",
|
"svelte-i18n": "^4.0.1",
|
||||||
"svelvet": "^11.0.5",
|
"svelvet": "^11.0.5",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
|
|
|
@ -10,24 +10,40 @@
|
||||||
import { getContext } from 'svelte';
|
import { getContext } from 'svelte';
|
||||||
import Nav from '$lib/components/nav.svelte';
|
import Nav from '$lib/components/nav.svelte';
|
||||||
import type { User } from '$lib/server/db/schema/users';
|
import type { User } from '$lib/server/db/schema/users';
|
||||||
|
import ThemeToggle from './theme-toggle.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
breadcrumbs,
|
breadcrumbs,
|
||||||
className,
|
className,
|
||||||
|
fullWidth = false,
|
||||||
children
|
children
|
||||||
}: { breadcrumbs: BreadcrumbItemType[]; className?: string; children: any } = $props();
|
}: {
|
||||||
|
breadcrumbs: BreadcrumbItemType[];
|
||||||
|
className?: string;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
children: any;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
const user = getContext<User>('user');
|
const user = getContext<User>('user');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto flex min-h-screen max-w-7xl flex-col px-4 pt-4 pb-8 sm:px-6 lg:px-8">
|
<div
|
||||||
|
class={cn(
|
||||||
|
'mx-auto flex min-h-screen flex-col px-4 pt-4 pb-8 sm:px-6 lg:px-8',
|
||||||
|
!fullWidth && 'max-w-7xl'
|
||||||
|
)}
|
||||||
|
>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="flex flex-col gap-4 pb-8">
|
<header class="flex flex-col gap-4 pb-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between border-b pb-4">
|
||||||
<div class="flex-1">
|
<div class="flex">
|
||||||
<a href="/" class="text-primary text-2xl font-bold">FlbxCup</a>
|
<a href="/" class="text-primary text-2xl font-bold">FlbxCup</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<Nav />
|
||||||
|
</div>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
|
<ThemeToggle />
|
||||||
{#if user}
|
{#if user}
|
||||||
<div class="text-muted-foreground text-sm">
|
<div class="text-muted-foreground text-sm">
|
||||||
Welcome, {user.username || 'User'}
|
Welcome, {user.username || 'User'}
|
||||||
|
@ -38,9 +54,6 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="border-b pb-2">
|
|
||||||
<Nav />
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Breadcrumbs -->
|
<!-- Breadcrumbs -->
|
||||||
|
|
26
src/lib/components/brackets/bracket.svelte
Normal file
26
src/lib/components/brackets/bracket.svelte
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import Button from '$ui/button/button.svelte';
|
||||||
|
import CardContent from '$ui/card/card-content.svelte';
|
||||||
|
import CardFooter from '$ui/card/card-footer.svelte';
|
||||||
|
import CardHeader from '$ui/card/card-header.svelte';
|
||||||
|
import Card from '$ui/card/card.svelte';
|
||||||
|
import type { Bracket } from '@/lib/server/db/schema/brackets';
|
||||||
|
|
||||||
|
let { id, bracket = $bindable() }: { id: string; bracket: Bracket } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card id={`round-${id}`}>
|
||||||
|
<CardHeader>{bracket.name}</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{#if !bracket.rounds || bracket.rounds.length <= 0}
|
||||||
|
No matches
|
||||||
|
{:else}
|
||||||
|
{bracket.rounds[bracket.position - 1].name} ({bracket.position}/{bracket.rounds.length})
|
||||||
|
{/if}
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter
|
||||||
|
><Button href={`/competitions/${bracket.competition_id}/bracket/${bracket.id}`}
|
||||||
|
>View Matches</Button
|
||||||
|
></CardFooter
|
||||||
|
>
|
||||||
|
</Card>
|
|
@ -7,6 +7,7 @@
|
||||||
import Label from '$ui/label/label.svelte';
|
import Label from '$ui/label/label.svelte';
|
||||||
import RadioGroupItem from '$ui/radio-group/radio-group-item.svelte';
|
import RadioGroupItem from '$ui/radio-group/radio-group-item.svelte';
|
||||||
import RadioGroup from '$ui/radio-group/radio-group.svelte';
|
import RadioGroup from '$ui/radio-group/radio-group.svelte';
|
||||||
|
import type { Bracket } from '@/lib/server/db/schema/brackets';
|
||||||
import type { Round } from '@/lib/server/db/schema/rounds';
|
import type { Round } from '@/lib/server/db/schema/rounds';
|
||||||
import { cn, instanceOf } from '@/lib/utils';
|
import { cn, instanceOf } from '@/lib/utils';
|
||||||
import { SchedulingMode } from '@/types';
|
import { SchedulingMode } from '@/types';
|
||||||
|
@ -19,15 +20,17 @@
|
||||||
|
|
||||||
let {
|
let {
|
||||||
competitionId,
|
competitionId,
|
||||||
rounds = $bindable(),
|
brackets = $bindable(),
|
||||||
showAddRound = $bindable(true)
|
showAddBracket = $bindable(true)
|
||||||
}: { competitionId: string; rounds: Round[]; showAddRound: boolean } = $props();
|
}: { competitionId: string; brackets: Bracket[]; showAddBracket: boolean } = $props();
|
||||||
|
|
||||||
let schedulingMode: SchedulingMode = $state(SchedulingMode.single);
|
let schedulingMode: SchedulingMode = $state(SchedulingMode.single);
|
||||||
let name = $state('');
|
let name = $state('');
|
||||||
let nameInvalid = $state(false);
|
let nameInvalid = $state(false);
|
||||||
let nbMatches: number | undefined = $state();
|
let size: number | undefined = $state();
|
||||||
let nbMatchesInvalid = $state(false);
|
let sizeInvalid = $state(false);
|
||||||
|
|
||||||
|
let buttonDisabled = $state(false);
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
if (name.length === 0) {
|
if (name.length === 0) {
|
||||||
|
@ -35,25 +38,27 @@
|
||||||
toast.error('Name is required');
|
toast.error('Name is required');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!nbMatches || nbMatches < 0) {
|
if (!size || size < 0) {
|
||||||
nbMatchesInvalid = true;
|
sizeInvalid = true;
|
||||||
toast.error('Number of matches must be greater than 0');
|
toast.error('Number of matches must be greater than 0');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
nameInvalid = false;
|
nameInvalid = false;
|
||||||
|
// loading/disable button
|
||||||
|
buttonDisabled = true;
|
||||||
const response = await fetch(`/api/competitions/${competitionId}`, {
|
const response = await fetch(`/api/competitions/${competitionId}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify({ scheduling_mode: schedulingMode, name: name, nb_matches: nbMatches })
|
body: JSON.stringify({ scheduling_mode: schedulingMode, name: name, size: size })
|
||||||
});
|
});
|
||||||
console.log('response', response);
|
|
||||||
// update rounds
|
// update rounds
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (instanceOf<Round>(data, 'id')) {
|
buttonDisabled = false;
|
||||||
rounds = [...rounds, data];
|
if (Array.isArray(data) && data.length > 0 && instanceOf<Bracket>(data[0], 'id')) {
|
||||||
showAddRound = false;
|
brackets = [...brackets, data[0]];
|
||||||
|
showAddBracket = false;
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Invalid round');
|
throw new Error('Invalid bracket');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -65,8 +70,8 @@
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
<Label for="nb_matches" class="text-start"
|
<Label for="size" class="text-start"
|
||||||
>Number of matches<span class="text-red-500">*</span><CircleQuestionMarkIcon /></Label
|
>Size<span class="text-red-500">*</span><CircleQuestionMarkIcon /></Label
|
||||||
>
|
>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Number of participants at the beginning of the stage.</TooltipContent>
|
<TooltipContent>Number of participants at the beginning of the stage.</TooltipContent>
|
||||||
|
@ -80,10 +85,10 @@
|
||||||
placeholder="Name"
|
placeholder="Name"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
aria-invalid={nbMatchesInvalid}
|
aria-invalid={sizeInvalid}
|
||||||
name="nb_matches"
|
name="size"
|
||||||
type="number"
|
type="number"
|
||||||
bind:value={nbMatches}
|
bind:value={size}
|
||||||
placeholder="Number of matches"
|
placeholder="Number of matches"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -110,6 +115,6 @@
|
||||||
{/each}
|
{/each}
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
<div class="flex w-full justify-end">
|
<div class="flex w-full justify-end">
|
||||||
<Button onclick={submit}>Add Round<ArrowRightIcon /></Button>
|
<Button disabled={buttonDisabled} onclick={submit}>Add Round<ArrowRightIcon /></Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
173
src/lib/components/brackets/match.svelte
Normal file
173
src/lib/components/brackets/match.svelte
Normal file
|
@ -0,0 +1,173 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { MatchWithRelations } from '@/lib/server/db/schema/matches';
|
||||||
|
import Card from '../ui/card/card.svelte';
|
||||||
|
import { CardContent } from '../ui/card';
|
||||||
|
import Input from '../ui/input/input.svelte';
|
||||||
|
import { MinusIcon, PlusIcon } from 'lucide-svelte';
|
||||||
|
import Button from '../ui/button/button.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
match,
|
||||||
|
isFinal,
|
||||||
|
updateScore
|
||||||
|
}: {
|
||||||
|
match: MatchWithRelations;
|
||||||
|
isFinal: boolean;
|
||||||
|
updateScore: (score: number, teamId: string) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let matchState = $state(match);
|
||||||
|
|
||||||
|
let isCompleted = $derived(match.score1 > 0 || match.score2 > 0);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
updateScore(matchState.score1, match.team1_id);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
updateScore(matchState.score2, match.team2_id);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
matchState = match;
|
||||||
|
});
|
||||||
|
|
||||||
|
function localUpdateScore(score: number, teamId: string) {
|
||||||
|
console.log('localUpdateScore', score, teamId);
|
||||||
|
updateScore(score, teamId);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<div class="match" class:final={isFinal} class:completed={isCompleted}>
|
||||||
|
<div class="match-content">
|
||||||
|
<div class="team flex justify-between" class:winner={match.winner_id === match.team1_id}>
|
||||||
|
<span class="team-name">Team {match.team1?.name}</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onclick={() => {
|
||||||
|
console.log('aadazd');
|
||||||
|
localUpdateScore(matchState.score1 + 1, matchState.team1_id);
|
||||||
|
}}><PlusIcon /></Button
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
class="team-score"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
bind:value={matchState.score1}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onclick={() => {
|
||||||
|
localUpdateScore(matchState.score1 - 1, matchState.team1_id);
|
||||||
|
}}><MinusIcon /></Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="team flex justify-between"
|
||||||
|
class:winner={matchState.winner_id === matchState.team2_id}
|
||||||
|
>
|
||||||
|
<span class="team-name">Team {match.team2?.name}</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onclick={() => {
|
||||||
|
localUpdateScore(matchState.score2 + 1, matchState.team2_id);
|
||||||
|
}}><PlusIcon /></Button
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
class="team-score"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
bind:value={matchState.score2}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onclick={() => {
|
||||||
|
localUpdateScore(match.score2 - 1, match.team2_id);
|
||||||
|
}}><MinusIcon /></Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.match {
|
||||||
|
/* border: 2px solid #dee2e6; */
|
||||||
|
/* background: white; */
|
||||||
|
/* box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); */
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match:hover {
|
||||||
|
/* box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); */
|
||||||
|
}
|
||||||
|
|
||||||
|
.match.final {
|
||||||
|
/* border-color: #ffc107; */
|
||||||
|
/* background: linear-gradient(135deg, #fff3cd, #ffffff); */
|
||||||
|
}
|
||||||
|
|
||||||
|
.match.completed {
|
||||||
|
/* border-color: #28a745; */
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-header {
|
||||||
|
/* background: #f8f9fa; */
|
||||||
|
/* border-bottom: 1px solid #dee2e6; */
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-date {
|
||||||
|
font-size: 0.8em;
|
||||||
|
/* color: #6c757d; */
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-content {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
/* border-bottom: 1px solid #dee2e6; */
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team.winner {
|
||||||
|
/* background: #d4edda; */
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-score {
|
||||||
|
font-weight: 600;
|
||||||
|
/* color: #495057; */
|
||||||
|
text-align: center;
|
||||||
|
/* background: #f8f9fa; */
|
||||||
|
}
|
||||||
|
|
||||||
|
.team.winner .team-score {
|
||||||
|
/* background: #c3e6cb; */
|
||||||
|
/* color: #155724; */
|
||||||
|
}
|
||||||
|
</style>
|
40
src/lib/components/brackets/round.svelte
Normal file
40
src/lib/components/brackets/round.svelte
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Round, RoundWithMatches } from '@/lib/server/db/schema/rounds';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
let { round, children } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="round w-full">
|
||||||
|
<div class="round-header mb-4">
|
||||||
|
<h3>{round.name}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-1 flex-col justify-center gap-4">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.round {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.round-header {
|
||||||
|
text-align: center;
|
||||||
|
/* border-bottom: 2px solid #007bff; */
|
||||||
|
}
|
||||||
|
|
||||||
|
.round-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
/* color: #007bff; */
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.round-matches {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
0
src/lib/components/brackets/types/double.svelte
Normal file
0
src/lib/components/brackets/types/double.svelte
Normal file
0
src/lib/components/brackets/types/round_robin.svelte
Normal file
0
src/lib/components/brackets/types/round_robin.svelte
Normal file
52
src/lib/components/brackets/types/single.svelte
Normal file
52
src/lib/components/brackets/types/single.svelte
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { BracketWithRelations } from '@/lib/server/db/schema/brackets';
|
||||||
|
import Round from '../round.svelte';
|
||||||
|
import Match from '../match.svelte';
|
||||||
|
import type { Team } from '@/lib/server/db/schema/teams';
|
||||||
|
|
||||||
|
let { bracket, teams }: { bracket: BracketWithRelations; teams: Team[] } = $props();
|
||||||
|
|
||||||
|
function updateScore(score: number, teamId: string) {
|
||||||
|
console.log('updateScore', score, teamId);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if bracket && bracket.rounds}
|
||||||
|
<div class="bracket">
|
||||||
|
<div class="bracket-header">
|
||||||
|
<h1 class="bracket-header">{bracket.name}</h1>
|
||||||
|
</div>
|
||||||
|
<div class="bracket-game w-full">
|
||||||
|
{#each bracket.rounds as round, index}
|
||||||
|
{#if round && round.matches}
|
||||||
|
<Round {round}>
|
||||||
|
{#each round.matches as match}
|
||||||
|
<Match {updateScore} {match} isFinal={index === bracket.rounds.length - 1} />
|
||||||
|
{/each}
|
||||||
|
</Round>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bracket {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bracket-header {
|
||||||
|
text-align: center;
|
||||||
|
/* background: #f8f9fa; */
|
||||||
|
/* border-bottom: 1px solid #dee2e6; */
|
||||||
|
}
|
||||||
|
|
||||||
|
.bracket-game {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
</style>
|
0
src/lib/components/brackets/types/swiss.svelte
Normal file
0
src/lib/components/brackets/types/swiss.svelte
Normal file
|
@ -1,8 +0,0 @@
|
||||||
import { SchedulingMode } from '@/types';
|
|
||||||
import z from 'zod';
|
|
||||||
|
|
||||||
export const formSchema = z.object({
|
|
||||||
scheduling_mode: z.nativeEnum(SchedulingMode)
|
|
||||||
});
|
|
||||||
|
|
||||||
export type FormSchema = typeof formSchema;
|
|
|
@ -1,29 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import Button from '$ui/button/button.svelte';
|
|
||||||
import CardContent from '$ui/card/card-content.svelte';
|
|
||||||
import CardFooter from '$ui/card/card-footer.svelte';
|
|
||||||
import CardHeader from '$ui/card/card-header.svelte';
|
|
||||||
import Card from '$ui/card/card.svelte';
|
|
||||||
import type { RoundWithRelations } from '@/lib/server/db/schema/rounds';
|
|
||||||
import { redirect } from '@sveltejs/kit';
|
|
||||||
|
|
||||||
let { id, round, round_number }: { id: string; round: RoundWithRelations; round_number: number } =
|
|
||||||
$props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Card id={`round-${id}`}>
|
|
||||||
<CardHeader>Round {round.name}</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{#if !round.matches || round.matches.length <= 0}
|
|
||||||
No matches, add some !
|
|
||||||
{:else}
|
|
||||||
Matches :{#each round.matches as match (match.id)}
|
|
||||||
{JSON.stringify(match)}
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter
|
|
||||||
><Button href={`/competitions/${round.competition_id}/rounds/${round.id}`}>View Matches</Button
|
|
||||||
></CardFooter
|
|
||||||
>
|
|
||||||
</Card>
|
|
17
src/lib/components/theme-toggle.svelte
Normal file
17
src/lib/components/theme-toggle.svelte
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import SunIcon from '@lucide/svelte/icons/sun';
|
||||||
|
import MoonIcon from '@lucide/svelte/icons/moon';
|
||||||
|
|
||||||
|
import { toggleMode } from 'mode-watcher';
|
||||||
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Button onclick={toggleMode} variant="outline" size="icon">
|
||||||
|
<SunIcon
|
||||||
|
class="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 !transition-all dark:scale-0 dark:-rotate-90"
|
||||||
|
/>
|
||||||
|
<MoonIcon
|
||||||
|
class="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 !transition-all dark:scale-100 dark:rotate-0"
|
||||||
|
/>
|
||||||
|
<span class="sr-only">Toggle theme</span>
|
||||||
|
</Button>
|
|
@ -1,36 +1,36 @@
|
||||||
<script lang="ts" module>
|
<script lang="ts" module>
|
||||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
|
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
|
||||||
import { type VariantProps, tv } from 'tailwind-variants';
|
import { type VariantProps, tv } from "tailwind-variants";
|
||||||
|
|
||||||
export const buttonVariants = tv({
|
export const buttonVariants = tv({
|
||||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
|
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||||
destructive:
|
destructive:
|
||||||
'bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white',
|
"bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white",
|
||||||
outline:
|
outline:
|
||||||
'bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border',
|
"bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border",
|
||||||
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||||
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
link: 'text-primary underline-offset-4 hover:underline'
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
|
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
icon: 'size-9'
|
icon: "size-9",
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: 'default',
|
variant: "default",
|
||||||
size: 'default'
|
size: "default",
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
|
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
|
||||||
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
|
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
|
||||||
|
|
||||||
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
||||||
WithElementRef<HTMLAnchorAttributes> & {
|
WithElementRef<HTMLAnchorAttributes> & {
|
||||||
|
@ -42,11 +42,11 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let {
|
let {
|
||||||
class: className,
|
class: className,
|
||||||
variant = 'default',
|
variant = "default",
|
||||||
size = 'default',
|
size = "default",
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
href = undefined,
|
href = undefined,
|
||||||
type = 'button',
|
type = "button",
|
||||||
disabled,
|
disabled,
|
||||||
children,
|
children,
|
||||||
...restProps
|
...restProps
|
||||||
|
@ -60,7 +60,7 @@
|
||||||
class={cn(buttonVariants({ variant, size }), className)}
|
class={cn(buttonVariants({ variant, size }), className)}
|
||||||
href={disabled ? undefined : href}
|
href={disabled ? undefined : href}
|
||||||
aria-disabled={disabled}
|
aria-disabled={disabled}
|
||||||
role={disabled ? 'link' : undefined}
|
role={disabled ? "link" : undefined}
|
||||||
tabindex={disabled ? -1 : undefined}
|
tabindex={disabled ? -1 : undefined}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
|
|
|
@ -2,8 +2,8 @@ import Root, {
|
||||||
type ButtonProps,
|
type ButtonProps,
|
||||||
type ButtonSize,
|
type ButtonSize,
|
||||||
type ButtonVariant,
|
type ButtonVariant,
|
||||||
buttonVariants
|
buttonVariants,
|
||||||
} from './button.svelte';
|
} from "./button.svelte";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Root,
|
Root,
|
||||||
|
@ -13,5 +13,5 @@ export {
|
||||||
buttonVariants,
|
buttonVariants,
|
||||||
type ButtonProps,
|
type ButtonProps,
|
||||||
type ButtonSize,
|
type ButtonSize,
|
||||||
type ButtonVariant
|
type ButtonVariant,
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ComponentProps } from 'svelte';
|
import type { ComponentProps } from "svelte";
|
||||||
import type Calendar from './calendar.svelte';
|
import type Calendar from "./calendar.svelte";
|
||||||
import CalendarMonthSelect from './calendar-month-select.svelte';
|
import CalendarMonthSelect from "./calendar-month-select.svelte";
|
||||||
import CalendarYearSelect from './calendar-year-select.svelte';
|
import CalendarYearSelect from "./calendar-year-select.svelte";
|
||||||
import { DateFormatter, getLocalTimeZone, type DateValue } from '@internationalized/date';
|
import { DateFormatter, getLocalTimeZone, type DateValue } from "@internationalized/date";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
captionLayout,
|
captionLayout,
|
||||||
|
@ -14,13 +14,13 @@
|
||||||
month,
|
month,
|
||||||
locale,
|
locale,
|
||||||
placeholder = $bindable(),
|
placeholder = $bindable(),
|
||||||
monthIndex = 0
|
monthIndex = 0,
|
||||||
}: {
|
}: {
|
||||||
captionLayout: ComponentProps<typeof Calendar>['captionLayout'];
|
captionLayout: ComponentProps<typeof Calendar>["captionLayout"];
|
||||||
months: ComponentProps<typeof CalendarMonthSelect>['months'];
|
months: ComponentProps<typeof CalendarMonthSelect>["months"];
|
||||||
monthFormat: ComponentProps<typeof CalendarMonthSelect>['monthFormat'];
|
monthFormat: ComponentProps<typeof CalendarMonthSelect>["monthFormat"];
|
||||||
years: ComponentProps<typeof CalendarYearSelect>['years'];
|
years: ComponentProps<typeof CalendarYearSelect>["years"];
|
||||||
yearFormat: ComponentProps<typeof CalendarYearSelect>['yearFormat'];
|
yearFormat: ComponentProps<typeof CalendarYearSelect>["yearFormat"];
|
||||||
month: DateValue;
|
month: DateValue;
|
||||||
placeholder: DateValue | undefined;
|
placeholder: DateValue | undefined;
|
||||||
locale: string;
|
locale: string;
|
||||||
|
@ -29,13 +29,13 @@
|
||||||
|
|
||||||
function formatYear(date: DateValue) {
|
function formatYear(date: DateValue) {
|
||||||
const dateObj = date.toDate(getLocalTimeZone());
|
const dateObj = date.toDate(getLocalTimeZone());
|
||||||
if (typeof yearFormat === 'function') return yearFormat(dateObj.getFullYear());
|
if (typeof yearFormat === "function") return yearFormat(dateObj.getFullYear());
|
||||||
return new DateFormatter(locale, { year: yearFormat }).format(dateObj);
|
return new DateFormatter(locale, { year: yearFormat }).format(dateObj);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatMonth(date: DateValue) {
|
function formatMonth(date: DateValue) {
|
||||||
const dateObj = date.toDate(getLocalTimeZone());
|
const dateObj = date.toDate(getLocalTimeZone());
|
||||||
if (typeof monthFormat === 'function') return monthFormat(dateObj.getMonth() + 1);
|
if (typeof monthFormat === "function") return monthFormat(dateObj.getMonth() + 1);
|
||||||
return new DateFormatter(locale, { month: monthFormat }).format(dateObj);
|
return new DateFormatter(locale, { month: monthFormat }).format(dateObj);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -58,15 +58,15 @@
|
||||||
<CalendarYearSelect {years} {yearFormat} value={month.year} />
|
<CalendarYearSelect {years} {yearFormat} value={month.year} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#if captionLayout === 'dropdown'}
|
{#if captionLayout === "dropdown"}
|
||||||
{@render MonthSelect()}
|
{@render MonthSelect()}
|
||||||
{@render YearSelect()}
|
{@render YearSelect()}
|
||||||
{:else if captionLayout === 'dropdown-months'}
|
{:else if captionLayout === "dropdown-months"}
|
||||||
{@render MonthSelect()}
|
{@render MonthSelect()}
|
||||||
{#if placeholder}
|
{#if placeholder}
|
||||||
{formatYear(placeholder)}
|
{formatYear(placeholder)}
|
||||||
{/if}
|
{/if}
|
||||||
{:else if captionLayout === 'dropdown-years'}
|
{:else if captionLayout === "dropdown-years"}
|
||||||
{#if placeholder}
|
{#if placeholder}
|
||||||
{formatMonth(placeholder)}
|
{formatMonth(placeholder)}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
import { cn } from '$lib/utils.js';
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
|
@ -12,7 +12,7 @@
|
||||||
<CalendarPrimitive.Cell
|
<CalendarPrimitive.Cell
|
||||||
bind:ref
|
bind:ref
|
||||||
class={cn(
|
class={cn(
|
||||||
'relative size-(--cell-size) p-0 text-center text-sm focus-within:z-20 [&:first-child[data-selected]_[data-bits-day]]:rounded-l-md [&:last-child[data-selected]_[data-bits-day]]:rounded-r-md',
|
"size-(--cell-size) relative p-0 text-center text-sm focus-within:z-20 [&:first-child[data-selected]_[data-bits-day]]:rounded-l-md [&:last-child[data-selected]_[data-bits-day]]:rounded-r-md",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { buttonVariants } from '$lib/components/ui/button/index.js';
|
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
||||||
import { cn } from '$lib/utils.js';
|
import { cn } from "$lib/utils.js";
|
||||||
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
|
@ -13,22 +13,22 @@
|
||||||
<CalendarPrimitive.Day
|
<CalendarPrimitive.Day
|
||||||
bind:ref
|
bind:ref
|
||||||
class={cn(
|
class={cn(
|
||||||
buttonVariants({ variant: 'ghost' }),
|
buttonVariants({ variant: "ghost" }),
|
||||||
'flex size-(--cell-size) flex-col items-center justify-center gap-1 p-0 leading-none font-normal whitespace-nowrap select-none',
|
"size-(--cell-size) flex select-none flex-col items-center justify-center gap-1 whitespace-nowrap p-0 font-normal leading-none",
|
||||||
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground [&[data-today][data-disabled]]:text-muted-foreground',
|
"[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground [&[data-today][data-disabled]]:text-muted-foreground",
|
||||||
'data-[selected]:bg-primary dark:data-[selected]:hover:bg-accent/50 data-[selected]:text-primary-foreground',
|
"data-[selected]:bg-primary dark:data-[selected]:hover:bg-accent/50 data-[selected]:text-primary-foreground",
|
||||||
// Outside months
|
// Outside months
|
||||||
'[&[data-outside-month]:not([data-selected])]:text-muted-foreground [&[data-outside-month]:not([data-selected])]:hover:text-accent-foreground',
|
"[&[data-outside-month]:not([data-selected])]:text-muted-foreground [&[data-outside-month]:not([data-selected])]:hover:text-accent-foreground",
|
||||||
// Disabled
|
// Disabled
|
||||||
'data-[disabled]:text-muted-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
"data-[disabled]:text-muted-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
// Unavailable
|
// Unavailable
|
||||||
'data-[unavailable]:text-muted-foreground data-[unavailable]:line-through',
|
"data-[unavailable]:text-muted-foreground data-[unavailable]:line-through",
|
||||||
// hover
|
// hover
|
||||||
'dark:hover:text-accent-foreground',
|
"dark:hover:text-accent-foreground",
|
||||||
// focus
|
// focus
|
||||||
'focus:border-ring focus:ring-ring/50 focus:relative',
|
"focus:border-ring focus:ring-ring/50 focus:relative",
|
||||||
// inner spans
|
// inner spans
|
||||||
'[&>span]:text-xs [&>span]:opacity-70',
|
"[&>span]:text-xs [&>span]:opacity-70",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
import { cn } from '$lib/utils.js';
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
import { cn } from '$lib/utils.js';
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
import { cn } from '$lib/utils.js';
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
|
@ -9,4 +9,4 @@
|
||||||
}: CalendarPrimitive.GridRowProps = $props();
|
}: CalendarPrimitive.GridRowProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CalendarPrimitive.GridRow bind:ref class={cn('flex', className)} {...restProps} />
|
<CalendarPrimitive.GridRow bind:ref class={cn("flex", className)} {...restProps} />
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
import { cn } from '$lib/utils.js';
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
|
@ -11,6 +11,6 @@
|
||||||
|
|
||||||
<CalendarPrimitive.Grid
|
<CalendarPrimitive.Grid
|
||||||
bind:ref
|
bind:ref
|
||||||
class={cn('mt-4 flex w-full border-collapse flex-col gap-1', className)}
|
class={cn("mt-4 flex w-full border-collapse flex-col gap-1", className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
import { cn } from '$lib/utils.js';
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
|
@ -12,7 +12,7 @@
|
||||||
<CalendarPrimitive.HeadCell
|
<CalendarPrimitive.HeadCell
|
||||||
bind:ref
|
bind:ref
|
||||||
class={cn(
|
class={cn(
|
||||||
'text-muted-foreground w-(--cell-size) rounded-md text-[0.8rem] font-normal',
|
"text-muted-foreground w-(--cell-size) rounded-md text-[0.8rem] font-normal",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
import { cn } from '$lib/utils.js';
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
|
@ -12,7 +12,7 @@
|
||||||
<CalendarPrimitive.Header
|
<CalendarPrimitive.Header
|
||||||
bind:ref
|
bind:ref
|
||||||
class={cn(
|
class={cn(
|
||||||
'flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium',
|
"h-(--cell-size) flex w-full items-center justify-center gap-1.5 text-sm font-medium",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
import { cn } from '$lib/utils.js';
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
|
@ -11,6 +11,6 @@
|
||||||
|
|
||||||
<CalendarPrimitive.Heading
|
<CalendarPrimitive.Heading
|
||||||
bind:ref
|
bind:ref
|
||||||
class={cn('px-(--cell-size) text-sm font-medium', className)}
|
class={cn("px-(--cell-size) text-sm font-medium", className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
|
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
|
@ -14,7 +14,7 @@
|
||||||
|
|
||||||
<span
|
<span
|
||||||
class={cn(
|
class={cn(
|
||||||
'has-focus:border-ring border-input has-focus:ring-ring/50 relative flex rounded-md border shadow-xs has-focus:ring-[3px]',
|
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative flex rounded-md border",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
@ -33,7 +33,7 @@
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
<span
|
<span
|
||||||
class="[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm font-medium select-none [&>svg]:size-3.5"
|
class="[&>svg]:text-muted-foreground flex h-8 select-none items-center gap-1 rounded-md pl-2 pr-1 text-sm font-medium [&>svg]:size-3.5"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{monthItems.find((item) => item.value === value)?.label || selectedMonthItem.label}
|
{monthItems.find((item) => item.value === value)?.label || selectedMonthItem.label}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { type WithElementRef, cn } from '$lib/utils.js';
|
import { type WithElementRef, cn } from "$lib/utils.js";
|
||||||
import type { HTMLAttributes } from 'svelte/elements';
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
|
@ -10,6 +10,6 @@
|
||||||
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div {...restProps} bind:this={ref} class={cn('flex flex-col', className)}>
|
<div {...restProps} bind:this={ref} class={cn("flex flex-col", className)}>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAttributes } from 'svelte/elements';
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
|
@ -12,7 +12,7 @@
|
||||||
|
|
||||||
<div
|
<div
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
class={cn('relative flex flex-col gap-4 md:flex-row', className)}
|
class={cn("relative flex flex-col gap-4 md:flex-row", className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
import type { HTMLAttributes } from 'svelte/elements';
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
|
@ -13,7 +13,7 @@
|
||||||
<nav
|
<nav
|
||||||
{...restProps}
|
{...restProps}
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
class={cn('absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1', className)}
|
class={cn("absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", className)}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
@ -1,14 +1,14 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
|
||||||
import { buttonVariants, type ButtonVariant } from '$lib/components/ui/button/index.js';
|
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
|
||||||
import { cn } from '$lib/utils.js';
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
children,
|
children,
|
||||||
variant = 'ghost',
|
variant = "ghost",
|
||||||
...restProps
|
...restProps
|
||||||
}: CalendarPrimitive.NextButtonProps & {
|
}: CalendarPrimitive.NextButtonProps & {
|
||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
|
@ -23,7 +23,7 @@
|
||||||
bind:ref
|
bind:ref
|
||||||
class={cn(
|
class={cn(
|
||||||
buttonVariants({ variant }),
|
buttonVariants({ variant }),
|
||||||
'size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180',
|
"size-(--cell-size) select-none bg-transparent p-0 disabled:opacity-50 rtl:rotate-180",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
children={children || Fallback}
|
children={children || Fallback}
|
||||||
|
|
|
@ -1,14 +1,14 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
|
import ChevronLeftIcon from "@lucide/svelte/icons/chevron-left";
|
||||||
import { buttonVariants, type ButtonVariant } from '$lib/components/ui/button/index.js';
|
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
|
||||||
import { cn } from '$lib/utils.js';
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
children,
|
children,
|
||||||
variant = 'ghost',
|
variant = "ghost",
|
||||||
...restProps
|
...restProps
|
||||||
}: CalendarPrimitive.PrevButtonProps & {
|
}: CalendarPrimitive.PrevButtonProps & {
|
||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
|
@ -23,7 +23,7 @@
|
||||||
bind:ref
|
bind:ref
|
||||||
class={cn(
|
class={cn(
|
||||||
buttonVariants({ variant }),
|
buttonVariants({ variant }),
|
||||||
'size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180',
|
"size-(--cell-size) select-none bg-transparent p-0 disabled:opacity-50 rtl:rotate-180",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
children={children || Fallback}
|
children={children || Fallback}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
|
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
|
@ -13,7 +13,7 @@
|
||||||
|
|
||||||
<span
|
<span
|
||||||
class={cn(
|
class={cn(
|
||||||
'has-focus:border-ring border-input has-focus:ring-ring/50 relative flex rounded-md border shadow-xs has-focus:ring-[3px]',
|
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative flex rounded-md border",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
@ -32,7 +32,7 @@
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
<span
|
<span
|
||||||
class="[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm font-medium select-none [&>svg]:size-3.5"
|
class="[&>svg]:text-muted-foreground flex h-8 select-none items-center gap-1 rounded-md pl-2 pr-1 text-sm font-medium [&>svg]:size-3.5"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{yearItems.find((item) => item.value === value)?.label || selectedYearItem.label}
|
{yearItems.find((item) => item.value === value)?.label || selectedYearItem.label}
|
||||||
|
|
|
@ -1,41 +1,41 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
import * as Calendar from './index.js';
|
import * as Calendar from "./index.js";
|
||||||
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
|
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
import type { ButtonVariant } from '../button/button.svelte';
|
import type { ButtonVariant } from "../button/button.svelte";
|
||||||
import { isEqualMonth, type DateValue } from '@internationalized/date';
|
import { isEqualMonth, type DateValue } from "@internationalized/date";
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
value = $bindable(),
|
value = $bindable(),
|
||||||
placeholder = $bindable(),
|
placeholder = $bindable(),
|
||||||
class: className,
|
class: className,
|
||||||
weekdayFormat = 'short',
|
weekdayFormat = "short",
|
||||||
buttonVariant = 'ghost',
|
buttonVariant = "ghost",
|
||||||
captionLayout = 'label',
|
captionLayout = "label",
|
||||||
locale = 'en-US',
|
locale = "en-US",
|
||||||
months: monthsProp,
|
months: monthsProp,
|
||||||
years,
|
years,
|
||||||
monthFormat: monthFormatProp,
|
monthFormat: monthFormatProp,
|
||||||
yearFormat = 'numeric',
|
yearFormat = "numeric",
|
||||||
day,
|
day,
|
||||||
disableDaysOutsideMonth = false,
|
disableDaysOutsideMonth = false,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithoutChildrenOrChild<CalendarPrimitive.RootProps> & {
|
}: WithoutChildrenOrChild<CalendarPrimitive.RootProps> & {
|
||||||
buttonVariant?: ButtonVariant;
|
buttonVariant?: ButtonVariant;
|
||||||
captionLayout?: 'dropdown' | 'dropdown-months' | 'dropdown-years' | 'label';
|
captionLayout?: "dropdown" | "dropdown-months" | "dropdown-years" | "label";
|
||||||
months?: CalendarPrimitive.MonthSelectProps['months'];
|
months?: CalendarPrimitive.MonthSelectProps["months"];
|
||||||
years?: CalendarPrimitive.YearSelectProps['years'];
|
years?: CalendarPrimitive.YearSelectProps["years"];
|
||||||
monthFormat?: CalendarPrimitive.MonthSelectProps['monthFormat'];
|
monthFormat?: CalendarPrimitive.MonthSelectProps["monthFormat"];
|
||||||
yearFormat?: CalendarPrimitive.YearSelectProps['yearFormat'];
|
yearFormat?: CalendarPrimitive.YearSelectProps["yearFormat"];
|
||||||
day?: Snippet<[{ day: DateValue; outsideMonth: boolean }]>;
|
day?: Snippet<[{ day: DateValue; outsideMonth: boolean }]>;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const monthFormat = $derived.by(() => {
|
const monthFormat = $derived.by(() => {
|
||||||
if (monthFormatProp) return monthFormatProp;
|
if (monthFormatProp) return monthFormatProp;
|
||||||
if (captionLayout.startsWith('dropdown')) return 'short';
|
if (captionLayout.startsWith("dropdown")) return "short";
|
||||||
return 'long';
|
return "long";
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -50,7 +50,7 @@ get along, so we shut typescript up by casting `value` to `never`.
|
||||||
{weekdayFormat}
|
{weekdayFormat}
|
||||||
{disableDaysOutsideMonth}
|
{disableDaysOutsideMonth}
|
||||||
class={cn(
|
class={cn(
|
||||||
'bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
|
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{locale}
|
{locale}
|
||||||
|
@ -97,7 +97,7 @@ get along, so we shut typescript up by casting `value` to `never`.
|
||||||
{#if day}
|
{#if day}
|
||||||
{@render day({
|
{@render day({
|
||||||
day: date,
|
day: date,
|
||||||
outsideMonth: !isEqualMonth(date, month.value)
|
outsideMonth: !isEqualMonth(date, month.value),
|
||||||
})}
|
})}
|
||||||
{:else}
|
{:else}
|
||||||
<Calendar.Day />
|
<Calendar.Day />
|
||||||
|
|
|
@ -1,21 +1,21 @@
|
||||||
import Root from './calendar.svelte';
|
import Root from "./calendar.svelte";
|
||||||
import Cell from './calendar-cell.svelte';
|
import Cell from "./calendar-cell.svelte";
|
||||||
import Day from './calendar-day.svelte';
|
import Day from "./calendar-day.svelte";
|
||||||
import Grid from './calendar-grid.svelte';
|
import Grid from "./calendar-grid.svelte";
|
||||||
import Header from './calendar-header.svelte';
|
import Header from "./calendar-header.svelte";
|
||||||
import Months from './calendar-months.svelte';
|
import Months from "./calendar-months.svelte";
|
||||||
import GridRow from './calendar-grid-row.svelte';
|
import GridRow from "./calendar-grid-row.svelte";
|
||||||
import Heading from './calendar-heading.svelte';
|
import Heading from "./calendar-heading.svelte";
|
||||||
import GridBody from './calendar-grid-body.svelte';
|
import GridBody from "./calendar-grid-body.svelte";
|
||||||
import GridHead from './calendar-grid-head.svelte';
|
import GridHead from "./calendar-grid-head.svelte";
|
||||||
import HeadCell from './calendar-head-cell.svelte';
|
import HeadCell from "./calendar-head-cell.svelte";
|
||||||
import NextButton from './calendar-next-button.svelte';
|
import NextButton from "./calendar-next-button.svelte";
|
||||||
import PrevButton from './calendar-prev-button.svelte';
|
import PrevButton from "./calendar-prev-button.svelte";
|
||||||
import MonthSelect from './calendar-month-select.svelte';
|
import MonthSelect from "./calendar-month-select.svelte";
|
||||||
import YearSelect from './calendar-year-select.svelte';
|
import YearSelect from "./calendar-year-select.svelte";
|
||||||
import Month from './calendar-month.svelte';
|
import Month from "./calendar-month.svelte";
|
||||||
import Nav from './calendar-nav.svelte';
|
import Nav from "./calendar-nav.svelte";
|
||||||
import Caption from './calendar-caption.svelte';
|
import Caption from "./calendar-caption.svelte";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Day,
|
Day,
|
||||||
|
@ -36,5 +36,5 @@ export {
|
||||||
MonthSelect,
|
MonthSelect,
|
||||||
Caption,
|
Caption,
|
||||||
//
|
//
|
||||||
Root as Calendar
|
Root as Calendar,
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import Root from './pagination.svelte';
|
import Root from "./pagination.svelte";
|
||||||
import Content from './pagination-content.svelte';
|
import Content from "./pagination-content.svelte";
|
||||||
import Item from './pagination-item.svelte';
|
import Item from "./pagination-item.svelte";
|
||||||
import Link from './pagination-link.svelte';
|
import Link from "./pagination-link.svelte";
|
||||||
import PrevButton from './pagination-prev-button.svelte';
|
import PrevButton from "./pagination-prev-button.svelte";
|
||||||
import NextButton from './pagination-next-button.svelte';
|
import NextButton from "./pagination-next-button.svelte";
|
||||||
import Ellipsis from './pagination-ellipsis.svelte';
|
import Ellipsis from "./pagination-ellipsis.svelte";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Root,
|
Root,
|
||||||
|
@ -21,5 +21,5 @@ export {
|
||||||
Link as PaginationLink,
|
Link as PaginationLink,
|
||||||
PrevButton as PaginationPrevButton,
|
PrevButton as PaginationPrevButton,
|
||||||
NextButton as PaginationNextButton,
|
NextButton as PaginationNextButton,
|
||||||
Ellipsis as PaginationEllipsis
|
Ellipsis as PaginationEllipsis,
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLAttributes } from 'svelte/elements';
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
|
@ -13,7 +13,7 @@
|
||||||
<ul
|
<ul
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="pagination-content"
|
data-slot="pagination-content"
|
||||||
class={cn('flex flex-row items-center gap-1', className)}
|
class={cn("flex flex-row items-center gap-1", className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import EllipsisIcon from '@lucide/svelte/icons/ellipsis';
|
import EllipsisIcon from "@lucide/svelte/icons/ellipsis";
|
||||||
import { cn, type WithElementRef, type WithoutChildren } from '$lib/utils.js';
|
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
|
||||||
import type { HTMLAttributes } from 'svelte/elements';
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
|
@ -14,7 +14,7 @@
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
data-slot="pagination-ellipsis"
|
data-slot="pagination-ellipsis"
|
||||||
class={cn('flex size-9 items-center justify-center', className)}
|
class={cn("flex size-9 items-center justify-center", className)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
<EllipsisIcon class="size-4" />
|
<EllipsisIcon class="size-4" />
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { HTMLLiAttributes } from 'svelte/elements';
|
import type { HTMLLiAttributes } from "svelte/elements";
|
||||||
import type { WithElementRef } from '$lib/utils.js';
|
import type { WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Pagination as PaginationPrimitive } from 'bits-ui';
|
import { Pagination as PaginationPrimitive } from "bits-ui";
|
||||||
import { cn } from '$lib/utils.js';
|
import { cn } from "$lib/utils.js";
|
||||||
import { type Props, buttonVariants } from '$lib/components/ui/button/index.js';
|
import { type Props, buttonVariants } from "$lib/components/ui/button/index.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
size = 'icon',
|
size = "icon",
|
||||||
isActive,
|
isActive,
|
||||||
page,
|
page,
|
||||||
children,
|
children,
|
||||||
|
@ -24,13 +24,13 @@
|
||||||
<PaginationPrimitive.Page
|
<PaginationPrimitive.Page
|
||||||
bind:ref
|
bind:ref
|
||||||
{page}
|
{page}
|
||||||
aria-current={isActive ? 'page' : undefined}
|
aria-current={isActive ? "page" : undefined}
|
||||||
data-slot="pagination-link"
|
data-slot="pagination-link"
|
||||||
data-active={isActive}
|
data-active={isActive}
|
||||||
class={cn(
|
class={cn(
|
||||||
buttonVariants({
|
buttonVariants({
|
||||||
variant: isActive ? 'outline' : 'ghost',
|
variant: isActive ? "outline" : "ghost",
|
||||||
size
|
size,
|
||||||
}),
|
}),
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Pagination as PaginationPrimitive } from 'bits-ui';
|
import { Pagination as PaginationPrimitive } from "bits-ui";
|
||||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
|
||||||
import { buttonVariants } from '$lib/components/ui/button/index.js';
|
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
||||||
import { cn } from '$lib/utils.js';
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
|
@ -22,9 +22,9 @@
|
||||||
aria-label="Go to next page"
|
aria-label="Go to next page"
|
||||||
class={cn(
|
class={cn(
|
||||||
buttonVariants({
|
buttonVariants({
|
||||||
size: 'default',
|
size: "default",
|
||||||
variant: 'ghost',
|
variant: "ghost",
|
||||||
class: 'gap-1 px-2.5 sm:pr-2.5'
|
class: "gap-1 px-2.5 sm:pr-2.5",
|
||||||
}),
|
}),
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Pagination as PaginationPrimitive } from 'bits-ui';
|
import { Pagination as PaginationPrimitive } from "bits-ui";
|
||||||
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
|
import ChevronLeftIcon from "@lucide/svelte/icons/chevron-left";
|
||||||
import { buttonVariants } from '$lib/components/ui/button/index.js';
|
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
||||||
import { cn } from '$lib/utils.js';
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
|
@ -22,9 +22,9 @@
|
||||||
aria-label="Go to previous page"
|
aria-label="Go to previous page"
|
||||||
class={cn(
|
class={cn(
|
||||||
buttonVariants({
|
buttonVariants({
|
||||||
size: 'default',
|
size: "default",
|
||||||
variant: 'ghost',
|
variant: "ghost",
|
||||||
class: 'gap-1 px-2.5 sm:pl-2.5'
|
class: "gap-1 px-2.5 sm:pl-2.5",
|
||||||
}),
|
}),
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Pagination as PaginationPrimitive } from 'bits-ui';
|
import { Pagination as PaginationPrimitive } from "bits-ui";
|
||||||
|
|
||||||
import { cn } from '$lib/utils.js';
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
|
@ -20,7 +20,7 @@
|
||||||
role="navigation"
|
role="navigation"
|
||||||
aria-label="pagination"
|
aria-label="pagination"
|
||||||
data-slot="pagination"
|
data-slot="pagination"
|
||||||
class={cn('mx-auto flex w-full justify-center', className)}
|
class={cn("mx-auto flex w-full justify-center", className)}
|
||||||
{count}
|
{count}
|
||||||
{perPage}
|
{perPage}
|
||||||
{siblingCount}
|
{siblingCount}
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { env } from '$env/dynamic/private';
|
||||||
import { env as envPublic } from '$env/dynamic/public';
|
import { env as envPublic } from '$env/dynamic/public';
|
||||||
import { sessions, type Session } from './db/schema/sessions';
|
import { sessions, type Session } from './db/schema/sessions';
|
||||||
import { users } from './db/schema/users';
|
import { users } from './db/schema/users';
|
||||||
|
import { v7 } from 'uuid';
|
||||||
|
|
||||||
const DAY_IN_MS = 1000 * 60 * 60 * 24;
|
const DAY_IN_MS = 1000 * 60 * 60 * 24;
|
||||||
|
|
||||||
|
@ -20,20 +21,7 @@ export const keycloak = new KeyCloak(
|
||||||
);
|
);
|
||||||
|
|
||||||
function generateSecureRandomString(): string {
|
function generateSecureRandomString(): string {
|
||||||
// Human readable alphabet (a-z, 0-9 without l, o, 0, 1 to avoid confusion)
|
return v7();
|
||||||
const alphabet = 'abcdefghijklmnpqrstuvwxyz23456789';
|
|
||||||
|
|
||||||
// Generate 24 bytes = 192 bits of entropy.
|
|
||||||
// We're only going to use 5 bits per byte so the total entropy will be 192 * 5 / 8 = 120 bits
|
|
||||||
const bytes = new Uint8Array(24);
|
|
||||||
crypto.getRandomValues(bytes);
|
|
||||||
|
|
||||||
let id = '';
|
|
||||||
for (let i = 0; i < bytes.length; i++) {
|
|
||||||
// >> 3 s"removes" the right-most 3 bits of the byte
|
|
||||||
id += alphabet[bytes[i] >> 3];
|
|
||||||
}
|
|
||||||
return id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hashSecret(secret: string): Promise<Uint8Array> {
|
async function hashSecret(secret: string): Promise<Uint8Array> {
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||||
import postgres from 'postgres';
|
|
||||||
import { env } from '$env/dynamic/private';
|
import { env } from '$env/dynamic/private';
|
||||||
import * as brackets from './schema/brackets';
|
import * as brackets from './schema/brackets';
|
||||||
import * as breakperiods from './schema/breakperiods';
|
import * as breakperiods from './schema/breakperiods';
|
||||||
|
@ -12,10 +11,13 @@ import * as rounds from './schema/rounds';
|
||||||
import * as sessions from './schema/sessions';
|
import * as sessions from './schema/sessions';
|
||||||
import * as teams from './schema/teams';
|
import * as teams from './schema/teams';
|
||||||
import * as users from './schema/users';
|
import * as users from './schema/users';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
|
||||||
if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
|
if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
|
||||||
|
|
||||||
const client = postgres(env.DATABASE_URL);
|
const pool = new Pool({
|
||||||
|
connectionString: env.DATABASE_URL
|
||||||
|
});
|
||||||
|
|
||||||
export const schema = {
|
export const schema = {
|
||||||
...brackets,
|
...brackets,
|
||||||
|
@ -31,6 +33,4 @@ export const schema = {
|
||||||
...users
|
...users
|
||||||
};
|
};
|
||||||
|
|
||||||
export const db = drizzle(client, {
|
export const db = drizzle({ client: pool, schema: schema });
|
||||||
schema: schema
|
|
||||||
});
|
|
||||||
|
|
51
src/lib/server/db/queries/brackets.ts
Normal file
51
src/lib/server/db/queries/brackets.ts
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
import { and, eq, inArray } from 'drizzle-orm';
|
||||||
|
import { db } from '..';
|
||||||
|
import { brackets, type BracketInsert } from '../schema/brackets';
|
||||||
|
|
||||||
|
export async function insertBracket(competitionId: string, bracket: BracketInsert) {
|
||||||
|
return await db.insert(brackets).values(bracket).returning();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBrackets(competitionId: string) {
|
||||||
|
return await db.select().from(brackets).where(eq(brackets.competition_id, competitionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBracket(bracketId: string) {
|
||||||
|
return await db.query.brackets.findFirst({
|
||||||
|
where: eq(brackets.id, bracketId)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBracketWithRoundsAndMatches(bracketId: string) {
|
||||||
|
return await db.query.brackets.findFirst({
|
||||||
|
where: eq(brackets.id, bracketId),
|
||||||
|
with: {
|
||||||
|
rounds: {
|
||||||
|
with: {
|
||||||
|
matches: {
|
||||||
|
with: {
|
||||||
|
team1: true,
|
||||||
|
team2: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBracketsWithRounds(competitionId: string) {
|
||||||
|
return await db.query.brackets.findMany({
|
||||||
|
where: eq(brackets.competition_id, competitionId),
|
||||||
|
with: {
|
||||||
|
rounds: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBracketsByListIds(bracketIds: string[], withRelations: object = {}) {
|
||||||
|
return await db.query.brackets.findMany({
|
||||||
|
where: inArray(brackets.id, bracketIds),
|
||||||
|
with: withRelations
|
||||||
|
});
|
||||||
|
}
|
|
@ -14,9 +14,13 @@ export async function getCompetitionWithAll(id: string) {
|
||||||
with: {
|
with: {
|
||||||
breakperiods: true,
|
breakperiods: true,
|
||||||
fields: true,
|
fields: true,
|
||||||
rounds: {
|
brackets: {
|
||||||
with: {
|
with: {
|
||||||
matches: true
|
rounds: {
|
||||||
|
with: {
|
||||||
|
matches: true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
teams: true
|
teams: true
|
||||||
|
@ -30,9 +34,13 @@ export async function getCompetitionsWithAll(skip: number = 0, take: number = 10
|
||||||
with: {
|
with: {
|
||||||
breakperiods: true,
|
breakperiods: true,
|
||||||
fields: true,
|
fields: true,
|
||||||
rounds: {
|
brackets: {
|
||||||
with: {
|
with: {
|
||||||
matches: true
|
rounds: {
|
||||||
|
with: {
|
||||||
|
matches: true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
teams: true
|
teams: true
|
||||||
|
|
|
@ -2,24 +2,21 @@ import { and, eq } from 'drizzle-orm';
|
||||||
import { db } from '..';
|
import { db } from '..';
|
||||||
import { rounds, type RoundInsert } from '../schema/rounds';
|
import { rounds, type RoundInsert } from '../schema/rounds';
|
||||||
|
|
||||||
export async function insertRound(competitionId: string, round: RoundInsert) {
|
export async function insertRound(round: RoundInsert) {
|
||||||
return await db.insert(rounds).values(round).returning();
|
return await db.insert(rounds).values(round).returning();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRounds(competitionId: string) {
|
export async function getRounds(bracketId: string) {
|
||||||
return await db.select().from(rounds).where(eq(rounds.competition_id, competitionId));
|
return await db.select().from(rounds).where(eq(rounds.bracket_id, bracketId));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRound(competitionId: string, roundId: string) {
|
export async function getRound(roundId: string) {
|
||||||
return await db
|
return await db.select().from(rounds).where(eq(rounds.id, roundId));
|
||||||
.select()
|
|
||||||
.from(rounds)
|
|
||||||
.where(and(eq(rounds.competition_id, competitionId), eq(rounds.id, roundId)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRoundWithMatches(competitionId: string, roundId: string) {
|
export async function getRoundWithMatches(bracketId: string, roundId: string) {
|
||||||
return await db.query.rounds.findMany({
|
return await db.query.rounds.findMany({
|
||||||
where: and(eq(rounds.competition_id, competitionId), eq(rounds.id, roundId)),
|
where: and(eq(rounds.bracket_id, bracketId), eq(rounds.id, roundId)),
|
||||||
with: {
|
with: {
|
||||||
matches: true
|
matches: true
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
import * as t from 'drizzle-orm/pg-core';
|
import * as t from 'drizzle-orm/pg-core';
|
||||||
import { timestamps } from '../util';
|
import { timestamps, type TModelWithRelations } from '../util';
|
||||||
import { competitions } from './competitions';
|
import { competitions } from './competitions';
|
||||||
import { rounds } from './rounds';
|
import { rounds, type RoundWithMatches } from './rounds';
|
||||||
import { relations } from 'drizzle-orm';
|
import { relations } from 'drizzle-orm';
|
||||||
|
import { BracketType, enumToPgEnum, SchedulingMode } from '../../../../types';
|
||||||
|
|
||||||
export const bracketTypes = t.pgEnum('bracket_type', ['WINNER', 'LOSER', 'CONSOLATION', 'MAIN']);
|
export const bracketTypes = t.pgEnum('bracket_type', enumToPgEnum(BracketType));
|
||||||
|
export const schedulingModes = t.pgEnum('scheduling_modes', enumToPgEnum(SchedulingMode));
|
||||||
|
|
||||||
export const brackets = t.pgTable('brackets', {
|
export const brackets = t.pgTable('brackets', {
|
||||||
id: t.uuid('id').primaryKey().defaultRandom(),
|
id: t.uuid('id').primaryKey().defaultRandom(),
|
||||||
|
@ -12,6 +14,7 @@ export const brackets = t.pgTable('brackets', {
|
||||||
bracketType: bracketTypes('bracket_type').notNull(),
|
bracketType: bracketTypes('bracket_type').notNull(),
|
||||||
position: t.integer('position').default(1).notNull(),
|
position: t.integer('position').default(1).notNull(),
|
||||||
isActive: t.boolean('is_active').default(true).notNull(),
|
isActive: t.boolean('is_active').default(true).notNull(),
|
||||||
|
scheduling_mode: schedulingModes('scheduling_modes').notNull(),
|
||||||
competition_id: t.uuid('competition_id').notNull(),
|
competition_id: t.uuid('competition_id').notNull(),
|
||||||
...timestamps
|
...timestamps
|
||||||
});
|
});
|
||||||
|
@ -19,7 +22,15 @@ export const brackets = t.pgTable('brackets', {
|
||||||
export const bracketsRelations = relations(brackets, ({ many, one }) => ({
|
export const bracketsRelations = relations(brackets, ({ many, one }) => ({
|
||||||
competition: one(competitions, {
|
competition: one(competitions, {
|
||||||
fields: [brackets.competition_id],
|
fields: [brackets.competition_id],
|
||||||
references: [competitions.id]
|
references: [competitions.id],
|
||||||
|
relationName: 'bracketCompetition'
|
||||||
}),
|
}),
|
||||||
rounds: many(rounds)
|
rounds: many(rounds)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export type BracketInsert = typeof brackets.$inferInsert;
|
||||||
|
export type Bracket = TModelWithRelations<'brackets'>;
|
||||||
|
export type BracketWithRelations = TModelWithRelations<'brackets'>;
|
||||||
|
export type BracketAndRoundsWithMatches = typeof brackets.$inferSelect & {
|
||||||
|
rounds: RoundWithMatches[];
|
||||||
|
};
|
||||||
|
|
|
@ -25,11 +25,13 @@ export const breakperiods = t.pgTable('breakperiods', {
|
||||||
export const breakperiodsRelations = relations(breakperiods, ({ one }) => ({
|
export const breakperiodsRelations = relations(breakperiods, ({ one }) => ({
|
||||||
competition: one(competitions, {
|
competition: one(competitions, {
|
||||||
fields: [breakperiods.competition_id],
|
fields: [breakperiods.competition_id],
|
||||||
references: [competitions.id]
|
references: [competitions.id],
|
||||||
|
relationName: 'breakPeriodCompetition'
|
||||||
}),
|
}),
|
||||||
field: one(fields, {
|
field: one(fields, {
|
||||||
fields: [breakperiods.field_id],
|
fields: [breakperiods.field_id],
|
||||||
references: [fields.id]
|
references: [fields.id],
|
||||||
|
relationName: 'breakPeriodField'
|
||||||
})
|
})
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { users } from './users';
|
||||||
import { breakperiods } from './breakperiods';
|
import { breakperiods } from './breakperiods';
|
||||||
import { fields } from './fields';
|
import { fields } from './fields';
|
||||||
import { teams } from './teams';
|
import { teams } from './teams';
|
||||||
import { rounds } from './rounds';
|
import { brackets } from './brackets';
|
||||||
|
|
||||||
export const competitions = t.pgTable('competitions', {
|
export const competitions = t.pgTable('competitions', {
|
||||||
id: t.uuid('id').primaryKey().defaultRandom(),
|
id: t.uuid('id').primaryKey().defaultRandom(),
|
||||||
|
@ -20,12 +20,13 @@ export const competitions = t.pgTable('competitions', {
|
||||||
export const competitionsRelations = relations(competitions, ({ one, many }) => ({
|
export const competitionsRelations = relations(competitions, ({ one, many }) => ({
|
||||||
owner: one(users, {
|
owner: one(users, {
|
||||||
fields: [competitions.owner],
|
fields: [competitions.owner],
|
||||||
references: [users.id]
|
references: [users.id],
|
||||||
|
relationName: 'competitionOwner'
|
||||||
}),
|
}),
|
||||||
breakperiods: many(breakperiods),
|
breakperiods: many(breakperiods),
|
||||||
fields: many(fields),
|
fields: many(fields),
|
||||||
teams: many(teams),
|
teams: many(teams),
|
||||||
rounds: many(rounds)
|
brackets: many(brackets)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export type Competition = typeof competitions.$inferSelect;
|
export type Competition = typeof competitions.$inferSelect;
|
||||||
|
|
|
@ -14,7 +14,8 @@ export const fields = t.pgTable('fields', {
|
||||||
export const fieldsRelations = relations(fields, ({ one }) => ({
|
export const fieldsRelations = relations(fields, ({ one }) => ({
|
||||||
competition: one(competitions, {
|
competition: one(competitions, {
|
||||||
fields: [fields.competition_id],
|
fields: [fields.competition_id],
|
||||||
references: [competitions.id]
|
references: [competitions.id],
|
||||||
|
relationName: 'fieldCompetition'
|
||||||
})
|
})
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
import * as t from 'drizzle-orm/pg-core';
|
import * as t from 'drizzle-orm/pg-core';
|
||||||
import { timestamps } from '../util';
|
import { timestamps, type TModelWithRelations } from '../util';
|
||||||
import { relations } from 'drizzle-orm';
|
import { relations } from 'drizzle-orm';
|
||||||
import { fields } from './fields';
|
import { fields } from './fields';
|
||||||
import { rounds } from './rounds';
|
import { rounds } from './rounds';
|
||||||
import { teams } from './teams';
|
import { teams } from './teams';
|
||||||
import { brackets } from './brackets';
|
import { brackets } from './brackets';
|
||||||
|
import { enumToPgEnum, MatchStatus } from '../../../../types';
|
||||||
|
|
||||||
export const status = t.pgEnum('match_status', ['pending', 'running', 'finished', 'cancelled']);
|
export const status = t.pgEnum('match_status', enumToPgEnum(MatchStatus));
|
||||||
|
|
||||||
export const matches = t.pgTable('matches', {
|
export const matches = t.pgTable('matches', {
|
||||||
id: t.uuid('id').primaryKey().defaultRandom(),
|
id: t.uuid('id').primaryKey().defaultRandom(),
|
||||||
|
@ -23,32 +24,46 @@ export const matches = t.pgTable('matches', {
|
||||||
position: t.integer('position').notNull(),
|
position: t.integer('position').notNull(),
|
||||||
table: t.integer('table').notNull(), // swis/round robin
|
table: t.integer('table').notNull(), // swis/round robin
|
||||||
round_id: t.uuid('round_id').notNull(),
|
round_id: t.uuid('round_id').notNull(),
|
||||||
bracket_id: t.uuid('bracket_id').notNull(),
|
bracket_id: t
|
||||||
|
.uuid('bracket_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => brackets.id, { onDelete: 'cascade' }),
|
||||||
...timestamps
|
...timestamps
|
||||||
});
|
});
|
||||||
|
|
||||||
export const matchRelations = relations(matches, ({ one }) => ({
|
export const matchRelations = relations(matches, ({ one }) => ({
|
||||||
field: one(fields, {
|
field: one(fields, {
|
||||||
fields: [matches.field_id],
|
fields: [matches.field_id],
|
||||||
references: [fields.id]
|
references: [fields.id],
|
||||||
|
relationName: 'matchField'
|
||||||
}),
|
}),
|
||||||
round: one(rounds, {
|
round: one(rounds, {
|
||||||
fields: [matches.round_id],
|
fields: [matches.round_id],
|
||||||
references: [rounds.id]
|
references: [rounds.id],
|
||||||
|
relationName: 'round'
|
||||||
}),
|
}),
|
||||||
team1: one(teams, {
|
team1: one(teams, {
|
||||||
fields: [matches.team1_id],
|
fields: [matches.team1_id],
|
||||||
references: [teams.id]
|
references: [teams.id],
|
||||||
|
relationName: 'matchTeam1'
|
||||||
}),
|
}),
|
||||||
team2: one(teams, {
|
team2: one(teams, {
|
||||||
fields: [matches.team2_id],
|
fields: [matches.team2_id],
|
||||||
references: [teams.id]
|
references: [teams.id],
|
||||||
|
relationName: 'matchTeam2'
|
||||||
}),
|
}),
|
||||||
winner: one(teams, {
|
winner: one(teams, {
|
||||||
fields: [matches.winner_id],
|
fields: [matches.winner_id],
|
||||||
references: [teams.id]
|
references: [teams.id],
|
||||||
|
relationName: 'matchWinner'
|
||||||
}),
|
}),
|
||||||
bracket: one(brackets)
|
bracket: one(brackets, {
|
||||||
|
fields: [matches.bracket_id],
|
||||||
|
references: [brackets.id],
|
||||||
|
relationName: 'matchBracket'
|
||||||
|
})
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export type Match = typeof matches.$inferSelect;
|
export type Match = typeof matches.$inferSelect;
|
||||||
|
|
||||||
|
export type MatchWithRelations = TModelWithRelations<'matches'>;
|
||||||
|
|
|
@ -1,33 +1,24 @@
|
||||||
import { relations } from 'drizzle-orm';
|
import { relations } from 'drizzle-orm';
|
||||||
import * as t from 'drizzle-orm/pg-core';
|
import * as t from 'drizzle-orm/pg-core';
|
||||||
import { matches } from './matches';
|
import { matches } from './matches';
|
||||||
import { competitions } from './competitions';
|
|
||||||
import { enumToPgEnum, SchedulingMode } from '../../../../types';
|
|
||||||
import { timestamps, type TModelWithRelations } from '../util';
|
import { timestamps, type TModelWithRelations } from '../util';
|
||||||
import { brackets } from './brackets';
|
import { brackets } from './brackets';
|
||||||
|
|
||||||
export const schedulingModes = t.pgEnum('scheduling_modes', enumToPgEnum(SchedulingMode));
|
|
||||||
|
|
||||||
export const rounds = t.pgTable('rounds', {
|
export const rounds = t.pgTable('rounds', {
|
||||||
id: t.uuid('id').primaryKey().defaultRandom(),
|
id: t.uuid('id').primaryKey().defaultRandom(),
|
||||||
name: t.text('name').notNull(),
|
name: t.text('name').notNull(),
|
||||||
round_number: t.integer('round_number').notNull(),
|
round_number: t.integer('round_number').notNull(),
|
||||||
nb_matches: t.integer('nb_matches').notNull(),
|
nb_matches: t.integer('nb_matches').notNull(),
|
||||||
competition_id: t.uuid('competition_id').notNull(),
|
bracket_id: t.uuid('bracket_id').references(() => brackets.id, { onDelete: 'cascade' }),
|
||||||
scheduling_mode: schedulingModes('scheduling_modes'),
|
|
||||||
bracket_id: t.uuid('bracket_id'),
|
|
||||||
...timestamps
|
...timestamps
|
||||||
});
|
});
|
||||||
|
|
||||||
export const roundsRelations = relations(rounds, ({ many, one }) => ({
|
export const roundsRelations = relations(rounds, ({ many, one }) => ({
|
||||||
competition: one(competitions, {
|
|
||||||
fields: [rounds.competition_id],
|
|
||||||
references: [competitions.id]
|
|
||||||
}),
|
|
||||||
matches: many(matches),
|
matches: many(matches),
|
||||||
bracket: one(brackets, {
|
bracket: one(brackets, {
|
||||||
fields: [rounds.bracket_id],
|
fields: [rounds.bracket_id],
|
||||||
references: [brackets.id]
|
references: [brackets.id],
|
||||||
|
relationName: 'bracket'
|
||||||
})
|
})
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
@ -36,3 +27,7 @@ export type Round = typeof rounds.$inferSelect;
|
||||||
export type RoundInsert = typeof rounds.$inferInsert;
|
export type RoundInsert = typeof rounds.$inferInsert;
|
||||||
|
|
||||||
export type RoundWithRelations = TModelWithRelations<'rounds'>;
|
export type RoundWithRelations = TModelWithRelations<'rounds'>;
|
||||||
|
|
||||||
|
export type RoundWithMatches = typeof rounds.$inferSelect & {
|
||||||
|
matches: (typeof matches.$inferSelect)[];
|
||||||
|
};
|
||||||
|
|
|
@ -25,11 +25,13 @@ export const permissionsToRoles = t.pgTable(
|
||||||
export const permissionsToRolesRelations = relations(permissionsToRoles, ({ one }) => ({
|
export const permissionsToRolesRelations = relations(permissionsToRoles, ({ one }) => ({
|
||||||
permission: one(permissions, {
|
permission: one(permissions, {
|
||||||
fields: [permissionsToRoles.permission_id],
|
fields: [permissionsToRoles.permission_id],
|
||||||
references: [permissions.id]
|
references: [permissions.id],
|
||||||
|
relationName: 'permissionRole'
|
||||||
}),
|
}),
|
||||||
role: one(roles, {
|
role: one(roles, {
|
||||||
fields: [permissionsToRoles.role_id],
|
fields: [permissionsToRoles.role_id],
|
||||||
references: [roles.id]
|
references: [roles.id],
|
||||||
|
relationName: 'rolePermission'
|
||||||
})
|
})
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
@ -53,11 +55,13 @@ export const usersToRoles = t.pgTable(
|
||||||
export const usersToRolesRelations = relations(usersToRoles, ({ one }) => ({
|
export const usersToRolesRelations = relations(usersToRoles, ({ one }) => ({
|
||||||
user: one(users, {
|
user: one(users, {
|
||||||
fields: [usersToRoles.user_id],
|
fields: [usersToRoles.user_id],
|
||||||
references: [users.id]
|
references: [users.id],
|
||||||
|
relationName: 'userRole'
|
||||||
}),
|
}),
|
||||||
role: one(roles, {
|
role: one(roles, {
|
||||||
fields: [usersToRoles.role_id],
|
fields: [usersToRoles.role_id],
|
||||||
references: [roles.id]
|
references: [roles.id],
|
||||||
|
relationName: 'roleUser'
|
||||||
})
|
})
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
@ -81,10 +85,12 @@ export const usersToPermissions = t.pgTable(
|
||||||
export const usersToPermissionsRelations = relations(usersToPermissions, ({ one }) => ({
|
export const usersToPermissionsRelations = relations(usersToPermissions, ({ one }) => ({
|
||||||
user: one(users, {
|
user: one(users, {
|
||||||
fields: [usersToPermissions.user_id],
|
fields: [usersToPermissions.user_id],
|
||||||
references: [users.id]
|
references: [users.id],
|
||||||
|
relationName: 'userPermission'
|
||||||
}),
|
}),
|
||||||
permission: one(permissions, {
|
permission: one(permissions, {
|
||||||
fields: [usersToPermissions.permission_id],
|
fields: [usersToPermissions.permission_id],
|
||||||
references: [permissions.id]
|
references: [permissions.id],
|
||||||
|
relationName: 'permissionUser'
|
||||||
})
|
})
|
||||||
}));
|
}));
|
||||||
|
|
|
@ -13,7 +13,8 @@ export const teams = t.pgTable('teams', {
|
||||||
export const teamsRelations = relations(teams, ({ one }) => ({
|
export const teamsRelations = relations(teams, ({ one }) => ({
|
||||||
competition: one(competitions, {
|
competition: one(competitions, {
|
||||||
fields: [teams.competition_id],
|
fields: [teams.competition_id],
|
||||||
references: [competitions.id]
|
references: [competitions.id],
|
||||||
|
relationName: 'teamCompetition'
|
||||||
})
|
})
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import * as t from 'drizzle-orm/pg-core';
|
import * as t from 'drizzle-orm/pg-core';
|
||||||
import { permissionsToRoles, usersToRoles } from './schema';
|
import { usersToPermissions, usersToRoles } from './schema';
|
||||||
import { relations } from 'drizzle-orm';
|
import { relations } from 'drizzle-orm';
|
||||||
import { timestamps } from '../util';
|
import { timestamps } from '../util';
|
||||||
|
|
||||||
|
@ -12,7 +12,7 @@ export const users = t.pgTable('user', {
|
||||||
});
|
});
|
||||||
|
|
||||||
export const usersRelations = relations(users, ({ many }) => ({
|
export const usersRelations = relations(users, ({ many }) => ({
|
||||||
permissionsToRoles: many(permissionsToRoles),
|
usersToPermissions: many(usersToPermissions),
|
||||||
usersToRoles: many(usersToRoles)
|
usersToRoles: many(usersToRoles)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
635
src/lib/server/tournament/double-elimination.ts
Normal file
635
src/lib/server/tournament/double-elimination.ts
Normal file
|
@ -0,0 +1,635 @@
|
||||||
|
import { db } from '../db';
|
||||||
|
import { brackets } from '../db/schema/brackets';
|
||||||
|
import { rounds } from '../db/schema/rounds';
|
||||||
|
import { matches, type Match } from '../db/schema/matches';
|
||||||
|
import { BracketType, MatchStatus, SchedulingMode } from '@/types';
|
||||||
|
import { BaseTournamentGenerator, type SeededTeam } from './types';
|
||||||
|
import type { RoundInsert } from '../db/schema/rounds';
|
||||||
|
import { v7 } from 'uuid';
|
||||||
|
import { and, desc, eq } from 'drizzle-orm';
|
||||||
|
import { fields } from '../db/schema/fields';
|
||||||
|
import type { Team } from '../db/schema/teams';
|
||||||
|
|
||||||
|
export class DoubleEliminationGenerator extends BaseTournamentGenerator {
|
||||||
|
getMode(): SchedulingMode {
|
||||||
|
return SchedulingMode.double;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate brackets for a double elimination tournament
|
||||||
|
* Double elimination requires both winners and losers brackets
|
||||||
|
*/
|
||||||
|
async generateBrackets(): Promise<string[]> {
|
||||||
|
// Insert the winner's bracket
|
||||||
|
const [winnersBracket] = await db
|
||||||
|
.insert(brackets)
|
||||||
|
.values({
|
||||||
|
id: v7(),
|
||||||
|
name: 'Winners Bracket',
|
||||||
|
bracketType: BracketType.WINNER,
|
||||||
|
position: 1,
|
||||||
|
isActive: true,
|
||||||
|
competition_id: this.competitionId,
|
||||||
|
scheduling_mode: SchedulingMode.double
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Insert the losers bracket
|
||||||
|
const [losersBracket] = await db
|
||||||
|
.insert(brackets)
|
||||||
|
.values({
|
||||||
|
id: v7(),
|
||||||
|
name: 'Losers Bracket',
|
||||||
|
bracketType: BracketType.LOSER,
|
||||||
|
position: 2,
|
||||||
|
isActive: true,
|
||||||
|
competition_id: this.competitionId,
|
||||||
|
scheduling_mode: SchedulingMode.double
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return [winnersBracket.id, losersBracket.id];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate rounds for a specific bracket in a double elimination tournament
|
||||||
|
*/
|
||||||
|
async generateRounds(bracketId: string): Promise<RoundInsert[]> {
|
||||||
|
// Get the bracket to determine if it's winners or losers bracket
|
||||||
|
const [bracket] = await db.select().from(brackets).where(eq(brackets.id, bracketId));
|
||||||
|
|
||||||
|
if (!bracket) {
|
||||||
|
throw new Error(`Bracket not found: ${bracketId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isWinnersBracket = bracket.bracketType === BracketType.WINNER;
|
||||||
|
const createdRounds: RoundInsert[] = [];
|
||||||
|
|
||||||
|
// Calculate the number of rounds needed
|
||||||
|
const roundsInWinnersBracket = Math.ceil(Math.log2(this.teamsCount));
|
||||||
|
|
||||||
|
// For winners bracket
|
||||||
|
if (isWinnersBracket) {
|
||||||
|
// Create all rounds for the winners bracket
|
||||||
|
for (let i = 0; i < roundsInWinnersBracket; i++) {
|
||||||
|
const roundNumber = i + 1;
|
||||||
|
let roundName = '';
|
||||||
|
|
||||||
|
// Name rounds appropriately
|
||||||
|
if (i === roundsInWinnersBracket - 1) {
|
||||||
|
roundName = 'Winners Finals';
|
||||||
|
} else if (i === roundsInWinnersBracket - 2) {
|
||||||
|
roundName = 'Winners Semi-Finals';
|
||||||
|
} else {
|
||||||
|
roundName = `Winners Round ${roundNumber}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate matches in this round
|
||||||
|
const matchesInRound = Math.floor(this.teamsCount / Math.pow(2, i));
|
||||||
|
|
||||||
|
const roundData: RoundInsert = {
|
||||||
|
name: roundName,
|
||||||
|
round_number: roundNumber,
|
||||||
|
nb_matches: matchesInRound,
|
||||||
|
bracket_id: bracketId
|
||||||
|
};
|
||||||
|
|
||||||
|
const [insertedRound] = await db.insert(rounds).values(roundData).returning();
|
||||||
|
createdRounds.push(insertedRound);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a Grand Finals round for the winners bracket
|
||||||
|
const grandFinalsRound: RoundInsert = {
|
||||||
|
name: 'Grand Finals',
|
||||||
|
round_number: roundsInWinnersBracket + 1,
|
||||||
|
nb_matches: 1,
|
||||||
|
bracket_id: bracketId
|
||||||
|
};
|
||||||
|
|
||||||
|
const [insertedGrandFinals] = await db.insert(rounds).values(grandFinalsRound).returning();
|
||||||
|
createdRounds.push(insertedGrandFinals);
|
||||||
|
|
||||||
|
// Add a potential reset bracket for the Grand Finals (if loser of winners bracket wins)
|
||||||
|
const resetRound: RoundInsert = {
|
||||||
|
name: 'Grand Finals Reset',
|
||||||
|
round_number: roundsInWinnersBracket + 2,
|
||||||
|
nb_matches: 1,
|
||||||
|
bracket_id: bracketId
|
||||||
|
};
|
||||||
|
|
||||||
|
const [insertedReset] = await db.insert(rounds).values(resetRound).returning();
|
||||||
|
createdRounds.push(insertedReset);
|
||||||
|
}
|
||||||
|
// For losers bracket
|
||||||
|
else {
|
||||||
|
// Losers bracket has 2 * log2(n) - 1 rounds
|
||||||
|
const totalLosersRounds = 2 * roundsInWinnersBracket - 1;
|
||||||
|
|
||||||
|
for (let i = 0; i < totalLosersRounds; i++) {
|
||||||
|
const roundNumber = i + 1;
|
||||||
|
let roundName = '';
|
||||||
|
|
||||||
|
// Name rounds appropriately
|
||||||
|
if (i === totalLosersRounds - 1) {
|
||||||
|
roundName = 'Losers Finals';
|
||||||
|
} else if (i === totalLosersRounds - 2) {
|
||||||
|
roundName = 'Losers Semi-Finals';
|
||||||
|
} else {
|
||||||
|
roundName = `Losers Round ${roundNumber}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate matches in this round - more complex for losers bracket
|
||||||
|
// In double elimination, losers bracket structure depends on the round
|
||||||
|
let matchesInRound: number;
|
||||||
|
|
||||||
|
if (i % 2 === 0) {
|
||||||
|
// Rounds where losers from winners bracket enter
|
||||||
|
matchesInRound = Math.floor(this.teamsCount / Math.pow(2, Math.floor(i / 2) + 1));
|
||||||
|
} else {
|
||||||
|
// Rounds where losers bracket teams play each other
|
||||||
|
matchesInRound = Math.floor(this.teamsCount / Math.pow(2, Math.floor(i / 2) + 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure at least 1 match
|
||||||
|
matchesInRound = Math.max(1, matchesInRound);
|
||||||
|
|
||||||
|
const roundData: RoundInsert = {
|
||||||
|
name: roundName,
|
||||||
|
round_number: roundNumber,
|
||||||
|
nb_matches: matchesInRound,
|
||||||
|
bracket_id: bracketId
|
||||||
|
};
|
||||||
|
|
||||||
|
const [insertedRound] = await db.insert(rounds).values(roundData).returning();
|
||||||
|
createdRounds.push(insertedRound);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdRounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate matches for the first round of a bracket
|
||||||
|
*/
|
||||||
|
async generateMatches(roundId: string, teams: Team[]): Promise<Match[]> {
|
||||||
|
// Get the round
|
||||||
|
const [round] = await db.select().from(rounds).where(eq(rounds.id, roundId));
|
||||||
|
|
||||||
|
if (!round) {
|
||||||
|
throw new Error(`Round not found: ${roundId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!round.bracket_id) {
|
||||||
|
throw new Error(`Round ${roundId} has no bracket`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the bracket
|
||||||
|
const [bracket] = await db.select().from(brackets).where(eq(brackets.id, round.bracket_id));
|
||||||
|
|
||||||
|
if (!bracket) {
|
||||||
|
throw new Error(`Bracket not found for round: ${roundId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get a default field
|
||||||
|
const [defaultField] = await db
|
||||||
|
.select()
|
||||||
|
.from(fields)
|
||||||
|
.where(eq(fields.competition_id, this.competitionId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!defaultField) {
|
||||||
|
throw new Error('No field available for matches');
|
||||||
|
}
|
||||||
|
|
||||||
|
// For winners bracket first round, seed teams appropriately
|
||||||
|
if (bracket.bracketType === BracketType.WINNER && round.round_number === 1) {
|
||||||
|
// Create seeded teams
|
||||||
|
const seededTeams: SeededTeam[] = teams.map((team, index) => ({
|
||||||
|
teamId: team.id,
|
||||||
|
seed: index + 1
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Generate optimal pairings for single elimination (same as first round of double elimination)
|
||||||
|
const pairings = this.createDoubleEliminationPairings(seededTeams);
|
||||||
|
|
||||||
|
// Create matches
|
||||||
|
const matchesToCreate: Match[] = pairings.map((pairing, index) => {
|
||||||
|
const now = new Date();
|
||||||
|
const endTime = new Date(now);
|
||||||
|
endTime.setHours(endTime.getHours() + 1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: v7(),
|
||||||
|
match_number: index + 1,
|
||||||
|
field_id: defaultField.id,
|
||||||
|
team1_id: pairing.team1Id,
|
||||||
|
team2_id: pairing.team2Id || '00000000-0000-0000-0000-000000000000', // Default "bye" UUID if needed
|
||||||
|
winner_id: null,
|
||||||
|
start_date: now,
|
||||||
|
end_date: endTime,
|
||||||
|
score1: 0,
|
||||||
|
score2: 0,
|
||||||
|
status: MatchStatus.PENDING,
|
||||||
|
position: index + 1,
|
||||||
|
table: 0, // Not relevant for elimination tournaments
|
||||||
|
round_id: roundId,
|
||||||
|
bracket_id: bracket.id,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: null,
|
||||||
|
deleted_at: null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Insert matches and return them
|
||||||
|
const createdMatches = await db.insert(matches).values(matchesToCreate).returning();
|
||||||
|
return createdMatches;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For losers bracket first round, this is generated based on results from winners bracket
|
||||||
|
// This would be handled separately by a specialized method
|
||||||
|
|
||||||
|
throw new Error('For rounds after the first round or losers bracket, use generateNextRound');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate the next round based on previous round results
|
||||||
|
* This is complex for double elimination as it involves tracking both winners and losers
|
||||||
|
*/
|
||||||
|
async generateNextRound(previousRoundId: string, bracketId: string): Promise<RoundInsert | null> {
|
||||||
|
// Get the previous round
|
||||||
|
const [previousRound] = await db.select().from(rounds).where(eq(rounds.id, previousRoundId));
|
||||||
|
|
||||||
|
if (!previousRound) {
|
||||||
|
throw new Error(`Previous round not found: ${previousRoundId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the bracket
|
||||||
|
const [bracket] = await db.select().from(brackets).where(eq(brackets.id, bracketId));
|
||||||
|
|
||||||
|
if (!bracket) {
|
||||||
|
throw new Error(`Bracket not found: ${bracketId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all rounds in the current bracket
|
||||||
|
const allRounds = await db
|
||||||
|
.select()
|
||||||
|
.from(rounds)
|
||||||
|
.where(eq(rounds.bracket_id, bracketId))
|
||||||
|
.orderBy(rounds.round_number);
|
||||||
|
|
||||||
|
// Find the next round
|
||||||
|
const nextRound = allRounds.find((r) => r.round_number === previousRound.round_number + 1);
|
||||||
|
|
||||||
|
if (!nextRound) {
|
||||||
|
// This could happen if we're at the final round of a bracket
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get previous round's matches
|
||||||
|
const previousMatches = await db
|
||||||
|
.select()
|
||||||
|
.from(matches)
|
||||||
|
.where(eq(matches.round_id, previousRoundId))
|
||||||
|
.orderBy(matches.position);
|
||||||
|
|
||||||
|
// Check if all matches have results
|
||||||
|
const allMatchesComplete = previousMatches.every((match) => match.winner_id);
|
||||||
|
if (!allMatchesComplete) {
|
||||||
|
throw new Error(
|
||||||
|
'Cannot generate next round until all matches in the current round have winners'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default field
|
||||||
|
const [defaultField] = await db
|
||||||
|
.select()
|
||||||
|
.from(fields)
|
||||||
|
.where(eq(fields.competition_id, this.competitionId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!defaultField) {
|
||||||
|
throw new Error('No field available for matches');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle based on bracket type
|
||||||
|
if (bracket.bracketType === BracketType.WINNER) {
|
||||||
|
// For winners bracket, winners advance to next round
|
||||||
|
const matchesToCreate: Match[] = [];
|
||||||
|
|
||||||
|
// Special case for Grand Finals
|
||||||
|
if (nextRound.name === 'Grand Finals') {
|
||||||
|
// Get winners bracket finalist
|
||||||
|
const winnersBracketFinalist = previousMatches[0].winner_id;
|
||||||
|
if (!winnersBracketFinalist) {
|
||||||
|
throw new Error('No winners bracket finalist found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get losers bracket finalist
|
||||||
|
const losersBrackets = await db
|
||||||
|
.select()
|
||||||
|
.from(brackets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(brackets.competition_id, this.competitionId),
|
||||||
|
eq(brackets.bracketType, BracketType.LOSER)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (losersBrackets.length === 0) {
|
||||||
|
throw new Error('No losers bracket found for grand finals');
|
||||||
|
}
|
||||||
|
|
||||||
|
const losersFinals = await db
|
||||||
|
.select()
|
||||||
|
.from(rounds)
|
||||||
|
.where(eq(rounds.bracket_id, losersBrackets[0].id))
|
||||||
|
.orderBy(desc(rounds.round_number))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (losersFinals.length === 0) {
|
||||||
|
throw new Error('No losers finals found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const losersFinalsMatches = await db
|
||||||
|
.select()
|
||||||
|
.from(matches)
|
||||||
|
.where(eq(matches.round_id, losersFinals[0].id));
|
||||||
|
|
||||||
|
if (losersFinalsMatches.length === 0 || !losersFinalsMatches[0].winner_id) {
|
||||||
|
throw new Error('Losers finalist not determined yet');
|
||||||
|
}
|
||||||
|
|
||||||
|
const losersBracketFinalist = losersFinalsMatches[0].winner_id;
|
||||||
|
|
||||||
|
// Create grand finals match
|
||||||
|
const now = new Date();
|
||||||
|
const endTime = new Date(now);
|
||||||
|
endTime.setHours(endTime.getHours() + 1);
|
||||||
|
|
||||||
|
matchesToCreate.push({
|
||||||
|
id: v7(),
|
||||||
|
match_number: 1,
|
||||||
|
team1_id: winnersBracketFinalist,
|
||||||
|
team2_id: losersBracketFinalist,
|
||||||
|
start_date: now,
|
||||||
|
end_date: endTime,
|
||||||
|
position: 1,
|
||||||
|
table: 0,
|
||||||
|
round_id: nextRound.id,
|
||||||
|
bracket_id: bracketId,
|
||||||
|
field_id: defaultField.id,
|
||||||
|
score1: 0,
|
||||||
|
score2: 0,
|
||||||
|
status: MatchStatus.PENDING,
|
||||||
|
winner_id: null,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: null,
|
||||||
|
deleted_at: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// For Reset bracket (if loser's bracket winner wins grand finals)
|
||||||
|
else if (nextRound.name === 'Grand Finals Reset') {
|
||||||
|
// This would be triggered after grand finals if the loser's bracket winner wins
|
||||||
|
// Implementation depends on your reset bracket logic
|
||||||
|
}
|
||||||
|
// Normal winners bracket progression
|
||||||
|
else {
|
||||||
|
// Create next round matches by pairing winners
|
||||||
|
for (let i = 0; i < nextRound.nb_matches; i++) {
|
||||||
|
const team1Id = previousMatches[i * 2].winner_id;
|
||||||
|
const team2Id = previousMatches[i * 2 + 1].winner_id;
|
||||||
|
|
||||||
|
if (!team1Id || !team2Id) {
|
||||||
|
throw new Error('Missing team IDs');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const endTime = new Date(now);
|
||||||
|
endTime.setHours(endTime.getHours() + 1);
|
||||||
|
|
||||||
|
matchesToCreate.push({
|
||||||
|
id: v7(),
|
||||||
|
match_number: i + 1,
|
||||||
|
team1_id: team1Id,
|
||||||
|
team2_id: team2Id,
|
||||||
|
start_date: now,
|
||||||
|
end_date: endTime,
|
||||||
|
position: i + 1,
|
||||||
|
table: 0,
|
||||||
|
round_id: nextRound.id,
|
||||||
|
bracket_id: bracketId,
|
||||||
|
field_id: defaultField.id,
|
||||||
|
score1: 0,
|
||||||
|
score2: 0,
|
||||||
|
status: MatchStatus.PENDING,
|
||||||
|
winner_id: null,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: null,
|
||||||
|
deleted_at: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also send losers to the losers bracket
|
||||||
|
await this.sendLosersToLosersBracket(previousMatches, previousRound.round_number);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchesToCreate.length > 0) {
|
||||||
|
await db.insert(matches).values(matchesToCreate);
|
||||||
|
}
|
||||||
|
} else if (bracket.bracketType === BracketType.LOSER) {
|
||||||
|
// Losers bracket logic is more complex and depends on the round
|
||||||
|
// In odd-numbered rounds, losers from winners bracket join
|
||||||
|
// In even-numbered rounds, losers bracket teams face each other
|
||||||
|
|
||||||
|
const matchesToCreate: Match[] = [];
|
||||||
|
|
||||||
|
// Logic varies based on the round number
|
||||||
|
if (previousRound.round_number % 2 === 1) {
|
||||||
|
// In odd rounds, winners advance to next round
|
||||||
|
for (let i = 0; i < nextRound.nb_matches; i++) {
|
||||||
|
// If there are enough matches in the previous round
|
||||||
|
if (i * 2 + 1 < previousMatches.length) {
|
||||||
|
const team1Id = previousMatches[i * 2].winner_id;
|
||||||
|
const team2Id = previousMatches[i * 2 + 1].winner_id;
|
||||||
|
|
||||||
|
if (!team1Id || !team2Id) {
|
||||||
|
throw new Error('Missing team IDs');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const endTime = new Date(now);
|
||||||
|
endTime.setHours(endTime.getHours() + 1);
|
||||||
|
|
||||||
|
matchesToCreate.push({
|
||||||
|
id: v7(),
|
||||||
|
match_number: i + 1,
|
||||||
|
team1_id: team1Id,
|
||||||
|
team2_id: team2Id,
|
||||||
|
start_date: now,
|
||||||
|
end_date: endTime,
|
||||||
|
position: i + 1,
|
||||||
|
table: 0,
|
||||||
|
round_id: nextRound.id,
|
||||||
|
bracket_id: bracketId,
|
||||||
|
field_id: defaultField.id,
|
||||||
|
score1: 0,
|
||||||
|
score2: 0,
|
||||||
|
status: MatchStatus.PENDING,
|
||||||
|
winner_id: null,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: null,
|
||||||
|
deleted_at: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// In even rounds, losers from winners bracket join
|
||||||
|
// This requires looking up the corresponding winners bracket matches
|
||||||
|
// Implementation would depend on your exact double elimination structure
|
||||||
|
// This is a complex part that varies by tournament design
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchesToCreate.length > 0) {
|
||||||
|
await db.insert(matches).values(matchesToCreate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextRound;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send losers from winners bracket to losers bracket
|
||||||
|
* This is a key part of double elimination
|
||||||
|
*/
|
||||||
|
private async sendLosersToLosersBracket(
|
||||||
|
winnersBracketMatches: any[],
|
||||||
|
winnersRoundNumber: number
|
||||||
|
): Promise<void> {
|
||||||
|
// Get the losers bracket
|
||||||
|
const [losersBracket] = await db
|
||||||
|
.select()
|
||||||
|
.from(brackets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(brackets.competition_id, this.competitionId),
|
||||||
|
eq(brackets.bracketType, BracketType.LOSER)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!losersBracket) {
|
||||||
|
throw new Error('No losers bracket found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the appropriate losers bracket round
|
||||||
|
// In standard double elimination, losers from winners round N go to losers round 2N-1
|
||||||
|
const targetLosersRound = winnersRoundNumber * 2 - 1;
|
||||||
|
|
||||||
|
const [losersRound] = await db
|
||||||
|
.select()
|
||||||
|
.from(rounds)
|
||||||
|
.where(
|
||||||
|
and(eq(rounds.bracket_id, losersBracket.id), eq(rounds.round_number, targetLosersRound))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!losersRound) {
|
||||||
|
throw new Error(`No losers round found for winners round ${winnersRoundNumber}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get a default field
|
||||||
|
const [defaultField] = await db
|
||||||
|
.select()
|
||||||
|
.from(fields)
|
||||||
|
.where(eq(fields.competition_id, this.competitionId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!defaultField) {
|
||||||
|
throw new Error('No field available for matches');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create matches in the losers bracket with the losers from winners bracket
|
||||||
|
const matchesToCreate: Match[] = [];
|
||||||
|
let matchCount = 0;
|
||||||
|
|
||||||
|
// For each match in the winners bracket, get the loser
|
||||||
|
for (let i = 0; i < winnersBracketMatches.length; i++) {
|
||||||
|
const match = winnersBracketMatches[i];
|
||||||
|
if (!match.winner_id) continue;
|
||||||
|
|
||||||
|
// Determine the loser
|
||||||
|
const loserId = match.winner_id === match.team1_id ? match.team2_id : match.team1_id;
|
||||||
|
|
||||||
|
// In first losers round, each loser gets their own match (often with a bye)
|
||||||
|
if (targetLosersRound === 1) {
|
||||||
|
const now = new Date();
|
||||||
|
const endTime = new Date(now);
|
||||||
|
endTime.setHours(endTime.getHours() + 1);
|
||||||
|
|
||||||
|
matchesToCreate.push({
|
||||||
|
id: v7(),
|
||||||
|
match_number: matchCount + 1,
|
||||||
|
team1_id: loserId,
|
||||||
|
team2_id: '00000000-0000-0000-0000-000000000000', // Initial bye or determined by bracket structure
|
||||||
|
start_date: now,
|
||||||
|
end_date: endTime,
|
||||||
|
position: matchCount + 1,
|
||||||
|
table: 0,
|
||||||
|
round_id: losersRound.id,
|
||||||
|
bracket_id: losersBracket.id,
|
||||||
|
field_id: defaultField.id,
|
||||||
|
score1: 0,
|
||||||
|
score2: 0,
|
||||||
|
status: MatchStatus.PENDING,
|
||||||
|
winner_id: null,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: null,
|
||||||
|
deleted_at: null
|
||||||
|
});
|
||||||
|
|
||||||
|
matchCount++;
|
||||||
|
}
|
||||||
|
// In later rounds, losers typically play against survivors from previous losers rounds
|
||||||
|
// This would be handled in the main generateNextRound method for the losers bracket
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchesToCreate.length > 0) {
|
||||||
|
await db.insert(matches).values(matchesToCreate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create optimal pairings for the first round of double elimination
|
||||||
|
* Uses standard tournament seeding to ensure balanced bracket
|
||||||
|
*/
|
||||||
|
private createDoubleEliminationPairings(
|
||||||
|
seededTeams: SeededTeam[]
|
||||||
|
): { team1Id: string; team2Id: string | null }[] {
|
||||||
|
// For first round, use same seeding as single elimination
|
||||||
|
const totalTeamsInFullBracket = Math.pow(2, this.calculateRoundsNeeded());
|
||||||
|
const pairings: { team1Id: string; team2Id: string | null }[] = [];
|
||||||
|
|
||||||
|
// Sort teams by seed
|
||||||
|
const sortedTeams = [...seededTeams].sort((a, b) => a.seed - b.seed);
|
||||||
|
|
||||||
|
// If we need to give byes, calculate them
|
||||||
|
const teamsWithByes = sortedTeams.slice(0, 2 * sortedTeams.length - totalTeamsInFullBracket);
|
||||||
|
const teamsWithoutByes = sortedTeams.slice(teamsWithByes.length);
|
||||||
|
|
||||||
|
// Add matches for teams with direct advancement (byes)
|
||||||
|
for (const team of teamsWithByes) {
|
||||||
|
pairings.push({
|
||||||
|
team1Id: team.teamId,
|
||||||
|
team2Id: null // Bye
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create standard bracket pairings for remaining teams
|
||||||
|
for (let i = 0; i < teamsWithoutByes.length; i += 2) {
|
||||||
|
pairings.push({
|
||||||
|
team1Id: teamsWithoutByes[i].teamId,
|
||||||
|
team2Id: teamsWithoutByes[i + 1].teamId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return pairings;
|
||||||
|
}
|
||||||
|
}
|
238
src/lib/server/tournament/double-round-robin.ts
Normal file
238
src/lib/server/tournament/double-round-robin.ts
Normal file
|
@ -0,0 +1,238 @@
|
||||||
|
import { db } from '../db';
|
||||||
|
import { brackets } from '../db/schema/brackets';
|
||||||
|
import { rounds } from '../db/schema/rounds';
|
||||||
|
import { matches, type Match } from '../db/schema/matches';
|
||||||
|
import { BracketType, MatchStatus, SchedulingMode } from '@/types';
|
||||||
|
import { BaseTournamentGenerator } from './types';
|
||||||
|
import type { RoundInsert } from '../db/schema/rounds';
|
||||||
|
import { v7 } from 'uuid';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { fields } from '../db/schema/fields';
|
||||||
|
import type { Team } from '../db/schema/teams';
|
||||||
|
|
||||||
|
export class DoubleRoundRobinGenerator extends BaseTournamentGenerator {
|
||||||
|
getMode(): SchedulingMode {
|
||||||
|
return SchedulingMode.double_round_robin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a single main bracket for double round robin
|
||||||
|
*/
|
||||||
|
async generateBrackets(): Promise<string[]> {
|
||||||
|
const [bracket] = await db
|
||||||
|
.insert(brackets)
|
||||||
|
.values({
|
||||||
|
id: v7(),
|
||||||
|
name: 'Main Bracket',
|
||||||
|
bracketType: BracketType.MAIN,
|
||||||
|
position: 1,
|
||||||
|
isActive: true,
|
||||||
|
competition_id: this.competitionId,
|
||||||
|
scheduling_mode: SchedulingMode.double_round_robin
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return [bracket.id];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate all rounds for a double round robin tournament
|
||||||
|
* In double round robin, each team plays against every other team twice
|
||||||
|
* (once home, once away)
|
||||||
|
*/
|
||||||
|
async generateRounds(bracketId: string): Promise<RoundInsert[]> {
|
||||||
|
// Number of rounds needed = 2 * (n-1) where n is the number of teams
|
||||||
|
const totalRounds = 2 * (this.teamsCount - 1);
|
||||||
|
const createdRounds: RoundInsert[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < totalRounds; i++) {
|
||||||
|
const roundNumber = i + 1;
|
||||||
|
const roundName = `Round ${roundNumber}`;
|
||||||
|
|
||||||
|
// Each round has floor(n/2) matches
|
||||||
|
const matchesInRound = Math.floor(this.teamsCount / 2);
|
||||||
|
|
||||||
|
const roundData: RoundInsert = {
|
||||||
|
name: roundName,
|
||||||
|
round_number: roundNumber,
|
||||||
|
nb_matches: matchesInRound,
|
||||||
|
bracket_id: bracketId
|
||||||
|
};
|
||||||
|
|
||||||
|
const [insertedRound] = await db.insert(rounds).values(roundData).returning();
|
||||||
|
createdRounds.push(insertedRound);
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdRounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate matches for a double round robin tournament
|
||||||
|
* This extends the round robin algorithm to create two sets of matches
|
||||||
|
* with home/away teams reversed in the second half
|
||||||
|
*/
|
||||||
|
async generateMatches(roundId: string, teams: Team[]): Promise<Match[]> {
|
||||||
|
// Get the round
|
||||||
|
const [round] = await db.select().from(rounds).where(eq(rounds.id, roundId));
|
||||||
|
|
||||||
|
if (!round) {
|
||||||
|
throw new Error(`Round not found: ${roundId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!round.bracket_id) {
|
||||||
|
throw new Error(`Round ${roundId} has no bracket`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the bracket
|
||||||
|
const [bracket] = await db.select().from(brackets).where(eq(brackets.id, round.bracket_id));
|
||||||
|
|
||||||
|
if (!bracket) {
|
||||||
|
throw new Error(`Bracket not found for round: ${roundId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default field (could be enhanced to assign different fields)
|
||||||
|
const [defaultField] = await db
|
||||||
|
.select()
|
||||||
|
.from(fields)
|
||||||
|
.where(eq(fields.competition_id, this.competitionId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!defaultField) {
|
||||||
|
throw new Error('No field available for matches');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate pairings using the circle method
|
||||||
|
const singleRoundRobinRounds = this.teamsCount - 1;
|
||||||
|
const isSecondHalf = round.round_number > singleRoundRobinRounds;
|
||||||
|
|
||||||
|
// For second half, we need to flip home/away teams
|
||||||
|
const effectiveRoundNumber = isSecondHalf
|
||||||
|
? round.round_number - singleRoundRobinRounds
|
||||||
|
: round.round_number;
|
||||||
|
|
||||||
|
// Generate pairings for this round
|
||||||
|
const pairings = this.generateRoundRobinPairings(teams, effectiveRoundNumber);
|
||||||
|
|
||||||
|
// Create match objects
|
||||||
|
const matchesToCreate: Match[] = pairings.map((pairing, index) => {
|
||||||
|
const now = new Date();
|
||||||
|
const endTime = new Date(now);
|
||||||
|
endTime.setHours(endTime.getHours() + 1);
|
||||||
|
|
||||||
|
// For the second half of matches, swap team1 and team2 to reverse home/away
|
||||||
|
const [team1Id, team2Id] = isSecondHalf
|
||||||
|
? [pairing.team2Id, pairing.team1Id]
|
||||||
|
: [pairing.team1Id, pairing.team2Id];
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: v7(),
|
||||||
|
match_number: index + 1,
|
||||||
|
team1_id: team1Id,
|
||||||
|
team2_id: team2Id,
|
||||||
|
start_date: now,
|
||||||
|
end_date: endTime,
|
||||||
|
position: index + 1,
|
||||||
|
table: index + 1, // Table assignment
|
||||||
|
round_id: roundId,
|
||||||
|
bracket_id: bracket.id,
|
||||||
|
field_id: defaultField.id,
|
||||||
|
score1: 0,
|
||||||
|
score2: 0,
|
||||||
|
status: MatchStatus.PENDING,
|
||||||
|
winner_id: null,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: null,
|
||||||
|
deleted_at: null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Insert matches and return them
|
||||||
|
const createdMatches = await db.insert(matches).values(matchesToCreate).returning();
|
||||||
|
return createdMatches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In double round robin, there's no "next round" generation based on results
|
||||||
|
* All rounds are created at the beginning
|
||||||
|
*/
|
||||||
|
async generateNextRound(previousRoundId: string, bracketId: string): Promise<RoundInsert | null> {
|
||||||
|
// Get the previous round
|
||||||
|
const [previousRound] = await db.select().from(rounds).where(eq(rounds.id, previousRoundId));
|
||||||
|
|
||||||
|
if (!previousRound) {
|
||||||
|
throw new Error(`Previous round not found: ${previousRoundId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all rounds in the bracket
|
||||||
|
const allRounds = await db
|
||||||
|
.select()
|
||||||
|
.from(rounds)
|
||||||
|
.where(eq(rounds.bracket_id, bracketId))
|
||||||
|
.orderBy(rounds.round_number);
|
||||||
|
|
||||||
|
// Check if this was the final round
|
||||||
|
if (previousRound.round_number === allRounds.length) {
|
||||||
|
return null; // Tournament is complete
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the next round
|
||||||
|
const nextRound = allRounds.find((r) => r.round_number === previousRound.round_number + 1);
|
||||||
|
|
||||||
|
if (!nextRound) {
|
||||||
|
throw new Error(`Next round not found for round ${previousRound.round_number}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextRound;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate pairings for a round robin tournament using the circle method
|
||||||
|
* In this method, one team stays fixed (team at index 0) and others rotate clockwise
|
||||||
|
*/
|
||||||
|
private generateRoundRobinPairings(teams: Team[], roundNumber: number) {
|
||||||
|
// If odd number of teams, add a "bye" team
|
||||||
|
if (teams.length % 2 !== 0) {
|
||||||
|
teams.push({
|
||||||
|
id: '00000000-0000-0000-0000-000000000000',
|
||||||
|
name: '',
|
||||||
|
created_at: new Date(Date.now()),
|
||||||
|
updated_at: null,
|
||||||
|
deleted_at: null,
|
||||||
|
competition_id: ''
|
||||||
|
}); // Bye team ID
|
||||||
|
}
|
||||||
|
|
||||||
|
const n = teams.length;
|
||||||
|
const pairings: { team1Id: string; team2Id: string }[] = [];
|
||||||
|
|
||||||
|
// Create a copy of the teams array that we can manipulate
|
||||||
|
const teamsForRound = [...teams];
|
||||||
|
|
||||||
|
// Rotate the teams (except the first one) based on the round number
|
||||||
|
// For round 1, use teams as is
|
||||||
|
// For subsequent rounds, rotate teams[1...n-1] clockwise
|
||||||
|
if (roundNumber > 1) {
|
||||||
|
// Apply rotation (roundNumber - 1) times
|
||||||
|
for (let i = 0; i < roundNumber - 1; i++) {
|
||||||
|
const lastTeam = teamsForRound.pop()!;
|
||||||
|
teamsForRound.splice(1, 0, lastTeam);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create pairings for this round
|
||||||
|
for (let i = 0; i < n / 2; i++) {
|
||||||
|
// Match teams[i] with teams[n-1-i]
|
||||||
|
pairings.push({
|
||||||
|
team1Id: teamsForRound[i].id,
|
||||||
|
team2Id: teamsForRound[n - 1 - i].id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out pairings with the bye team
|
||||||
|
return pairings.filter(
|
||||||
|
(pairing) =>
|
||||||
|
pairing.team1Id !== '00000000-0000-0000-0000-000000000000' &&
|
||||||
|
pairing.team2Id !== '00000000-0000-0000-0000-000000000000'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
33
src/lib/server/tournament/index.ts
Normal file
33
src/lib/server/tournament/index.ts
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import { SingleEliminationGenerator } from './single-elimination';
|
||||||
|
import { DoubleEliminationGenerator } from './double-elimination';
|
||||||
|
import { SwissGenerator } from './swiss';
|
||||||
|
import { RoundRobinGenerator } from './round-robin';
|
||||||
|
import { DoubleRoundRobinGenerator } from './double-round-robin';
|
||||||
|
import { SchedulingMode } from '../../../types';
|
||||||
|
import type { TournamentGenerator } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory function to create the appropriate tournament generator based on the scheduling mode
|
||||||
|
*/
|
||||||
|
export function createTournamentGenerator(
|
||||||
|
mode: SchedulingMode,
|
||||||
|
competitionId: string,
|
||||||
|
teamsCount: number
|
||||||
|
): TournamentGenerator {
|
||||||
|
switch (mode) {
|
||||||
|
case SchedulingMode.single:
|
||||||
|
return new SingleEliminationGenerator(competitionId, teamsCount);
|
||||||
|
case SchedulingMode.double:
|
||||||
|
return new DoubleEliminationGenerator(competitionId, teamsCount);
|
||||||
|
case SchedulingMode.swiss:
|
||||||
|
return new SwissGenerator(competitionId, teamsCount);
|
||||||
|
case SchedulingMode.round_robin:
|
||||||
|
return new RoundRobinGenerator(competitionId, teamsCount);
|
||||||
|
case SchedulingMode.double_round_robin:
|
||||||
|
return new DoubleRoundRobinGenerator(competitionId, teamsCount);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported scheduling mode: ${mode}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export * from './types';
|
222
src/lib/server/tournament/round-robin.ts
Normal file
222
src/lib/server/tournament/round-robin.ts
Normal file
|
@ -0,0 +1,222 @@
|
||||||
|
import { db } from '../db';
|
||||||
|
import { brackets, schedulingModes } from '../db/schema/brackets';
|
||||||
|
import { rounds } from '../db/schema/rounds';
|
||||||
|
import { matches, type Match } from '../db/schema/matches';
|
||||||
|
import { BracketType, MatchStatus, SchedulingMode } from '@/types';
|
||||||
|
import { BaseTournamentGenerator } from './types';
|
||||||
|
import type { RoundInsert } from '../db/schema/rounds';
|
||||||
|
import { v7 } from 'uuid';
|
||||||
|
import { fields } from '../db/schema/fields';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import type { Team } from '../db/schema/teams';
|
||||||
|
|
||||||
|
export class RoundRobinGenerator extends BaseTournamentGenerator {
|
||||||
|
getMode(): SchedulingMode {
|
||||||
|
return SchedulingMode.round_robin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a single main bracket for round robin
|
||||||
|
*/
|
||||||
|
async generateBrackets(): Promise<string[]> {
|
||||||
|
const [bracket] = await db
|
||||||
|
.insert(brackets)
|
||||||
|
.values({
|
||||||
|
id: v7(),
|
||||||
|
name: 'Main Bracket',
|
||||||
|
bracketType: BracketType.MAIN,
|
||||||
|
position: 1,
|
||||||
|
isActive: true,
|
||||||
|
competition_id: this.competitionId,
|
||||||
|
scheduling_mode: SchedulingMode.round_robin
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return [bracket.id];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate all rounds for a round robin tournament
|
||||||
|
* In round robin, each team plays against every other team once
|
||||||
|
*/
|
||||||
|
async generateRounds(bracketId: string): Promise<RoundInsert[]> {
|
||||||
|
// Number of rounds needed = n-1 where n is the number of teams
|
||||||
|
const totalRounds = this.teamsCount - 1;
|
||||||
|
const createdRounds: RoundInsert[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < totalRounds; i++) {
|
||||||
|
const roundNumber = i + 1;
|
||||||
|
const roundName = `Round ${roundNumber}`;
|
||||||
|
|
||||||
|
// Each round has floor(n/2) matches
|
||||||
|
const matchesInRound = Math.floor(this.teamsCount / 2);
|
||||||
|
|
||||||
|
const roundData: RoundInsert = {
|
||||||
|
name: roundName,
|
||||||
|
round_number: roundNumber,
|
||||||
|
nb_matches: matchesInRound,
|
||||||
|
bracket_id: bracketId
|
||||||
|
};
|
||||||
|
|
||||||
|
const [insertedRound] = await db.insert(rounds).values(roundData).returning();
|
||||||
|
createdRounds.push(insertedRound);
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdRounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate matches for a round robin tournament using the circle method
|
||||||
|
* This creates optimal pairings for each round
|
||||||
|
*/
|
||||||
|
async generateMatches(roundId: string, teams: Team[]): Promise<Match[]> {
|
||||||
|
// Get the round
|
||||||
|
const [round] = await db.select().from(rounds).where(eq(rounds.id, roundId));
|
||||||
|
|
||||||
|
if (!round) {
|
||||||
|
throw new Error(`Round not found: ${roundId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!round.bracket_id) {
|
||||||
|
throw new Error(`Round ${roundId} has no bracket`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the bracket
|
||||||
|
const [bracket] = await db.select().from(brackets).where(eq(brackets.id, round.bracket_id));
|
||||||
|
|
||||||
|
if (!bracket) {
|
||||||
|
throw new Error(`Bracket not found for round: ${roundId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default field (could be enhanced to assign different fields)
|
||||||
|
const [defaultField] = await db
|
||||||
|
.select()
|
||||||
|
.from(fields)
|
||||||
|
.where(eq(fields.competition_id, this.competitionId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!defaultField) {
|
||||||
|
throw new Error('No field available for matches');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate pairings using the circle method
|
||||||
|
const pairings = this.generateRoundRobinPairings(teams, round.round_number);
|
||||||
|
|
||||||
|
// Create match objects
|
||||||
|
const matchesToCreate: Match[] = pairings.map((pairing, index) => {
|
||||||
|
const now = new Date();
|
||||||
|
const endTime = new Date(now);
|
||||||
|
endTime.setHours(endTime.getHours() + 1); // Default 1-hour match
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: v7(),
|
||||||
|
match_number: index + 1,
|
||||||
|
team1_id: pairing.team1Id,
|
||||||
|
team2_id: pairing.team2Id,
|
||||||
|
start_date: now,
|
||||||
|
end_date: endTime,
|
||||||
|
position: index + 1,
|
||||||
|
table: index + 1, // Table assignment (could be enhanced)
|
||||||
|
round_id: roundId,
|
||||||
|
bracket_id: bracket.id,
|
||||||
|
field_id: defaultField.id,
|
||||||
|
score1: 0,
|
||||||
|
score2: 0,
|
||||||
|
status: MatchStatus.PENDING,
|
||||||
|
winner_id: null,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: null,
|
||||||
|
deleted_at: null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Insert matches and return them
|
||||||
|
const createdMatches = await db.insert(matches).values(matchesToCreate).returning();
|
||||||
|
return createdMatches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In round robin, there's no "next round" generation based on results
|
||||||
|
* All rounds are created at the beginning
|
||||||
|
*/
|
||||||
|
async generateNextRound(previousRoundId: string, bracketId: string): Promise<RoundInsert | null> {
|
||||||
|
// Get the previous round
|
||||||
|
const [previousRound] = await db.select().from(rounds).where(eq(rounds.id, previousRoundId));
|
||||||
|
|
||||||
|
if (!previousRound) {
|
||||||
|
throw new Error(`Previous round not found: ${previousRoundId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all rounds in the bracket
|
||||||
|
const allRounds = await db
|
||||||
|
.select()
|
||||||
|
.from(rounds)
|
||||||
|
.where(eq(rounds.bracket_id, bracketId))
|
||||||
|
.orderBy(rounds.round_number);
|
||||||
|
|
||||||
|
// Check if this was the final round
|
||||||
|
if (previousRound.round_number === allRounds.length) {
|
||||||
|
return null; // Tournament is complete
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the next round
|
||||||
|
const nextRound = allRounds.find((r) => r.round_number === previousRound.round_number + 1);
|
||||||
|
|
||||||
|
if (!nextRound) {
|
||||||
|
throw new Error(`Next round not found for round ${previousRound.round_number}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextRound;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate pairings for a round robin tournament using the circle method
|
||||||
|
* In this method, one team stays fixed (team at index 0) and others rotate clockwise
|
||||||
|
*/
|
||||||
|
private generateRoundRobinPairings(teams: Team[], roundNumber: number) {
|
||||||
|
// If odd number of teams, add a "bye" team
|
||||||
|
if (teams.length % 2 !== 0) {
|
||||||
|
teams.push({
|
||||||
|
id: '00000000-0000-0000-0000-000000000000',
|
||||||
|
name: '',
|
||||||
|
created_at: new Date(Date.now()),
|
||||||
|
updated_at: null,
|
||||||
|
deleted_at: null,
|
||||||
|
competition_id: ''
|
||||||
|
}); // Bye team ID
|
||||||
|
}
|
||||||
|
|
||||||
|
const n = teams.length;
|
||||||
|
const pairings: { team1Id: string; team2Id: string }[] = [];
|
||||||
|
|
||||||
|
// Create a copy of the teams array that we can manipulate
|
||||||
|
const teamsForRound = [...teams];
|
||||||
|
|
||||||
|
// Rotate the teams (except the first one) based on the round number
|
||||||
|
// For round 1, use teams as is
|
||||||
|
// For subsequent rounds, rotate teams[1...n-1] clockwise
|
||||||
|
if (roundNumber > 1) {
|
||||||
|
// Apply rotation (roundNumber - 1) times
|
||||||
|
for (let i = 0; i < roundNumber - 1; i++) {
|
||||||
|
const lastTeam = teamsForRound.pop()!;
|
||||||
|
teamsForRound.splice(1, 0, lastTeam);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create pairings for this round
|
||||||
|
for (let i = 0; i < n / 2; i++) {
|
||||||
|
// Match teams[i] with teams[n-1-i]
|
||||||
|
pairings.push({
|
||||||
|
team1Id: teamsForRound[i].id,
|
||||||
|
team2Id: teamsForRound[n - 1 - i].id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out pairings with the bye team
|
||||||
|
return pairings.filter(
|
||||||
|
(pairing) =>
|
||||||
|
pairing.team1Id !== '00000000-0000-0000-0000-000000000000' &&
|
||||||
|
pairing.team2Id !== '00000000-0000-0000-0000-000000000000'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
295
src/lib/server/tournament/single-elimination.ts
Normal file
295
src/lib/server/tournament/single-elimination.ts
Normal file
|
@ -0,0 +1,295 @@
|
||||||
|
import { db } from '../db';
|
||||||
|
import { brackets } from '../db/schema/brackets';
|
||||||
|
import { rounds } from '../db/schema/rounds';
|
||||||
|
import { matches, type Match } from '../db/schema/matches';
|
||||||
|
import { BracketType, MatchStatus, SchedulingMode } from '@/types';
|
||||||
|
import { BaseTournamentGenerator, type SeededTeam } from './types';
|
||||||
|
import type { RoundInsert } from '../db/schema/rounds';
|
||||||
|
import { fields } from '../db/schema/fields';
|
||||||
|
import { v7 } from 'uuid';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import type { Team } from '../db/schema/teams';
|
||||||
|
|
||||||
|
export class SingleEliminationGenerator extends BaseTournamentGenerator {
|
||||||
|
getMode(): SchedulingMode {
|
||||||
|
return SchedulingMode.single;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a single bracket for the single elimination tournament
|
||||||
|
*/
|
||||||
|
async generateBrackets(): Promise<string[]> {
|
||||||
|
// Insert the winner's bracket
|
||||||
|
const [bracket] = await db
|
||||||
|
.insert(brackets)
|
||||||
|
.values({
|
||||||
|
name: 'Winner Bracket',
|
||||||
|
bracketType: BracketType.WINNER,
|
||||||
|
position: 1,
|
||||||
|
isActive: true,
|
||||||
|
competition_id: this.competitionId,
|
||||||
|
scheduling_mode: SchedulingMode.single
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return [bracket.id];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate all rounds needed for a single elimination tournament
|
||||||
|
*/
|
||||||
|
async generateRounds(bracketId: string): Promise<RoundInsert[]> {
|
||||||
|
const totalRounds = this.calculateRoundsNeeded();
|
||||||
|
const createdRounds: RoundInsert[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < totalRounds; i++) {
|
||||||
|
const roundNumber = i + 1;
|
||||||
|
let roundName = '';
|
||||||
|
|
||||||
|
// Name the rounds appropriately
|
||||||
|
if (i === totalRounds - 1) {
|
||||||
|
roundName = 'Final';
|
||||||
|
} else if (i === totalRounds - 2) {
|
||||||
|
roundName = 'Semi-Finals';
|
||||||
|
} else if (i === totalRounds - 3) {
|
||||||
|
roundName = 'Quarter-Finals';
|
||||||
|
} else {
|
||||||
|
roundName = `Round ${roundNumber}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate number of matches in this round
|
||||||
|
const matchesInRound = Math.floor(this.teamsCount / Math.pow(2, i));
|
||||||
|
|
||||||
|
const roundData: RoundInsert = {
|
||||||
|
name: roundName,
|
||||||
|
round_number: roundNumber,
|
||||||
|
nb_matches: matchesInRound,
|
||||||
|
bracket_id: bracketId
|
||||||
|
};
|
||||||
|
|
||||||
|
// Insert the round
|
||||||
|
const [insertedRound] = await db.insert(rounds).values(roundData).returning();
|
||||||
|
createdRounds.push(insertedRound);
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdRounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate matches for the first round of a single elimination tournament
|
||||||
|
* For subsequent rounds, use generateNextRound
|
||||||
|
*/
|
||||||
|
async generateMatches(roundId: string, teams: Team[]): Promise<Match[]> {
|
||||||
|
// Get the round
|
||||||
|
const [round] = await db.select().from(rounds).where(eq(rounds.id, roundId));
|
||||||
|
|
||||||
|
if (!round) {
|
||||||
|
throw new Error(`Round not found: ${roundId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!round.bracket_id) {
|
||||||
|
throw new Error(`Round has no bracket: ${roundId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the bracket
|
||||||
|
const [bracket] = await db.select().from(brackets).where(eq(brackets.id, round.bracket_id));
|
||||||
|
|
||||||
|
if (!bracket) {
|
||||||
|
throw new Error(`Bracket not found for round: ${roundId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed the teams (this is a simple seeding, could be enhanced)
|
||||||
|
const seededTeams: SeededTeam[] = teams.map((team, index) => ({
|
||||||
|
teamId: team.id,
|
||||||
|
seed: index + 1
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Distribute teams according to standard tournament seeding
|
||||||
|
const pairings = this.createSingleEliminationPairings(seededTeams, round.round_number);
|
||||||
|
|
||||||
|
// Default field (could be enhanced to assign different fields)
|
||||||
|
const [defaultField] = await db
|
||||||
|
.select()
|
||||||
|
.from(fields)
|
||||||
|
.where(eq(fields.competition_id, this.competitionId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!defaultField) {
|
||||||
|
throw new Error('No field available for matches');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create match objects
|
||||||
|
const matchesToCreate: Match[] = pairings.map((pairing, index) => {
|
||||||
|
const now = new Date();
|
||||||
|
const endTime = new Date(now);
|
||||||
|
endTime.setHours(endTime.getHours() + 1); // Default 1-hour match
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: v7(),
|
||||||
|
match_number: index + 1,
|
||||||
|
team1_id: pairing.team1Id,
|
||||||
|
team2_id: pairing.team2Id || '00000000-0000-0000-0000-000000000000', // Default "bye" UUID if needed
|
||||||
|
start_date: now,
|
||||||
|
end_date: endTime,
|
||||||
|
position: index + 1,
|
||||||
|
table: 0, // Not relevant for elimination tournaments
|
||||||
|
round_id: roundId,
|
||||||
|
bracket_id: bracket.id,
|
||||||
|
field_id: defaultField.id,
|
||||||
|
score1: 0,
|
||||||
|
score2: 0,
|
||||||
|
status: MatchStatus.PENDING,
|
||||||
|
winner_id: null,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: null,
|
||||||
|
deleted_at: null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Insert matches and return them
|
||||||
|
const createdMatches = await db.insert(matches).values(matchesToCreate).returning();
|
||||||
|
return createdMatches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate the next round based on the results of a previous round
|
||||||
|
*/
|
||||||
|
async generateNextRound(previousRoundId: string, bracketId: string): Promise<RoundInsert | null> {
|
||||||
|
// Get the previous round
|
||||||
|
const [previousRound] = await db.select().from(rounds).where(eq(rounds.id, previousRoundId));
|
||||||
|
|
||||||
|
if (!previousRound) {
|
||||||
|
throw new Error(`Previous round not found: ${previousRoundId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this was the final round
|
||||||
|
if (previousRound.nb_matches === 1) {
|
||||||
|
return null; // Tournament is complete
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the previous round's matches
|
||||||
|
const previousMatches = await db
|
||||||
|
.select()
|
||||||
|
.from(matches)
|
||||||
|
.where(eq(matches.round_id, previousRoundId))
|
||||||
|
.orderBy(matches.position);
|
||||||
|
|
||||||
|
// Check if all matches have winners
|
||||||
|
const allMatchesComplete = previousMatches.every((match) => match.winner_id);
|
||||||
|
|
||||||
|
if (!allMatchesComplete) {
|
||||||
|
throw new Error(
|
||||||
|
'Cannot generate next round until all matches in the current round have winners'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the next round
|
||||||
|
const nextRoundNumber = previousRound.round_number + 1;
|
||||||
|
const nextRoundMatchCount = Math.floor(previousRound.nb_matches / 2);
|
||||||
|
|
||||||
|
let roundName = '';
|
||||||
|
if (nextRoundMatchCount === 1) {
|
||||||
|
roundName = 'Final';
|
||||||
|
} else if (nextRoundMatchCount === 2) {
|
||||||
|
roundName = 'Semi-Finals';
|
||||||
|
} else if (nextRoundMatchCount === 4) {
|
||||||
|
roundName = 'Quarter-Finals';
|
||||||
|
} else {
|
||||||
|
roundName = `Round ${nextRoundNumber}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextRoundData: RoundInsert = {
|
||||||
|
name: roundName,
|
||||||
|
round_number: nextRoundNumber,
|
||||||
|
nb_matches: nextRoundMatchCount,
|
||||||
|
bracket_id: bracketId
|
||||||
|
};
|
||||||
|
|
||||||
|
// Insert the next round
|
||||||
|
const [nextRound] = await db.insert(rounds).values(nextRoundData).returning();
|
||||||
|
|
||||||
|
// Create matches for the next round
|
||||||
|
const nextRoundMatches: Match[] = [];
|
||||||
|
|
||||||
|
// Create pairings for the next round based on winners
|
||||||
|
for (let i = 0; i < nextRoundMatchCount; i++) {
|
||||||
|
const team1Id = previousMatches[i * 2].winner_id;
|
||||||
|
const team2Id = previousMatches[i * 2 + 1].winner_id;
|
||||||
|
|
||||||
|
if (!team1Id || !team2Id) {
|
||||||
|
throw new Error('Missing winner ID for matches');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const endTime = new Date(now);
|
||||||
|
endTime.setHours(endTime.getHours() + 1);
|
||||||
|
|
||||||
|
// Get the same field from previous match or default
|
||||||
|
const field_id = previousMatches[i].field_id;
|
||||||
|
|
||||||
|
nextRoundMatches.push({
|
||||||
|
id: v7(),
|
||||||
|
match_number: i + 1,
|
||||||
|
team1_id: team1Id,
|
||||||
|
team2_id: team2Id,
|
||||||
|
start_date: now,
|
||||||
|
end_date: endTime,
|
||||||
|
position: i + 1,
|
||||||
|
table: 0,
|
||||||
|
round_id: nextRound.id,
|
||||||
|
bracket_id: bracketId,
|
||||||
|
field_id,
|
||||||
|
score1: 0,
|
||||||
|
score2: 0,
|
||||||
|
status: MatchStatus.PENDING,
|
||||||
|
winner_id: null,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: null,
|
||||||
|
deleted_at: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert the matches for the next round
|
||||||
|
await db.insert(matches).values(nextRoundMatches);
|
||||||
|
|
||||||
|
return nextRound;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates optimal pairings for a single elimination tournament
|
||||||
|
* Uses the standard tournament bracket seeding algorithm
|
||||||
|
*/
|
||||||
|
private createSingleEliminationPairings(
|
||||||
|
seededTeams: SeededTeam[],
|
||||||
|
roundNumber: number
|
||||||
|
): { team1Id: string; team2Id: string | null }[] {
|
||||||
|
// Handle first round with proper seeding
|
||||||
|
if (roundNumber === 1) {
|
||||||
|
const totalTeamsInFullBracket = seededTeams.length;
|
||||||
|
const pairings: { team1Id: string; team2Id: string | null }[] = [];
|
||||||
|
const nextPowerOfTwo = 1 << Math.ceil(Math.log2(totalTeamsInFullBracket));
|
||||||
|
// Sort teams by seed
|
||||||
|
const sortedTeams = [...seededTeams].sort((a, b) => a.seed - b.seed);
|
||||||
|
|
||||||
|
if (sortedTeams.length % 2 !== 0) {
|
||||||
|
sortedTeams.push({
|
||||||
|
teamId: '00000000-0000-0000-0000-000000000000',
|
||||||
|
seed: sortedTeams.length + 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < nextPowerOfTwo; i += 2) {
|
||||||
|
pairings.push({
|
||||||
|
team1Id: sortedTeams[i].teamId,
|
||||||
|
team2Id: sortedTeams[i + 1].teamId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return pairings;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other rounds, this should not be called directly
|
||||||
|
// Use generateNextRound instead
|
||||||
|
throw new Error('Use generateNextRound for rounds after the first round');
|
||||||
|
}
|
||||||
|
}
|
472
src/lib/server/tournament/swiss.ts
Normal file
472
src/lib/server/tournament/swiss.ts
Normal file
|
@ -0,0 +1,472 @@
|
||||||
|
import { db } from '../db';
|
||||||
|
import { brackets } from '../db/schema/brackets';
|
||||||
|
import { rounds } from '../db/schema/rounds';
|
||||||
|
import { matches, type Match } from '../db/schema/matches';
|
||||||
|
import { BracketType, MatchStatus, SchedulingMode } from '@/types';
|
||||||
|
import { BaseTournamentGenerator } from './types';
|
||||||
|
import type { RoundInsert } from '../db/schema/rounds';
|
||||||
|
import { fields } from '../db/schema/fields';
|
||||||
|
import { v7 } from 'uuid';
|
||||||
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
import { teams, type Team } from '../db/schema/teams';
|
||||||
|
|
||||||
|
export class SwissGenerator extends BaseTournamentGenerator {
|
||||||
|
getMode(): SchedulingMode {
|
||||||
|
return SchedulingMode.swiss;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a single main bracket for Swiss tournament
|
||||||
|
*/
|
||||||
|
async generateBrackets(): Promise<string[]> {
|
||||||
|
const [bracket] = await db
|
||||||
|
.insert(brackets)
|
||||||
|
.values({
|
||||||
|
name: 'Swiss Bracket',
|
||||||
|
bracketType: BracketType.MAIN,
|
||||||
|
position: 1,
|
||||||
|
isActive: true,
|
||||||
|
competition_id: this.competitionId,
|
||||||
|
scheduling_mode: SchedulingMode.swiss
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return [bracket.id];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate rounds for a Swiss tournament
|
||||||
|
* In Swiss, the number of rounds is typically log2(n), where n is the number of teams
|
||||||
|
* This ensures that a single undefeated team will emerge
|
||||||
|
*/
|
||||||
|
async generateRounds(bracketId: string): Promise<RoundInsert[]> {
|
||||||
|
// Recommended number of rounds is log2(n) rounded up
|
||||||
|
const recommendedRounds = Math.ceil(Math.log2(this.teamsCount));
|
||||||
|
const totalRounds = Math.min(recommendedRounds, this.teamsCount - 1);
|
||||||
|
const createdRounds: RoundInsert[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < totalRounds; i++) {
|
||||||
|
const roundNumber = i + 1;
|
||||||
|
const roundName = `Round ${roundNumber}`;
|
||||||
|
|
||||||
|
// Each round has floor(n/2) matches
|
||||||
|
const matchesInRound = Math.floor(this.teamsCount / 2);
|
||||||
|
|
||||||
|
const roundData: RoundInsert = {
|
||||||
|
name: roundName,
|
||||||
|
round_number: roundNumber,
|
||||||
|
nb_matches: matchesInRound,
|
||||||
|
bracket_id: bracketId
|
||||||
|
};
|
||||||
|
|
||||||
|
const [insertedRound] = await db.insert(rounds).values(roundData).returning();
|
||||||
|
createdRounds.push(insertedRound);
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdRounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate matches for a Swiss tournament
|
||||||
|
* First round is random or seeded pairings
|
||||||
|
* Subsequent rounds are handled by generateNextRound
|
||||||
|
*/
|
||||||
|
async generateMatches(roundId: string, teams: Team[]): Promise<Match[]> {
|
||||||
|
// Get the round
|
||||||
|
const [round] = await db.select().from(rounds).where(eq(rounds.id, roundId));
|
||||||
|
|
||||||
|
if (!round) {
|
||||||
|
throw new Error(`Round not found: ${roundId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!round.bracket_id) {
|
||||||
|
throw new Error(`Round ${roundId} has no bracket`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the bracket
|
||||||
|
const [bracket] = await db.select().from(brackets).where(eq(brackets.id, round.bracket_id));
|
||||||
|
|
||||||
|
if (!bracket) {
|
||||||
|
throw new Error(`Bracket not found for round: ${roundId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default field (could be enhanced to assign different fields)
|
||||||
|
const [defaultField] = await db
|
||||||
|
.select()
|
||||||
|
.from(fields)
|
||||||
|
.where(eq(fields.competition_id, this.competitionId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!defaultField) {
|
||||||
|
throw new Error('No field available for matches');
|
||||||
|
}
|
||||||
|
|
||||||
|
// For first round, generate random pairings
|
||||||
|
// In a real implementation, this could be seeded pairings
|
||||||
|
const pairings = this.generateFirstRoundPairings(teams);
|
||||||
|
|
||||||
|
// Create match objects
|
||||||
|
const matchesToCreate: Match[] = pairings.map((pairing, index) => {
|
||||||
|
const now = new Date();
|
||||||
|
const endTime = new Date(now);
|
||||||
|
endTime.setHours(endTime.getHours() + 1); // Default 1-hour match
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: v7(),
|
||||||
|
match_number: index + 1,
|
||||||
|
team1_id: pairing.team1.id,
|
||||||
|
team2_id: pairing.team2?.id || '00000000-0000-0000-0000-000000000000', // Default "bye" UUID if needed
|
||||||
|
start_date: now,
|
||||||
|
end_date: endTime,
|
||||||
|
position: index + 1,
|
||||||
|
table: index + 1, // Table assignment
|
||||||
|
round_id: roundId,
|
||||||
|
bracket_id: bracket.id,
|
||||||
|
field_id: defaultField.id,
|
||||||
|
score1: 0,
|
||||||
|
score2: 0,
|
||||||
|
status: MatchStatus.PENDING,
|
||||||
|
winner_id: null,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: null,
|
||||||
|
deleted_at: null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Insert matches and return them
|
||||||
|
const createdMatches = await db.insert(matches).values(matchesToCreate).returning();
|
||||||
|
return createdMatches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate the next round based on current standings
|
||||||
|
* This is the core of the Swiss system - pair teams with similar records
|
||||||
|
*/
|
||||||
|
async generateNextRound(previousRoundId: string, bracketId: string): Promise<RoundInsert | null> {
|
||||||
|
// Get the previous round
|
||||||
|
const [previousRound] = await db.select().from(rounds).where(eq(rounds.id, previousRoundId));
|
||||||
|
|
||||||
|
if (!previousRound) {
|
||||||
|
throw new Error(`Previous round not found: ${previousRoundId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all rounds in the bracket
|
||||||
|
const allRounds = await db
|
||||||
|
.select()
|
||||||
|
.from(rounds)
|
||||||
|
.where(eq(rounds.bracket_id, bracketId))
|
||||||
|
.orderBy(rounds.round_number);
|
||||||
|
|
||||||
|
// Check if this was the final round
|
||||||
|
if (previousRound.round_number === allRounds.length) {
|
||||||
|
return null; // Tournament is complete
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the next round
|
||||||
|
const nextRound = allRounds.find((r) => r.round_number === previousRound.round_number + 1);
|
||||||
|
|
||||||
|
if (!nextRound) {
|
||||||
|
throw new Error(`Next round not found for round ${previousRound.round_number}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate standings to generate pairings
|
||||||
|
const standings = await this.calculateStandings(bracketId);
|
||||||
|
|
||||||
|
// Generate pairings based on standings
|
||||||
|
const pairings = this.generateSwissPairings(standings);
|
||||||
|
|
||||||
|
// Default field
|
||||||
|
const [defaultField] = await db
|
||||||
|
.select()
|
||||||
|
.from(fields)
|
||||||
|
.where(eq(fields.competition_id, this.competitionId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!defaultField) {
|
||||||
|
throw new Error('No field available for matches');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create matches for the next round
|
||||||
|
const matchesToCreate: Match[] = pairings.map((pairing, index) => {
|
||||||
|
const now = new Date();
|
||||||
|
const endTime = new Date(now);
|
||||||
|
endTime.setHours(endTime.getHours() + 1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: v7(),
|
||||||
|
match_number: index + 1,
|
||||||
|
team1_id: pairing.team1Id,
|
||||||
|
team2_id: pairing.team2Id || '00000000-0000-0000-0000-000000000000',
|
||||||
|
start_date: now,
|
||||||
|
end_date: endTime,
|
||||||
|
position: index + 1,
|
||||||
|
table: index + 1,
|
||||||
|
round_id: nextRound.id,
|
||||||
|
bracket_id: bracketId,
|
||||||
|
field_id: defaultField.id,
|
||||||
|
score1: 0,
|
||||||
|
score2: 0,
|
||||||
|
status: MatchStatus.PENDING,
|
||||||
|
winner_id: null,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: null,
|
||||||
|
deleted_at: null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Insert the matches for the next round
|
||||||
|
await db.insert(matches).values(matchesToCreate);
|
||||||
|
|
||||||
|
return nextRound;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate pairings for the first round
|
||||||
|
* In Swiss, the first round can be random or seeded
|
||||||
|
*/
|
||||||
|
private generateFirstRoundPairings(teams: Team[]): { team1: Team; team2: Team | null }[] {
|
||||||
|
const pairings: { team1: Team; team2: Team | null }[] = [];
|
||||||
|
|
||||||
|
// Handle odd number of teams
|
||||||
|
if (teams.length % 2 !== 0) {
|
||||||
|
// Give the bye to a random team
|
||||||
|
const byeTeamIndex = Math.floor(Math.random() * teams.length);
|
||||||
|
const byeTeam = teams.splice(byeTeamIndex, 1)[0];
|
||||||
|
pairings.push({ team1: byeTeam, team2: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shuffle the remaining teams for random pairings
|
||||||
|
// In a real system, this could be seeded based on team rankings
|
||||||
|
this.shuffleArray(teams);
|
||||||
|
|
||||||
|
// Create pairings
|
||||||
|
for (let i = 0; i < teams.length; i += 2) {
|
||||||
|
pairings.push({
|
||||||
|
team1: teams[i],
|
||||||
|
team2: teams[i + 1]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return pairings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate current standings for all teams
|
||||||
|
* This is a key component for Swiss pairings
|
||||||
|
*/
|
||||||
|
private async calculateStandings(bracketId: string): Promise<TeamStanding[]> {
|
||||||
|
// Get all completed matches in the bracket
|
||||||
|
const completedMatches = await db
|
||||||
|
.select()
|
||||||
|
.from(matches)
|
||||||
|
.where(and(eq(matches.bracket_id, bracketId), eq(matches.status, MatchStatus.FINISHED)));
|
||||||
|
|
||||||
|
// Get all teams in the competition
|
||||||
|
const teamList = await db
|
||||||
|
.select()
|
||||||
|
.from(teams)
|
||||||
|
.where(eq(teams.competition_id, this.competitionId));
|
||||||
|
|
||||||
|
// Calculate standings
|
||||||
|
const standings: Record<string, TeamStanding> = {};
|
||||||
|
|
||||||
|
// Initialize standings for all teams
|
||||||
|
teamList.forEach((team) => {
|
||||||
|
standings[team.id] = {
|
||||||
|
teamId: team.id,
|
||||||
|
wins: 0,
|
||||||
|
losses: 0,
|
||||||
|
draws: 0,
|
||||||
|
points: 0,
|
||||||
|
opponentWinPercentage: 0, // For tiebreakers
|
||||||
|
previousOpponents: []
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update standings based on match results
|
||||||
|
completedMatches.forEach((match) => {
|
||||||
|
const team1 = standings[match.team1_id];
|
||||||
|
const team2 = standings[match.team2_id];
|
||||||
|
|
||||||
|
// Record that these teams played each other
|
||||||
|
if (team1 && team2) {
|
||||||
|
team1.previousOpponents.push(match.team2_id);
|
||||||
|
team2.previousOpponents.push(match.team1_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there's a winner, update win/loss records
|
||||||
|
if (match.winner_id) {
|
||||||
|
if (match.winner_id === match.team1_id) {
|
||||||
|
if (team1) {
|
||||||
|
team1.wins++;
|
||||||
|
team1.points += 3; // Typical scoring: 3 points for a win
|
||||||
|
}
|
||||||
|
if (team2) {
|
||||||
|
team2.losses++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (team2) {
|
||||||
|
team2.wins++;
|
||||||
|
team2.points += 3;
|
||||||
|
}
|
||||||
|
if (team1) {
|
||||||
|
team1.losses++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (match.score1 === match.score2) {
|
||||||
|
// Draw
|
||||||
|
if (team1) {
|
||||||
|
team1.draws++;
|
||||||
|
team1.points += 1; // Typical scoring: 1 point for a draw
|
||||||
|
}
|
||||||
|
if (team2) {
|
||||||
|
team2.draws++;
|
||||||
|
team2.points += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate opponent win percentage (Buchholz tiebreaker)
|
||||||
|
Object.values(standings).forEach((standing) => {
|
||||||
|
if (standing.previousOpponents.length > 0) {
|
||||||
|
const opponentWins = standing.previousOpponents.reduce((sum, opponentId) => {
|
||||||
|
return sum + (standings[opponentId]?.wins || 0);
|
||||||
|
}, 0);
|
||||||
|
const opponentMatches = standing.previousOpponents.reduce((sum, opponentId) => {
|
||||||
|
const opponent = standings[opponentId];
|
||||||
|
return sum + (opponent ? opponent.wins + opponent.losses + opponent.draws : 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
standing.opponentWinPercentage = opponentMatches > 0 ? opponentWins / opponentMatches : 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.values(standings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate Swiss pairings based on current standings
|
||||||
|
* Pairs teams with similar records while avoiding rematches
|
||||||
|
*/
|
||||||
|
private generateSwissPairings(
|
||||||
|
standings: TeamStanding[]
|
||||||
|
): { team1Id: string; team2Id: string | null }[] {
|
||||||
|
// Sort standings by points (descending), then by tiebreaker
|
||||||
|
const sortedStandings = [...standings].sort((a, b) => {
|
||||||
|
if (a.points !== b.points) {
|
||||||
|
return b.points - a.points; // Sort by points (descending)
|
||||||
|
}
|
||||||
|
return b.opponentWinPercentage - a.opponentWinPercentage; // Then by opponent win %
|
||||||
|
});
|
||||||
|
|
||||||
|
const pairings: { team1Id: string; team2Id: string | null }[] = [];
|
||||||
|
const unpairedTeams = [...sortedStandings];
|
||||||
|
|
||||||
|
// Handle odd number of teams
|
||||||
|
if (unpairedTeams.length % 2 !== 0) {
|
||||||
|
// Find the lowest-ranked team without a bye yet
|
||||||
|
// In a real implementation, you would track byes across rounds
|
||||||
|
const byeTeamIndex = unpairedTeams.length - 1;
|
||||||
|
const byeTeam = unpairedTeams.splice(byeTeamIndex, 1)[0];
|
||||||
|
pairings.push({ team1Id: byeTeam.teamId, team2Id: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group teams by points
|
||||||
|
const teamsByPoints: Record<number, TeamStanding[]> = {};
|
||||||
|
unpairedTeams.forEach((team) => {
|
||||||
|
if (!teamsByPoints[team.points]) {
|
||||||
|
teamsByPoints[team.points] = [];
|
||||||
|
}
|
||||||
|
teamsByPoints[team.points].push(team);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort point groups in descending order
|
||||||
|
const pointGroups = Object.keys(teamsByPoints)
|
||||||
|
.map(Number)
|
||||||
|
.sort((a, b) => b - a);
|
||||||
|
|
||||||
|
// Try to pair within same point group first
|
||||||
|
for (const points of pointGroups) {
|
||||||
|
const teamsInGroup = teamsByPoints[points];
|
||||||
|
|
||||||
|
// For each team in this group
|
||||||
|
while (teamsInGroup.length > 0) {
|
||||||
|
const team1 = teamsInGroup.shift()!;
|
||||||
|
let paired = false;
|
||||||
|
|
||||||
|
// Try to find a valid opponent in the same group
|
||||||
|
for (let i = 0; i < teamsInGroup.length; i++) {
|
||||||
|
const team2 = teamsInGroup[i];
|
||||||
|
|
||||||
|
// Check if these teams have already played each other
|
||||||
|
if (!team1.previousOpponents.includes(team2.teamId)) {
|
||||||
|
// Valid pairing found
|
||||||
|
teamsInGroup.splice(i, 1);
|
||||||
|
pairings.push({ team1Id: team1.teamId, team2Id: team2.teamId });
|
||||||
|
paired = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no valid pairing found in the same group
|
||||||
|
if (!paired) {
|
||||||
|
// Put this team back and we'll handle cross-group pairings later
|
||||||
|
teamsInGroup.unshift(team1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle remaining unpaired teams (cross-group pairings)
|
||||||
|
const remainingTeams = pointGroups.flatMap((points) => teamsByPoints[points]);
|
||||||
|
|
||||||
|
while (remainingTeams.length > 0) {
|
||||||
|
const team1 = remainingTeams.shift()!;
|
||||||
|
|
||||||
|
// Find the best opponent that hasn't already played against team1
|
||||||
|
let bestOpponentIndex = -1;
|
||||||
|
|
||||||
|
for (let i = 0; i < remainingTeams.length; i++) {
|
||||||
|
if (!team1.previousOpponents.includes(remainingTeams[i].teamId)) {
|
||||||
|
bestOpponentIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no valid opponent is found, this shouldn't happen with proper implementation
|
||||||
|
// In a real system, you might relax constraints or create a "forced" pairing
|
||||||
|
if (bestOpponentIndex === -1) {
|
||||||
|
bestOpponentIndex = 0; // Force a rematch as last resort
|
||||||
|
}
|
||||||
|
|
||||||
|
const team2 = remainingTeams.splice(bestOpponentIndex, 1)[0];
|
||||||
|
pairings.push({ team1Id: team1.teamId, team2Id: team2.teamId });
|
||||||
|
}
|
||||||
|
|
||||||
|
return pairings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function to shuffle an array (Fisher-Yates algorithm)
|
||||||
|
*/
|
||||||
|
private shuffleArray<T>(array: T[]): T[] {
|
||||||
|
for (let i = array.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[array[i], array[j]] = [array[j], array[i]];
|
||||||
|
}
|
||||||
|
return array;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface representing a team's standing in a Swiss tournament
|
||||||
|
*/
|
||||||
|
interface TeamStanding {
|
||||||
|
teamId: string;
|
||||||
|
wins: number;
|
||||||
|
losses: number;
|
||||||
|
draws: number;
|
||||||
|
points: number;
|
||||||
|
opponentWinPercentage: number;
|
||||||
|
previousOpponents: string[];
|
||||||
|
}
|
97
src/lib/server/tournament/types.ts
Normal file
97
src/lib/server/tournament/types.ts
Normal file
|
@ -0,0 +1,97 @@
|
||||||
|
import { BracketType, SchedulingMode } from '@/types';
|
||||||
|
import type { RoundInsert } from '../db/schema/rounds';
|
||||||
|
import type { Match } from '../db/schema/matches';
|
||||||
|
import type { Team } from '../db/schema/teams';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for generating tournament structures
|
||||||
|
*/
|
||||||
|
export interface TournamentGenerator {
|
||||||
|
/**
|
||||||
|
* Get the scheduling mode this generator implements
|
||||||
|
*/
|
||||||
|
getMode(): SchedulingMode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate the bracket structure needed for this tournament type
|
||||||
|
* Returns an array of bracket IDs that were created
|
||||||
|
*/
|
||||||
|
generateBrackets(): Promise<string[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate all rounds for a specific bracket
|
||||||
|
* @param bracketId The bracket to generate rounds for
|
||||||
|
*/
|
||||||
|
generateRounds(bracketId: string): Promise<RoundInsert[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate all matches for a specific round
|
||||||
|
* @param roundId The round to generate matches for
|
||||||
|
* @param teams Array of team IDs participating in the tournament
|
||||||
|
*/
|
||||||
|
generateMatches(roundId: string, teams: Team[]): Promise<Match[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate the next round based on previous round results
|
||||||
|
* @param previousRoundId ID of the previous round
|
||||||
|
* @param bracketId ID of the bracket
|
||||||
|
*/
|
||||||
|
generateNextRound(previousRoundId: string, bracketId: string): Promise<RoundInsert | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a team seeded in the tournament
|
||||||
|
*/
|
||||||
|
export interface SeededTeam {
|
||||||
|
teamId: string;
|
||||||
|
seed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for bracket generation
|
||||||
|
*/
|
||||||
|
export interface BracketConfig {
|
||||||
|
name: string;
|
||||||
|
bracketType: BracketType;
|
||||||
|
position: number;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for all tournament generators
|
||||||
|
*/
|
||||||
|
export abstract class BaseTournamentGenerator implements TournamentGenerator {
|
||||||
|
protected competitionId: string;
|
||||||
|
protected teamsCount: number;
|
||||||
|
|
||||||
|
constructor(competitionId: string, teamsCount: number) {
|
||||||
|
this.competitionId = competitionId;
|
||||||
|
this.teamsCount = teamsCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract getMode(): SchedulingMode;
|
||||||
|
abstract generateBrackets(): Promise<string[]>;
|
||||||
|
abstract generateRounds(bracketId: string): Promise<RoundInsert[]>;
|
||||||
|
abstract generateMatches(roundId: string, teams: Team[]): Promise<Match[]>;
|
||||||
|
abstract generateNextRound(
|
||||||
|
previousRoundId: string,
|
||||||
|
bracketId: string
|
||||||
|
): Promise<RoundInsert | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the number of rounds needed for a single elimination tournament
|
||||||
|
*/
|
||||||
|
protected calculateRoundsNeeded(): number {
|
||||||
|
return Math.ceil(Math.log2(this.teamsCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the number of matches in the first round
|
||||||
|
*/
|
||||||
|
protected calculateFirstRoundMatches(): number {
|
||||||
|
const roundsNeeded = this.calculateRoundsNeeded();
|
||||||
|
const fullBracketTeams = Math.pow(2, roundsNeeded);
|
||||||
|
const byes = fullBracketTeams - this.teamsCount;
|
||||||
|
return this.teamsCount - byes;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,9 +1,11 @@
|
||||||
import { insertRound } from '@/lib/server/db/queries/rounds';
|
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import type { RoundInsert } from '@/lib/server/db/schema/rounds';
|
import type { RoundInsert } from '@/lib/server/db/schema/rounds';
|
||||||
import { SchedulingMode } from '@/types';
|
import { SchedulingMode } from '@/types';
|
||||||
|
import { createTournamentGenerator } from '@/lib/server/tournament';
|
||||||
|
import { getBracketsByListIds } from '@/lib/server/db/queries/brackets';
|
||||||
|
import { getCompetitionWithAll } from '@/lib/server/db/queries/competitions';
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ request, params }) => {
|
export const POST: RequestHandler = async ({ request, params, locals }) => {
|
||||||
// get body from request
|
// get body from request
|
||||||
// get competition id from url
|
// get competition id from url
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
@ -17,30 +19,60 @@ export const POST: RequestHandler = async ({ request, params }) => {
|
||||||
throw new Error('Invalid competition id');
|
throw new Error('Invalid competition id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// get competition
|
||||||
|
const competition = await getCompetitionWithAll(competitionId);
|
||||||
|
|
||||||
|
if (!competition) {
|
||||||
|
throw new Error('Competition not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (competition.owner != locals.user.id) {
|
||||||
|
throw new Error('YYou are not the owner of this competition');
|
||||||
|
}
|
||||||
|
|
||||||
let schedulingMode: SchedulingMode;
|
let schedulingMode: SchedulingMode;
|
||||||
let name: string;
|
let name: string;
|
||||||
let nbMmatches: number;
|
let size: number;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
schedulingMode = SchedulingMode[body.scheduling_mode as keyof typeof SchedulingMode];
|
schedulingMode = SchedulingMode[body.scheduling_mode as keyof typeof SchedulingMode];
|
||||||
name = body.name;
|
name = body.name;
|
||||||
nbMmatches = body.nb_matches;
|
size = body.size;
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error('Invalid scheduling mode');
|
throw new Error('Invalid scheduling mode');
|
||||||
}
|
}
|
||||||
|
|
||||||
// create round
|
// generate bracket
|
||||||
const roundInsert: RoundInsert = {
|
const tournamentGenerator = createTournamentGenerator(schedulingMode, competitionId, size);
|
||||||
nb_matches: nbMmatches,
|
const bracketsIds = await tournamentGenerator.generateBrackets();
|
||||||
name: name,
|
if (bracketsIds.length === 0) {
|
||||||
competition_id: competitionId,
|
throw new Error('No brackets created');
|
||||||
scheduling_mode: schedulingMode,
|
}
|
||||||
round_number: -1
|
const brackets = await getBracketsByListIds(bracketsIds, {});
|
||||||
};
|
|
||||||
|
|
||||||
const [round] = await insertRound(competitionId, roundInsert);
|
if (brackets.length === 0) {
|
||||||
|
throw new Error('No brackets created');
|
||||||
|
}
|
||||||
|
|
||||||
return new Response(JSON.stringify(round), {
|
let rounds: RoundInsert[] = [];
|
||||||
|
|
||||||
|
const firstRoundOfBracket: RoundInsert[] = [];
|
||||||
|
|
||||||
|
for (const bracketId of bracketsIds) {
|
||||||
|
const roundInserts = await tournamentGenerator.generateRounds(bracketId);
|
||||||
|
rounds = [...rounds, ...roundInserts];
|
||||||
|
firstRoundOfBracket.push(roundInserts[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// only generate first round matches of each bracket
|
||||||
|
brackets.forEach(async (bracket, index) => {
|
||||||
|
if (!firstRoundOfBracket[index].id) {
|
||||||
|
throw new Error('First round of bracket not found');
|
||||||
|
}
|
||||||
|
await tournamentGenerator.generateMatches(firstRoundOfBracket[index].id, competition.teams);
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(brackets), {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
}
|
}
|
||||||
|
|
|
@ -103,6 +103,5 @@ async function createUser(keycloakUserId: string, username: string, email: strin
|
||||||
|
|
||||||
// add user to default role
|
// add user to default role
|
||||||
|
|
||||||
console.log(JSON.stringify(result));
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,8 +7,8 @@ import { and } from 'drizzle-orm';
|
||||||
import { teams } from '$lib/server/db/schema/teams';
|
import { teams } from '$lib/server/db/schema/teams';
|
||||||
import { superValidate } from 'sveltekit-superforms';
|
import { superValidate } from 'sveltekit-superforms';
|
||||||
import { zod } from 'sveltekit-superforms/adapters';
|
import { zod } from 'sveltekit-superforms/adapters';
|
||||||
import { formSchema } from '$lib/components/rounds/create-schema';
|
|
||||||
import { getCompetitionWithAll } from '@/lib/server/db/queries/competitions';
|
import { getCompetitionWithAll } from '@/lib/server/db/queries/competitions';
|
||||||
|
import { fields } from '@/lib/server/db/schema/fields';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||||
try {
|
try {
|
||||||
|
@ -23,8 +23,7 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
competition: competition,
|
competition: competition
|
||||||
createForm: await superValidate(zod(formSchema))
|
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return redirect(302, '/');
|
return redirect(302, '/');
|
||||||
|
@ -145,6 +144,85 @@ export const actions = {
|
||||||
throw error(403, 'You are not authorized to update this competition');
|
throw error(403, 'You are not authorized to update this competition');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
comp
|
||||||
|
};
|
||||||
|
},
|
||||||
|
addField: async (event) => {
|
||||||
|
const data = await event.request.formData();
|
||||||
|
|
||||||
|
const fieldName = data.get('field_name')?.toString();
|
||||||
|
|
||||||
|
if (!fieldName) {
|
||||||
|
throw error(400, 'Field name is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// get competition id from url
|
||||||
|
const id = event.params.id;
|
||||||
|
|
||||||
|
if (id === '') {
|
||||||
|
throw error(400, 'Invalid competition id');
|
||||||
|
}
|
||||||
|
|
||||||
|
let [competition] = await db
|
||||||
|
.select()
|
||||||
|
.from(competitions)
|
||||||
|
.leftJoin(fields, eq(competitions.id, fields.competition_id))
|
||||||
|
.where(and(eq(competitions.id, id), eq(competitions.owner, event.locals.user.id)));
|
||||||
|
|
||||||
|
if (!competition) {
|
||||||
|
throw error(404, 'Competition not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// add field to competition's fields array
|
||||||
|
await db.insert(fields).values({
|
||||||
|
competition_id: id,
|
||||||
|
name: fieldName
|
||||||
|
});
|
||||||
|
const comp = await getCompetitionWithAll(id);
|
||||||
|
|
||||||
|
if (!comp || comp.owner !== event.locals.user.id) {
|
||||||
|
throw error(403, 'You are not authorized to update this competition');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
comp
|
||||||
|
};
|
||||||
|
},
|
||||||
|
deleteField: async (event) => {
|
||||||
|
const data = await event.request.formData();
|
||||||
|
|
||||||
|
const fieldId = data.get('id')?.toString();
|
||||||
|
|
||||||
|
if (!fieldId) {
|
||||||
|
throw error(400, 'Team name is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// get competition id from url
|
||||||
|
const id = event.params.id;
|
||||||
|
|
||||||
|
if (id === '') {
|
||||||
|
throw error(400, 'Invalid competition id');
|
||||||
|
}
|
||||||
|
|
||||||
|
let [competition] = await db
|
||||||
|
.select()
|
||||||
|
.from(competitions)
|
||||||
|
.leftJoin(fields, eq(competitions.id, fields.competition_id))
|
||||||
|
.where(and(eq(competitions.id, id), eq(competitions.owner, event.locals.user.id)));
|
||||||
|
|
||||||
|
if (!competition) {
|
||||||
|
throw error(404, 'Competition not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove team to competition's teams array
|
||||||
|
await db.delete(fields).where(and(eq(fields.id, fieldId), eq(fields.competition_id, id)));
|
||||||
|
const comp = await getCompetitionWithAll(id);
|
||||||
|
|
||||||
|
if (!comp || comp.owner !== event.locals.user.id) {
|
||||||
|
throw error(403, 'You are not authorized to update this competition');
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
comp
|
comp
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import Round from '$lib/components/rounds/round.svelte';
|
|
||||||
import Badge from '$lib/components/ui/badge/badge.svelte';
|
import Badge from '$lib/components/ui/badge/badge.svelte';
|
||||||
import Button from '$lib/components/ui/button/button.svelte';
|
import Button from '$lib/components/ui/button/button.svelte';
|
||||||
import CardContent from '$lib/components/ui/card/card-content.svelte';
|
import CardContent from '$lib/components/ui/card/card-content.svelte';
|
||||||
|
@ -14,30 +13,46 @@
|
||||||
import Tabs from '$lib/components/ui/tabs/tabs.svelte';
|
import Tabs from '$lib/components/ui/tabs/tabs.svelte';
|
||||||
import Textarea from '$lib/components/ui/textarea/textarea.svelte';
|
import Textarea from '$lib/components/ui/textarea/textarea.svelte';
|
||||||
import { formatDate } from '$lib/utils';
|
import { formatDate } from '$lib/utils';
|
||||||
import CreateRound from '@/lib/components/rounds/create-round.svelte';
|
import Bracket from '@/lib/components/brackets/bracket.svelte';
|
||||||
|
import CreateBracket from '@/lib/components/brackets/create-bracket.svelte';
|
||||||
import { CalendarDays, Clock, MapPin, PlusIcon, Trash2Icon, Users } from 'lucide-svelte';
|
import { CalendarDays, Clock, MapPin, PlusIcon, Trash2Icon, Users } from 'lucide-svelte';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
import type { BreadcrumbItem } from '@/types';
|
||||||
|
import AppLayout from '@/lib/components/app-layout.svelte';
|
||||||
|
|
||||||
|
const breadcrumbs: BreadcrumbItem[] = [
|
||||||
|
{
|
||||||
|
title: 'Home',
|
||||||
|
href: '/'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: data.competition.name,
|
||||||
|
href: `/competitions/${data.competition.id}`
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
|
|
||||||
let competition = $state(data.competition);
|
let competition = $state(data.competition);
|
||||||
|
|
||||||
let tabValue = $state('rounds');
|
let tabValue = $state('general');
|
||||||
|
|
||||||
let showInputTeam = $state(false);
|
let showInputTeam = $state(false);
|
||||||
let showInputDescription = $state(false);
|
let showInputDescription = $state(false);
|
||||||
let description = $state(data.competition.description);
|
let description = $state(data.competition.description);
|
||||||
|
|
||||||
let showAddRound = $state(false);
|
let showInputField = $state(false);
|
||||||
|
|
||||||
|
let showAddBracket = $state(false);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
competition = data.competition;
|
competition = data.competition;
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (competition.rounds) {
|
if (competition.brackets) {
|
||||||
// on new one added scroll to it
|
// on new one added scroll to it
|
||||||
const round = competition.rounds[competition.rounds.length - 1];
|
const round = competition.brackets[competition.brackets.length - 1];
|
||||||
if (round) {
|
if (round) {
|
||||||
console.log(round.id);
|
console.log(round.id);
|
||||||
const roundElement = document.getElementById(`round-${round.id}`);
|
const roundElement = document.getElementById(`round-${round.id}`);
|
||||||
|
@ -60,156 +75,229 @@
|
||||||
<title>{competition.name} | Competition Details</title>
|
<title>{competition.name} | Competition Details</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="container mx-auto max-w-4xl px-4 py-8">
|
<AppLayout {breadcrumbs}>
|
||||||
<div class="mb-8 flex items-start justify-between">
|
<div class="mx-auto max-w-4xl px-4 py-8">
|
||||||
<div>
|
<div class="mb-8 flex items-start justify-between">
|
||||||
<h1 class="text-3xl font-bold">{competition.name}</h1>
|
<div class="grid grid-cols-2 grid-rows-2 gap-x-10">
|
||||||
<div class="mt-2 flex items-center gap-2">
|
<div class="flex items-center gap-10">
|
||||||
<Badge variant="outline" class="flex items-center gap-1">
|
<h1 class="text-3xl font-bold">{competition.name}</h1>
|
||||||
<CalendarDays class="h-4 w-4" />
|
<p>
|
||||||
{formatDate(new Date(competition.start_date))}
|
{#if competition.description}
|
||||||
</Badge>
|
{competition.description}
|
||||||
{#if competition.location}
|
{:else}
|
||||||
|
<span class="text-gray-500 italic">No description available</span>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Clock class="h-5 w-5 text-blue-500" />
|
||||||
|
<span>Created: {formatDate(new Date(competition.created_at))}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 flex items-center gap-2">
|
||||||
<Badge variant="outline" class="flex items-center gap-1">
|
<Badge variant="outline" class="flex items-center gap-1">
|
||||||
<MapPin class="h-4 w-4" />
|
<CalendarDays class="h-4 w-4" />
|
||||||
{competition.location}
|
{formatDate(new Date(competition.start_date))}
|
||||||
</Badge>
|
</Badge>
|
||||||
{/if}
|
{#if competition.location}
|
||||||
<Badge variant="outline" class="flex items-center gap-1">
|
<Badge variant="outline" class="flex items-center gap-1">
|
||||||
<Users class="h-4 w-4" />
|
<MapPin class="h-4 w-4" />
|
||||||
Teams: {competition.teams?.length || 0}
|
{competition.location}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{/if}
|
||||||
|
<Badge variant="outline" class="flex items-center gap-1">
|
||||||
|
<Users class="h-4 w-4" />
|
||||||
|
Teams: {competition.teams?.length || 0}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" class="flex items-center gap-1">
|
||||||
|
<Clock class="h-4 w-4" />
|
||||||
|
{formatDate(new Date(competition.start_date))}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{#if competition.updated_at}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Clock class="h-5 w-5 text-green-500" />
|
||||||
|
<span>Updated: {formatDate(new Date(competition.updated_at))}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button variant="destructive">Delete</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
|
||||||
<Button variant="destructive">Delete</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tabs bind:value={tabValue}>
|
<Tabs bind:value={tabValue}>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="general">General</TabsTrigger>
|
<TabsTrigger value="general">General</TabsTrigger>
|
||||||
<TabsTrigger value="rounds">Rounds</TabsTrigger>
|
<TabsTrigger value="brackets">Brackets</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<Button
|
<Button
|
||||||
hidden={tabValue !== 'rounds' || !competition.rounds || competition.rounds.length === 0}
|
hidden={tabValue !== 'brackets' ||
|
||||||
onclick={() => (showAddRound = !showAddRound)}>Add Round<PlusIcon /></Button
|
!competition.brackets ||
|
||||||
>
|
competition.brackets.length === 0}
|
||||||
</div>
|
onclick={() => (showAddBracket = !showAddBracket)}>Add Round<PlusIcon /></Button
|
||||||
<TabsContent value="general">
|
>
|
||||||
<div class="grid gap-6 md:grid-cols-3">
|
</div>
|
||||||
<Card class=" md:col-span-2">
|
<TabsContent value="general" class="flex w-full gap-6">
|
||||||
<CardHeader>
|
<!--- <div class="grid gap-6 md:grid-cols-3">
|
||||||
<h2 class=" text-xl font-semibold">Description</h2>
|
<Card class=" md:col-span-2">
|
||||||
</CardHeader>
|
<CardHeader>
|
||||||
<CardContent>
|
<h2 class=" text-xl font-semibold">Description</h2>
|
||||||
{#if showInputDescription}
|
</CardHeader>
|
||||||
<form
|
<CardContent>
|
||||||
method="post"
|
{#if showInputDescription}
|
||||||
action="?/updateDescription"
|
<form
|
||||||
class="flex flex-col gap-4"
|
method="post"
|
||||||
use:enhance
|
action="?/updateDescription"
|
||||||
>
|
class="flex flex-col gap-4"
|
||||||
<Textarea name="description" placeholder="Description" bind:value={description} />
|
use:enhance
|
||||||
<div class="flex gap-2">
|
>
|
||||||
<Button formaction="?/updateDescription" type="submit">Save</Button>
|
<Textarea name="description" placeholder="Description" bind:value={description} />
|
||||||
<Button onclick={() => clearAndCloseDescription()}>Cancel</Button>
|
<div class="flex gap-2">
|
||||||
</div>
|
<Button formaction="?/updateDescription" type="submit">Save</Button>
|
||||||
</form>
|
<Button onclick={() => clearAndCloseDescription()}>Cancel</Button>
|
||||||
{:else if competition.description}
|
</div>
|
||||||
<p class="text-gray-700 dark:text-gray-300">{competition.description}</p>
|
</form>
|
||||||
{:else}
|
{:else if competition.description}
|
||||||
<p class="text-gray-500 italic">No description available</p>
|
<p class="text-gray-700 dark:text-gray-300">{competition.description}</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-gray-500 italic">No description available</p>
|
||||||
|
{/if}
|
||||||
|
</CardContent>
|
||||||
|
{#if !showInputDescription}
|
||||||
|
<CardFooter class="flex gap-2">
|
||||||
|
<Button onclick={() => (showInputDescription = true)}>Edit</Button>
|
||||||
|
</CardFooter>
|
||||||
{/if}
|
{/if}
|
||||||
</CardContent>
|
</Card>
|
||||||
{#if !showInputDescription}
|
|
||||||
<CardFooter class="flex gap-2">
|
|
||||||
<Button onclick={() => (showInputDescription = true)}>Edit</Button>
|
|
||||||
</CardFooter>
|
|
||||||
{/if}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h2 class="text-xl font-semibold">Details</h2>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Clock class="h-5 w-5 text-blue-500" />
|
||||||
|
<span>Created: {formatDate(new Date(competition.created_at))}</span>
|
||||||
|
</div>
|
||||||
|
{#if competition.updated_at}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Clock class="h-5 w-5 text-green-500" />
|
||||||
|
<span>Updated: {formatDate(new Date(competition.updated_at))}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter></CardFooter>
|
||||||
|
</Card>
|
||||||
|
</div>-->
|
||||||
|
|
||||||
|
<Card class="w-full">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<h2 class="text-xl font-semibold">Details</h2>
|
<h2 class="text-xl font-semibold">Teams</h2>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div class="flex items-center gap-2">
|
{#if !competition.teams || competition.teams.length === 0}
|
||||||
<Clock class="h-5 w-5 text-blue-500" />
|
<p class="text-gray-500 italic">No teams have been added yet</p>
|
||||||
<span>Created: {formatDate(new Date(competition.created_at))}</span>
|
{:else}
|
||||||
</div>
|
<div class="flex flex-col gap-4">
|
||||||
{#if competition.updated_at}
|
{#each competition.teams as team}
|
||||||
<div class="flex items-center gap-2">
|
<div class="">
|
||||||
<Clock class="h-5 w-5 text-green-500" />
|
<form
|
||||||
<span>Updated: {formatDate(new Date(competition.updated_at))}</span>
|
method="post"
|
||||||
|
action="?/deleteTeam"
|
||||||
|
class="flex items-center justify-between gap-2"
|
||||||
|
use:enhance
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<Users class="h-5 w-5 text-indigo-500" />
|
||||||
|
<input type="hidden" name="id" value={team.id} />
|
||||||
|
<span>{team.name}</span>
|
||||||
|
</div>
|
||||||
|
<Button formaction="?/deleteTeam" variant="destructive" type="submit"
|
||||||
|
>Delete<Trash2Icon /></Button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if showInputTeam}
|
||||||
|
<form method="post" action="?/addTeam" class="mt-4 flex gap-4" use:enhance>
|
||||||
|
<Input name="team_name" placeholder="Team Name" />
|
||||||
|
<Button formaction="?/addTeam" type="submit">Save</Button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter></CardFooter>
|
<CardFooter>
|
||||||
|
<Button
|
||||||
|
disabled={showInputTeam}
|
||||||
|
onclick={() => {
|
||||||
|
showInputTeam = true;
|
||||||
|
}}>Add</Button
|
||||||
|
>
|
||||||
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
<Card class="w-full">
|
||||||
|
<CardHeader><h2 class="text-xl font-semibold">Fields</h2></CardHeader>
|
||||||
<Card class="mt-6">
|
<CardContent
|
||||||
<CardHeader>
|
>{#if !competition.fields || competition.fields.length === 0}
|
||||||
<h2 class="text-xl font-semibold">Teams</h2>
|
<p class="text-gray-500 italic">No Fields have been added yet</p>
|
||||||
</CardHeader>
|
{:else}
|
||||||
<CardContent>
|
<div class="flex flex-col gap-4">
|
||||||
{#if !competition.teams || competition.teams.length === 0}
|
{#each competition.fields as field (field.id)}
|
||||||
<p class="text-gray-500 italic">No teams have been added yet</p>
|
<div class="">
|
||||||
{:else}
|
<form
|
||||||
<div class="flex flex-col gap-4">
|
method="post"
|
||||||
{#each competition.teams as team}
|
action="?/deleteField"
|
||||||
<div class="">
|
class="flex items-center justify-between gap-2"
|
||||||
<form
|
use:enhance
|
||||||
method="post"
|
|
||||||
action="?/deleteTeam"
|
|
||||||
class="flex items-center justify-between gap-2"
|
|
||||||
use:enhance
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<Users class="h-5 w-5 text-indigo-500" />
|
|
||||||
<input type="hidden" name="id" value={team.id} />
|
|
||||||
<span>{team.name}</span>
|
|
||||||
</div>
|
|
||||||
<Button formaction="?/deleteTeam" variant="destructive" type="submit"
|
|
||||||
>Delete<Trash2Icon /></Button
|
|
||||||
>
|
>
|
||||||
</form>
|
<div class="flex items-center gap-4">
|
||||||
</div>
|
<Users class="h-5 w-5 text-indigo-500" />
|
||||||
{/each}
|
<input type="hidden" name="id" value={field.id} />
|
||||||
</div>
|
<span>{field.name}</span>
|
||||||
{/if}
|
</div>
|
||||||
{#if showInputTeam}
|
<Button formaction="?/deleteField" variant="destructive" type="submit"
|
||||||
<form method="post" action="?/addTeam" class="mt-4 flex gap-4" use:enhance>
|
>Delete<Trash2Icon /></Button
|
||||||
<Input name="team_name" placeholder="Team Name" />
|
>
|
||||||
<Button formaction="?/addTeam" type="submit">Save</Button>
|
</form>
|
||||||
</form>
|
</div>
|
||||||
{/if}
|
{/each}
|
||||||
</CardContent>
|
</div>
|
||||||
<CardFooter>
|
{/if}
|
||||||
<Button
|
{#if showInputField}
|
||||||
disabled={showInputTeam}
|
<form method="post" action="?/addField" class="mt-4 flex gap-4" use:enhance>
|
||||||
onclick={() => {
|
<Input name="field_name" placeholder="Field Name" />
|
||||||
showInputTeam = true;
|
<Button formaction="?/addField" type="submit">Save</Button>
|
||||||
}}>Add</Button
|
</form>
|
||||||
|
{/if}</CardContent
|
||||||
>
|
>
|
||||||
</CardFooter>
|
<CardFooter
|
||||||
</Card>
|
><Button
|
||||||
</TabsContent>
|
disabled={showInputField}
|
||||||
|
onclick={() => {
|
||||||
|
showInputField = true;
|
||||||
|
}}>Add</Button
|
||||||
|
></CardFooter
|
||||||
|
>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="rounds">
|
<TabsContent value="brackets">
|
||||||
{#if showAddRound || !competition.rounds || competition.rounds.length === 0}
|
{#if showAddBracket || !competition.brackets || competition.brackets.length === 0}
|
||||||
<CreateRound
|
<CreateBracket
|
||||||
bind:showAddRound
|
bind:showAddBracket
|
||||||
bind:rounds={competition.rounds}
|
bind:brackets={competition.brackets}
|
||||||
competitionId={competition.id}
|
competitionId={competition.id}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
{#each competition.rounds as round, round_number (round.id)}
|
{#each competition.brackets as bracket (bracket.id)}
|
||||||
<Round id={round.id} {round} {round_number} />
|
<Bracket id={bracket.id} {bracket} />
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
import { getCompetition } from '@/lib/server/db/queries/competitions';
|
import { getCompetition } from '@/lib/server/db/queries/competitions';
|
||||||
import { getRound } from '@/lib/server/db/queries/rounds';
|
import { getRound } from '@/lib/server/db/queries/rounds';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { getBracketWithRoundsAndMatches } from '@/lib/server/db/queries/brackets';
|
||||||
|
import { getTeamsByCompetition } from '@/lib/server/db/queries/teams';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||||
const competitionId = params.id;
|
const competitionId = params.id;
|
||||||
const roundId = params.round_id;
|
const bracketId = params.bracket_id;
|
||||||
const { user } = locals;
|
const { user } = locals;
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error('Unauthorized');
|
throw new Error('Unauthorized');
|
||||||
|
@ -12,7 +14,7 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||||
if (competitionId === '') {
|
if (competitionId === '') {
|
||||||
throw new Error('Invalid competition id');
|
throw new Error('Invalid competition id');
|
||||||
}
|
}
|
||||||
if (roundId === '') {
|
if (bracketId === '') {
|
||||||
throw new Error('Invalid round id');
|
throw new Error('Invalid round id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -23,9 +25,10 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||||
if (competition.owner !== locals.user.id) {
|
if (competition.owner !== locals.user.id) {
|
||||||
throw new Error('Unauthorized');
|
throw new Error('Unauthorized');
|
||||||
}
|
}
|
||||||
const round = await getRound(competitionId, roundId);
|
const bracket = await getBracketWithRoundsAndMatches(bracketId);
|
||||||
if (!round) {
|
if (!bracket) {
|
||||||
throw new Error('Invalid round');
|
throw new Error('Invalid round');
|
||||||
}
|
}
|
||||||
return { competition: competition, round };
|
|
||||||
|
return { competition: competition, bracket };
|
||||||
};
|
};
|
|
@ -0,0 +1,37 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { SchedulingMode, type BreadcrumbItem } from '@/types';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
import Single from '@/lib/components/brackets/types/single.svelte';
|
||||||
|
import AppLayout from '@/lib/components/app-layout.svelte';
|
||||||
|
|
||||||
|
let { data }: PageProps = $props();
|
||||||
|
|
||||||
|
const breadcrumbs: BreadcrumbItem[] = [
|
||||||
|
{
|
||||||
|
title: 'Home',
|
||||||
|
href: '/'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: data.competition.name,
|
||||||
|
href: `/competitions/${data.competition.id}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: data.bracket.name,
|
||||||
|
href: `/competitions/${data.competition.id}/bracket/${data.bracket.id}`
|
||||||
|
}
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AppLayout {breadcrumbs} fullWidth={true}>
|
||||||
|
{#if data.bracket.scheduling_mode === SchedulingMode.single}
|
||||||
|
<Single teams={data.teams} bracket={data.bracket} />
|
||||||
|
{:else if data.bracket.scheduling_mode === SchedulingMode.double}
|
||||||
|
double
|
||||||
|
{:else if data.bracket.scheduling_mode === SchedulingMode.round_robin}
|
||||||
|
round_robin
|
||||||
|
{:else if data.bracket.scheduling_mode === SchedulingMode.swiss}
|
||||||
|
swiss
|
||||||
|
{:else if data.bracket.scheduling_mode === SchedulingMode.double_round_robin}
|
||||||
|
double_round_robin
|
||||||
|
{/if}
|
||||||
|
</AppLayout>
|
|
@ -1,7 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { PageProps } from './$types';
|
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{JSON.stringify(data)}
|
|
|
@ -1,8 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Input } from '$lib/components/ui/input';
|
|
||||||
import { Label } from '$lib/components/ui/label';
|
|
||||||
import { Textarea } from '$lib/components/ui/textarea';
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
@ -10,8 +7,23 @@
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle
|
CardTitle
|
||||||
} from '$lib/components/ui/card';
|
} from '$lib/components/ui/card';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { CalendarIcon, MapPin, Users, Trophy } from 'lucide-svelte';
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
import { Textarea } from '$lib/components/ui/textarea';
|
||||||
|
import AppLayout from '@/lib/components/app-layout.svelte';
|
||||||
|
import type { BreadcrumbItem } from '@/types';
|
||||||
|
import { CalendarIcon, MapPin, Trophy, Users } from 'lucide-svelte';
|
||||||
|
|
||||||
|
const breadcrumbs: BreadcrumbItem[] = [
|
||||||
|
{
|
||||||
|
title: 'Home',
|
||||||
|
href: '/'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'New Competition',
|
||||||
|
href: '/competitions/new'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
let formData = $state({
|
let formData = $state({
|
||||||
name: '',
|
name: '',
|
||||||
|
@ -26,142 +38,146 @@
|
||||||
let isSubmitting = $state(false);
|
let isSubmitting = $state(false);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="from-primary/5 to-primary/10 min-h-screen bg-gradient-to-br p-4">
|
<svelte:head>
|
||||||
<div class="mx-auto max-w-2xl">
|
<title>Create Competition | FlbxCup</title>
|
||||||
<div class="mb-8 text-center">
|
</svelte:head>
|
||||||
<div
|
|
||||||
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-blue-600 text-white"
|
<AppLayout {breadcrumbs}>
|
||||||
>
|
<div class="from-primary/5 to-primary/10 min-h-screen bg-gradient-to-br p-4">
|
||||||
<Trophy class="h-8 w-8" />
|
<div class="mx-auto max-w-2xl">
|
||||||
|
<div class="mb-8 text-center">
|
||||||
|
<div
|
||||||
|
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-blue-600 text-white"
|
||||||
|
>
|
||||||
|
<Trophy class="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
<h1 class="text-3xl font-bold">Create New Competition</h1>
|
||||||
|
<p class="mt-2 text-gray-400">Set up your tournament and start competing</p>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-3xl font-bold">Create New Competition</h1>
|
|
||||||
<p class="mt-2 text-gray-400">Set up your tournament and start competing</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card class="shadow-lg">
|
<Card class="shadow-lg">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle class="flex items-center gap-2">
|
<CardTitle class="flex items-center gap-2">
|
||||||
<Trophy class="h-5 w-5" />
|
<Trophy class="h-5 w-5" />
|
||||||
Competition Details
|
Competition Details
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Fill in the information below to create your competition</CardDescription>
|
<CardDescription>Fill in the information below to create your competition</CardDescription
|
||||||
</CardHeader>
|
>
|
||||||
<CardContent>
|
</CardHeader>
|
||||||
<form method="POST" class="space-y-6">
|
<CardContent>
|
||||||
<div class="grid gap-6 md:grid-cols-2">
|
<form method="POST" class="space-y-6">
|
||||||
<!-- Competition Name -->
|
<div class="grid gap-6 md:grid-cols-2">
|
||||||
<div class="space-y-2 md:col-span-2">
|
<!-- Competition Name -->
|
||||||
<Label for="name">Competition Name *</Label>
|
<div class="space-y-2 md:col-span-2">
|
||||||
<Input
|
<Label for="name">Competition Name *</Label>
|
||||||
id="name"
|
<Input
|
||||||
name="name"
|
id="name"
|
||||||
bind:value={formData.name}
|
name="name"
|
||||||
placeholder="Enter competition name"
|
bind:value={formData.name}
|
||||||
class={errors.name ? 'border-red-500' : ''}
|
placeholder="Enter competition name"
|
||||||
|
class={errors.name ? 'border-red-500' : ''}
|
||||||
|
/>
|
||||||
|
{#if errors.name}
|
||||||
|
<p class="text-sm text-red-500">{errors.name}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Start Date -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="start_date">Start Date *</Label>
|
||||||
|
<div class="relative">
|
||||||
|
<Input
|
||||||
|
name="start_date"
|
||||||
|
id="start_date"
|
||||||
|
type="date"
|
||||||
|
bind:value={formData.start_date}
|
||||||
|
class={errors.start_date ? 'border-red-500' : ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{#if errors.start_date}
|
||||||
|
<p class="text-sm text-red-500">{errors.start_date}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Location -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="location">Location</Label>
|
||||||
|
<div class="relative">
|
||||||
|
<Input
|
||||||
|
name="location"
|
||||||
|
id="location"
|
||||||
|
bind:value={formData.location}
|
||||||
|
placeholder="Competition venue"
|
||||||
|
/>
|
||||||
|
<MapPin class="absolute top-1/2 right-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Max Number of Teams -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="max_teams">Maximum Teams *</Label>
|
||||||
|
<div class="relative">
|
||||||
|
<Input
|
||||||
|
id="max_teams"
|
||||||
|
name="max_teams"
|
||||||
|
type="number"
|
||||||
|
min="2"
|
||||||
|
bind:value={formData.max_number_teams}
|
||||||
|
placeholder="e.g., 16"
|
||||||
|
class={errors.max_number_teams ? 'border-red-500' : ''}
|
||||||
|
/>
|
||||||
|
<Users class="absolute top-1/2 right-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
{#if errors.max_number_teams}
|
||||||
|
<p class="text-sm text-red-500">{errors.max_number_teams}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="description">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
name="description"
|
||||||
|
id="description"
|
||||||
|
bind:value={formData.description}
|
||||||
|
placeholder="Describe your competition, rules, prizes, etc."
|
||||||
/>
|
/>
|
||||||
{#if errors.name}
|
|
||||||
<p class="text-sm text-red-500">{errors.name}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Start Date -->
|
<!-- Submit Button -->
|
||||||
<div class="space-y-2">
|
<div class="flex justify-end space-x-4">
|
||||||
<Label for="start_date">Start Date *</Label>
|
<Button type="button" variant="outline">Cancel</Button>
|
||||||
<div class="relative">
|
<Button type="submit" class="min-w-32">Create Competition</Button>
|
||||||
<Input
|
|
||||||
name="start_date"
|
|
||||||
id="start_date"
|
|
||||||
type="datetime-local"
|
|
||||||
bind:value={formData.start_date}
|
|
||||||
class={errors.start_date ? 'border-red-500' : ''}
|
|
||||||
/>
|
|
||||||
<CalendarIcon
|
|
||||||
class="absolute top-1/2 right-3 h-4 w-4 -translate-y-1/2 text-gray-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{#if errors.start_date}
|
|
||||||
<p class="text-sm text-red-500">{errors.start_date}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
<!-- Location -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="location">Location</Label>
|
|
||||||
<div class="relative">
|
|
||||||
<Input
|
|
||||||
name="location"
|
|
||||||
id="location"
|
|
||||||
bind:value={formData.location}
|
|
||||||
placeholder="Competition venue"
|
|
||||||
/>
|
|
||||||
<MapPin class="absolute top-1/2 right-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Max Number of Teams -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="max_teams">Maximum Teams *</Label>
|
|
||||||
<div class="relative">
|
|
||||||
<Input
|
|
||||||
id="max_teams"
|
|
||||||
name="max_teams"
|
|
||||||
type="number"
|
|
||||||
min="2"
|
|
||||||
bind:value={formData.max_number_teams}
|
|
||||||
placeholder="e.g., 16"
|
|
||||||
class={errors.max_number_teams ? 'border-red-500' : ''}
|
|
||||||
/>
|
|
||||||
<Users class="absolute top-1/2 right-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
|
||||||
</div>
|
|
||||||
{#if errors.max_number_teams}
|
|
||||||
<p class="text-sm text-red-500">{errors.max_number_teams}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Description -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="description">Description</Label>
|
|
||||||
<Textarea
|
|
||||||
name="description"
|
|
||||||
id="description"
|
|
||||||
bind:value={formData.description}
|
|
||||||
placeholder="Describe your competition, rules, prizes, etc."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Submit Button -->
|
|
||||||
<div class="flex justify-end space-x-4">
|
|
||||||
<Button type="button" variant="outline">Cancel</Button>
|
|
||||||
<Button type="submit" class="min-w-32">Create Competition</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Info Cards -->
|
|
||||||
<div class="mt-8 grid gap-4 md:grid-cols-3">
|
|
||||||
<Card class="text-center">
|
|
||||||
<CardContent class="pt-6">
|
|
||||||
<Trophy class="mx-auto mb-2 h-8 w-8 text-blue-600" />
|
|
||||||
<h3 class="font-semibold">Professional Setup</h3>
|
|
||||||
<p class="text-sm text-gray-600">Create tournaments with multiple scheduling modes</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card class="text-center">
|
|
||||||
<CardContent class="pt-6">
|
|
||||||
<Users class="mx-auto mb-2 h-8 w-8 text-green-600" />
|
|
||||||
<h3 class="font-semibold">Team Management</h3>
|
|
||||||
<p class="text-sm text-gray-600">Set team limits and manage participants</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card class="text-center">
|
|
||||||
<CardContent class="pt-6">
|
|
||||||
<CalendarIcon class="mx-auto mb-2 h-8 w-8 text-purple-600" />
|
|
||||||
<h3 class="font-semibold">Schedule Control</h3>
|
|
||||||
<p class="text-sm text-gray-600">Plan your competition timeline</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<!-- Info Cards -->
|
||||||
|
<div class="mt-8 grid gap-4 md:grid-cols-3">
|
||||||
|
<Card class="text-center">
|
||||||
|
<CardContent class="pt-6">
|
||||||
|
<Trophy class="mx-auto mb-2 h-8 w-8 text-blue-600" />
|
||||||
|
<h3 class="font-semibold">Professional Setup</h3>
|
||||||
|
<p class="text-sm text-gray-600">Create tournaments with multiple scheduling modes</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card class="text-center">
|
||||||
|
<CardContent class="pt-6">
|
||||||
|
<Users class="mx-auto mb-2 h-8 w-8 text-green-600" />
|
||||||
|
<h3 class="font-semibold">Team Management</h3>
|
||||||
|
<p class="text-sm text-gray-600">Set team limits and manage participants</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card class="text-center">
|
||||||
|
<CardContent class="pt-6">
|
||||||
|
<CalendarIcon class="mx-auto mb-2 h-8 w-8 text-purple-600" />
|
||||||
|
<h3 class="font-semibold">Schedule Control</h3>
|
||||||
|
<p class="text-sm text-gray-600">Plan your competition timeline</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</AppLayout>
|
||||||
|
|
15
src/types.ts
15
src/types.ts
|
@ -21,9 +21,18 @@ export enum SchedulingMode {
|
||||||
double_round_robin = 'double_round_robin'
|
double_round_robin = 'double_round_robin'
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum Bracket {
|
export enum BracketType {
|
||||||
winners = 'winner',
|
WINNER = 'WINNER',
|
||||||
losers = 'loser'
|
LOSER = 'LOSER',
|
||||||
|
CONSOLATION = 'CONSOLATION',
|
||||||
|
MAIN = 'MAIN'
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum MatchStatus {
|
||||||
|
PENDING = 'pending',
|
||||||
|
RUNNING = 'running',
|
||||||
|
FINISHED = 'finished',
|
||||||
|
CANCELLED = 'cancelled'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function enumToPgEnum<T extends Record<string, any>>(
|
export function enumToPgEnum<T extends Record<string, any>>(
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue