Customize profile colors (#603)
* Variables rearranged * Make patron.json empty again * Fix styles.css * Working prototype * Table legible color * UI furthened * Set opaque theme color with custom colors * Control Color Selector with buttons * Show info if can't edit colors * borzoic can also edit colors * Add migration * Can send colors to backend * Edit existing colors * Use new layering strat for footer * useMutation custom hook * Footer adjusted text color * Reset style after profile visit * Set squid color after page load * Mutate user after color selection
|
|
@ -48,9 +48,9 @@ export function TableHeader(props: BoxProps) {
|
|||
px="4"
|
||||
py="3"
|
||||
backgroundColor={CSSVariables.themeColor}
|
||||
color={CSSVariables.secondaryBgColor}
|
||||
textAlign="left"
|
||||
fontSize="xs"
|
||||
textColor="black"
|
||||
textTransform="uppercase"
|
||||
letterSpacing="wider"
|
||||
lineHeight="1rem"
|
||||
|
|
|
|||
|
|
@ -1,352 +1,7 @@
|
|||
import { Image as ChakraImage, useColorMode } from "@chakra-ui/react";
|
||||
import randomColor from "randomcolor";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface HSL {
|
||||
h: number;
|
||||
s: number;
|
||||
l: number;
|
||||
}
|
||||
|
||||
class Color {
|
||||
public r: number;
|
||||
public g: number;
|
||||
public b: number;
|
||||
constructor(r: number, g: number, b: number) {
|
||||
this.r = this.clamp(r);
|
||||
this.g = this.clamp(g);
|
||||
this.b = this.clamp(b);
|
||||
}
|
||||
|
||||
toString() {
|
||||
return `rgb(${Math.round(this.r)}, ${Math.round(this.g)}, ${Math.round(
|
||||
this.b
|
||||
)})`;
|
||||
}
|
||||
|
||||
set(r: number, g: number, b: number) {
|
||||
this.r = this.clamp(r);
|
||||
this.g = this.clamp(g);
|
||||
this.b = this.clamp(b);
|
||||
}
|
||||
|
||||
hueRotate(angle = 0) {
|
||||
angle = (angle / 180) * Math.PI;
|
||||
const sin = Math.sin(angle);
|
||||
const cos = Math.cos(angle);
|
||||
|
||||
this.multiply([
|
||||
0.213 + cos * 0.787 - sin * 0.213,
|
||||
0.715 - cos * 0.715 - sin * 0.715,
|
||||
0.072 - cos * 0.072 + sin * 0.928,
|
||||
0.213 - cos * 0.213 + sin * 0.143,
|
||||
0.715 + cos * 0.285 + sin * 0.14,
|
||||
0.072 - cos * 0.072 - sin * 0.283,
|
||||
0.213 - cos * 0.213 - sin * 0.787,
|
||||
0.715 - cos * 0.715 + sin * 0.715,
|
||||
0.072 + cos * 0.928 + sin * 0.072,
|
||||
]);
|
||||
}
|
||||
|
||||
grayscale(value = 1) {
|
||||
this.multiply([
|
||||
0.2126 + 0.7874 * (1 - value),
|
||||
0.7152 - 0.7152 * (1 - value),
|
||||
0.0722 - 0.0722 * (1 - value),
|
||||
0.2126 - 0.2126 * (1 - value),
|
||||
0.7152 + 0.2848 * (1 - value),
|
||||
0.0722 - 0.0722 * (1 - value),
|
||||
0.2126 - 0.2126 * (1 - value),
|
||||
0.7152 - 0.7152 * (1 - value),
|
||||
0.0722 + 0.9278 * (1 - value),
|
||||
]);
|
||||
}
|
||||
|
||||
sepia(value = 1) {
|
||||
this.multiply([
|
||||
0.393 + 0.607 * (1 - value),
|
||||
0.769 - 0.769 * (1 - value),
|
||||
0.189 - 0.189 * (1 - value),
|
||||
0.349 - 0.349 * (1 - value),
|
||||
0.686 + 0.314 * (1 - value),
|
||||
0.168 - 0.168 * (1 - value),
|
||||
0.272 - 0.272 * (1 - value),
|
||||
0.534 - 0.534 * (1 - value),
|
||||
0.131 + 0.869 * (1 - value),
|
||||
]);
|
||||
}
|
||||
|
||||
saturate(value = 1) {
|
||||
this.multiply([
|
||||
0.213 + 0.787 * value,
|
||||
0.715 - 0.715 * value,
|
||||
0.072 - 0.072 * value,
|
||||
0.213 - 0.213 * value,
|
||||
0.715 + 0.285 * value,
|
||||
0.072 - 0.072 * value,
|
||||
0.213 - 0.213 * value,
|
||||
0.715 - 0.715 * value,
|
||||
0.072 + 0.928 * value,
|
||||
]);
|
||||
}
|
||||
|
||||
multiply(matrix: any) {
|
||||
const newR = this.clamp(
|
||||
this.r * matrix[0] + this.g * matrix[1] + this.b * matrix[2]
|
||||
);
|
||||
const newG = this.clamp(
|
||||
this.r * matrix[3] + this.g * matrix[4] + this.b * matrix[5]
|
||||
);
|
||||
const newB = this.clamp(
|
||||
this.r * matrix[6] + this.g * matrix[7] + this.b * matrix[8]
|
||||
);
|
||||
this.r = newR;
|
||||
this.g = newG;
|
||||
this.b = newB;
|
||||
}
|
||||
|
||||
brightness(value = 1) {
|
||||
this.linear(value);
|
||||
}
|
||||
contrast(value = 1) {
|
||||
this.linear(value, -(0.5 * value) + 0.5);
|
||||
}
|
||||
|
||||
linear(slope = 1, intercept = 0) {
|
||||
this.r = this.clamp(this.r * slope + intercept * 255);
|
||||
this.g = this.clamp(this.g * slope + intercept * 255);
|
||||
this.b = this.clamp(this.b * slope + intercept * 255);
|
||||
}
|
||||
|
||||
invert(value = 1) {
|
||||
this.r = this.clamp((value + (this.r / 255) * (1 - 2 * value)) * 255);
|
||||
this.g = this.clamp((value + (this.g / 255) * (1 - 2 * value)) * 255);
|
||||
this.b = this.clamp((value + (this.b / 255) * (1 - 2 * value)) * 255);
|
||||
}
|
||||
|
||||
hsl(): HSL {
|
||||
// Code taken from https://stackoverflow.com/a/9493060/2688027, licensed under CC BY-SA.
|
||||
const r = this.r / 255;
|
||||
const g = this.g / 255;
|
||||
const b = this.b / 255;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
let l = (max + min) / 2;
|
||||
|
||||
if (max === min) {
|
||||
h = s = 0;
|
||||
} else {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case r:
|
||||
h = (g - b) / d + (g < b ? 6 : 0);
|
||||
break;
|
||||
|
||||
case g:
|
||||
h = (b - r) / d + 2;
|
||||
break;
|
||||
|
||||
case b:
|
||||
h = (r - g) / d + 4;
|
||||
break;
|
||||
}
|
||||
h /= 6;
|
||||
}
|
||||
|
||||
return {
|
||||
h: h * 100,
|
||||
s: s * 100,
|
||||
l: l * 100,
|
||||
};
|
||||
}
|
||||
|
||||
clamp(value: number): number {
|
||||
if (value > 255) {
|
||||
value = 255;
|
||||
} else if (value < 0) {
|
||||
value = 0;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
interface Solution {
|
||||
loss: number;
|
||||
values: number[];
|
||||
}
|
||||
class Solver {
|
||||
private target: Color;
|
||||
private targetHSL: HSL;
|
||||
private reusedColor: Color;
|
||||
constructor(target: Color) {
|
||||
this.target = target;
|
||||
this.targetHSL = target.hsl();
|
||||
this.reusedColor = new Color(0, 0, 0);
|
||||
}
|
||||
|
||||
solve() {
|
||||
const result = this.solveNarrow(this.solveWide());
|
||||
return {
|
||||
values: result.values,
|
||||
loss: result.loss,
|
||||
filter: this.css(result.values),
|
||||
};
|
||||
}
|
||||
|
||||
solveWide(): Solution {
|
||||
const A = 5;
|
||||
const c = 15;
|
||||
const a = [60, 180, 18000, 600, 1.2, 1.2];
|
||||
|
||||
let best = { loss: Infinity, values: [] as number[] };
|
||||
for (let i = 0; best.loss > 25 && i < 3; i++) {
|
||||
const initial = [50, 20, 3750, 50, 100, 100];
|
||||
const result = this.spsa(A, a, c, initial, 1000);
|
||||
if (result.loss < best.loss) {
|
||||
best = result;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
solveNarrow(wide: Solution) {
|
||||
const A = wide.loss;
|
||||
const c = 2;
|
||||
const A1 = A + 1;
|
||||
const a = [0.25 * A1, 0.25 * A1, A1, 0.25 * A1, 0.2 * A1, 0.2 * A1];
|
||||
return this.spsa(A, a, c, wide.values, 500);
|
||||
}
|
||||
|
||||
spsa(
|
||||
A: number,
|
||||
a: number[],
|
||||
c: number,
|
||||
values: number[],
|
||||
iters: number
|
||||
): Solution {
|
||||
const alpha = 1;
|
||||
const gamma = 0.16666666666666666;
|
||||
|
||||
let best = [] as number[];
|
||||
let bestLoss = Infinity;
|
||||
const deltas = new Array(6);
|
||||
const highArgs = new Array(6);
|
||||
const lowArgs = new Array(6);
|
||||
|
||||
for (let k = 0; k < iters; k++) {
|
||||
const ck = c / Math.pow(k + 1, gamma);
|
||||
for (let i = 0; i < 6; i++) {
|
||||
deltas[i] = Math.random() > 0.5 ? 1 : -1;
|
||||
highArgs[i] = values[i] + ck * deltas[i];
|
||||
lowArgs[i] = values[i] - ck * deltas[i];
|
||||
}
|
||||
|
||||
const lossDiff = this.loss(highArgs) - this.loss(lowArgs);
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const g = (lossDiff / (2 * ck)) * deltas[i];
|
||||
const ak = a[i] / Math.pow(A + k + 1, alpha);
|
||||
values[i] = fix(values[i] - ak * g, i);
|
||||
}
|
||||
|
||||
const loss = this.loss(values);
|
||||
if (loss < bestLoss) {
|
||||
best = values.slice(0);
|
||||
bestLoss = loss;
|
||||
}
|
||||
}
|
||||
return { values: best, loss: bestLoss };
|
||||
|
||||
function fix(value: number, idx: number): number {
|
||||
let max = 100;
|
||||
if (idx === 2 /* saturate */) {
|
||||
max = 7500;
|
||||
} else if (idx === 4 /* brightness */ || idx === 5 /* contrast */) {
|
||||
max = 200;
|
||||
}
|
||||
|
||||
if (idx === 3 /* hue-rotate */) {
|
||||
if (value > max) {
|
||||
value %= max;
|
||||
} else if (value < 0) {
|
||||
value = max + (value % max);
|
||||
}
|
||||
} else if (value < 0) {
|
||||
value = 0;
|
||||
} else if (value > max) {
|
||||
value = max;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
loss(filters: number[]) {
|
||||
// Argument is array of percentages.
|
||||
const color = this.reusedColor;
|
||||
color.set(0, 0, 0);
|
||||
|
||||
color.invert(filters[0] / 100);
|
||||
color.sepia(filters[1] / 100);
|
||||
color.saturate(filters[2] / 100);
|
||||
color.hueRotate(filters[3] * 3.6);
|
||||
color.brightness(filters[4] / 100);
|
||||
color.contrast(filters[5] / 100);
|
||||
|
||||
const colorHSL = color.hsl();
|
||||
return (
|
||||
Math.abs(color.r - this.target.r) +
|
||||
Math.abs(color.g - this.target.g) +
|
||||
Math.abs(color.b - this.target.b) +
|
||||
Math.abs(colorHSL.h - this.targetHSL.h) +
|
||||
Math.abs(colorHSL.s - this.targetHSL.s) +
|
||||
Math.abs(colorHSL.l - this.targetHSL.l)
|
||||
);
|
||||
}
|
||||
|
||||
css(filters: number[]) {
|
||||
function fmt(idx: number, multiplier = 1) {
|
||||
return Math.round(filters[idx] * multiplier);
|
||||
}
|
||||
return `invert(${fmt(0)}%) sepia(${fmt(1)}%) saturate(${fmt(
|
||||
2
|
||||
)}%) hue-rotate(${fmt(3, 3.6)}deg) brightness(${fmt(4)}%) contrast(${fmt(
|
||||
5
|
||||
)}%);`;
|
||||
}
|
||||
}
|
||||
|
||||
type RGB = [number, number, number];
|
||||
function hexToRgb(hex: string): RGB {
|
||||
// Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
|
||||
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
|
||||
hex = hex.replace(shorthandRegex, (m, r, g, b) => {
|
||||
return r + r + g + g + b + b;
|
||||
});
|
||||
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
if (result) {
|
||||
return [
|
||||
parseInt(result[1], 16),
|
||||
parseInt(result[2], 16),
|
||||
parseInt(result[3], 16),
|
||||
];
|
||||
}
|
||||
|
||||
throw new Error("Error parsing hex: " + hex);
|
||||
}
|
||||
|
||||
function getFilters(hex: string) {
|
||||
let rgb = [255, 255, 255];
|
||||
try {
|
||||
rgb = hexToRgb(hex);
|
||||
} catch (e) {}
|
||||
const color = new Color(rgb[0], rgb[1], rgb[2]);
|
||||
const solver = new Solver(color);
|
||||
const result = solver.solve();
|
||||
return result.filter;
|
||||
}
|
||||
import { getFilters } from "utils/getFilters";
|
||||
|
||||
const BeautifulDrawingOfBorzoic = ({ type }: { type: "girl" | "boy" }) => {
|
||||
const [hexCode, setHexCode] = useState(randomColor());
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ const ColorModeSwitcher = ({ isMobile }: { isMobile?: boolean }) => {
|
|||
icon={colorMode === "light" ? <FiSun /> : <FiMoon />}
|
||||
_hover={
|
||||
colorMode === "dark"
|
||||
? { bg: "white", color: "black" }
|
||||
: { bg: "black", color: "white" }
|
||||
? { bg: "white !important", color: "black" }
|
||||
: { bg: "black !important", color: "white" }
|
||||
}
|
||||
borderRadius={isMobile ? "50%" : "0"}
|
||||
size={isMobile ? "lg" : "sm"}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,30 @@
|
|||
import { Box, Image, useColorModeValue } from "@chakra-ui/react";
|
||||
import { CSSVariables } from "utils/CSSVariables";
|
||||
import { Box, Image } from "@chakra-ui/react";
|
||||
import { useRouter } from "next/dist/client/router";
|
||||
import { useLayoutEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { getFilters } from "utils/getFilters";
|
||||
import FooterContent from "./FooterContent";
|
||||
import FooterWaves from "./FooterWaves";
|
||||
|
||||
const Footer: React.FC = () => {
|
||||
const species = useRouter().asPath.charCodeAt(1) % 2 === 0 ? "squid" : "octo";
|
||||
const footerImageSrc = useColorModeValue(
|
||||
{ octo: "b8ing_light", squid: "boing_light" },
|
||||
{ octo: "b8ing_dark", squid: "boing_dark" }
|
||||
)[species];
|
||||
const [bgColor, setBgColor] = useState(() =>
|
||||
getComputedStyle(document.body).getPropertyValue("--theme-color").trim()
|
||||
);
|
||||
const imgBase =
|
||||
useRouter().asPath.charCodeAt(1) % 2 === 0 ? "boing" : "b8ing";
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const customColor = getComputedStyle(document.body)
|
||||
.getPropertyValue("--custom-theme-color")
|
||||
.trim();
|
||||
|
||||
if (customColor) setBgColor(customColor);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box as="footer" mt="auto">
|
||||
<Box as="footer" mt="auto" display="grid" gridTemplateColumns="1fr">
|
||||
<Image
|
||||
src={`/layout/${footerImageSrc}.png`}
|
||||
bg={CSSVariables.themeColor}
|
||||
src={`/layout/${imgBase}_bg.png`}
|
||||
w="80px"
|
||||
ml="auto"
|
||||
mr="35%"
|
||||
|
|
@ -24,6 +33,24 @@ const Footer: React.FC = () => {
|
|||
userSelect="none"
|
||||
loading="lazy"
|
||||
alt=""
|
||||
filter={getFilters(bgColor)}
|
||||
gridRow="1"
|
||||
gridColumn="1 / 2"
|
||||
zIndex="1"
|
||||
/>
|
||||
<Image
|
||||
src={`/layout/${imgBase}.png`}
|
||||
w="80px"
|
||||
ml="auto"
|
||||
mr="35%"
|
||||
mb="-5.1%"
|
||||
mt="5rem"
|
||||
userSelect="none"
|
||||
loading="lazy"
|
||||
alt=""
|
||||
gridRow="1"
|
||||
gridColumn="1 / 2"
|
||||
zIndex="10"
|
||||
/>
|
||||
<FooterWaves />
|
||||
<FooterContent />
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { getFullUsername } from "utils/strings";
|
|||
|
||||
const FooterContent: React.FC = () => {
|
||||
return (
|
||||
<Box bg={CSSVariables.themeColor} color="black">
|
||||
<Box bg={CSSVariables.themeColor} color={CSSVariables.bgColor}>
|
||||
<Flex
|
||||
flexDir={["column", null, "row"]}
|
||||
alignItems="center"
|
||||
|
|
@ -116,7 +116,7 @@ function ExternalLink({
|
|||
alignItems: "center",
|
||||
mx: 2,
|
||||
my: 2,
|
||||
color: "black",
|
||||
color: CSSVariables.bgColor,
|
||||
}}
|
||||
>
|
||||
<Box as={icon} display="inline" mr={1} size="20px" /> {children}
|
||||
|
|
|
|||
|
|
@ -44,8 +44,8 @@ const Header = ({ openNav }: { openNav: () => void }) => {
|
|||
icon={<FiMenu />}
|
||||
_hover={
|
||||
colorMode === "dark"
|
||||
? { bg: "white", color: "black" }
|
||||
: { bg: "black", color: "white" }
|
||||
? { bg: "white !important", color: "black" }
|
||||
: { bg: "black !important", color: "white" }
|
||||
}
|
||||
borderRadius="0"
|
||||
display={["flex", null, null, "none"]}
|
||||
|
|
@ -80,8 +80,8 @@ const Header = ({ openNav }: { openNav: () => void }) => {
|
|||
leftIcon={<FiHeart />}
|
||||
_hover={
|
||||
colorMode === "dark"
|
||||
? { bg: "white", color: "black" }
|
||||
: { bg: "black", color: "white" }
|
||||
? { bg: "white !important", color: "black" }
|
||||
: { bg: "black !important", color: "white" }
|
||||
}
|
||||
borderRadius="0"
|
||||
size="xs"
|
||||
|
|
@ -103,8 +103,8 @@ const Header = ({ openNav }: { openNav: () => void }) => {
|
|||
leftIcon={user ? <FiLogOut /> : <FiLogIn />}
|
||||
_hover={
|
||||
colorMode === "dark"
|
||||
? { bg: "white", color: "black" }
|
||||
: { bg: "black", color: "white" }
|
||||
? { bg: "white !important", color: "black" }
|
||||
: { bg: "black !important", color: "white" }
|
||||
}
|
||||
borderRadius="0"
|
||||
size="xs"
|
||||
|
|
|
|||
|
|
@ -46,8 +46,8 @@ export const LanguageSwitcher = ({ isMobile }: { isMobile?: boolean }) => {
|
|||
icon={<FiGlobe />}
|
||||
_hover={
|
||||
colorMode === "dark"
|
||||
? { bg: "white", color: "black" }
|
||||
: { bg: "black", color: "white" }
|
||||
? { bg: "white !important", color: "black" }
|
||||
: { bg: "black !important", color: "white" }
|
||||
}
|
||||
borderRadius={isMobile ? "50%" : "0"}
|
||||
size={isMobile ? "lg" : "sm"}
|
||||
|
|
|
|||
159
components/u/ProfileColorSelectors.tsx
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { Box, Button, Flex } from "@chakra-ui/react";
|
||||
import { useDebounce, useMutation } from "hooks/common";
|
||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||
import { CSSVariables } from "utils/CSSVariables";
|
||||
|
||||
const themeValues: { name: string; displayName: string }[] = [
|
||||
{
|
||||
name: "theme-color",
|
||||
displayName: "Theme",
|
||||
},
|
||||
{
|
||||
name: "theme-gray",
|
||||
displayName: "Theme Secondary",
|
||||
},
|
||||
{
|
||||
name: "bg-color",
|
||||
displayName: "Background",
|
||||
},
|
||||
{
|
||||
name: "secondary-bg-color",
|
||||
displayName: "Secondary Background",
|
||||
},
|
||||
{
|
||||
name: "text-color",
|
||||
displayName: "Text",
|
||||
},
|
||||
];
|
||||
|
||||
const ProfileColorSelectors = ({
|
||||
hide,
|
||||
mutateUser,
|
||||
previousColors,
|
||||
}: {
|
||||
hide: () => void;
|
||||
mutateUser: () => void;
|
||||
previousColors?: Record<string, string>;
|
||||
}) => {
|
||||
const [currentColors, setCurrentColors] = useState<
|
||||
Record<string, string | undefined>
|
||||
>(previousColors ?? {});
|
||||
|
||||
const debouncedCurrentColors = useDebounce(currentColors);
|
||||
|
||||
const { mutate, isMutating } = useMutation({
|
||||
data: { colors: currentColors },
|
||||
successToastMsg: "Colors updated",
|
||||
url: "/api/me/colors",
|
||||
afterSuccess: () => {
|
||||
hide();
|
||||
mutateUser();
|
||||
},
|
||||
});
|
||||
|
||||
const defaultColors = useMemo(() => {
|
||||
const bodyStyles = getComputedStyle(
|
||||
document.getElementsByTagName("body")[0]
|
||||
);
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
for (const themeValue of themeValues) {
|
||||
let value = previousColors?.[themeValue.name];
|
||||
if (!value) {
|
||||
value = bodyStyles.getPropertyValue(`--${themeValue.name}`).trim();
|
||||
}
|
||||
result[themeValue.name] = value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const body = document.getElementsByTagName("body")[0];
|
||||
|
||||
for (const [key, value] of Object.entries(debouncedCurrentColors)) {
|
||||
body.style.setProperty(`--custom-${key}`, value ?? "");
|
||||
if (key === "theme-color") {
|
||||
body.style.setProperty(`--custom-${key}-opaque`, `${value}${20}` ?? "");
|
||||
}
|
||||
}
|
||||
}, [debouncedCurrentColors]);
|
||||
|
||||
const onSubmitHandler = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmitHandler}>
|
||||
<Flex flexWrap="wrap" justify="space-evenly" mt={4}>
|
||||
{themeValues.map((value) => {
|
||||
return (
|
||||
<Flex key={value.name} flexDir="column" align="center" mb={4}>
|
||||
<Box
|
||||
as="label"
|
||||
fontSize="sm"
|
||||
mb={2}
|
||||
fontWeight="bold"
|
||||
color={CSSVariables.themeGray}
|
||||
display="block"
|
||||
htmlFor={`${value.name}-input`}
|
||||
>
|
||||
{value.displayName}
|
||||
</Box>
|
||||
<input
|
||||
id={`${value.name}-input`}
|
||||
value={
|
||||
debouncedCurrentColors[value.name] ??
|
||||
defaultColors[value.name]
|
||||
}
|
||||
type="color"
|
||||
onChange={(e) =>
|
||||
setCurrentColors((c) => ({
|
||||
...c,
|
||||
[value.name]: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
onClick={() =>
|
||||
setCurrentColors((c) => ({
|
||||
...c,
|
||||
[value.name]: undefined,
|
||||
}))
|
||||
}
|
||||
size="xs"
|
||||
colorScheme="red"
|
||||
mt={3}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
<Flex justify="center">
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
isLoading={isMutating}
|
||||
mr={2}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
ml={2}
|
||||
colorScheme="red"
|
||||
onClick={hide}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileColorSelectors;
|
||||
90
components/u/ProfileOwnersButton.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import {
|
||||
Button,
|
||||
HStack,
|
||||
IconButton,
|
||||
Popover,
|
||||
PopoverBody,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@chakra-ui/react";
|
||||
import { Trans } from "@lingui/macro";
|
||||
import MyLink from "components/common/MyLink";
|
||||
import { FiEdit, FiInfo } from "react-icons/fi";
|
||||
import { IoColorPalette } from "react-icons/io5";
|
||||
import { RiTShirtLine } from "react-icons/ri";
|
||||
import { CSSVariables } from "utils/CSSVariables";
|
||||
|
||||
const ProfileOwnersButtons = ({
|
||||
canPostBuilds,
|
||||
isColorEditorsButtonClickable,
|
||||
setShowProfileModal,
|
||||
setBuildToEdit,
|
||||
setShowColorSelectors,
|
||||
}: {
|
||||
canPostBuilds: boolean;
|
||||
isColorEditorsButtonClickable: boolean;
|
||||
setShowProfileModal: (isOpen: boolean) => void;
|
||||
setBuildToEdit: (isOpen: boolean) => void;
|
||||
setShowColorSelectors: (isOpen: boolean) => void;
|
||||
}) => {
|
||||
return (
|
||||
<HStack spacing={4}>
|
||||
<Button
|
||||
leftIcon={<FiEdit />}
|
||||
variant="outline"
|
||||
onClick={() => setShowProfileModal(true)}
|
||||
size="sm"
|
||||
>
|
||||
<Trans>Edit profile</Trans>
|
||||
</Button>
|
||||
{canPostBuilds && (
|
||||
<Button
|
||||
leftIcon={<RiTShirtLine />}
|
||||
variant="outline"
|
||||
onClick={() => setBuildToEdit(true)}
|
||||
size="sm"
|
||||
>
|
||||
<Trans>Add build</Trans>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
leftIcon={<IoColorPalette />}
|
||||
variant="outline"
|
||||
disabled={!isColorEditorsButtonClickable}
|
||||
onClick={() => setShowColorSelectors(true)}
|
||||
size="sm"
|
||||
>
|
||||
Edit profile colors
|
||||
</Button>
|
||||
{!isColorEditorsButtonClickable ? (
|
||||
<Popover placement="top" trigger="hover">
|
||||
<PopoverTrigger>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
isRound
|
||||
aria-label="Show description"
|
||||
fontSize="20px"
|
||||
icon={<FiInfo />}
|
||||
marginLeft="5px !important"
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
zIndex={4}
|
||||
width="220px"
|
||||
backgroundColor={CSSVariables.secondaryBgColor}
|
||||
>
|
||||
<PopoverBody whiteSpace="pre-wrap">
|
||||
Editing profile colors is available for{" "}
|
||||
<MyLink href="https://www.patreon.com/sendou" isExternal>
|
||||
patrons
|
||||
</MyLink>{" "}
|
||||
of tier "Supporter" ($5 dollar tier)
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : null}
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileOwnersButtons;
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
import { useToast } from "@chakra-ui/react";
|
||||
import { User as PrismaUser } from "@prisma/client";
|
||||
import { useSession } from "next-auth/client";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { navItems } from "utils/constants";
|
||||
import { getToastOptions } from "utils/objects";
|
||||
|
||||
// https://usehooks.com/useDebounce/
|
||||
export function useDebounce(value: string, delay: number = 500) {
|
||||
export function useDebounce<T>(value: T, delay: number = 500) {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -38,3 +40,55 @@ export const useActiveNavItem = () => {
|
|||
|
||||
return navItem;
|
||||
};
|
||||
|
||||
export const useMutation = ({
|
||||
url,
|
||||
method = "POST",
|
||||
data,
|
||||
successToastMsg,
|
||||
afterSuccess,
|
||||
}: {
|
||||
url: string;
|
||||
method?: "POST" | "DELETE" | "PUT";
|
||||
data: Object;
|
||||
successToastMsg: string;
|
||||
afterSuccess?: () => void;
|
||||
}) => {
|
||||
const toast = useToast();
|
||||
const [isMutating, setIsMutating] = useState(false);
|
||||
const mutate = async () => {
|
||||
setIsMutating(true);
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
setIsMutating(false);
|
||||
|
||||
if (response.status < 200 || response.status > 299) {
|
||||
let description = "An error occurred";
|
||||
try {
|
||||
const error = await response.json();
|
||||
if (error.message) {
|
||||
description = error.message;
|
||||
console.error(error.message);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
toast({
|
||||
duration: null,
|
||||
isClosable: true,
|
||||
position: "top-right",
|
||||
status: "error",
|
||||
description,
|
||||
});
|
||||
} else {
|
||||
toast(getToastOptions(successToastMsg, "success"));
|
||||
afterSuccess?.();
|
||||
}
|
||||
};
|
||||
|
||||
return { mutate, isMutating };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,8 +8,9 @@
|
|||
"build": "lingui compile && npm run prebuild && next build",
|
||||
"build:analyze": "lingui compile && npm run prebuild && ANALYZE=true next build",
|
||||
"start": "next start",
|
||||
"migrate": "prisma migrate deploy --preview-feature",
|
||||
"migrate:save": "prisma migrate dev --create-only --preview-feature",
|
||||
"compile": "lingui compile",
|
||||
"migrate": "npx prisma migrate deploy",
|
||||
"migrate:save": "npx prisma migrate dev --create-only",
|
||||
"migrate:reset": "prisma migrate reset",
|
||||
"gen": "npx prisma generate",
|
||||
"prebuild": "ts-node prisma/scripts/preBuild.ts",
|
||||
|
|
|
|||
|
|
@ -113,16 +113,16 @@ const extendedTheme = extendTheme({
|
|||
},
|
||||
colors: {
|
||||
theme: {
|
||||
50: "#e4ffdf",
|
||||
100: "#bbffb0",
|
||||
200: "#92ff7f",
|
||||
300: "#68ff4d",
|
||||
400: "#3fff1d",
|
||||
500: "#27e606",
|
||||
600: "#1bb300",
|
||||
700: "#108000",
|
||||
800: "#054d00",
|
||||
900: "#001b00",
|
||||
50: CSSVariables.themeColorOpaque,
|
||||
100: CSSVariables.themeColor,
|
||||
200: CSSVariables.themeColor,
|
||||
300: CSSVariables.themeColor,
|
||||
400: CSSVariables.themeColor,
|
||||
500: CSSVariables.themeColor,
|
||||
600: CSSVariables.themeColor,
|
||||
700: CSSVariables.themeColor,
|
||||
800: CSSVariables.themeColor,
|
||||
900: CSSVariables.themeColor,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -189,7 +189,7 @@ const MyApp = ({ Component, pageProps }: AppProps) => {
|
|||
/>
|
||||
|
||||
<NextAuthProvider session={pageProps.session}>
|
||||
<ChakraProvider theme={extendedTheme}>
|
||||
<ChakraProvider theme={extendedTheme} cssVarsRoot="body">
|
||||
<I18nProvider i18n={i18n}>
|
||||
<Layout>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
|
|
|||
32
pages/api/me/colors.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import prisma from "prisma/client";
|
||||
import { createHandler, getMySession } from "utils/api";
|
||||
|
||||
async function POST(req: NextApiRequest, res: NextApiResponse) {
|
||||
const user = await getMySession(req);
|
||||
if (!user) return res.status(401).end();
|
||||
|
||||
const { colors } = req.body;
|
||||
const hexCodeRegex = new RegExp(
|
||||
/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3}|[A-Fa-f0-9]{8})$/
|
||||
);
|
||||
|
||||
if (typeof colors !== "object" || colors === null) {
|
||||
return res.status(400).json({ message: "invalid type for colors" });
|
||||
}
|
||||
|
||||
for (const [_key, value] of Object.entries(colors)) {
|
||||
if (!hexCodeRegex.test(value as string)) {
|
||||
return res.status(400).json({ message: `invalid hex code: ${value}` });
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.profile.update({ where: { userId: user.id }, data: { colors } });
|
||||
|
||||
res.status(200).end();
|
||||
}
|
||||
|
||||
const colorsHandler = (req: NextApiRequest, res: NextApiResponse) =>
|
||||
createHandler(req, res, { POST });
|
||||
|
||||
export default colorsHandler;
|
||||
104
pages/styles.css
|
|
@ -1,31 +1,29 @@
|
|||
@import "/nprogress.css";
|
||||
|
||||
body.chakra-ui-light {
|
||||
--theme-color: #79ff61;
|
||||
--theme-color-opaque: hsla(111, 100%, 69%, 0.1);
|
||||
--theme-gray: var(--chakra-colors-gray-600);
|
||||
--bg-color: #eff0f3;
|
||||
--secondary-bg-color: #fffafa;
|
||||
--text-color: var(--chakra-colors-blackAlpha-900);
|
||||
--border-color: #e2e8f0;
|
||||
}
|
||||
|
||||
body.chakra-ui-dark {
|
||||
--theme-color: #79ff61;
|
||||
--theme-color-opaque: hsla(111, 100%, 69%, 0.1);
|
||||
--theme-gray: var(--chakra-colors-gray-300);
|
||||
--bg-color: #031e3e;
|
||||
--secondary-bg-color: #0e2a56;
|
||||
--text-color: var(--chakra-colors-whiteAlpha-900);
|
||||
--border-color: #2e466c;
|
||||
}
|
||||
|
||||
body {
|
||||
--theme-color: var(--custom-theme-color, #79ff61);
|
||||
--theme-color-opaque: var(--custom-theme-color-opaque, #79ff6120;);
|
||||
--theme-gray: var(--custom-theme-gray, var(--chakra-colors-gray-300));
|
||||
--bg-color: var(--custom-bg-color, #031e3e);
|
||||
--secondary-bg-color: var(--custom-secondary-bg-color, #0e2a56);
|
||||
--text-color: var(--custom-text-color, #f5f5f5);
|
||||
--border-color: var(--custom-border-color, #2e466c);
|
||||
|
||||
margin: 0;
|
||||
color: var(--text-color);
|
||||
background-color: var(--bg-color);
|
||||
}
|
||||
|
||||
body.chakra-ui-light {
|
||||
--theme-color: var(--custom-theme-color, #1bb300);
|
||||
--theme-color-opaque: var(--custom-theme-color-opaque, #1bb30020);
|
||||
--theme-gray: var(--custom-theme-gray, var(--chakra-colors-gray-600));
|
||||
--bg-color: var(--custom-bg-color, #eff0f3);
|
||||
--secondary-bg-color: var(--custom-secondary-bg-color, #fffafa);
|
||||
--text-color: var(--custom-text-color, var(--chakra-colors-blackAlpha-900));
|
||||
--border-color: var(--custom-border-color, #e2e8f0);
|
||||
}
|
||||
|
||||
header,
|
||||
footer {
|
||||
grid-column-end: span 3;
|
||||
|
|
@ -76,72 +74,6 @@ header {
|
|||
}
|
||||
}
|
||||
|
||||
.rgb {
|
||||
background: linear-gradient(
|
||||
124deg,
|
||||
#ff2400,
|
||||
#e81d1d,
|
||||
#e8b71d,
|
||||
#e3e81d,
|
||||
#1de840,
|
||||
#1ddde8,
|
||||
#2b1de8,
|
||||
#dd00f3,
|
||||
#dd00f3
|
||||
);
|
||||
|
||||
-webkit-animation: rainbow 18s ease infinite;
|
||||
-z-animation: rainbow 18s ease infinite;
|
||||
-o-animation: rainbow 18s ease infinite;
|
||||
animation: rainbow 18s ease infinite;
|
||||
background-size: 1800% 1800%;
|
||||
}
|
||||
|
||||
@-webkit-keyframes rainbow {
|
||||
0% {
|
||||
background-position: 0% 82%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 19%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 82%;
|
||||
}
|
||||
}
|
||||
@-moz-keyframes rainbow {
|
||||
0% {
|
||||
background-position: 0% 82%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 19%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 82%;
|
||||
}
|
||||
}
|
||||
@-o-keyframes rainbow {
|
||||
0% {
|
||||
background-position: 0% 82%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 19%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 82%;
|
||||
}
|
||||
}
|
||||
@keyframes rainbow {
|
||||
0% {
|
||||
background-position: 0% 82%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 19%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 82%;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
For Next.JS image
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Button, Divider, HStack, Select } from "@chakra-ui/react";
|
||||
import { t, Trans } from "@lingui/macro";
|
||||
import { Divider, Select } from "@chakra-ui/react";
|
||||
import { t } from "@lingui/macro";
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { Build, LeagueType, RankedMode } from "@prisma/client";
|
||||
import BuildCard from "components/builds/BuildCard";
|
||||
|
|
@ -8,7 +8,9 @@ import MyInfiniteScroller from "components/common/MyInfiniteScroller";
|
|||
import AvatarWithInfo from "components/u/AvatarWithInfo";
|
||||
import Badges from "components/u/Badges";
|
||||
import BuildModal from "components/u/BuildModal";
|
||||
import ProfileColorSelectors from "components/u/ProfileColorSelectors";
|
||||
import ProfileModal from "components/u/ProfileModal";
|
||||
import ProfileOwnersButtons from "components/u/ProfileOwnersButton";
|
||||
import { useUser } from "hooks/common";
|
||||
import { useBuildsByUser } from "hooks/u";
|
||||
import { GetStaticPaths, GetStaticProps } from "next";
|
||||
|
|
@ -19,10 +21,12 @@ import {
|
|||
GetUserByIdentifierData,
|
||||
} from "prisma/queries/getUserByIdentifier";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FiEdit } from "react-icons/fi";
|
||||
import { RiTShirtLine } from "react-icons/ri";
|
||||
import useSWR from "swr";
|
||||
import { GANBA_DISCORD_ID } from "utils/constants";
|
||||
import {
|
||||
ADMIN_DISCORD_ID,
|
||||
BORZOIC_DISCORD_ID,
|
||||
GANBA_DISCORD_ID,
|
||||
} from "utils/constants";
|
||||
import { isCustomUrl } from "utils/validators/profile";
|
||||
import MyHead from "../../components/common/MyHead";
|
||||
|
||||
|
|
@ -36,6 +40,7 @@ const ProfilePage = (props: Props) => {
|
|||
const router = useRouter();
|
||||
const [showProfileModal, setShowProfileModal] = useState(false);
|
||||
const [buildToEdit, setBuildToEdit] = useState<boolean | Build>(false);
|
||||
const [showColorSelectors, setShowColorSelectors] = useState(false);
|
||||
const [userId, setUserId] = useState<number | undefined>(undefined);
|
||||
|
||||
const apiUrl = () => {
|
||||
|
|
@ -47,7 +52,7 @@ const ProfilePage = (props: Props) => {
|
|||
};
|
||||
|
||||
const [loggedInUser] = useUser();
|
||||
const { data } = useSWR<GetUserByIdentifierData>(apiUrl(), {
|
||||
const { data, mutate } = useSWR<GetUserByIdentifierData>(apiUrl(), {
|
||||
initialData: props.user,
|
||||
});
|
||||
|
||||
|
|
@ -69,6 +74,16 @@ const ProfilePage = (props: Props) => {
|
|||
return true;
|
||||
})();
|
||||
|
||||
const canEditProfileColors = (() => {
|
||||
if ([ADMIN_DISCORD_ID, BORZOIC_DISCORD_ID].includes(user.discordId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (user.patreonTier ?? -1 < 2) return false;
|
||||
|
||||
return true;
|
||||
})();
|
||||
|
||||
useEffect(() => {
|
||||
if (!router.query.build || !canPostBuilds) return;
|
||||
|
||||
|
|
@ -85,6 +100,24 @@ const ProfilePage = (props: Props) => {
|
|||
setUserId(props.user.id);
|
||||
}, [props.user.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const colors = user.profile?.colors;
|
||||
if (!colors) return;
|
||||
if (!canEditProfileColors) return;
|
||||
|
||||
const body = document.getElementsByTagName("body")[0];
|
||||
|
||||
for (const [key, value] of Object.entries(colors)) {
|
||||
body.style.setProperty(`--custom-${key}`, value);
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const key of Object.keys(colors)) {
|
||||
body.style.removeProperty(`--custom-${key}`);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MyHead title={user.username} />
|
||||
|
|
@ -118,7 +151,24 @@ const ProfilePage = (props: Props) => {
|
|||
)[0]
|
||||
}
|
||||
/>
|
||||
<ProfileOwnersButtons />
|
||||
{loggedInUser?.id === user.id ? (
|
||||
<ProfileOwnersButtons
|
||||
isColorEditorsButtonClickable={canEditProfileColors}
|
||||
canPostBuilds={canPostBuilds}
|
||||
setBuildToEdit={setBuildToEdit}
|
||||
setShowColorSelectors={setShowColorSelectors}
|
||||
setShowProfileModal={setShowProfileModal}
|
||||
/>
|
||||
) : null}
|
||||
{showColorSelectors ? (
|
||||
<ProfileColorSelectors
|
||||
hide={() => setShowColorSelectors(false)}
|
||||
previousColors={
|
||||
(user.profile?.colors as Record<string, string>) ?? undefined
|
||||
}
|
||||
mutateUser={mutate}
|
||||
/>
|
||||
) : null}
|
||||
{user.profile?.bio && user.profile?.bio.trim().length > 0 && (
|
||||
<>
|
||||
<Divider my={6} />
|
||||
|
|
@ -166,35 +216,6 @@ const ProfilePage = (props: Props) => {
|
|||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
function ProfileOwnersButtons() {
|
||||
if (user && loggedInUser?.id === user.id) {
|
||||
return (
|
||||
<HStack spacing={4}>
|
||||
<Button
|
||||
leftIcon={<FiEdit />}
|
||||
variant="outline"
|
||||
onClick={() => setShowProfileModal(true)}
|
||||
size="sm"
|
||||
>
|
||||
<Trans>Edit profile</Trans>
|
||||
</Button>
|
||||
{canPostBuilds && (
|
||||
<Button
|
||||
leftIcon={<RiTShirtLine />}
|
||||
variant="outline"
|
||||
onClick={() => setBuildToEdit(true)}
|
||||
size="sm"
|
||||
>
|
||||
<Trans>Add build</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = async () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Profile" ADD COLUMN "colors" JSONB;
|
||||
|
|
@ -42,6 +42,7 @@ export const getUserByIdentifier = (identifier: string) =>
|
|||
twitterName: true,
|
||||
weaponPool: true,
|
||||
youtubeId: true,
|
||||
colors: true,
|
||||
},
|
||||
},
|
||||
player: {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ datasource db {
|
|||
}
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
provider = "prisma-client-js"
|
||||
previewFeatures = ["orderByRelation"]
|
||||
}
|
||||
|
||||
|
|
@ -50,6 +50,7 @@ model Profile {
|
|||
weaponPool String[]
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
customUrlPath String? @unique
|
||||
colors Json?
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int @unique
|
||||
}
|
||||
|
|
|
|||
BIN
public/layout/b8ing.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
public/layout/b8ing_bg.png
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 14 KiB |
BIN
public/layout/boing.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
public/layout/boing_bg.png
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
|
@ -5,6 +5,7 @@
|
|||
export const ADMIN_DISCORD_ID = "79237403620945920";
|
||||
export const ADMIN_ID = 8;
|
||||
export const GANBA_DISCORD_ID = "312082701865713665";
|
||||
export const BORZOIC_DISCORD_ID = "335179210878353409";
|
||||
export const SALMON_RUN_ADMIN_DISCORD_IDS = [
|
||||
ADMIN_DISCORD_ID,
|
||||
"81154649993785344", // Brian
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
[{"username":"Kabagu","discriminator":"6766","patreonTier":3,"discordId":"399267313674747905"},{"username":"Adept","discriminator":"7777","patreonTier":3,"discordId":"68811718473555968"},{"username":"RevengeZ","discriminator":"1919","patreonTier":3,"discordId":"305453601474609153"},{"username":"Krent0n","discriminator":"9587","patreonTier":3,"discordId":"429651155224887299"},{"username":"beta","discriminator":"3025","patreonTier":3,"discordId":"364469424960438272"},{"username":"Fusion","discriminator":"1883","patreonTier":2,"discordId":"78615504511574016"},{"username":"aceticke","discriminator":"0001","patreonTier":2,"discordId":"574626573152419840"},{"username":"prosper","discriminator":"8265","patreonTier":2,"discordId":"412754879933841409"},{"username":"CoconutTank","discriminator":"1053","patreonTier":2,"discordId":"78901351706271744"},{"username":"Lucyfer","discriminator":"0666","patreonTier":2,"discordId":"254943418755710976"},{"username":"fancy","discriminator":"8811","patreonTier":2,"discordId":"125757563047247873"},{"username":"Sakaali","discriminator":"2092","patreonTier":2,"discordId":"140911330059091969"},{"username":"littlepetfrog","discriminator":"6051","patreonTier":2,"discordId":"106462785549897728"},{"username":"Brock.com","discriminator":"0048","patreonTier":2,"discordId":"123514351641559042"},{"username":"Cue","discriminator":"1132","patreonTier":2,"discordId":"347036855453220865"},{"username":"EdmundDickens","discriminator":"1955","patreonTier":2,"discordId":"428618743153688576"},{"username":"miyn","discriminator":"2522","patreonTier":2,"discordId":"358623826721898496"},{"username":"Fuwa","discriminator":"8633","patreonTier":1,"discordId":"150612545336508418"},{"username":"Smaack","discriminator":"8232","patreonTier":1,"discordId":"262647368183185408"}]
|
||||
[]
|
||||
|
|
|
|||
345
utils/getFilters.ts
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
interface HSL {
|
||||
h: number;
|
||||
s: number;
|
||||
l: number;
|
||||
}
|
||||
|
||||
class Color {
|
||||
public r: number;
|
||||
public g: number;
|
||||
public b: number;
|
||||
constructor(r: number, g: number, b: number) {
|
||||
this.r = this.clamp(r);
|
||||
this.g = this.clamp(g);
|
||||
this.b = this.clamp(b);
|
||||
}
|
||||
|
||||
toString() {
|
||||
return `rgb(${Math.round(this.r)}, ${Math.round(this.g)}, ${Math.round(
|
||||
this.b
|
||||
)})`;
|
||||
}
|
||||
|
||||
set(r: number, g: number, b: number) {
|
||||
this.r = this.clamp(r);
|
||||
this.g = this.clamp(g);
|
||||
this.b = this.clamp(b);
|
||||
}
|
||||
|
||||
hueRotate(angle = 0) {
|
||||
angle = (angle / 180) * Math.PI;
|
||||
const sin = Math.sin(angle);
|
||||
const cos = Math.cos(angle);
|
||||
|
||||
this.multiply([
|
||||
0.213 + cos * 0.787 - sin * 0.213,
|
||||
0.715 - cos * 0.715 - sin * 0.715,
|
||||
0.072 - cos * 0.072 + sin * 0.928,
|
||||
0.213 - cos * 0.213 + sin * 0.143,
|
||||
0.715 + cos * 0.285 + sin * 0.14,
|
||||
0.072 - cos * 0.072 - sin * 0.283,
|
||||
0.213 - cos * 0.213 - sin * 0.787,
|
||||
0.715 - cos * 0.715 + sin * 0.715,
|
||||
0.072 + cos * 0.928 + sin * 0.072,
|
||||
]);
|
||||
}
|
||||
|
||||
grayscale(value = 1) {
|
||||
this.multiply([
|
||||
0.2126 + 0.7874 * (1 - value),
|
||||
0.7152 - 0.7152 * (1 - value),
|
||||
0.0722 - 0.0722 * (1 - value),
|
||||
0.2126 - 0.2126 * (1 - value),
|
||||
0.7152 + 0.2848 * (1 - value),
|
||||
0.0722 - 0.0722 * (1 - value),
|
||||
0.2126 - 0.2126 * (1 - value),
|
||||
0.7152 - 0.7152 * (1 - value),
|
||||
0.0722 + 0.9278 * (1 - value),
|
||||
]);
|
||||
}
|
||||
|
||||
sepia(value = 1) {
|
||||
this.multiply([
|
||||
0.393 + 0.607 * (1 - value),
|
||||
0.769 - 0.769 * (1 - value),
|
||||
0.189 - 0.189 * (1 - value),
|
||||
0.349 - 0.349 * (1 - value),
|
||||
0.686 + 0.314 * (1 - value),
|
||||
0.168 - 0.168 * (1 - value),
|
||||
0.272 - 0.272 * (1 - value),
|
||||
0.534 - 0.534 * (1 - value),
|
||||
0.131 + 0.869 * (1 - value),
|
||||
]);
|
||||
}
|
||||
|
||||
saturate(value = 1) {
|
||||
this.multiply([
|
||||
0.213 + 0.787 * value,
|
||||
0.715 - 0.715 * value,
|
||||
0.072 - 0.072 * value,
|
||||
0.213 - 0.213 * value,
|
||||
0.715 + 0.285 * value,
|
||||
0.072 - 0.072 * value,
|
||||
0.213 - 0.213 * value,
|
||||
0.715 - 0.715 * value,
|
||||
0.072 + 0.928 * value,
|
||||
]);
|
||||
}
|
||||
|
||||
multiply(matrix: any) {
|
||||
const newR = this.clamp(
|
||||
this.r * matrix[0] + this.g * matrix[1] + this.b * matrix[2]
|
||||
);
|
||||
const newG = this.clamp(
|
||||
this.r * matrix[3] + this.g * matrix[4] + this.b * matrix[5]
|
||||
);
|
||||
const newB = this.clamp(
|
||||
this.r * matrix[6] + this.g * matrix[7] + this.b * matrix[8]
|
||||
);
|
||||
this.r = newR;
|
||||
this.g = newG;
|
||||
this.b = newB;
|
||||
}
|
||||
|
||||
brightness(value = 1) {
|
||||
this.linear(value);
|
||||
}
|
||||
contrast(value = 1) {
|
||||
this.linear(value, -(0.5 * value) + 0.5);
|
||||
}
|
||||
|
||||
linear(slope = 1, intercept = 0) {
|
||||
this.r = this.clamp(this.r * slope + intercept * 255);
|
||||
this.g = this.clamp(this.g * slope + intercept * 255);
|
||||
this.b = this.clamp(this.b * slope + intercept * 255);
|
||||
}
|
||||
|
||||
invert(value = 1) {
|
||||
this.r = this.clamp((value + (this.r / 255) * (1 - 2 * value)) * 255);
|
||||
this.g = this.clamp((value + (this.g / 255) * (1 - 2 * value)) * 255);
|
||||
this.b = this.clamp((value + (this.b / 255) * (1 - 2 * value)) * 255);
|
||||
}
|
||||
|
||||
hsl(): HSL {
|
||||
// Code taken from https://stackoverflow.com/a/9493060/2688027, licensed under CC BY-SA.
|
||||
const r = this.r / 255;
|
||||
const g = this.g / 255;
|
||||
const b = this.b / 255;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
let l = (max + min) / 2;
|
||||
|
||||
if (max === min) {
|
||||
h = s = 0;
|
||||
} else {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case r:
|
||||
h = (g - b) / d + (g < b ? 6 : 0);
|
||||
break;
|
||||
|
||||
case g:
|
||||
h = (b - r) / d + 2;
|
||||
break;
|
||||
|
||||
case b:
|
||||
h = (r - g) / d + 4;
|
||||
break;
|
||||
}
|
||||
h /= 6;
|
||||
}
|
||||
|
||||
return {
|
||||
h: h * 100,
|
||||
s: s * 100,
|
||||
l: l * 100,
|
||||
};
|
||||
}
|
||||
|
||||
clamp(value: number): number {
|
||||
if (value > 255) {
|
||||
value = 255;
|
||||
} else if (value < 0) {
|
||||
value = 0;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
interface Solution {
|
||||
loss: number;
|
||||
values: number[];
|
||||
}
|
||||
class Solver {
|
||||
private target: Color;
|
||||
private targetHSL: HSL;
|
||||
private reusedColor: Color;
|
||||
constructor(target: Color) {
|
||||
this.target = target;
|
||||
this.targetHSL = target.hsl();
|
||||
this.reusedColor = new Color(0, 0, 0);
|
||||
}
|
||||
|
||||
solve() {
|
||||
const result = this.solveNarrow(this.solveWide());
|
||||
return {
|
||||
values: result.values,
|
||||
loss: result.loss,
|
||||
filter: this.css(result.values),
|
||||
};
|
||||
}
|
||||
|
||||
solveWide(): Solution {
|
||||
const A = 5;
|
||||
const c = 15;
|
||||
const a = [60, 180, 18000, 600, 1.2, 1.2];
|
||||
|
||||
let best = { loss: Infinity, values: [] as number[] };
|
||||
for (let i = 0; best.loss > 25 && i < 3; i++) {
|
||||
const initial = [50, 20, 3750, 50, 100, 100];
|
||||
const result = this.spsa(A, a, c, initial, 1000);
|
||||
if (result.loss < best.loss) {
|
||||
best = result;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
solveNarrow(wide: Solution) {
|
||||
const A = wide.loss;
|
||||
const c = 2;
|
||||
const A1 = A + 1;
|
||||
const a = [0.25 * A1, 0.25 * A1, A1, 0.25 * A1, 0.2 * A1, 0.2 * A1];
|
||||
return this.spsa(A, a, c, wide.values, 500);
|
||||
}
|
||||
|
||||
spsa(
|
||||
A: number,
|
||||
a: number[],
|
||||
c: number,
|
||||
values: number[],
|
||||
iters: number
|
||||
): Solution {
|
||||
const alpha = 1;
|
||||
const gamma = 0.16666666666666666;
|
||||
|
||||
let best = [] as number[];
|
||||
let bestLoss = Infinity;
|
||||
const deltas = new Array(6);
|
||||
const highArgs = new Array(6);
|
||||
const lowArgs = new Array(6);
|
||||
|
||||
for (let k = 0; k < iters; k++) {
|
||||
const ck = c / Math.pow(k + 1, gamma);
|
||||
for (let i = 0; i < 6; i++) {
|
||||
deltas[i] = Math.random() > 0.5 ? 1 : -1;
|
||||
highArgs[i] = values[i] + ck * deltas[i];
|
||||
lowArgs[i] = values[i] - ck * deltas[i];
|
||||
}
|
||||
|
||||
const lossDiff = this.loss(highArgs) - this.loss(lowArgs);
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const g = (lossDiff / (2 * ck)) * deltas[i];
|
||||
const ak = a[i] / Math.pow(A + k + 1, alpha);
|
||||
values[i] = fix(values[i] - ak * g, i);
|
||||
}
|
||||
|
||||
const loss = this.loss(values);
|
||||
if (loss < bestLoss) {
|
||||
best = values.slice(0);
|
||||
bestLoss = loss;
|
||||
}
|
||||
}
|
||||
return { values: best, loss: bestLoss };
|
||||
|
||||
function fix(value: number, idx: number): number {
|
||||
let max = 100;
|
||||
if (idx === 2 /* saturate */) {
|
||||
max = 7500;
|
||||
} else if (idx === 4 /* brightness */ || idx === 5 /* contrast */) {
|
||||
max = 200;
|
||||
}
|
||||
|
||||
if (idx === 3 /* hue-rotate */) {
|
||||
if (value > max) {
|
||||
value %= max;
|
||||
} else if (value < 0) {
|
||||
value = max + (value % max);
|
||||
}
|
||||
} else if (value < 0) {
|
||||
value = 0;
|
||||
} else if (value > max) {
|
||||
value = max;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
loss(filters: number[]) {
|
||||
// Argument is array of percentages.
|
||||
const color = this.reusedColor;
|
||||
color.set(0, 0, 0);
|
||||
|
||||
color.invert(filters[0] / 100);
|
||||
color.sepia(filters[1] / 100);
|
||||
color.saturate(filters[2] / 100);
|
||||
color.hueRotate(filters[3] * 3.6);
|
||||
color.brightness(filters[4] / 100);
|
||||
color.contrast(filters[5] / 100);
|
||||
|
||||
const colorHSL = color.hsl();
|
||||
return (
|
||||
Math.abs(color.r - this.target.r) +
|
||||
Math.abs(color.g - this.target.g) +
|
||||
Math.abs(color.b - this.target.b) +
|
||||
Math.abs(colorHSL.h - this.targetHSL.h) +
|
||||
Math.abs(colorHSL.s - this.targetHSL.s) +
|
||||
Math.abs(colorHSL.l - this.targetHSL.l)
|
||||
);
|
||||
}
|
||||
|
||||
css(filters: number[]) {
|
||||
function fmt(idx: number, multiplier = 1) {
|
||||
return Math.round(filters[idx] * multiplier);
|
||||
}
|
||||
return `invert(${fmt(0)}%) sepia(${fmt(1)}%) saturate(${fmt(
|
||||
2
|
||||
)}%) hue-rotate(${fmt(3, 3.6)}deg) brightness(${fmt(4)}%) contrast(${fmt(
|
||||
5
|
||||
)}%);`;
|
||||
}
|
||||
}
|
||||
|
||||
type RGB = [number, number, number];
|
||||
function hexToRgb(hex: string): RGB {
|
||||
// Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
|
||||
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
|
||||
hex = hex.replace(shorthandRegex, (m, r, g, b) => {
|
||||
return r + r + g + g + b + b;
|
||||
});
|
||||
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
if (result) {
|
||||
return [
|
||||
parseInt(result[1], 16),
|
||||
parseInt(result[2], 16),
|
||||
parseInt(result[3], 16),
|
||||
];
|
||||
}
|
||||
|
||||
throw new Error("Error parsing hex: " + hex);
|
||||
}
|
||||
|
||||
export function getFilters(hex: string) {
|
||||
let rgb = [255, 255, 255];
|
||||
try {
|
||||
rgb = hexToRgb(hex);
|
||||
} catch (e) {}
|
||||
const color = new Color(rgb[0], rgb[1], rgb[2]);
|
||||
const solver = new Solver(color);
|
||||
const result = solver.solve();
|
||||
return result.filter;
|
||||
}
|
||||
|
|
@ -16,7 +16,10 @@ export async function sendData(method = "POST", url = "", data = {}) {
|
|||
let description = t`An error occurred`;
|
||||
try {
|
||||
const error = await response.json();
|
||||
if (error.message) description = error.message;
|
||||
if (error.message) {
|
||||
description = error.message;
|
||||
console.error(error.message);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
toast({
|
||||
|
|
|
|||