redirecter for ao3 that adds opengraph metadata

remove unneeded styles, add fonts and generator page

+15
baseFonts.js
···
+
const baseFonts = {
+
georgia: 'Georgia',
+
verdana: 'Verdana',
+
opensans: 'Open Sans',
+
bricolage: 'Bricolage Grotesque',
+
spacemono: 'Space Mono',
+
inconsolata: 'Inconsolata',
+
bitter: 'Bitter',
+
archivo: 'Archivo',
+
outfit: 'Outfit',
+
notosans: 'Noto Sans',
+
librebaskerville: 'Libre Baskerville'
+
}
+
+
export default baseFonts
+66 -61
main.jsx
···
-
import { getSeries, getUser, getWork } from "@fujocoded/ao3.js";
-
import { Hono } from "hono";
-
import PageSkeleton from "./pages/PageSkeleton.jsx";
-
import Home from "./pages/Home.jsx";
-
import Image from "./pages/Image.jsx";
+
import { getSeries, getUser, getWork } from "@fujocoded/ao3.js"
+
import { Hono } from "hono"
+
import PageSkeleton from "./pages/PageSkeleton.jsx"
+
import Home from "./pages/Home.jsx"
+
import Image from "./pages/Image.jsx"
+
import Generator from "./pages/Generator.jsx"
-
const app = new Hono();
+
const app = new Hono()
app.get("/", (c) => {
-
return c.html(<Home />);
-
});
+
return c.html(<Home />)
+
})
+
+
app.get("/generator", (c) => {
+
return c.html(<Generator />)
+
})
app.get("/works/:workId", async (c) => {
-
const workId = c.req.param("workId");
+
const workId = c.req.param("workId")
const work = await getWork({
workId: c.req.param("workId"),
chapterId: c.req.param("chapterId"),
-
});
+
})
const authorsFormatted = work.authors.map((a) => {
-
if (a.anonymous) return "Anonymous";
-
if (a.pseud !== a.username) return `${a.pseud} (${a.username})`;
-
return a.username;
-
});
+
if (a.anonymous) return "Anonymous"
+
if (a.pseud !== a.username) return `${a.pseud} (${a.username})`
+
return a.username
+
})
const authors = authorsFormatted.length > 1
? authorsFormatted.slice(0, -1).join(", ") + " & " +
authorsFormatted.slice(-1)
-
: authorsFormatted[0];
-
const title = `${work.title} by ${authors} - ${work.fandoms.join(", ")}`;
+
: authorsFormatted[0]
+
const title = `${work.title} by ${authors} - ${work.fandoms.join(", ")}`
const desc = `Rating: ${work.rating} | ${work.category} | Updated ${
work.updatedAt ? work.updatedAt : work.publishedAt
} | Words: ${work.words} | ${
work.complete ? "Complete | " : ""
-
} ${work.summary}`;
+
} ${work.summary}`
return c.html(
<PageSkeleton title={title} description={desc} addr={`works/${workId}`} />,
-
);
-
});
+
)
+
})
app.get("/works/:workId/chapters/:chapterId", async (c) => {
-
const workId = c.req.param("workId");
-
const chapterId = c.req.param("chapterId");
+
const workId = c.req.param("workId")
+
const chapterId = c.req.param("chapterId")
const work = await getWork({
workId: c.req.param("workId"),
chapterId: c.req.param("chapterId"),
-
});
+
})
const authorsFormatted = work.authors.map((a) => {
-
if (a.anonymous) return "Anonymous";
-
if (a.pseud !== a.username) return `${a.pseud} (${a.username})`;
-
return a.username;
-
});
+
if (a.anonymous) return "Anonymous"
+
if (a.pseud !== a.username) return `${a.pseud} (${a.username})`
+
return a.username
+
})
const authors = authorsFormatted.length > 1
? authorsFormatted.slice(0, -1).join(", ") + " & " +
authorsFormatted.slice(-1)
-
: authorsFormatted[0];
+
: authorsFormatted[0]
const title =
`${work.title} by ${authors}, Chapter ${work.chapterInfo.index}${
work.chapterInfo.name ? `: ${work.chapterInfo.name}` : ""
-
} - ${work.fandoms.join(", ")}`;
+
} - ${work.fandoms.join(", ")}`
const desc = `Rating: ${work.rating} | ${work.category} | Updated ${
work.updatedAt ? work.updatedAt : work.publishedAt
} | Words: ${work.words} | ${work.complete ? "Complete | " : ""} ${
work.chapterInfo.summary ? work.chapterInfo.summary : work.summary
-
}`;
+
}`
return c.html(
<PageSkeleton
title={title}
description={desc}
addr={`works/${workId}/chapters/${chapterId}`}
/>,
-
);
-
});
+
)
+
})
app.get("/series/:seriesId", async (c) => {
-
const seriesId = c.req.param("seriesId");
-
const series = await getSeries({ seriesId: seriesId });
+
const seriesId = c.req.param("seriesId")
+
const series = await getSeries({ seriesId: seriesId })
const authorsFormatted = series.authors.map((a) => {
-
if (a.anonymous) return "Anonymous";
-
if (a.pseud !== a.username) return `${a.pseud} (${a.username})`;
-
return a.username;
-
});
+
if (a.anonymous) return "Anonymous"
+
if (a.pseud !== a.username) return `${a.pseud} (${a.username})`
+
return a.username
+
})
const authors = authorsFormatted.length > 1
? authorsFormatted.slice(0, -1).join(", ") + " & " +
authorsFormatted.slice(-1)
-
: authorsFormatted[0];
-
const title = `${series.name} by ${authors}`;
+
: authorsFormatted[0]
+
const title = `${series.name} by ${authors}`
const desc = ` Updated ${
series.updatedAt ? series.updatedAt : series.publishedAt
} | Works: ${series.worksCount} | ${
series.complete ? "Complete | " : ""
-
} ${series.notes}`;
+
} ${series.notes}`
return c.html(
<PageSkeleton
title={title}
description={desc}
addr={`series/${seriesId}`}
/>,
-
);
-
});
+
)
+
})
app.get("/users/:username", async (c) => {
-
const username = c.req.param("username");
-
const user = await getUser({ username: username });
+
const username = c.req.param("username")
+
const user = await getUser({ username: username })
return c.html(
<PageSkeleton
title={`${username}`}
description={user.header}
addr={`users/${username}`}
/>,
-
);
-
});
+
)
+
})
app.get("/users/:username/pseuds/:pseud", async (c) => {
-
const username = c.req.param("username");
-
const pseud = c.req.param("pseud");
-
const user = await getUser({ username: username });
+
const username = c.req.param("username")
+
const pseud = c.req.param("pseud")
+
const user = await getUser({ username: username })
return c.html(
<PageSkeleton
title={`${pseud} (${username})`}
description={user.header}
addr={`users/${username}`}
/>,
-
);
-
});
+
)
+
})
app.get("/preview/works/:workId", async (c) => {
-
const workId = c.req.param("workId");
+
const workId = c.req.param("workId")
const work = await getWork({
workId: workId,
-
});
-
const addr = `works/${workId}`;
-
return Image({ data: work, addr: addr });
-
});
+
})
+
const addr = `works/${workId}`
+
return Image({ data: work, addr: addr })
+
})
app.get("/preview/works/:workId/chapters/:chapterId", async (c) => {
-
const workId = c.req.param("workId");
-
const chapterId = c.req.param("chapterId");
+
const workId = c.req.param("workId")
+
const chapterId = c.req.param("chapterId")
const work = await getWork({
workId: workId,
chapterId: chapterId,
-
});
-
const addr = `works/${workId}/chapters/${chapterId}`;
+
})
+
const addr = `works/${workId}/chapters/${chapterId}`
return Image({ data: work, addr: addr })
})
-
export default app;
+
export default app
+138
pages/Generator.jsx
···
+
import { raw } from "hono/html"
+
import { getSeries, getWork } from "@fujocoded/ao3.js"
+
import { useEffect, useState, useRef } from "hono/jsx/dom"
+
import Image from './Image.jsx'
+
import themes from '../themes.js'
+
import baseFonts from '../baseFonts.js'
+
import titleFonts from '../titleFonts.js'
+
+
const Generator = ({ title, description, addr }) => {
+
const [url, setUrl] = useState('')
+
const [workData, setWorkData] = useState(null)
+
const [theme, setTheme] = useState('ao3')
+
const [baseFont, setBaseFont] = useState('verdana')
+
const [titleFont, setTitleFont] = useState('georgia')
+
const [props, setProps] = useState({
+
category: true,
+
rating: true,
+
warnings: false,
+
charTags: false,
+
relTags: false,
+
freeTags: false,
+
summary: true,
+
wordcount: true,
+
chapters: true
+
summaryType: 'basic',
+
customSummary: ''
+
})
+
+
const updateProp = (name, value) => {
+
const newProps = props
+
newProps[name] = value
+
setProps(newProps)
+
}
+
+
useEffect(async () => {
+
const workMatch = /\/works\/(?<workId>[0-9]+)(?\/chapters\/(?<chapterId>[0-9]+))?$/g
+
const seriesMatch = /\/series\/(?<seriesId>[0-9]+)$/g
+
if (workMatch.test(url)) {
+
const match = url.match(workMatch)
+
const data = match.groups.chapterId ? await getWork({workId: match.groups.workId, chapterId: match.groups.chapterId}) : await getWork({workId: match.groups.workId})
+
setWorkData(data)
+
} else if (seriesMatch.test(url)) {
+
const match = url.match(seriesMatch)
+
const data = await getSeries({seriesId: match.groups.seriesId})
+
setWorkData(data)
+
}
+
// otherwise do nothing
+
}, [url])
+
+
useEffect(async () => {
+
const image = await Image(workData, props);
+
}, [workData])
+
+
return (
+
<html lang="en">
+
<head>
+
<title>fixAO3 | embed card generator</title>
+
<link
+
rel="favicon"
+
href="https://imagedelivery.net/iHX6Ovru0O7AjmyT5yZRoA/e1bf3632-3127-4828-0e01-47af78b4c200/public"
+
/>
+
+
</head>
+
<body>
+
<form id="generator">
+
<div class="input-field">
+
<label htmlFor="url">Work/Series URL</label>
+
<input type="text" name="url" id="url" onChange={e => setUrl(e.target.value)} />
+
</div>
+
<details><summary>Style Settings</summary>
+
<div class="input-field">
+
<label htmlFor="theme">Theme</label>
+
<select name="theme" id="theme" onChange={e => setTheme(e.target.value)}>
+
{Object.keys(themes).map((th) => {
+
return (
+
<option value={th}>{themes[th]}</option>
+
)
+
})}
+
</select>
+
</div>
+
<div class="input-field">
+
<label htmlFor="baseFont">Base Font</label>
+
<select name="baseFont" id="baseFont" onChange={e => setTheme(e.target.value)}>
+
{Object.keys(baseFonts).sort().map((bf) => {
+
return (
+
<option value={bf}>{baseFonts[bf].displayName}</option>
+
)
+
})}
+
</select>
+
</div>
+
<div class="input-field">
+
<label htmlFor="titleFont">Title Font</label>
+
<select name="titleFont" id="titleFont" onChange={e => setTheme(e.target.value)}>
+
{Object.keys({...titleFonts, ...baseFonts}).sort().map((tf) => {
+
return (
+
<option value={tf}>{{...titleFonts, ...baseFonts}.displayName}</option>
+
)
+
})}
+
</select>
+
</div>
+
<div class="input-field">
+
<label htmlFor="features">Features:</label>
+
<ul>
+
<li><label><input type="checkbox" name="features[]" value="category" onChange={e => updateProps()} /> Category</label></li>
+
<li><label><input type="checkbox" name="features[]" value="rating" /> Rating</label></li>
+
<li><label><input type="checkbox" name="features[]" value="warnings" /> Archive Warnings</label></li>
+
<li><label><input type="checkbox" name="features[]" value="chartags" /> Character Tags</label></li>
+
<li><label><input type="checkbox" name="features[]" value="reltags" /> Relationship Tags</label></li>
+
<li><label><input type="checkbox" name="features[]" value="freetags" /> Free Tags</label></li>
+
<li><label><input type="checkbox" name="features[]" value="summary" /> Summary</label></li>
+
<li><label><input type="checkbox" name="features[]" value="wordcount" /> Wordcount</label></li>
+
<li><label><input type="checkbox" name="features[]" value="chapters" /> Chapters</label></li>
+
</ul>
+
</div>
+
<div class="input-field">
+
<label htmlFor="summaryOptions">Summary Type</label>
+
<ul>
+
<li><label><input type="radio" name="summaryType" value="basic" onChange={e => updateProp(e.target.name, e.target.value)} /> Story Summary</label></li>
+
<li><label><input type="radio" name="summaryType" value="chapter" onChange={e => updateProp(e.target.name, e.target.value) /> Chapter Summary (if available)</label></li>
+
<li><label><input type="radio" name="summaryType" value="custom" onChange={e => updateProp(e.target.name, e.target.value) /> Custom Summary</label></li>
+
</ul>
+
</div>
+
{props.summaryType === 'custom' && (
+
<div class="input-field">
+
<label htmlFor="customSummary">Custom Summary</label>
+
<textarea name="customSummary" id="customSummary" onChange={e => updateProp(e.target.name, e.target.value)}></textarea>
+
</div>
+
)}
+
</details>
+
</form>
+
<div id="output">
+
</div>
+
</body>
+
</html>
+
)
+
}
+
+
export default Generator
+1 -1
pages/Home.jsx
···
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
-
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,200..800&family=Momo+Signature&family=Parkinsans:wght@300..800&family=Stack+Sans+Notch:wght@200..700&display=swap"
+
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,200..800&family=Stack+Sans+Notch:wght@200..700&display=swap"
rel="stylesheet"
/>
<link
+3 -54
pages/Image.jsx
···
import { $ } from "bun"
import DOM from "fauxdom"
-
-
const themes = {
-
ao3: {
-
background: '#990000',
-
color: '#FFFFFF',
-
descBackground: '#FFFFFF',
-
descColor: '#000000',
-
accent: '#990000'
-
},
-
softEra: {
-
background: '#F9F5F5',
-
color: '#C8B3B3',
-
descBackground: '#F9F5F5',
-
descColor: '#414141',
-
accent: '#DB90A7'
-
},
-
wildCherry: {
-
-
},
-
rosePine: {
-
background: '#191724',
-
color: '#e0def4',
-
descBackground: '#1f1d2e',
-
descColor: '#e0def4',
-
accent: '#eb6f92'
-
},
-
rosePineDawn: {
-
background: '#faf4ed',
-
color: '#575279',
-
descBackground: '#fffaf3',
-
descColor: '#575279',
-
accent: '#eb6f92'
-
},
-
rosePineMoon: {
-
background: '#232136',
-
color: '#e0def4',
-
descBackground: '#2a273f',
-
descColor: '#e0def4',
-
accent: '#b4637a'
-
},
-
solarizedLight: {
-
-
},
-
solarizedDark: {
-
-
},
-
squidgeworld: {
-
-
},
-
superlove: {
-
-
}
-
}
+
import themes from '../themes.js'
+
import baseFonts from '../baseFonts.js'
async function Image ({ data, addr, opts = {} }) {
const filename = addr.replaceAll("/", "-")
···
const theme = opts.theme ? opts.theme : 'ao3'
const baseFont = opts.baseFont ? opts.baseFont : 'Verdana'
const titleFont = opts.titleFont ? opts.titleFont : 'Georgia'
-
await $`magick -size 1520x300 -background none -font ${titleFont} -pointsize 64 -fill ${theme.color} -gravity SouthWest pango:"<span size='16834'>${fandomString}</span>\n${titleString}${chapterString !== '' ? "\n<span size='36864'><i>"+chapterString+"</i></span>" : ''}" tmp/${filename}-title.png`
+
await $`magick -size 1520x300 -background none -font ${titleFont} -pointsize 64 -fill ${theme.color} pango:"<span gravity='south' gravity_hint='strong'><span size='16pt'>${fandomString}</span>\n${titleString}${chapterString !== '' ? "\n<span size='36pt'><i>"+chapterString+"</i></span></span>" : ''}" tmp/${filename}-title.png`
await $`magick -size 1520x480 xc:${theme.descBackground} tmp/${filename}-box.png`
await $`magick -size 1440x20 -background none -gravity East -font ${baseFont} -pointsize 18 -fill ${theme.descColor} caption:"https://archiveofourown.org/${addr}" tmp/${filename}-addr.png`
await $`magick -size 1440x400 -background none -font ${baseFont} -pointsize 22 -fill ${theme.descColor} pango:"<b>Wordcount:</b> ${data.words}${chapterCountString} | <b>Rating:</b> ${data.rating}\n\n${summaryFormatted}" tmp/${filename}-desc.png`
+54
themes.js
···
+
const themes = {
+
ao3: {
+
background: '#990000',
+
color: '#FFFFFF',
+
descBackground: '#FFFFFF',
+
descColor: '#000000',
+
accent: '#990000'
+
},
+
softEra: {
+
background: '#F9F5F5',
+
color: '#C8B3B3',
+
descBackground: '#F9F5F5',
+
descColor: '#414141',
+
accent: '#DB90A7'
+
},
+
wildCherry: {
+
+
},
+
rosePine: {
+
background: '#191724',
+
color: '#e0def4',
+
descBackground: '#1f1d2e',
+
descColor: '#e0def4',
+
accent: '#eb6f92'
+
},
+
rosePineDawn: {
+
background: '#faf4ed',
+
color: '#575279',
+
descBackground: '#fffaf3',
+
descColor: '#575279',
+
accent: '#eb6f92'
+
},
+
rosePineMoon: {
+
background: '#232136',
+
color: '#e0def4',
+
descBackground: '#2a273f',
+
descColor: '#e0def4',
+
accent: '#b4637a'
+
},
+
solarizedLight: {
+
+
},
+
solarizedDark: {
+
+
},
+
squidgeworld: {
+
+
},
+
superlove: {
+
+
}
+
}
+
+
export default themes