Compare commits
No commits in common. "main" and "gh-pages" have entirely different histories.
21
README.md
|
@ -1,21 +0,0 @@
|
|||
# Tauri + Solid + Typescript
|
||||
|
||||
This is a side project to reimplement the NYT game Connections, but with a feature to pin words so they are unaffected by Shuffle. I also wanted to make some UI improvements over the connections archive app I have been using on my phone.
|
||||
|
||||
I made this hoping to get some exposure to Tauri and SolidJS.
|
||||
|
||||
## Dev setup
|
||||
|
||||
`npm i` should install everything you need.
|
||||
`npm run` will list the possible commands:
|
||||
- `start` / `dev`: live development
|
||||
- `build`: bundle web assets to `/dist`
|
||||
- `serve`: serve web assets locally
|
||||
- `tauri`: send commands to tauri CLI ([reference](https://v2.tauri.app/reference/cli/))
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
|
||||
|
||||
## Connections data
|
||||
Data from https://github.com/Eyefyre/NYT-Connections-Answers/blob/main/connections.json
|
1
assets/index-64ysZdl6.css
Normal file
3
assets/index-CbWMZWYQ.js
Normal file
1
assets/index-CiTuSxU_.js
Normal file
|
@ -0,0 +1 @@
|
|||
function c(e,r,t,a){if(typeof r=="function"?e!==r||!0:!r.has(e))throw new TypeError("Cannot read private member from an object whose class did not declare it");return t==="m"?a:t==="a"?a.call(e):a?a.value:r.get(e)}function p(e,r,t,a,o){if(typeof r=="function"?e!==r||!0:!r.has(e))throw new TypeError("Cannot write private member to an object whose class did not declare it");return r.set(e,t),t}var s;function y(e,r=!1){return window.__TAURI_INTERNALS__.transformCallback(e,r)}async function i(e,r={},t){return window.__TAURI_INTERNALS__.invoke(e,r,t)}class h{get rid(){return c(this,s,"f")}constructor(r){s.set(this,void 0),p(this,s,r)}async close(){return i("plugin:resources|close",{rid:this.rid})}}s=new WeakMap;var d;(function(e){e.WINDOW_RESIZED="tauri://resize",e.WINDOW_MOVED="tauri://move",e.WINDOW_CLOSE_REQUESTED="tauri://close-requested",e.WINDOW_DESTROYED="tauri://destroyed",e.WINDOW_FOCUS="tauri://focus",e.WINDOW_BLUR="tauri://blur",e.WINDOW_SCALE_FACTOR_CHANGED="tauri://scale-change",e.WINDOW_THEME_CHANGED="tauri://theme-changed",e.WINDOW_CREATED="tauri://window-created",e.WEBVIEW_CREATED="tauri://webview-created",e.DRAG_ENTER="tauri://drag-enter",e.DRAG_OVER="tauri://drag-over",e.DRAG_DROP="tauri://drag-drop",e.DRAG_LEAVE="tauri://drag-leave"})(d||(d={}));async function g(e,r){await i("plugin:event|unlisten",{event:e,eventId:r})}async function u(e,r,t){var a;const o=(a=void 0)!==null&&a!==void 0?a:{kind:"Any"};return i("plugin:event|listen",{event:e,target:o,handler:y(r)}).then(l=>async()=>g(e,l))}class n extends h{constructor(r){super(r)}static async load(r,t){const a=await i("plugin:store|load",{path:r,...t});return new n(a)}static async get(r){return await i("plugin:store|get_store",{path:r}).then(t=>t?new n(t):null)}async set(r,t){await i("plugin:store|set",{rid:this.rid,key:r,value:t})}async get(r){const[t,a]=await i("plugin:store|get",{rid:this.rid,key:r});return a?t:void 0}async has(r){return await i("plugin:store|has",{rid:this.rid,key:r})}async delete(r){return await i("plugin:store|delete",{rid:this.rid,key:r})}async clear(){await i("plugin:store|clear",{rid:this.rid})}async reset(){await i("plugin:store|reset",{rid:this.rid})}async keys(){return await i("plugin:store|keys",{rid:this.rid})}async values(){return await i("plugin:store|values",{rid:this.rid})}async entries(){return await i("plugin:store|entries",{rid:this.rid})}async length(){return await i("plugin:store|length",{rid:this.rid})}async reload(){await i("plugin:store|reload",{rid:this.rid})}async save(){await i("plugin:store|save",{rid:this.rid})}async onKeyChange(r,t){return await u("store://change",a=>{a.payload.resourceId===this.rid&&a.payload.key===r&&t(a.payload.exists?a.payload.value:void 0)})}async onChange(r){return await u("store://change",t=>{t.payload.resourceId===this.rid&&r(t.payload.key,t.payload.exists?t.payload.value:void 0)})}}export{n as Store};
|
|
@ -6,12 +6,13 @@
|
|||
<meta name="theme-color" content="#000000" />
|
||||
<link rel="icon" type="image/svg+xml" href="/src/assets/logo.svg" />
|
||||
<title>Slick Connections</title>
|
||||
<script type="module" crossorigin src="/assets/index-CbWMZWYQ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-64ysZdl6.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
|
||||
<script src="/src/index.tsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
2684
package-lock.json
generated
32
package.json
|
@ -1,32 +0,0 @@
|
|||
{
|
||||
"name": "slick-connections",
|
||||
"version": "0.1.0",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"publish": "vite build && gh-pages -d dist"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@solid-primitives/storage": "^4.3.1",
|
||||
"@solidjs/router": "^0.15.3",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-store": "^2.2.0",
|
||||
"solid-icons": "^1.1.0",
|
||||
"solid-js": "^1.9.3",
|
||||
"vite-plugin-inline-css-modules": "^0.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"gh-pages": "^6.3.0",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3",
|
||||
"vite-plugin-solid": "^2.11.0"
|
||||
}
|
||||
}
|
5239
src-tauri/Cargo.lock
generated
|
@ -1,26 +0,0 @@
|
|||
[package]
|
||||
name = "slick-connections"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "slick_connections_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri-plugin-store = "2"
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
|
@ -1,18 +0,0 @@
|
|||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"store:allow-get",
|
||||
"store:allow-set",
|
||||
"store:allow-delete",
|
||||
"store:allow-keys",
|
||||
"store:allow-clear",
|
||||
"store:default"
|
||||
]
|
||||
}
|
Before Width: | Height: | Size: 3.4 KiB |
Before Width: | Height: | Size: 6.8 KiB |
Before Width: | Height: | Size: 974 B |
Before Width: | Height: | Size: 2.8 KiB |
Before Width: | Height: | Size: 3.8 KiB |
Before Width: | Height: | Size: 3.9 KiB |
Before Width: | Height: | Size: 7.6 KiB |
Before Width: | Height: | Size: 903 B |
Before Width: | Height: | Size: 8.4 KiB |
Before Width: | Height: | Size: 1.3 KiB |
Before Width: | Height: | Size: 2 KiB |
Before Width: | Height: | Size: 2.4 KiB |
Before Width: | Height: | Size: 1.5 KiB |
Before Width: | Height: | Size: 85 KiB |
Before Width: | Height: | Size: 14 KiB |
|
@ -1,15 +0,0 @@
|
|||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||
#[tauri::command]
|
||||
fn greet(name: &str) -> String {
|
||||
format!("Hello, {}! You've been greeted from Rust!", name)
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_store::Builder::new().build())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.invoke_handler(tauri::generate_handler![greet])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
// initialize store plugin:
|
||||
.plugin(tauri_plugin_store::Builder::new().build())
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
slick_connections_lib::run()
|
||||
}
|
|
@ -1,35 +0,0 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "slick-connections",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.slick-connections.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "slick-connections",
|
||||
"width": 800,
|
||||
"height": 600
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
261
src/App.css
|
@ -1,261 +0,0 @@
|
|||
*, :before, :after {
|
||||
box-sizing: inherit;
|
||||
}
|
||||
|
||||
:root {
|
||||
box-sizing: border-box;
|
||||
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: 400;
|
||||
|
||||
--background-lightness: 90%;
|
||||
--background-chroma: 0;
|
||||
--background-hue: 0;
|
||||
--foreground-lightness: 10%;
|
||||
--foreground-chroma: 0;
|
||||
--foreground-hue: 0;
|
||||
|
||||
--color-foreground: lch(var(--foreground-lightness) var(--foreground-chroma) var(--foreground-hue));
|
||||
--color-background: lch(var(--background-lightness) var(--background-chroma) var(--background-hue));
|
||||
--color-foreground-trace: lch(var(--foreground-lightness) var(--foreground-chroma) var(--foreground-hue) / 0.1);
|
||||
--color-foreground-faint: lch(var(--foreground-lightness) var(--foreground-chroma) var(--foreground-hue) / 0.25);
|
||||
--color-foreground-medium: lch(var(--foreground-lightness) var(--foreground-chroma) var(--foreground-hue) / 0.5);
|
||||
|
||||
color: var(--color-foreground);
|
||||
background-color: var(--color-background);
|
||||
|
||||
/* --group-green: lch(74.83% 54.18 117.22);
|
||||
--group-blue: lch(78.8% 23.7 270.9);
|
||||
--group-yellow: lch(89.36% 57.75 90.6);
|
||||
--group-purple: lch(61.69% 41.01 319.57); */
|
||||
--group-green: lch(74.83% 54.18 117.22);
|
||||
--group-blue: lch(75% 23.7 270.9);
|
||||
--group-yellow: lch(85% 57.75 90.6);
|
||||
--group-purple: lch(65% 41.01 319.57);
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background-lightness: 10%;
|
||||
--background-chroma: 0;
|
||||
--background-hue: 0;
|
||||
--foreground-lightness: 90%;
|
||||
--foreground-chroma: 0;
|
||||
--foreground-hue: 0;
|
||||
|
||||
--group-blue: lch(30% 23.7 270.9);
|
||||
--group-green: lch(30% 54.18 117.22);
|
||||
--group-yellow: lch(30% 57.75 90.6);
|
||||
--group-purple: lch(30% 41.01 319.57);
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
margin: 0;
|
||||
padding-top: 10vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.puzzle {
|
||||
--unit: min(0.5rem, 1vw);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(1 * var(--unit));
|
||||
font-size: calc(3 * var(--unit));
|
||||
width: calc((20 * 4 + 3) * var(--unit));
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.puzzle-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-direction: row;
|
||||
margin-bottom: 1em;
|
||||
> h2 {
|
||||
font-size: 2em;
|
||||
line-height: 1;
|
||||
flex-grow: 1;
|
||||
text-align: left;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.puzzle-header-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: calc(1 * var(--unit));
|
||||
& button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.puzzle-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
height: calc(12 * var(--unit));
|
||||
gap: calc(1 * var(--unit));
|
||||
}
|
||||
.puzzle-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
flex-basis: 0;
|
||||
flex-grow: 1;
|
||||
padding: calc(0.5 * var(--unit));
|
||||
border-radius: calc(1 * var(--unit));
|
||||
background-color: var(--color-foreground-trace);
|
||||
font-size: calc(5 * var(--unit));
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge {
|
||||
font-size: calc(3 * var(--unit));
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
filter: grayscale(100%);
|
||||
}
|
||||
.puzzle-item.is-selected {
|
||||
background-color: var(--color-foreground-medium);
|
||||
outline: calc(0.33 * var(--unit)) solid var(--color-foreground);
|
||||
}
|
||||
.puzzle-group {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: calc(1 * var(--unit));
|
||||
font-weight: 500;
|
||||
|
||||
&[data-level="0"] {
|
||||
background-color: var(--group-yellow);
|
||||
}
|
||||
|
||||
&[data-level="1"] {
|
||||
background-color: var(--group-green);
|
||||
}
|
||||
|
||||
&[data-level="2"] {
|
||||
background-color: var(--group-blue);
|
||||
}
|
||||
|
||||
&[data-level="3"] {
|
||||
background-color: var(--group-purple);
|
||||
}
|
||||
}
|
||||
.puzzle-group-name {
|
||||
font-weight: 700;
|
||||
}
|
||||
.puzzle-group-members {
|
||||
font-weight: 400;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
.puzzle-actions {
|
||||
display: flex;
|
||||
margin-top: calc(4 * var(--unit));
|
||||
gap: calc(1 * var(--unit));
|
||||
&>button {
|
||||
flex-basis: 0;
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
font-size: 0.8em;
|
||||
line-height: 1;
|
||||
margin: 0;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
button {
|
||||
appearance: none;
|
||||
overflow: visible;
|
||||
min-width: 2em;
|
||||
background: var(--color-foreground-faint);
|
||||
color: var(--color-foreground);
|
||||
font-size: inherit;
|
||||
border: none;
|
||||
padding: 0.5em;
|
||||
border-radius: calc(1 * var(--unit, 0.5em));
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.25;
|
||||
}
|
||||
button:focus-visible, button:hover {
|
||||
box-shadow: 0 0 3em -1.5em inset var(--color-foreground)
|
||||
}
|
||||
button:active {
|
||||
box-shadow: 0 0 3em -1.5em inset var(--color-foreground)
|
||||
}
|
||||
@keyframes spin {
|
||||
from { transform: scale(1.5) rotate(-10deg) }
|
||||
to { transform: scale(1.5) rotate(10deg) }
|
||||
}
|
||||
.celebration {
|
||||
z-index: -1;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
overflow: hidden;
|
||||
&:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background-image: repeating-conic-gradient(
|
||||
var(--group-yellow) 0deg 5deg,
|
||||
var(--group-green) 5deg 10deg,
|
||||
var(--group-blue) 10deg 15deg,
|
||||
var(--group-purple) 15deg 20deg
|
||||
);
|
||||
animation: spin 2s linear infinite;
|
||||
}
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2rem;
|
||||
right: 2rem;
|
||||
bottom: 2rem;
|
||||
left: 2rem;
|
||||
background-color: var(--color-background);
|
||||
box-shadow: 0 0 1rem 1rem var(--color-background);
|
||||
border-radius: 1rem;
|
||||
}
|
||||
}
|
18
src/App.tsx
|
@ -1,18 +0,0 @@
|
|||
import { type JSX } from "solid-js"
|
||||
|
||||
type AppProps = {
|
||||
children?: JSX.Element
|
||||
}
|
||||
|
||||
export default function App({ children }: AppProps) {
|
||||
return (
|
||||
<>
|
||||
<header>
|
||||
<h1>Slick Connections</h1>
|
||||
</header>
|
||||
<main>
|
||||
{children}
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -1,126 +0,0 @@
|
|||
import { For, Show, createMemo } from "solid-js";
|
||||
import useAppModel from "./useAppModel";
|
||||
import { css } from "vite-plugin-inline-css-modules";
|
||||
|
||||
const styles = css`
|
||||
.calendarWrapper {
|
||||
column-width: 14em;
|
||||
}
|
||||
.calendar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25em;
|
||||
}
|
||||
.calendarHeader {
|
||||
flex-shrink: 1;
|
||||
width: 100%;
|
||||
border-bottom: 1px solid var(--color-foreground-trace);
|
||||
}
|
||||
.entry {
|
||||
break-before: avoid-column;
|
||||
break-inside: avoid-column;
|
||||
width: 2em;
|
||||
display: inline-block;
|
||||
height: 2em;
|
||||
border-radius: 10%;
|
||||
background-color: var(--color-foreground-faint);
|
||||
&:empty {
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
.entryDate {
|
||||
color: var(--color-foreground);
|
||||
margin: 0.2em;
|
||||
font-size: 1.2em;
|
||||
font-weight: 300;
|
||||
}
|
||||
.nextPuzzle {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
font-size: 3em;
|
||||
line-height: 1;
|
||||
padding: 1em;
|
||||
color: var(--color-foreground);
|
||||
background-color: var(--group-green);
|
||||
margin-bottom: 1em;
|
||||
&:hover,
|
||||
&:focus-visible,
|
||||
&:active {
|
||||
color: var(--color-foreground);
|
||||
outline: none;
|
||||
background-color: var(--group-yellow);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const colorStrings = [
|
||||
"var(--group-purple)",
|
||||
"var(--group-blue)",
|
||||
"var(--group-green)",
|
||||
"var(--group-yellow)",
|
||||
];
|
||||
|
||||
export default function Dashboard() {
|
||||
const { connections, store } = useAppModel();
|
||||
const nextUnsolvedId = createMemo(() => {
|
||||
return connections.find((x) => (store.solutions ?? [])[x.id] === undefined)?.id;
|
||||
});
|
||||
return (
|
||||
<div>
|
||||
<Show when={nextUnsolvedId() !== undefined}>
|
||||
<a class={styles.nextPuzzle} href={`/puzzle/${nextUnsolvedId()}`}>
|
||||
Next puzzle: #{nextUnsolvedId()}
|
||||
</a>
|
||||
</Show>
|
||||
<div class={styles.calendarWrapper}>
|
||||
<div class={styles.calendar}>
|
||||
<For each={connections}>
|
||||
{(item) => {
|
||||
const isSolved = (store.solutions ?? [])[item.id] !== undefined;
|
||||
const date = new Date(item.date);
|
||||
const showHeader = item.id === 1 || date.getDate() === 1;
|
||||
return (
|
||||
<>
|
||||
{showHeader && (
|
||||
<>
|
||||
<div class={styles.calendarHeader}>
|
||||
{date.toLocaleString("default", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
})}
|
||||
</div>
|
||||
<For each={Array.from({ length: date.getDay() })}>
|
||||
{() => (
|
||||
<div class={`${styles.entry} ${styles.entryBlank}`} />
|
||||
)}
|
||||
</For>
|
||||
</>
|
||||
)}
|
||||
<a
|
||||
href={`/puzzle/${item.id}`}
|
||||
class={styles.entry}
|
||||
style={
|
||||
isSolved
|
||||
? {
|
||||
"background-color":
|
||||
colorStrings[
|
||||
store.solutions[item.id].guesses - 4
|
||||
] ?? "var(--color-foreground)",
|
||||
}
|
||||
: {}
|
||||
}
|
||||
title={`#${item.id}, ${item.date}`}
|
||||
>
|
||||
<div class={styles.entryDate}>{date.getDate()}</div>
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,40 +0,0 @@
|
|||
import { createEffect, createSignal, type Accessor } from "solid-js";
|
||||
|
||||
export default function FitText(props: { body: Accessor<string> }) {
|
||||
let textRef: SVGTextElement | undefined;
|
||||
const [viewBox, setViewbox] = createSignal<string | undefined>()
|
||||
|
||||
createEffect(async () => {
|
||||
props.body();
|
||||
await Promise.resolve()
|
||||
const bounds = textRef?.getBBox();
|
||||
if (bounds === undefined) {
|
||||
return;
|
||||
}
|
||||
setViewbox(`0 0 ${bounds.width} ${bounds.height}`)
|
||||
});
|
||||
|
||||
return (
|
||||
<svg
|
||||
style={{
|
||||
width: "100%",
|
||||
"max-height": "40%",
|
||||
}}
|
||||
viewBox={viewBox()}
|
||||
fill="currentcolor"
|
||||
>
|
||||
<text
|
||||
ref={textRef}
|
||||
x="50%"
|
||||
y="50%"
|
||||
dominant-baseline="middle"
|
||||
text-anchor="middle"
|
||||
font-size="30px"
|
||||
font-family="inherit"
|
||||
font-weight="inherit"
|
||||
>
|
||||
{props.body()}
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
148
src/Puzzle.tsx
|
@ -1,148 +0,0 @@
|
|||
import { For, Show } from "solid-js";
|
||||
import "./App.css";
|
||||
import usePuzzleModel from "./usePuzzleModel";
|
||||
import FitText from "./FitText";
|
||||
import { useNavigate, useParams } from "@solidjs/router";
|
||||
import { TbArrowLeft, TbArrowRight, TbArrowUp } from 'solid-icons/tb'
|
||||
|
||||
// TODO
|
||||
// add routing
|
||||
// make overview page with calendar list of puzzles
|
||||
// show solved / aced / busted
|
||||
// make detail page with puzzle id in path
|
||||
// add nav links
|
||||
|
||||
function Puzzle() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const id = () => parseInt(params.id);
|
||||
|
||||
const {
|
||||
connections,
|
||||
store,
|
||||
setStore,
|
||||
handleGuess,
|
||||
handlePinUnpin,
|
||||
handleShuffle,
|
||||
handleDeselect,
|
||||
getFromPuzzle,
|
||||
} = usePuzzleModel(id);
|
||||
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<main class="container">
|
||||
<div class="puzzle">
|
||||
<header class="puzzle-header">
|
||||
<h2>#{id()}</h2>
|
||||
<div class="puzzle-header-actions">
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => {
|
||||
navigate(`/puzzle/${id() - 1}`);
|
||||
}}
|
||||
disabled={id() === 1}
|
||||
>
|
||||
<TbArrowLeft />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => {
|
||||
navigate('/');
|
||||
}}
|
||||
>
|
||||
<TbArrowUp />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => {
|
||||
navigate(`/puzzle/${id() + 1}`);
|
||||
}}
|
||||
disabled={id() === connections.length - 1}
|
||||
>
|
||||
<TbArrowRight />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div>Create four groups of four words!</div>
|
||||
<For each={store.solvedGroups}>
|
||||
{({ group, level, members }) => (
|
||||
<div class="puzzle-row">
|
||||
<div class="puzzle-group" data-level={level}>
|
||||
<div class="puzzle-group-name">{group}</div>
|
||||
<div class="puzzle-group-members">{members.join(", ")}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<For each={[0, 1, 2, 3].slice(0, store.puzzle.length / 4)}>
|
||||
{(row) => (
|
||||
<div class="puzzle-row">
|
||||
{[0, 1, 2, 3].map((col) => {
|
||||
const index = 4 * row + col;
|
||||
const answer = () => getFromPuzzle(index).answer;
|
||||
return (
|
||||
<button
|
||||
classList={{
|
||||
"puzzle-item": true,
|
||||
"is-selected": store.selected.includes(index),
|
||||
}}
|
||||
style={{ "view-transition-name": `puzzle-${answer}` }}
|
||||
type="button"
|
||||
on:click={() => {
|
||||
setStore({
|
||||
selected: store.selected.includes(index)
|
||||
? store.selected.filter((x) => x !== index)
|
||||
: [...store.selected, index],
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FitText body={answer} />
|
||||
{store.pinnedCount > index && <div class="badge">🔒</div>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<div class="puzzle-actions">
|
||||
<button
|
||||
type="button"
|
||||
disabled={store.selected.length === 0}
|
||||
on:click={handlePinUnpin}
|
||||
>
|
||||
{store.selected.length > 0 &&
|
||||
store.selected.every((x) => x < store.pinnedCount)
|
||||
? "Unpin"
|
||||
: "Pin"}
|
||||
</button>
|
||||
<button type="button" on:click={handleShuffle}>
|
||||
Shuffle
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
on:click={handleDeselect}
|
||||
disabled={store.selected.length === 0}
|
||||
>
|
||||
Deselect all
|
||||
</button>
|
||||
<button
|
||||
id="submitButton"
|
||||
type="button"
|
||||
on:click={handleGuess}
|
||||
disabled={store.selected.length !== 4}
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
<Show when={store.solvedGroups.length === 4}>
|
||||
<pre on:click={() => navigator.clipboard.writeText(store.guessHistory)}>
|
||||
{store.guessHistory}
|
||||
</pre>
|
||||
</Show>
|
||||
</div>
|
||||
{store.solvedGroups.length === 4 && <div class="celebration" />}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default Puzzle;
|
|
@ -1,22 +0,0 @@
|
|||
/* @refresh reload */
|
||||
import { render } from "solid-js/web";
|
||||
import App from "./App";
|
||||
import { Route, Router } from "@solidjs/router";
|
||||
import Dashboard from "./Dashboard";
|
||||
import Puzzle from "./Puzzle";
|
||||
|
||||
render(
|
||||
() => (
|
||||
<Router root={App}>
|
||||
<Route path="/" component={Dashboard} />
|
||||
<Route
|
||||
path="/puzzle/:id"
|
||||
component={Puzzle}
|
||||
matchFilters={{
|
||||
id: /^\d+$/,
|
||||
}}
|
||||
/>
|
||||
</Router>
|
||||
),
|
||||
document.getElementById("root") as HTMLElement
|
||||
);
|
|
@ -1,45 +0,0 @@
|
|||
import { makePersisted } from "@solid-primitives/storage";
|
||||
import connections from "./assets/connections.json";
|
||||
import { createStore } from "solid-js/store";
|
||||
import { tauriStorage } from "@solid-primitives/storage/tauri";
|
||||
|
||||
type Solution = {
|
||||
id: number
|
||||
guesses: number
|
||||
}
|
||||
|
||||
type AppStore = {
|
||||
solutions: Solution[]
|
||||
};
|
||||
|
||||
export default function useAppModel() {
|
||||
const storage =
|
||||
"__TAURI_INTERNALS__" in window ? tauriStorage("AppStore") : localStorage;
|
||||
const [store, setStore] = makePersisted(
|
||||
createStore<AppStore>({
|
||||
solutions: []
|
||||
}),
|
||||
{
|
||||
name: "slick-connections",
|
||||
storage,
|
||||
}
|
||||
);
|
||||
function setSolution(id: number, guesses: number) {
|
||||
const nextSolutions = [
|
||||
...(store.solutions ?? []),
|
||||
];
|
||||
nextSolutions[id] = {
|
||||
id,
|
||||
guesses,
|
||||
}
|
||||
setStore({
|
||||
solutions: nextSolutions
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
connections,
|
||||
store,
|
||||
setSolution,
|
||||
};
|
||||
}
|
|
@ -1,163 +0,0 @@
|
|||
import { Accessor, createEffect } from "solid-js";
|
||||
import connections from "./assets/connections.json";
|
||||
import { shuffleArray } from "./utils";
|
||||
import { createStore } from "solid-js/store";
|
||||
import useAppModel from "./useAppModel";
|
||||
|
||||
type Connection = (typeof connections)[number];
|
||||
type Answer = Connection["answers"][number];
|
||||
|
||||
function fromIndex(index: number): [number, number] {
|
||||
const col = index % 4;
|
||||
const row = (index - col) / 4;
|
||||
return [row, col];
|
||||
}
|
||||
|
||||
type PuzzleStore = {
|
||||
guesses: number;
|
||||
pinnedCount: number;
|
||||
selected: number[];
|
||||
solvedGroups: Answer[];
|
||||
puzzle: number[];
|
||||
guessHistory: string;
|
||||
};
|
||||
|
||||
const emoji = ["🟨", "🟩", "🟦", "🟪"];
|
||||
|
||||
export default function usePuzzleModel(id: Accessor<number>) {
|
||||
const [store, setStore] = createStore<PuzzleStore>({
|
||||
guesses: 0,
|
||||
pinnedCount: 0,
|
||||
selected: [],
|
||||
solvedGroups: [],
|
||||
puzzle: [],
|
||||
guessHistory: "",
|
||||
});
|
||||
|
||||
const { setSolution } = useAppModel();
|
||||
|
||||
createEffect(() => {
|
||||
setStore({
|
||||
guesses: 0,
|
||||
pinnedCount: 0,
|
||||
selected: [],
|
||||
solvedGroups: [],
|
||||
puzzle: shuffleArray(Array.from({ length: 16 }, (_, i) => i)),
|
||||
guessHistory: `Connections
|
||||
Puzzle #${id()}`,
|
||||
});
|
||||
});
|
||||
|
||||
const answers = (): Answer[] => {
|
||||
return connections.find((x) => x.id === id())!.answers;
|
||||
};
|
||||
|
||||
const getFromPuzzle = (index: number) => {
|
||||
const puzzleIndex = store.puzzle[index];
|
||||
const [groupIndex, memberIndex] = fromIndex(puzzleIndex);
|
||||
const group = answers()[groupIndex];
|
||||
return {
|
||||
group: group.group,
|
||||
level: group.level,
|
||||
answer: answers()[groupIndex].members[memberIndex],
|
||||
};
|
||||
};
|
||||
|
||||
const handleGuess = () => {
|
||||
setStore("guesses", (x) => x + 1);
|
||||
const selected = store.puzzle.length === 4 ? [0, 1, 2, 3] : store.selected;
|
||||
const selectedAnswers = selected.map((x) => getFromPuzzle(x));
|
||||
const { level } = selectedAnswers[0];
|
||||
const isCorrect = selectedAnswers.every((x) => x.level === level);
|
||||
const guessHistoryLine = selectedAnswers
|
||||
.map((x) => emoji[x.level])
|
||||
.join("");
|
||||
setStore({
|
||||
guessHistory: `${store.guessHistory}
|
||||
${guessHistoryLine}`,
|
||||
});
|
||||
if (!isCorrect) {
|
||||
// TODO you got it wrong
|
||||
alert("wrong");
|
||||
return;
|
||||
}
|
||||
const selectedPinnedCount = selected.reduce(
|
||||
(acc, cur) => acc + (cur < store.pinnedCount ? 1 : 0),
|
||||
0
|
||||
);
|
||||
setStore({
|
||||
pinnedCount: store.pinnedCount - selectedPinnedCount,
|
||||
puzzle: store.puzzle.filter((x) =>
|
||||
selected.every((s) => store.puzzle[s] !== x)
|
||||
),
|
||||
selected: [],
|
||||
});
|
||||
const newSolvedGroup = answers().find((x) => x.level === level);
|
||||
if (newSolvedGroup != null) {
|
||||
setStore("solvedGroups", (x) => x.concat(newSolvedGroup));
|
||||
}
|
||||
if (store.puzzle.length === 0) {
|
||||
// completely solved!
|
||||
setSolution(id(), store.guesses);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShuffle = () => {
|
||||
const pinned = store.puzzle.slice(0, store.pinnedCount);
|
||||
const toShuffle = store.puzzle.slice(store.pinnedCount);
|
||||
setStore({
|
||||
puzzle: [...pinned, ...shuffleArray(toShuffle)],
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeselect = () => {
|
||||
setStore({
|
||||
selected: [],
|
||||
});
|
||||
};
|
||||
|
||||
const handlePinUnpin = () => {
|
||||
if (store.selected.every((x) => x < store.pinnedCount)) {
|
||||
// we are unpinning
|
||||
const puzzleStart = Array.from({ length: store.pinnedCount }, (_, i) => i)
|
||||
.filter((x) => !store.selected.includes(x))
|
||||
.map((x) => store.puzzle[x]);
|
||||
const puzzleMiddle = store.selected.map((x) => store.puzzle[x]);
|
||||
const puzzleEnd = store.puzzle.slice(store.pinnedCount);
|
||||
const newPuzzle = [...puzzleStart, ...puzzleMiddle, ...puzzleEnd];
|
||||
setStore({
|
||||
pinnedCount: store.pinnedCount - store.selected.length,
|
||||
selected: [],
|
||||
puzzle: newPuzzle,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// we are pinning
|
||||
const puzzleStart = store.puzzle.slice(0, store.pinnedCount);
|
||||
const puzzleMid = store.selected
|
||||
.filter((x) => x >= store.pinnedCount)
|
||||
.map((x) => store.puzzle[x]);
|
||||
const puzzleEnd = Array.from(
|
||||
{ length: store.puzzle.length - store.pinnedCount },
|
||||
(_, i) => i + store.pinnedCount
|
||||
)
|
||||
.filter((x) => !store.selected.includes(x))
|
||||
.map((x) => store.puzzle[x]);
|
||||
setStore({
|
||||
pinnedCount: puzzleStart.length + puzzleMid.length,
|
||||
selected: [],
|
||||
puzzle: [...puzzleStart, ...puzzleMid, ...puzzleEnd],
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
connections,
|
||||
store,
|
||||
setStore,
|
||||
handleGuess,
|
||||
handlePinUnpin,
|
||||
handleShuffle,
|
||||
handleDeselect,
|
||||
getFromPuzzle,
|
||||
};
|
||||
}
|
|
@ -1,8 +0,0 @@
|
|||
export function shuffleArray<T>(array: T[]) {
|
||||
const copy = Array.from(array)
|
||||
for (let i = copy.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[copy[i], copy[j]] = [copy[j], copy[i]];
|
||||
}
|
||||
return copy
|
||||
}
|
1
src/vite-env.d.ts
vendored
|
@ -1 +0,0 @@
|
|||
/// <reference types="vite/client" />
|
|
@ -1,26 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
|
@ -1,10 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
|
@ -1,33 +0,0 @@
|
|||
import { defineConfig } from "vite";
|
||||
import solid from "vite-plugin-solid";
|
||||
import vitePluginInlineCSSModules from 'vite-plugin-inline-css-modules'
|
||||
|
||||
// @ts-expect-error process is a nodejs global
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [solid(), vitePluginInlineCSSModules()],
|
||||
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
//
|
||||
// 1. prevent vite from obscuring rust errors
|
||||
clearScreen: false,
|
||||
// 2. tauri expects a fixed port, fail if that port is not available
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: "ws",
|
||||
host,
|
||||
port: 1421,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
// 3. tell vite to ignore watching `src-tauri`
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
}));
|