feat: implement like button

closes issue #18
master
Katja Lutz 2 years ago
parent 153a86ab59
commit 6c801637f6

1
.gitignore vendored

@ -5,6 +5,7 @@ dist
.vercel
.netlify
netlify
/data.json
# dependencies
/node_modules

@ -1,3 +1,5 @@
# Storage
/data.json
# IDEs and editors
/.idea

75
package-lock.json generated

@ -44,6 +44,7 @@
"remark-rehype": "^10.1.0",
"shareon": "^2.0.0",
"solid-app-router": "^0.4.1",
"solid-confetti-explosion": "^1.0.3",
"solid-js": "^1.4.8",
"solid-meta": "^0.27.5",
"sortablejs": "^1.15.0",
@ -2926,6 +2927,13 @@
"node": ">=4"
}
},
"node_modules/csstype": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.0.tgz",
"integrity": "sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA==",
"dev": true,
"peer": true
},
"node_modules/daisyui": {
"version": "2.20.0",
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-2.20.0.tgz",
@ -4073,6 +4081,16 @@
"node": ">=4"
}
},
"node_modules/goober": {
"version": "2.1.10",
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.10.tgz",
"integrity": "sha512-7PpuQMH10jaTWm33sQgBQvz45pHR8N4l3Cu3WMGEWmHShAcTuuP7I+5/DwKo39fwti5A80WAjvqgz6SSlgWmGA==",
"dev": true,
"peer": true,
"peerDependencies": {
"csstype": "^3.0.10"
}
},
"node_modules/graceful-fs": {
"version": "4.2.10",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz",
@ -6932,6 +6950,16 @@
"solid-js": "^1.3.5"
}
},
"node_modules/solid-confetti-explosion": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/solid-confetti-explosion/-/solid-confetti-explosion-1.0.3.tgz",
"integrity": "sha512-rAxyxcyhkb7ZCO9fU6YAbH65YH/4+4sCAyaGasd4WzX7XZEpV4bO+VYGYNDV78e/RDwB/8b+PWH25fqwI/igHw==",
"dev": true,
"peerDependencies": {
"solid-js": "^1.4.5",
"solid-styled-components": "^0.28.4"
}
},
"node_modules/solid-js": {
"version": "1.4.8",
"resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.4.8.tgz",
@ -7020,6 +7048,20 @@
"vite": "*"
}
},
"node_modules/solid-styled-components": {
"version": "0.28.4",
"resolved": "https://registry.npmjs.org/solid-styled-components/-/solid-styled-components-0.28.4.tgz",
"integrity": "sha512-SGbXv5tLIs1qErr3x7M+HWE4lu+37C4myV8gbce7WnZumjBmM5sifKv/NulVeSf3nMRa3uwkAM14q7QmLGC2gQ==",
"dev": true,
"peer": true,
"dependencies": {
"csstype": "^3.1.0",
"goober": "^2.1.10"
},
"peerDependencies": {
"solid-js": "^1.4.4"
}
},
"node_modules/sortablejs": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.0.tgz",
@ -10254,6 +10296,13 @@
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
"dev": true
},
"csstype": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.0.tgz",
"integrity": "sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA==",
"dev": true,
"peer": true
},
"daisyui": {
"version": "2.20.0",
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-2.20.0.tgz",
@ -11013,6 +11062,14 @@
"resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
"integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="
},
"goober": {
"version": "2.1.10",
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.10.tgz",
"integrity": "sha512-7PpuQMH10jaTWm33sQgBQvz45pHR8N4l3Cu3WMGEWmHShAcTuuP7I+5/DwKo39fwti5A80WAjvqgz6SSlgWmGA==",
"dev": true,
"peer": true,
"requires": {}
},
"graceful-fs": {
"version": "4.2.10",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz",
@ -12975,6 +13032,13 @@
"integrity": "sha512-RKHyFQ+J5lXyE/SoyJVHgTBeBck2etYVJn1/9F7ehlzyD2pIOMqLpNXD1GfWQljHqNdXZBSyE+xB/Cck5l9Q/g==",
"requires": {}
},
"solid-confetti-explosion": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/solid-confetti-explosion/-/solid-confetti-explosion-1.0.3.tgz",
"integrity": "sha512-rAxyxcyhkb7ZCO9fU6YAbH65YH/4+4sCAyaGasd4WzX7XZEpV4bO+VYGYNDV78e/RDwB/8b+PWH25fqwI/igHw==",
"dev": true,
"requires": {}
},
"solid-js": {
"version": "1.4.8",
"resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.4.8.tgz",
@ -13041,6 +13105,17 @@
"sirv": "^1.0.12"
}
},
"solid-styled-components": {
"version": "0.28.4",
"resolved": "https://registry.npmjs.org/solid-styled-components/-/solid-styled-components-0.28.4.tgz",
"integrity": "sha512-SGbXv5tLIs1qErr3x7M+HWE4lu+37C4myV8gbce7WnZumjBmM5sifKv/NulVeSf3nMRa3uwkAM14q7QmLGC2gQ==",
"dev": true,
"peer": true,
"requires": {
"csstype": "^3.1.0",
"goober": "^2.1.10"
}
},
"sortablejs": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.0.tgz",

