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
··· 5 5 import baseFonts from "@/lib/baseFonts.js" 6 6 import titleFonts from "@/lib/titleFonts.js" 7 7 import styles from "./page.module.css" 8 + import defaults from "@/lib/ogdefaults.js" 8 9 9 10 export default function Generator() { 10 11 const [url, setUrl] = useState('') 11 12 const [workData, setWorkData] = useState(null) 12 13 const [addr, setAddr] = useState('') 13 14 const [imgData, setImgData] = useState(null) 14 - const [props, setProps] = useState({ 15 - theme: 'ao3', 16 - baseFont: 'bricolagegrotesque', 17 - titleFont: 'stacksansnotch', 18 - category: true, 19 - rating: true, 20 - warnings: false, 21 - charTags: false, 22 - relTags: false, 23 - freeTags: false, 24 - summary: true, 25 - wordcount: true, 26 - chapters: true, 27 - postedAt: true, 28 - updatedAt: false, 29 - summaryType: 'basic', 30 - customSummary: '' 31 - }) 15 + const [props, setProps] = useState(defaults) 32 16 33 17 const updateProp = (name, value) => { 34 18 const newProps = props ··· 132 116 <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> 133 117 </ul> 134 118 </div> 135 - <div className="input-field"> 136 - <label htmlFor="summaryOptions">Summary Type</label> 137 - <ul> 138 - <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> 139 - <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> 140 - <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> 141 - </ul> 142 - {props.summaryType === 'custom' && ( 143 - <div className="input-field"> 144 - <label htmlFor="customSummary">Custom Summary</label> 145 - <textarea name="customSummary" id="customSummary" onChange={e => updateProp(e.target.name, e.target.value)}></textarea> 146 - </div> 147 - )} 119 + <div className="col"> 120 + <div className="input-field"> 121 + <label htmlFor="displayOptions">Display Options</label> 122 + <ul> 123 + <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> 124 + <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> 125 + </ul> 126 + </div> 127 + <div className="input-field"> 128 + <label htmlFor="summaryOptions">Summary Type</label> 129 + <ul> 130 + <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> 131 + <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> 132 + <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> 133 + </ul> 134 + {props.summaryType === 'custom' && ( 135 + <div className="input-field"> 136 + <label htmlFor="customSummary">Custom Summary</label> 137 + <textarea name="customSummary" id="customSummary" onChange={e => updateProp(e.target.name, e.target.value)}></textarea> 138 + </div> 139 + )} 140 + </div> 148 141 </div> 149 142 </div> 150 143 </details>
+1 -20
src/app/series/[seriesId]/opengraph-image.jsx
··· 3 3 import OGImage from "@/lib/ogimage.js" 4 4 import baseFonts from "@/lib/baseFonts.js" 5 5 import titleFonts from "@/lib/titleFonts.js" 6 + import defaults from "@/lib/ogdefaults.js" 6 7 7 8 export const size = { 8 9 width: 1600, 9 10 height: 900, 10 11 } 11 12 export const alt = 'fixAO3' 12 - 13 13 export const contentType = 'image/webp' 14 - 15 - const defaults = new URLSearchParams({ 16 - theme: 'ao3', 17 - baseFont: 'bricolagegrotesque', 18 - titleFont: 'stacksansnotch', 19 - category: true, 20 - rating: true, 21 - warnings: false, 22 - charTags: false, 23 - relTags: false, 24 - freeTags: false, 25 - summary: true, 26 - wordcount: true, 27 - chapters: true, 28 - postedAt: true, 29 - updatedAt: false, 30 - summaryType: 'basic', 31 - customSummary: '' 32 - }) 33 14 34 15 export default async function Image({params, searchParams}) { 35 16 const { seriesId } = await params
+3 -1
src/app/series/[seriesId]/preview/route.js
··· 1 1 import { getSeries } from "@fujocoded/ao3.js" 2 + import querystring from 'node:querystring' 2 3 import sanitizeData from "@/lib/sanitizeData.js" 3 4 import OGImage from "@/lib/ogimage.js" 4 5 import baseFonts from "@/lib/baseFonts.js" ··· 13 14 14 15 export async function GET(req, ctx) { 15 16 const { seriesId } = await ctx.params 16 - const props = await req.nextUrl.searchParams 17 + const p = await req.nextUrl.searchParams 18 + const props = querystring.parse(p.toString()) 17 19 const addr = `series/${seriesId}` 18 20 const data = await getSeries({seriesId: seriesId}) 19 21 const imageParams = await sanitizeData({type: 'series', data: data, props: props})
+1 -20
src/app/works/[workId]/chapters/[chapterId]/opengraph-image.jsx
··· 3 3 import OGImage from "@/lib/ogimage.js" 4 4 import baseFonts from "@/lib/baseFonts.js" 5 5 import titleFonts from "@/lib/titleFonts.js" 6 + import defaults from "@/lib/ogdefaults.js" 6 7 7 8 export const size = { 8 9 width: 1600, 9 10 height: 900, 10 11 } 11 12 export const alt = 'fixAO3' 12 - 13 13 export const contentType = 'image/webp' 14 - 15 - const defaults = new URLSearchParams({ 16 - theme: 'ao3', 17 - baseFont: 'bricolagegrotesque', 18 - titleFont: 'stacksansnotch', 19 - category: true, 20 - rating: true, 21 - warnings: false, 22 - charTags: false, 23 - relTags: false, 24 - freeTags: false, 25 - summary: true, 26 - wordcount: true, 27 - chapters: true, 28 - postedAt: true, 29 - updatedAt: false, 30 - summaryType: 'basic', 31 - customSummary: '' 32 - }) 33 14 34 15 export default async function Image({params, searchParams}) { 35 16 const { workId, chapterId } = await params
+3 -1
src/app/works/[workId]/chapters/[chapterId]/preview/route.js
··· 1 1 import { getWork } from "@fujocoded/ao3.js" 2 + import querystring from 'node:querystring' 2 3 import sanitizeData from "@/lib/sanitizeData.js" 3 4 import OGImage from "@/lib/ogimage.js" 4 5 import baseFonts from "@/lib/baseFonts.js" ··· 13 14 14 15 export async function GET(req, ctx) { 15 16 const { workId, chapterId } = await ctx.params 16 - const props = await req.nextUrl.searchParams 17 + const p = await req.nextUrl.searchParams 18 + const props = querystring.parse(p.toString()) 17 19 const addr = `works/${workId}/chapters/${chapterId}` 18 20 const data = await getWork({workId: workId, chapterId: chapterId}) 19 21 const imageParams = await sanitizeData({type: 'work', data: data, props: props})
+1 -20
src/app/works/[workId]/opengraph-image.jsx
··· 3 3 import OGImage from "@/lib/ogimage.js" 4 4 import baseFonts from "@/lib/baseFonts.js" 5 5 import titleFonts from "@/lib/titleFonts.js" 6 + import defaults from "@/lib/ogdefaults.js" 6 7 7 8 export const size = { 8 9 width: 1600, 9 10 height: 900, 10 11 } 11 12 export const alt = 'fixAO3' 12 - 13 13 export const contentType = 'image/webp' 14 - 15 - const defaults = new URLSearchParams({ 16 - theme: 'ao3', 17 - baseFont: 'bricolagegrotesque', 18 - titleFont: 'stacksansnotch', 19 - category: true, 20 - rating: true, 21 - warnings: false, 22 - charTags: false, 23 - relTags: false, 24 - freeTags: false, 25 - summary: true, 26 - wordcount: true, 27 - chapters: true, 28 - postedAt: true, 29 - updatedAt: false, 30 - summaryType: 'basic', 31 - customSummary: '' 32 - }) 33 14 34 15 export default async function Image({params}) { 35 16 const { workId } = await params
+3 -1
src/app/works/[workId]/preview/route.js
··· 1 1 import { getWork } from "@fujocoded/ao3.js" 2 + import querystring from 'node:querystring' 2 3 import sanitizeData from "@/lib/sanitizeData.js" 3 4 import OGImage from "@/lib/ogimage.js" 4 5 import baseFonts from "@/lib/baseFonts.js" ··· 13 14 14 15 export async function GET(req, ctx) { 15 16 const { workId } = await ctx.params 16 - const props = await req.nextUrl.searchParams 17 + const p = await req.nextUrl.searchParams 18 + const props = querystring.parse(p.toString()) 17 19 const addr = `works/${workId}` 18 20 const data = await getWork({workId: workId}) 19 21 const imageParams = await sanitizeData({type: 'work', data: data, props: props})
+22
src/lib/ogdefaults.js
··· 1 + const defaults = { 2 + theme: 'ao3', 3 + baseFont: 'bricolagegrotesque', 4 + titleFont: 'stacksansnotch', 5 + category: true, 6 + rating: true, 7 + warnings: false, 8 + charTags: false, 9 + relTags: false, 10 + freeTags: false, 11 + summary: true, 12 + wordcount: true, 13 + chapters: true, 14 + postedAt: true, 15 + updatedAt: false, 16 + uppercaseTitle: false, 17 + uppercaseChapterName: false, 18 + summaryType: 'basic', 19 + customSummary: '' 20 + } 21 + 22 + export default defaults
+25 -23
src/lib/ogimage.js
··· 55 55 {image.topLine} 56 56 </div> 57 57 58 - {image.props.get('rating') === 'true' && image.rating === 'E' && (<Explicit fg={theme.background} bg={theme.accent} width={28} height={28} />)} 59 - {image.props.get('rating') === 'true' && image.rating === 'M' && (<Mature fg={theme.background} bg={theme.accent} width={28} height={28} />)} 60 - {image.props.get('rating') === 'true' && image.rating === 'T' && (<Teen fg={theme.background} bg={theme.accent} width={28} height={28} />)} 61 - {image.props.get('rating') === 'true' && image.rating === 'G' && (<General fg={theme.background} bg={theme.accent} width={28} height={28} />)} 62 - {image.props.get('rating') === 'true' && image.rating === 'NR' && (<NotRated fg={theme.background} bg={theme.accent} width={28} height={28} />)} 58 + {image.props.rating && image.rating === 'E' && (<Explicit fg={theme.background} bg={theme.accent} width={28} height={28} />)} 59 + {image.props.rating && image.rating === 'M' && (<Mature fg={theme.background} bg={theme.accent} width={28} height={28} />)} 60 + {image.props.rating && image.rating === 'T' && (<Teen fg={theme.background} bg={theme.accent} width={28} height={28} />)} 61 + {image.props.rating && image.rating === 'G' && (<General fg={theme.background} bg={theme.accent} width={28} height={28} />)} 62 + {image.props.rating && image.rating === 'NR' && (<NotRated fg={theme.background} bg={theme.accent} width={28} height={28} />)} 63 63 64 - {image.props.get('warnings') === 'true' && image.warning === 'NW' && (<NoWarnings fg={theme.background} bg={theme.accent2} width={28} height={28} />)} 65 - {image.props.get('warnings') === 'true' && image.warning === 'CNTW' && (<ChoseNotToWarn fg={theme.background} bg={theme.accent2} width={28} height={28} />)} 66 - {image.props.get('warnings') === 'true' && image.warning === 'W' && (<Warnings fg={theme.background} bg={theme.accent2} width={28} height={28} />)} 64 + {image.props.warnings && image.warning === 'NW' && (<NoWarnings fg={theme.background} bg={theme.accent2} width={28} height={28} />)} 65 + {image.props.warnings && image.warning === 'CNTW' && (<ChoseNotToWarn fg={theme.background} bg={theme.accent2} width={28} height={28} />)} 66 + {image.props.warnings && image.warning === 'W' && (<Warnings fg={theme.background} bg={theme.accent2} width={28} height={28} />)} 67 67 68 - {image.props.get('category') === 'true' && image.category === 'F' && (<Yuri fg={theme.background} bg={theme.accent3} width={28} height={28} />)} 69 - {image.props.get('category') === 'true' && image.category === 'M' && (<Yaoi fg={theme.background} bg={theme.accent3} width={28} height={28} />)} 70 - {image.props.get('category') === 'true' && image.category === 'FM' && (<Het fg={theme.background} bg={theme.accent3} width={28} height={28} />)} 71 - {image.props.get('category') === 'true' && image.category === 'G' && (<Gen fg={theme.background} bg={theme.accent3} width={28} height={28} />)} 72 - {image.props.get('category') === 'true' && image.category === 'MX' && (<MultiShip fg={theme.background} bg={theme.accent3} width={28} height={28} />)} 73 - {image.props.get('category') === 'true' && image.category === 'O' && (<OtherShip fg={theme.background} bg={theme.accent3} width={28} height={28} />)} 68 + {image.props.category && image.category === 'F' && (<Yuri fg={theme.background} bg={theme.accent3} width={28} height={28} />)} 69 + {image.props.category && image.category === 'M' && (<Yaoi fg={theme.background} bg={theme.accent3} width={28} height={28} />)} 70 + {image.props.category && image.category === 'FM' && (<Het fg={theme.background} bg={theme.accent3} width={28} height={28} />)} 71 + {image.props.category && image.category === 'G' && (<Gen fg={theme.background} bg={theme.accent3} width={28} height={28} />)} 72 + {image.props.category && image.category === 'MX' && (<MultiShip fg={theme.background} bg={theme.accent3} width={28} height={28} />)} 73 + {image.props.category && image.category === 'O' && (<OtherShip fg={theme.background} bg={theme.accent3} width={28} height={28} />)} 74 74 </div> 75 75 <div 76 76 style={{ ··· 78 78 justifyContent: "center", 79 79 fontFamily: titleFont, 80 80 fontWeight: "bold", 81 - color: theme.color 81 + color: theme.color, 82 + textTransform: (image.props.uppercaseTitle ? 'uppercase' : 'none') 82 83 }} 83 84 > 84 85 {image.titleLine} ··· 94 95 > 95 96 {`by ${image.authorLine}`} 96 97 </div> 97 - <div 98 + {image.chapterLine !== '' && (<div 98 99 style={{ 99 100 fontStyle: "italic", 100 101 fontSize: 36, 101 102 fontFamily: titleFont, 102 103 display: "flex", 103 104 justifyContent: "center", 104 - color: theme.color 105 + color: theme.color, 106 + textTransform: (image.props.uppercaseChapterName ? 'uppercase' : 'none') 105 107 }} 106 108 > 107 109 {image.chapterLine} 108 - </div> 110 + </div>)} 109 111 </div> 110 112 <div 111 113 style={{ ··· 118 120 alignItems: "flex-end" 119 121 }} 120 122 > 121 - {image.props.get("charTags") === 'true' && (<div 123 + {image.props.charTags && (<div 122 124 style={{ 123 125 display: "flex", 124 126 flexWrap: "wrap", ··· 141 143 </span> 142 144 ))} 143 145 </div>)} 144 - {image.props.get("relTags") === 'true' && (<div 146 + {image.props.relTags && (<div 145 147 style={{ 146 148 display: "flex", 147 149 flexWrap: "wrap", ··· 164 166 </span> 165 167 ))} 166 168 </div>)} 167 - {image.props.get("freeTags") === 'true' && (<div 169 + {image.props.freeTags && (<div 168 170 style={{ 169 171 display: "flex", 170 172 flexWrap: "wrap", ··· 187 189 </span> 188 190 ))} 189 191 </div>)} 190 - {image.props.get("summary") === 'true' && (<div 192 + {image.props.summary && (<div 191 193 style={{ 192 194 display: "flex", 193 195 flexDirection: "column", ··· 216 218 color: theme.accent2 217 219 }} 218 220 > 219 - {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} 221 + {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} 220 222 </div> 221 223 </div> 222 224 </div>
+25 -6
src/lib/sanitizeData.js
··· 56 56 return "MX" 57 57 } 58 58 59 + const sanitizeProps = (props) => { 60 + let propsParsed = {} 61 + Object.keys(props).forEach((pr) => { 62 + if (props[pr] === 'true') { 63 + propsParsed[pr] = true 64 + return 65 + } else if (props[pr] === 'false') { 66 + propsParsed[pr] = false 67 + return 68 + } else if (typeof parseInt(props[pr]) === 'Number') { 69 + propsParsed[pr] = parseInt(props[pr]) 70 + return 71 + } 72 + propsParsed[pr] = props[pr] 73 + }) 74 + return propsParsed 75 + } 76 + 59 77 export default async function sanitizeData ({ type, data, props}) { 60 - const baseFont = props.has('baseFont') ? props.get('baseFont') : process.env.DEFAULT_BASE_FONT 78 + const propsParsed = sanitizeProps(props) 79 + const baseFont = propsParsed.baseFont ? propsParsed.baseFont : process.env.DEFAULT_BASE_FONT 61 80 const baseFontData = baseFonts[baseFont] 62 - const titleFont = props.has('titleFont') ? props.get('titleFont') : process.env.DEFAULT_TITLE_FONT 81 + const titleFont = propsParsed.titleFont ? propsParsed.titleFont : process.env.DEFAULT_TITLE_FONT 63 82 const titleFontData = titleFonts[titleFont] 64 - const themeData = props.has('theme') ? themes[props.get('theme')] : themes[process.env.DEFAULT_THEME] 83 + const themeData = propsParsed.theme ? themes[propsParsed.theme] : themes[process.env.DEFAULT_THEME] 65 84 const parentWork = type === 'work' && data.chapterInfo ? await getWork({workId: data.id}) : null 66 85 const bfs = await Promise.all(baseFontData.defs.map(async (bf) => { 67 86 return { ··· 98 117 authorsFormatted.slice(-1)[0] 99 118 : authorsFormatted[0] 100 119 const summaryContent = type === 'work' 101 - ? (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 : '')))) 102 - : (props.get('summaryType') === 'custom' && props.has('customSummary') ? props.get('customSummary') : data.notes) 120 + ? (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 : '')))) 121 + : (propsParsed.summaryType === 'custom' && propsParsed.customSummary !== '' ? propsParsed.customSummary : data.notes) 103 122 const formatter = new Intl.NumberFormat('en-US') 104 123 const words = formatter.format(data.words) 105 124 const summaryDOM = new DOM(summaryContent, {decodeEntities: true}); ··· 153 172 updatedAt: data.updatedAt, 154 173 baseFont: baseFont, 155 174 titleFont: titleFont, 156 - props: props, 175 + props: propsParsed, 157 176 opts: { 158 177 fonts: bfs.concat(tfs) 159 178 }
+1
src/lib/themes.js
··· 127 127 descBackground: '#ccd0da', 128 128 descColor: '#5c5f77', 129 129 accent: '#dc8a78', 130 + accentColor: '#FFFFFF', 130 131 accent2: '#8839ef', 131 132 accent3: '#fe640b', 132 133 accent4: '#04a5e5'