Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 90 additions & 16 deletions backend/src/controllers/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Request, Response } from 'express'
import { PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'
import _ from 'underscore'
import {
filterDeepProperties,
getDefaultData,
getS3AndParams,
streamToString
Expand All @@ -19,19 +20,22 @@ export const getDefault = async (_: Request, res: Response) => {
}
}

export const saveUserConfig = async (req: Request, res: Response) => {
export const createUserConfig = async (req: Request, res: Response) => {
try {
const data = req.body
const tag = data.version || data.tag

if (!data.walletAddress) {
throw 'Wallet address is required'
}
const defaultData = await getDefaultData()
const defaultDataContent = JSON.parse(defaultData).default
defaultDataContent.walletAddress = decodeURIComponent(
`https://${data.walletAddress}`
)

const { s3, params } = getS3AndParams(data.walletAddress)

// get defaults, get existing config, then overwrite values
const defaultData = await getDefaultData()

let fileContentString = '{}'
try {
// existing config
Expand All @@ -51,23 +55,53 @@ export const saveUserConfig = async (req: Request, res: Response) => {
}
}

const changedValues = _.omit(data, function (value, key) {
return defaultData[key] === value
})
let currentData = JSON.parse(fileContentString)

const currentData = Object.assign(
JSON.parse(defaultData),
JSON.parse(fileContentString)
)
const fileContent = JSON.stringify(
Object.assign(currentData, ...[changedValues])
)
if (currentData?.default) {
currentData = Object.assign(filterDeepProperties(currentData), {
[tag]: defaultDataContent
})
} else {
currentData = Object.assign(
{ default: currentData },
{
[tag]: defaultDataContent
}
)
}

const fileContent = JSON.stringify(currentData)
const extendedParams = { ...params, Body: fileContent }

// save json to file
await s3.send(new PutObjectCommand(extendedParams))

res.status(200).send(currentData)
} catch (error) {
console.log(error)
res.status(500).send('An error occurred when fetching data')
}
}

export const saveUserConfig = async (req: Request, res: Response) => {
try {
const data = req.body

if (!data.walletAddress) {
throw 'Wallet address is required'
}

const { s3, params } = getS3AndParams(data.walletAddress)

// filter data so we are saving only config and none of the extra params received
const fullConfig = JSON.parse(data?.fullconfig)
const filteredData = filterDeepProperties(fullConfig)
const fileContent = JSON.stringify(filteredData)
const extendedParams = { ...params, Body: fileContent }

await s3.send(new PutObjectCommand(extendedParams))

res.status(200).send(data)
res.status(200).send(filteredData)
} catch (err) {
console.log(err)
res.status(500).send('An error occurred when saving data')
Expand All @@ -92,10 +126,49 @@ export const getUserConfig = async (req: Request, res: Response) => {
data.Body as NodeJS.ReadableStream
)

const fileContent = Object.assign(
let fileContent = Object.assign(
JSON.parse(defaultData),
...[JSON.parse(fileContentString)]
)
fileContent = filterDeepProperties(fileContent)

res.status(200).send(fileContent)
} catch (error) {
const err = error as Error
if (err.name === 'NoSuchKey') {
// file / config not found, serve default
const defaultData = await getDefaultData()
res.status(200).send(defaultData)
} else {
console.log(error)
res.status(500).send('An error occurred while fetching data')
}
}
}

export const getUserConfigByTag = async (req: Request, res: Response) => {
try {
const id = req.params.id
const tag = req.params.tag ?? 'default'

if (!id) {
throw new S3FileNotFoundError('Wallet address is required')
}

// ensure we have all keys w default values, user config will overwrite values that exist in saved json
const defaultDataResp = await getDefaultData()
const defaultData = JSON.parse(defaultDataResp)?.default

const { s3, params } = getS3AndParams(id)
const data = await s3.send(new GetObjectCommand(params))
// Convert the file stream to a string
const fileContentString = await streamToString(
data.Body as NodeJS.ReadableStream
)

const userConfig = JSON.parse(fileContentString)
const selectedConfig = userConfig[tag] ?? defaultData
const fileContent = Object.assign(defaultData, ...[selectedConfig])

res.status(200).send(fileContent)
} catch (error) {
Expand All @@ -114,5 +187,6 @@ export const getUserConfig = async (req: Request, res: Response) => {
export default {
getDefault,
getUserConfig,
createUserConfig,
saveUserConfig
}
58 changes: 30 additions & 28 deletions backend/src/data/default_config.json
Original file line number Diff line number Diff line change
@@ -1,30 +1,32 @@
{
"buttonFontName": "Arial",
"buttonText": "Support me",
"buttonBorder": "Light",
"buttonTextColor": "#ffffff",
"buttonBackgroundColor": "#ff808c",
"bannerFontName": "Arial",
"bannerFontSize": 16,
"bannerTitleText": "How to support?",
"bannerDescriptionText": "You can support this page and my work by a one time donation or proportional to the time you spend on this website through web monetization.",
"bannerSlideAnimation": "Down",
"bannerPosition": "Bottom",
"bannerTextColor": "#ffffff",
"bannerBackgroundColor": "#7f76b2",
"bannerBorder": "Light",
"widgetFontName": "Arial",
"widgetFontSize": 16,
"widgetDonateAmount": 1,
"widgetTitleText": "Future of support",
"widgetDescriptionText": "Experience the new way to support our content. Activate Web Monetization in your browser and support our work as you browse. Every visit helps us keep creating the content you love! You can also support us by a one time donation below!",
"widgetButtonText": "Support me",
"widgetButtonBackgroundColor": "#4ec6c0",
"widgetButtonTextColor": "#000000",
"widgetButtonBorder": "Light",
"widgetTextColor": "#000000",
"widgetBackgroundColor": "#ffffff",
"widgetTriggerBackgroundColor": "#ffffff",
"widgetTriggerIcon": "",
"css": "H4sIAAAAAAAAA61S227bMAz9lW5-WYFQCFIkA2ygQL8koCy64UZJrkR1Ngz_-2BtaRds6NP4JJE6Fx7IsIw4n2108zLEoDCgZ5nbp8QouzxnJQ-FdxlDhkyJh0_sx5gUg3Z9lJjaZqjVread684W1RjMD68wxORRFa0Q_Govt0CL_ffnFEtw8HuAB-uc7WxMjhIkdFxyuzcPX4-JfKcJQ2blGFoU2ZtjJsx0q2_QxxIUHOdRcN79ORPeNTQphcwxwIhztXgnaEmWD3ZqLoROKOfCUCMQSDHqgiJtoldK2q3_IuYwFr0S72t98NA4zltWbvk7F6p1zf10OnVr81KiUsU_On41g9DUBr1Af2FxXx7ul60DjhP1NbI-SvGhW5vzOdCkbyBzgaGI1POmZ1jJZ-gpKCXzrWTlYb5exwmOG_Lz_eby1uQ1uv8iUNFe4bA3SpOCxUyLx_TMATSO7WH7EOtPZC6FXcYCAAA="
"default": {
"buttonFontName": "Arial",
"buttonText": "Support me",
"buttonBorder": "Light",
"buttonTextColor": "#ffffff",
"buttonBackgroundColor": "#ff808c",
"bannerFontName": "Arial",
"bannerFontSize": 16,
"bannerTitleText": "How to support?",
"bannerDescriptionText": "You can support this page and my work by a one time donation or proportional to the time you spend on this website through web monetization.",
"bannerSlideAnimation": "Down",
"bannerPosition": "Bottom",
"bannerTextColor": "#ffffff",
"bannerBackgroundColor": "#7f76b2",
"bannerBorder": "Light",
"widgetFontName": "Arial",
"widgetFontSize": 16,
"widgetDonateAmount": 1,
"widgetTitleText": "Future of support",
"widgetDescriptionText": "Experience the new way to support our content. Activate Web Monetization in your browser and support our work as you browse. Every visit helps us keep creating the content you love! You can also support us by a one time donation below!",
"widgetButtonText": "Support me",
"widgetButtonBackgroundColor": "#4ec6c0",
"widgetButtonTextColor": "#000000",
"widgetButtonBorder": "Light",
"widgetTextColor": "#000000",
"widgetBackgroundColor": "#ffffff",
"widgetTriggerBackgroundColor": "#ffffff",
"widgetTriggerIcon": "",
"css": "H4sIAAAAAAAAA61S227bMAz9lW5-WYFQCFIkA2ygQL8koCy64UZJrkR1Ngz_-2BtaRds6NP4JJE6Fx7IsIw4n2108zLEoDCgZ5nbp8QouzxnJQ-FdxlDhkyJh0_sx5gUg3Z9lJjaZqjVread684W1RjMD68wxORRFa0Q_Govt0CL_ffnFEtw8HuAB-uc7WxMjhIkdFxyuzcPX4-JfKcJQ2blGFoU2ZtjJsx0q2_QxxIUHOdRcN79ORPeNTQphcwxwIhztXgnaEmWD3ZqLoROKOfCUCMQSDHqgiJtoldK2q3_IuYwFr0S72t98NA4zltWbvk7F6p1zf10OnVr81KiUsU_On41g9DUBr1Af2FxXx7ul60DjhP1NbI-SvGhW5vzOdCkbyBzgaGI1POmZ1jJZ-gpKCXzrWTlYb5exwmOG_Lz_eby1uQ1uv8iUNFe4bA3SpOCxUyLx_TMATSO7WH7EOtPZC6FXcYCAAA="
}
}
8 changes: 6 additions & 2 deletions backend/src/routes/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@ import type { Router } from 'express'
import {
getDefault,
getUserConfig,
saveUserConfig
createUserConfig,
saveUserConfig,
getUserConfigByTag
} from '../controllers/tools.js'

const userRoutes = (router: Router) => {
router.get('/tools/default', getDefault)
router.get('/tools/:id', getUserConfig)
router.post('/tools', saveUserConfig)
router.get('/tools/:id/:tag', getUserConfigByTag)
router.post('/tools', createUserConfig)
router.put('/tools', saveUserConfig)

return router
}
Expand Down
43 changes: 42 additions & 1 deletion backend/src/services/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export const getS3AndParams = (walletAddress: string) => {
})
})

const fileKey = `${walletAddress
const fileKey = `${decodeURIComponent(walletAddress)
.replace('$', '')
.replace('https://', '')}.json`

Expand Down Expand Up @@ -54,3 +54,44 @@ export const streamToString = (
readableStream.on('error', reject)
})
}

// return only properties that are at least levelCount deep
/* eslint-disable @typescript-eslint/no-explicit-any */
export const filterDeepProperties = (
obj: Record<string, any>,
levelCount: number = 2
): Record<string, any> => {
const result: Record<string, any> = {}

const traverse = (
current: any,
path: string[],
parent: Record<string, any>
) => {
if (typeof current === 'object' && current !== null) {
for (const key in current) {
if (Object.prototype.hasOwnProperty.call(current, key)) {
const newPath = [...path, key]

if (typeof current[key] === 'object' && current[key] !== null) {
// Ensure parent structure exists
if (path.length === 0) {
if (!result[key]) result[key] = {}
traverse(current[key], newPath, result[key])
} else {
if (!parent[key]) parent[key] = {}
traverse(current[key], newPath, parent[key])
}
} else if (path.length >= levelCount - 1) {
// Only keep properties that are at least levelCount levels deep
if (!result[path[0]]) result[path[0]] = {}
result[path[0]][key] = current[key]
}
}
}
}
}

traverse(obj, [], result)
return result
}
8 changes: 8 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ export default [
mjs: 'always',
jsx: 'always'
}
],
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_'
}
]
}
},
Expand Down
1 change: 1 addition & 0 deletions frontend/app/components/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const buttonStyles = cva(
'text-sm hover:from-wm-purple hover:to-[#7f7fff] hover:text-white hover:bg-gradient-to-r',
danger:
'disabled:bg-red-200 bg-red-500 hover:bg-red-600 shadow-md text-white',
icon: 'enabled:hover:bg-gradient-to-r enabled:hover:from-wm-green enabled:hover:to-wm-green-fade aspect-square shadow-md max-w-[30px] disabled:bg-gray-200 disabled:hover:bg-gray-200!',
invisible: 'px-1 border-none text-white'
},
size: {
Expand Down
45 changes: 43 additions & 2 deletions frontend/app/components/PageHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,29 @@
import { useNavigate } from '@remix-run/react'
import { cx } from 'class-variance-authority'
import { availableTools } from '~/lib/presets.js'
import { Button, InfoWithTooltip } from './index.js'
import { Button, InfoWithTooltip, Select, SelectOption } from './index.js'
import { Chevron } from './icons.js'

export const PageHeader = ({
elementType,
title,
link,
setImportModalOpen
setImportModalOpen,
setNewVersionModalOpen,
setConfirmModalOpen,
versionOptions,
selectedVersion,
setSelectedVersion
}: {
elementType: string | undefined
title: string
link: string
setImportModalOpen: React.Dispatch<React.SetStateAction<boolean>>
setNewVersionModalOpen: React.Dispatch<React.SetStateAction<boolean>>
setConfirmModalOpen: React.Dispatch<React.SetStateAction<boolean>>
versionOptions: SelectOption[]
selectedVersion: string
setSelectedVersion: (value: string) => void
}) => {
const navigate = useNavigate()

Expand All @@ -33,6 +43,37 @@ export const PageHeader = ({
<InfoWithTooltip tooltip={currentElement?.tooltip} />
</h3>
</div>
<div className="flex mr-2">
<Select
placeholder="Default"
options={versionOptions}
value={versionOptions.find((opt) => opt.value == selectedVersion)}
onChange={(value) => setSelectedVersion(value)}
/>
<Button
intent="icon"
className="mr-2 pt-0"
aria-label="add version"
title="add version"
onClick={() => {
setNewVersionModalOpen(true)
}}
>
+
</Button>
<Button
intent="icon"
className="mr-2 pt-0 text-red-500 "
aria-label="remove version"
title="remove version"
disabled={selectedVersion == 'default'}
onClick={() => {
setConfirmModalOpen(true)
}}
>
x
</Button>
</div>
<div className="ml-auto">
<Button
className="mr-2"
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/components/WalletAddressInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export const WalletAddress = ({
name="walletAddress"
label="Wallet address"
tooltip={tooltips.walletAddress}
value={config.walletAddress || ''}
value={config?.walletAddress || ''}
placeholder="https://ase-provider-url/jdoe"
error={errors?.fieldErrors.walletAddress}
onChange={(e) =>
Expand Down
Loading