redirecter for ao3 that adds opengraph metadata

move defaults to their own import file, sanitize info from searchparams so i don't have to do string comparisons all the time lol, add uppercasing, start adding contrast text colors for accents so i don't have contrast issues

Changed files
+109 -123
src
app
generator
series
[seriesId]
works
[workId]
chapters
[chapterId]
preview
lib
+24 -31
src/app/generator/page.js
···
import baseFonts from "@/lib/baseFonts.js"
import titleFonts from "@/lib/titleFonts.js"
import styles from "./page.module.css"
+
import defaults from "@/lib/ogdefaults.js"
export default function Generator() {
const [url, setUrl] = useState('')
const [workData, setWorkData] = useState(null)
const [addr, setAddr] = useState('')
const [imgData, setImgData] = useState(null)
-
const [props, setProps] = useState({
-
theme: 'ao3',
-
baseFont: 'bricolagegrotesque',
-
titleFont: 'stacksansnotch',
-
category: true,
-
rating: true,
-
warnings: false,
-
charTags: false,
-
relTags: false,
-
freeTags: false,
-
summary: true,
-
wordcount: true,
-
chapters: true,
-
postedAt: true,
-
updatedAt: false,
-
summaryType: 'basic',
-
customSummary: ''
-
})
+
const [props, setProps] = useState(defaults)
const updateProp = (name, value) => {
const newProps = props
···
<li><label><input type="checkbox" name="features[]" value="updatedAt" defaultChecked={props.updatedAt} onChange={e => updateProp(e.target.value, e.target.checked)} /> Updated Date</label></li>
</ul>
</div>
-
<div className="input-field">
-
<label htmlFor="summaryOptions">Summary Type</label>
-
<ul>
-
<li><label><input type="radio" name="summaryType" value="basic" defaultChecked={props.summaryType === 'basic'} onChange={e => updateProp(e.target.name, e.target.value)} /> Story Summary</label></li>
-
<li><label><input type="radio" name="summaryType" defaultChecked={props.summaryType === 'chapter'} value="chapter" onChange={e => updateProp(e.target.name, e.target.value)} /> Chapter Summary (if available)</label></li>
-
<li><label><input type="radio" name="summaryType" defaultChecked={props.summaryType === 'custom'} value="custom" onChange={e => updateProp(e.target.name, e.target.value)} /> Custom Summary</label></li>
-
</ul>
-
{props.summaryType === 'custom' && (
-
<div className="input-field">
-
<label htmlFor="customSummary">Custom Summary</label>
-
<textarea name="customSummary" id="customSummary" onChange={e => updateProp(e.target.name, e.target.value)}></textarea>
-
</div>
-
)}
+
<div className="col">
+
<div className="input-field">
+
<label htmlFor="displayOptions">Display Options</label>
+
<ul>
+
<li><label><input type="checkbox" name="uppercaseTitle" value="uppercaseTitle" defaultChecked={props.uppercaseTitle} onChange={e => updateProp(e.target.value, e.target.checked)} /> Uppercase Title?</label></li>
+
<li><label><input type="checkbox" name="uppercaseChapterName" value="uppercaseChapterName" defaultChecked={props.uppercaseChapterName} onChange={e => updateProp(e.target.value, e.target.checked)} /> Uppercase Chapter Name?</label></li>
+
</ul>
+
</div>
+
<div className="input-field">
+
<label htmlFor="summaryOptions">Summary Type</label>
+
<ul>
+
<li><label><input type="radio" name="summaryType" value="basic" defaultChecked={props.summaryType === 'basic'} onChange={e => updateProp(e.target.name, e.target.value)} /> Story Summary</label></li>
+
<li><label><input type="radio" name="summaryType" defaultChecked={props.summaryType === 'chapter'} value="chapter" onChange={e => updateProp(e.target.name, e.target.value)} /> Chapter Summary (if available)</label></li>
+
<li><label><input type="radio" name="summaryType" defaultChecked={props.summaryType === 'custom'} value="custom" onChange={e => updateProp(e.target.name, e.target.value)} /> Custom Summary</label></li>
+
</ul>
+
{props.summaryType === 'custom' && (
+
<div className="input-field">
+
<label htmlFor="customSummary">Custom Summary</label>
+
<textarea name="customSummary" id="customSummary" onChange={e => updateProp(e.target.name, e.target.value)}></textarea>
+
</div>
+
)}
+
</div>
</div>
</div>
</details>
+1 -20
src/app/series/[seriesId]/opengraph-image.jsx
···
import OGImage from "@/lib/ogimage.js"
import baseFonts from "@/lib/baseFonts.js"
import titleFonts from "@/lib/titleFonts.js"
+
import defaults from "@/lib/ogdefaults.js"
export const size = {
width: 1600,
height: 900,
}
export const alt = 'fixAO3'
-
export const contentType = 'image/webp'
-
-
const defaults = new URLSearchParams({
-
theme: 'ao3',
-
baseFont: 'bricolagegrotesque',
-
titleFont: 'stacksansnotch',
-
category: true,
-
rating: true,
-
warnings: false,
-
charTags: false,
-
relTags: false,
-
freeTags: false,
-
summary: true,
-
wordcount: true,
-
chapters: true,
-
postedAt: true,
-
updatedAt: false,
-
summaryType: 'basic',
-
customSummary: ''
-
})
export default async function Image({params, searchParams}) {
const { seriesId } = await params
+3 -1
src/app/series/[seriesId]/preview/route.js
···
import { getSeries } from "@fujocoded/ao3.js"
+
import querystring from 'node:querystring'
import sanitizeData from "@/lib/sanitizeData.js"
import OGImage from "@/lib/ogimage.js"
import baseFonts from "@/lib/baseFonts.js"
···
export async function GET(req, ctx) {
const { seriesId } = await ctx.params
-
const props = await req.nextUrl.searchParams
+
const p = await req.nextUrl.searchParams
+
const props = querystring.parse(p.toString())
const addr = `series/${seriesId}`
const data = await getSeries({seriesId: seriesId})
const imageParams = await sanitizeData({type: 'series', data: data, props: props})
+1 -20
src/app/works/[workId]/chapters/[chapterId]/opengraph-image.jsx
···
import OGImage from "@/lib/ogimage.js"
import baseFonts from "@/lib/baseFonts.js"
import titleFonts from "@/lib/titleFonts.js"
+
import defaults from "@/lib/ogdefaults.js"
export const size = {
width: 1600,
height: 900,
}
export const alt = 'fixAO3'
-
export const contentType = 'image/webp'
-
-
const defaults = new URLSearchParams({
-
theme: 'ao3',
-
baseFont: 'bricolagegrotesque',
-
titleFont: 'stacksansnotch',
-
category: true,
-
rating: true,
-
warnings: false,
-
charTags: false,
-
relTags: false,
-
freeTags: false,
-
summary: true,
-
wordcount: true,
-
chapters: true,
-
postedAt: true,
-
updatedAt: false,
-
summaryType: 'basic',
-
customSummary: ''
-
})
export default async function Image({params, searchParams}) {
const { workId, chapterId } = await params
+3 -1
src/app/works/[workId]/chapters/[chapterId]/preview/route.js
···
import { getWork } from "@fujocoded/ao3.js"
+
import querystring from 'node:querystring'
import sanitizeData from "@/lib/sanitizeData.js"
import OGImage from "@/lib/ogimage.js"
import baseFonts from "@/lib/baseFonts.js"
···
export async function GET(req, ctx) {
const { workId, chapterId } = await ctx.params
-
const props = await req.nextUrl.searchParams
+
const p = await req.nextUrl.searchParams
+
const props = querystring.parse(p.toString())
const addr = `works/${workId}/chapters/${chapterId}`
const data = await getWork({workId: workId, chapterId: chapterId})
const imageParams = await sanitizeData({type: 'work', data: data, props: props})
+1 -20
src/app/works/[workId]/opengraph-image.jsx
···
import OGImage from "@/lib/ogimage.js"
import baseFonts from "@/lib/baseFonts.js"
import titleFonts from "@/lib/titleFonts.js"
+
import defaults from "@/lib/ogdefaults.js"
export const size = {
width: 1600,
height: 900,
}
export const alt = 'fixAO3'
-
export const contentType = 'image/webp'
-
-
const defaults = new URLSearchParams({
-
theme: 'ao3',
-
baseFont: 'bricolagegrotesque',
-
titleFont: 'stacksansnotch',
-
category: true,
-
rating: true,
-
warnings: false,
-
charTags: false,
-
relTags: false,
-
freeTags: false,
-
summary: true,
-
wordcount: true,
-
chapters: true,
-
postedAt: true,
-
updatedAt: false,
-
summaryType: 'basic',
-
customSummary: ''
-
})
export default async function Image({params}) {
const { workId } = await params
+3 -1
src/app/works/[workId]/preview/route.js
···
import { getWork } from "@fujocoded/ao3.js"
+
import querystring from 'node:querystring'
import sanitizeData from "@/lib/sanitizeData.js"
import OGImage from "@/lib/ogimage.js"
import baseFonts from "@/lib/baseFonts.js"
···
export async function GET(req, ctx) {
const { workId } = await ctx.params
-
const props = await req.nextUrl.searchParams
+
const p = await req.nextUrl.searchParams
+
const props = querystring.parse(p.toString())
const addr = `works/${workId}`
const data = await getWork({workId: workId})
const imageParams = await sanitizeData({type: 'work', data: data, props: props})
+22
src/lib/ogdefaults.js
···
+
const defaults = {
+
theme: 'ao3',
+
baseFont: 'bricolagegrotesque',
+
titleFont: 'stacksansnotch',
+
category: true,
+
rating: true,
+
warnings: false,
+
charTags: false,
+
relTags: false,
+
freeTags: false,
+
summary: true,
+
wordcount: true,
+
chapters: true,
+
postedAt: true,
+
updatedAt: false,
+
uppercaseTitle: false,
+
uppercaseChapterName: false,
+
summaryType: 'basic',
+
customSummary: ''
+
}
+
+
export default defaults
+25 -23
src/lib/ogimage.js
···
{image.topLine}
</div>
-
{image.props.get('rating') === 'true' && image.rating === 'E' && (<Explicit fg={theme.background} bg={theme.accent} width={28} height={28} />)}
-
{image.props.get('rating') === 'true' && image.rating === 'M' && (<Mature fg={theme.background} bg={theme.accent} width={28} height={28} />)}
-
{image.props.get('rating') === 'true' && image.rating === 'T' && (<Teen fg={theme.background} bg={theme.accent} width={28} height={28} />)}
-
{image.props.get('rating') === 'true' && image.rating === 'G' && (<General fg={theme.background} bg={theme.accent} width={28} height={28} />)}
-
{image.props.get('rating') === 'true' && image.rating === 'NR' && (<NotRated fg={theme.background} bg={theme.accent} width={28} height={28} />)}
+
{image.props.rating && image.rating === 'E' && (<Explicit fg={theme.background} bg={theme.accent} width={28} height={28} />)}
+
{image.props.rating && image.rating === 'M' && (<Mature fg={theme.background} bg={theme.accent} width={28} height={28} />)}
+
{image.props.rating && image.rating === 'T' && (<Teen fg={theme.background} bg={theme.accent} width={28} height={28} />)}
+
{image.props.rating && image.rating === 'G' && (<General fg={theme.background} bg={theme.accent} width={28} height={28} />)}
+
{image.props.rating && image.rating === 'NR' && (<NotRated fg={theme.background} bg={theme.accent} width={28} height={28} />)}
-
{image.props.get('warnings') === 'true' && image.warning === 'NW' && (<NoWarnings fg={theme.background} bg={theme.accent2} width={28} height={28} />)}
-
{image.props.get('warnings') === 'true' && image.warning === 'CNTW' && (<ChoseNotToWarn fg={theme.background} bg={theme.accent2} width={28} height={28} />)}
-
{image.props.get('warnings') === 'true' && image.warning === 'W' && (<Warnings fg={theme.background} bg={theme.accent2} width={28} height={28} />)}
+
{image.props.warnings && image.warning === 'NW' && (<NoWarnings fg={theme.background} bg={theme.accent2} width={28} height={28} />)}
+
{image.props.warnings && image.warning === 'CNTW' && (<ChoseNotToWarn fg={theme.background} bg={theme.accent2} width={28} height={28} />)}
+
{image.props.warnings && image.warning === 'W' && (<Warnings fg={theme.background} bg={theme.accent2} width={28} height={28} />)}
-
{image.props.get('category') === 'true' && image.category === 'F' && (<Yuri fg={theme.background} bg={theme.accent3} width={28} height={28} />)}
-
{image.props.get('category') === 'true' && image.category === 'M' && (<Yaoi fg={theme.background} bg={theme.accent3} width={28} height={28} />)}
-
{image.props.get('category') === 'true' && image.category === 'FM' && (<Het fg={theme.background} bg={theme.accent3} width={28} height={28} />)}
-
{image.props.get('category') === 'true' && image.category === 'G' && (<Gen fg={theme.background} bg={theme.accent3} width={28} height={28} />)}
-
{image.props.get('category') === 'true' && image.category === 'MX' && (<MultiShip fg={theme.background} bg={theme.accent3} width={28} height={28} />)}
-
{image.props.get('category') === 'true' && image.category === 'O' && (<OtherShip fg={theme.background} bg={theme.accent3} width={28} height={28} />)}
+
{image.props.category && image.category === 'F' && (<Yuri fg={theme.background} bg={theme.accent3} width={28} height={28} />)}
+
{image.props.category && image.category === 'M' && (<Yaoi fg={theme.background} bg={theme.accent3} width={28} height={28} />)}
+
{image.props.category && image.category === 'FM' && (<Het fg={theme.background} bg={theme.accent3} width={28} height={28} />)}
+
{image.props.category && image.category === 'G' && (<Gen fg={theme.background} bg={theme.accent3} width={28} height={28} />)}
+
{image.props.category && image.category === 'MX' && (<MultiShip fg={theme.background} bg={theme.accent3} width={28} height={28} />)}
+
{image.props.category && image.category === 'O' && (<OtherShip fg={theme.background} bg={theme.accent3} width={28} height={28} />)}
</div>
<div
style={{
···
justifyContent: "center",
fontFamily: titleFont,
fontWeight: "bold",
-
color: theme.color
+
color: theme.color,
+
textTransform: (image.props.uppercaseTitle ? 'uppercase' : 'none')
}}
>
{image.titleLine}
···
>
{`by ${image.authorLine}`}
</div>
-
<div
+
{image.chapterLine !== '' && (<div
style={{
fontStyle: "italic",
fontSize: 36,
fontFamily: titleFont,
display: "flex",
justifyContent: "center",
-
color: theme.color
+
color: theme.color,
+
textTransform: (image.props.uppercaseChapterName ? 'uppercase' : 'none')
}}
>
{image.chapterLine}
-
</div>
+
</div>)}
</div>
<div
style={{
···
alignItems: "flex-end"
}}
>
-
{image.props.get("charTags") === 'true' && (<div
+
{image.props.charTags && (<div
style={{
display: "flex",
flexWrap: "wrap",
···
</span>
))}
</div>)}
-
{image.props.get("relTags") === 'true' && (<div
+
{image.props.relTags && (<div
style={{
display: "flex",
flexWrap: "wrap",
···
</span>
))}
</div>)}
-
{image.props.get("freeTags") === 'true' && (<div
+
{image.props.freeTags && (<div
style={{
display: "flex",
flexWrap: "wrap",
···
</span>
))}
</div>)}
-
{image.props.get("summary") === 'true' && (<div
+
{image.props.summary && (<div
style={{
display: "flex",
flexDirection: "column",
···
color: theme.accent2
}}
>
-
{image.props.get("wordcount") === 'true' && `${image.words} words • `}{(image.props.get("chapters") === 'true' && image.chapterCount !== null) && `${image.chapterCount} chapters • `}{image.props.get("postedAt") === 'true' && `posted on ${image.postedAt} • `}{image.props.get("updatedAt") === 'true' && `updated on ${image.updatedAt} • `}{process.env.ARCHIVE}/{addr}
+
{image.props.wordcount && `${image.words} words • `}{(image.props.chapters && image.chapterCount !== null) && `${image.chapterCount} chapters • `}{image.props.postedAt && `posted on ${image.postedAt} • `}{image.props.updatedAt && `updated on ${image.updatedAt} • `}{process.env.ARCHIVE}/{addr}
</div>
</div>
</div>
+25 -6
src/lib/sanitizeData.js
···
return "MX"
}
+
const sanitizeProps = (props) => {
+
let propsParsed = {}
+
Object.keys(props).forEach((pr) => {
+
if (props[pr] === 'true') {
+
propsParsed[pr] = true
+
return
+
} else if (props[pr] === 'false') {
+
propsParsed[pr] = false
+
return
+
} else if (typeof parseInt(props[pr]) === 'Number') {
+
propsParsed[pr] = parseInt(props[pr])
+
return
+
}
+
propsParsed[pr] = props[pr]
+
})
+
return propsParsed
+
}
+
export default async function sanitizeData ({ type, data, props}) {
-
const baseFont = props.has('baseFont') ? props.get('baseFont') : process.env.DEFAULT_BASE_FONT
+
const propsParsed = sanitizeProps(props)
+
const baseFont = propsParsed.baseFont ? propsParsed.baseFont : process.env.DEFAULT_BASE_FONT
const baseFontData = baseFonts[baseFont]
-
const titleFont = props.has('titleFont') ? props.get('titleFont') : process.env.DEFAULT_TITLE_FONT
+
const titleFont = propsParsed.titleFont ? propsParsed.titleFont : process.env.DEFAULT_TITLE_FONT
const titleFontData = titleFonts[titleFont]
-
const themeData = props.has('theme') ? themes[props.get('theme')] : themes[process.env.DEFAULT_THEME]
+
const themeData = propsParsed.theme ? themes[propsParsed.theme] : themes[process.env.DEFAULT_THEME]
const parentWork = type === 'work' && data.chapterInfo ? await getWork({workId: data.id}) : null
const bfs = await Promise.all(baseFontData.defs.map(async (bf) => {
return {
···
authorsFormatted.slice(-1)[0]
: authorsFormatted[0]
const summaryContent = type === 'work'
-
? (props.get('summaryType') === 'chapter' && data.chapterInfo && data.chapterInfo.summary ? data.chapterInfo.summary : (props.get('summaryType') === 'custom' && props.has('customSummary') ? props.get('customSummary') : (data.summary ? data.summary : (parentWork ? parentWork.summary : ''))))
-
: (props.get('summaryType') === 'custom' && props.has('customSummary') ? props.get('customSummary') : data.notes)
+
? (propsParsed.summaryType === 'chapter' && data.chapterInfo && data.chapterInfo.summary ? data.chapterInfo.summary : (propsParsed.summaryType === 'custom' && propsParsed.customSummary !== '' ? propsParsed.customSummary : (data.summary ? data.summary : (parentWork ? parentWork.summary : ''))))
+
: (propsParsed.summaryType === 'custom' && propsParsed.customSummary !== '' ? propsParsed.customSummary : data.notes)
const formatter = new Intl.NumberFormat('en-US')
const words = formatter.format(data.words)
const summaryDOM = new DOM(summaryContent, {decodeEntities: true});
···
updatedAt: data.updatedAt,
baseFont: baseFont,
titleFont: titleFont,
-
props: props,
+
props: propsParsed,
opts: {
fonts: bfs.concat(tfs)
}
+1
src/lib/themes.js
···
descBackground: '#ccd0da',
descColor: '#5c5f77',
accent: '#dc8a78',
+
accentColor: '#FFFFFF',
accent2: '#8839ef',
accent3: '#fe640b',
accent4: '#04a5e5'