@ -46,6 +46,7 @@
"remark-rehype": "^10.1.0",
"shareon": "^2.0.0",
"solid-app-router": "^0.4.1",
"solid-confetti-explosion": "^1.0.3",
"solid-js": "^1.4.8",
"solid-meta": "^0.27.5",
"sortablejs": "^1.15.0",

@ -20,7 +20,7 @@ const Modal: FlowComponent<{ open: boolean }> = (props) => {
"!visible": isVisible(),
}}
>
<div class="modal-box relative py-16 w-11/12 lg:w-2/3 xxl:w-1/2 lg:p-16 max-w-none overflow-hidden">
<div class="modal-box relative py-16 w-11/12 lg:w-3/4 xl:w-2/3 xxl:w-1/2 lg:p-16 max-w-none overflow-hidden">
{props.children}
</div>
</div>

@ -10,13 +10,20 @@ import {
splitProps,
createSignal,
Show,
For,
untrack,
createResource,
} from "solid-js";
import { createStore } from "solid-js/store";
import LufraiLogo from "~icons/custom/lufrai-logo";
import AppIcon from "~icons/custom/icon";
import ExternalLinkIcon from "~icons/carbon/launch";
import WarningIcon from "~icons/carbon/warning-alt-filled";
import LaunchIcon from "~icons/carbon/edit";
import DonationIcon from "~icons/carbon/favorite-filled";
import DonationIcon from "~icons/bxs/donate-heart";
import FavoriteIcon from "~icons/carbon/favorite";
import FavoriteHalfIcon from "~icons/carbon/favorite-half";
import FavoriteFilledIcon from "~icons/carbon/favorite-filled";
import WatermarkIcon from "~icons/carbon/bullhorn";
import FreedomIcon from "~icons/noto/butterfly";
import FreeIcon from "~icons/noto/seedling";
@ -38,14 +45,18 @@ import {
getHost,
onClickFocus,
} from "~/util";
import { capitalize, shuffle } from "froebel";
import { capitalize, clamp, shuffle } from "froebel";
import { markdownHelpUrl } from "./Markdown";
import AgileCalculator from "./AgileCalculator";
import server from "solid-start/server";
import { getLikes, increaseLikes } from "~/server/likes";
export const description =
"Räppli ist eine freie Web App zur Erstellung von Schweizerischen Rechnungen inklusive QR-Code. Erfasse deine Rechnungspositionen und erhalte unmittelbar eine druckbare Rechnung.";
const externalLinkClass = "inline-flex items-center gap-1";
import ConfettiExplosion from "solid-confetti-explosion";
// TODO: after solid-start update >= alpha.95, switch to lazy
// const ConfettiExplosion = lazy(() => import("solid-confetti-explosion"));
const ExternalLink: FlowComponent<
JSX.AnchorHTMLAttributes<HTMLAnchorElement>
@ -68,6 +79,8 @@ const WelcomeModal: Component = (props) => {
return localStateMounted() && localState.showWelcome;
});
const [showTrailer, setShowTrailer] = createSignal(false);
const increaseCount_ = server(increaseLikes);
const [likes] = createResource(server(getLikes));
onMount(function () {
let adjectives = [
@ -168,6 +181,29 @@ const WelcomeModal: Component = (props) => {
"Rahel Lutz",
]);
thankYouRahelAndFredi.splice(1, 0, " und ");
const [likeCount, setLikeCount] = createSignal(0);
const [confetti, setConfetti] = createStore([false, false, false]);
const confettiDuration = 2000;
createEffect(function () {
if (likeCount() == 0) {
return;
}
untrack(function () {
for (const idx of confetti.keys()) {
if (confetti[idx]) {
continue;
}
setConfetti(idx, true);
setTimeout(
() => setConfetti(idx, false),
(confettiDuration / 100) * 80
);
break;
}
});
});
return (
<Modal open={isOpen()}>
@ -953,10 +989,10 @@ const WelcomeModal: Component = (props) => {
</section>
</div>
</div>
<div class="mt-8 xl:mt-14 relative flex justify-center items-center gap-10">
<div class="mt-8 xl:mt-14 relative flex justify-center items-center gap-2 sm:gap-5 xl:gap-10">
<a
href="#welcome-patron"
class="hidden lg:flex btn btn-sm btn-secondary bg-purple-600 border-purple-600 gap-2 transition-all shadow-lg hover:shadow-lg shadow-purple-300 hover:shadow-purple-500 hover:ring-2 ring-offset-2 ring-purple-500"
class="flex btn btn-sm btn-secondary bg-purple-600 border-purple-600 gap-2 transition-all shadow-lg hover:shadow-lg shadow-purple-300 hover:shadow-purple-500 hover:ring-2 ring-offset-2 ring-purple-500"
>
<DonationIcon />
Spenden
@ -982,24 +1018,66 @@ const WelcomeModal: Component = (props) => {
Für Lufrai werben
</label>
</div>
<div
class="static md:relative lg:hidden tooltip tooltip-error"
data-tip="Bitte verwende einen Laptop oder Computer mit einer
Mindestbreite von 1024 Pixeln!"
>
<div class="btn hover:!bg-white hover:!text-black/50 btn-outline flex gap-2 transition-all shadow-indigo-300 hover:shadow-red-300 hover:ring-2 ring-offset-2 ring-red-400">
<WarningIcon /> Loslegen
</div>
</div>
<button
class="btn relative border-red-600 bg-red-500 hover:bg-red-600 hover:border-pink-700 hover:ring-2 ring-offset-2 ring-pink-500 shadow-red-300 shadow-lg lg:shadow-md lg:shadow-red-300 hover:shadow-red-400 tooltip tooltip-error stroke-1 stroke-white"
onClick={() => {
setLikeCount(likeCount() + 1);
increaseCount_();
}}
data-tip={(likes() || 0) + likeCount()}
>
<div
class="flex justify-center transition-transform"
style={{
transform: `scale(${clamp(0.9, 0.9 + likeCount() / 12, 1.3)})`,
}}
>
<Show when={likeCount() === 0}>
<FavoriteIcon />
</Show>
<Show when={likeCount() === 1}>
<FavoriteHalfIcon />
</Show>
<Show when={likeCount() > 1}>
<FavoriteFilledIcon />
</Show>
</div>
<span aria-disabled="true" class="text-xs text-orange-200 font-black">
{Intl.NumberFormat("en-US", {
notation: "compact",
maximumFractionDigits: 1,
}).format((likes() || 0) + likeCount())}
</span>
<For each={confetti}>
{(v) => (
<Show when={v}>
<ConfettiExplosion
stageHeight={1500}
duration={confettiDuration}
force={1}
shouldDestroyAfterDone={true}
/>
</Show>
)}
</For>
</button>
<button
onClick={() => setLocalState("showWelcome", false)}
class="hidden lg:flex btn btn-xl btn-primary gap-2 transition-all shadow-lg hover:shadow-lg shadow-indigo-300 hover:shadow-indigo-500 hover:ring-2 ring-offset-2 ring-indigo-500"
class="hidden lg:flex btn btn-lg btn-primary gap-2 transition-all shadow-lg shadow-indigo-300 hover:shadow-indigo-500 hover:ring-2 ring-offset-2 ring-indigo-500"
>
<LaunchIcon />
Loslegen
</button>
<div
style={{ animation: !showTrailer() ? "bounce 1.5s infinite;" : "" }}
classList={{
"w-full text-sm font-bold lg:hidden bg-red-300 ring-2 ring-offset-2 ring-error text-error-content rounded p-3 shadow-2xl flex items-center justify-center gap-3":
true,
"animate-bounce": !showTrailer(),
}}
>
<WarningIcon /> Bitte verwende einen Laptop oder Computer mit einer
Mindestbreite von 1024 Pixeln!
</div>
</div>
<a
href="https://git.lufrai.com/rappli/rappli/src/branch/master/CHANGELOG.md#changelog"

@ -3,6 +3,9 @@ import {
createHandler,
renderAsync,
} from "solid-start/entry-server";
import { state } from "./server/store";
state();
export default createHandler(
renderAsync((context) => <StartServer context={context} />)

@ -0,0 +1,8 @@
import { state } from "./store";
export const increaseLikes = () => {
state().likes++;
};
export const getLikes = () => {
return state().likes;
};

@ -0,0 +1,64 @@
import { writeFileSync } from "node:fs";
import { writeFile, readFile } from "node:fs/promises";
import path from "node:path";
import { onExit } from "./util";
const store = <T extends Object>(
name: string,
init: () => T,
{ persistInterval = 60 * 1000 } = {}
) => {
let state = init();
(state as any)._storeName = name;
const filePath = path.join(process.cwd(), `${name}.json`);
const persist = (sync = false) => {
const write = sync ? writeFileSync : writeFile;
return write(filePath, JSON.stringify(state));
};
const load = async () => {
try {
const newState = await readFile(filePath, { encoding: "utf8" });
state = JSON.parse(newState);
if ((state as any)._storeName !== name) {
console.error(`File at "${filePath}" expected to be a Rappli store`);
process.exit(1);
}
} catch (err) {
if (typeof err === "object" && (err as any).code !== "ENOENT") {
console.dir(err);
process.exit(1);
}
}
};
const globals = globalThis as any;
(async () => {
await load();
const intervalKey = `_store-persist-${name}`;
if (globals[intervalKey] != null) {
clearInterval(globals[intervalKey]);
}
globals[intervalKey] = setInterval(function () {
persist();
}, persistInterval);
})();
const exitKey = `_store-exit-${name}`;
if (globals[exitKey]) {
globals[exitKey]();
}
globals[exitKey] = onExit(() => persist(true));
const getState = () => state;
return [getState] as [typeof getState];
};
export const [state] = store("data", () => ({
version: 0,
likes: 0,
}));

@ -23,6 +23,9 @@ export default defineConfig({
server: {
host: "0.0.0.0",
},
ssr: {
noExternal: ["solid-confetti-explosion"],
},
define: {
__APP_VERSION__: JSON.stringify(process.env.npm_package_version),
__BUILD_TIME__: JSON.stringify(new Date().getTime()),

Loading…
Cancel
Save