Compare commits

...

No commits in common. "main" and "gh-pages" have entirely different histories.

44 changed files with 7 additions and 39348 deletions

View file

@ -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

File diff suppressed because one or more lines are too long

3
assets/index-CbWMZWYQ.js Normal file

File diff suppressed because one or more lines are too long

1
assets/index-CiTuSxU_.js Normal file
View 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};

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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"

View file

@ -1,3 +0,0 @@
fn main() {
tauri_build::build()
}

View file

@ -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"
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View file

@ -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");
}

View file

@ -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()
}

View file

@ -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"
]
}
}

View file

@ -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;
}
}

View file

@ -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>
</>
)
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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;

File diff suppressed because it is too large Load diff

View file

@ -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
);

View file

@ -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,
};
}

View file

@ -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,
};
}

View file

@ -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
View file

@ -1 +0,0 @@
/// <reference types="vite/client" />

View file

@ -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" }]
}

View file

@ -1,10 +0,0 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View file

@ -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/**"],
},
},
}));