diff --git a/assets/css/style.css b/assets/css/style.css index 0be57e3..f4a1931 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -43,3 +43,19 @@ body { .icon.icon-fansly { fill: #2699f6; } + +.icon.icon-linktree { + fill: #43e660; +} + +.icon.icon-pornhub { + fill: #ff9000; +} + +.icon.icon-cashapp { + fill: #00c853; +} + +.icon.icon-loyalfans { + fill: #d90a16; +} diff --git a/assets/img/icons/cashapp.svg b/assets/img/icons/cashapp.svg new file mode 100755 index 0000000..ed69d23 --- /dev/null +++ b/assets/img/icons/cashapp.svg @@ -0,0 +1,37 @@ + + + + + + diff --git a/assets/img/icons/linktree-full.svg b/assets/img/icons/linktree-full.svg new file mode 100755 index 0000000..558577a --- /dev/null +++ b/assets/img/icons/linktree-full.svg @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/assets/img/icons/linktree.svg b/assets/img/icons/linktree.svg new file mode 100755 index 0000000..5361cf0 --- /dev/null +++ b/assets/img/icons/linktree.svg @@ -0,0 +1,39 @@ + + + + + diff --git a/assets/img/icons/loyalfans.svg b/assets/img/icons/loyalfans.svg new file mode 100755 index 0000000..5c2e608 --- /dev/null +++ b/assets/img/icons/loyalfans.svg @@ -0,0 +1,40 @@ + + + + + + diff --git a/assets/img/icons/manyvids-full.svg b/assets/img/icons/manyvids-full.svg new file mode 100755 index 0000000..f021d35 --- /dev/null +++ b/assets/img/icons/manyvids-full.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/img/icons/manyvids.svg b/assets/img/icons/manyvids.svg new file mode 100644 index 0000000..5905d0e --- /dev/null +++ b/assets/img/icons/manyvids.svg @@ -0,0 +1,17 @@ + + + + + diff --git a/assets/img/icons/pornhub.svg b/assets/img/icons/pornhub.svg new file mode 100755 index 0000000..05c3272 --- /dev/null +++ b/assets/img/icons/pornhub.svg @@ -0,0 +1,45 @@ + + + + + + + + diff --git a/assets/img/icons/twitter-x.svg b/assets/img/icons/twitter-x.svg new file mode 100755 index 0000000..2fafcc2 --- /dev/null +++ b/assets/img/icons/twitter-x.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/img/icons/x.svg b/assets/img/icons/x.svg new file mode 100644 index 0000000..98a1784 --- /dev/null +++ b/assets/img/icons/x.svg @@ -0,0 +1,14 @@ + + + + + diff --git a/components/actors/bio.vue b/components/actors/bio.vue index 32a26bc..eafaa61 100644 --- a/components/actors/bio.vue +++ b/components/actors/bio.vue @@ -294,6 +294,44 @@ >{{ actor.agency }} +
+ +
+
  • import { ref, inject } from 'vue'; +import formatTemplate from 'template-format'; import getPath from '#/src/get-path.js'; import { formatDate } from '#/utils/format.js'; @@ -379,7 +418,7 @@ import { formatDate } from '#/utils/format.js'; const expanded = ref(false); const pageContext = inject('pageContext'); -const user = pageContext.user; +const { user, env } = pageContext; const props = defineProps({ actor: { @@ -388,6 +427,10 @@ const props = defineProps({ }, }); +const iconMap = { + twitter: 'twitter-x', +}; + // if the profile is empty, the expand button overlaps the header const showExpand = [ 'age', @@ -429,6 +472,25 @@ const descriptions = Object.values(Object.fromEntries(props.actor.profiles text: profile.description, entity: profile.entity, }]))); + +function getSocialUrl(social) { + if (social.url) { + return social.url; + } + + if (pageContext.env.socials.urls[social.platform]) { + return formatTemplate(pageContext.env.socials.urls[social.platform], { handle: social.handle }); + } + + return null; +} + +const socials = props.actor.socials.map((social) => ({ + ...social, + handle: social.url + ? new URL(social.url).hostname + : social.handle, +})); diff --git a/components/edit/revisions.vue b/components/edit/revisions.vue index 5dfd1e8..b38d027 100644 --- a/components/edit/revisions.vue +++ b/components/edit/revisions.vue @@ -104,8 +104,15 @@
    + +
      [
    • + +
        [
      • revisions.value.map((revision) => { }))]; } + if (key === 'socials') { + // new socials don't have IDs yet, so we need to compare the values + return [key, value.map((item) => ({ + ...item, + modified: revision.deltas.some((delta) => delta.key === key && !delta.value.some((deltaItem) => deltaItem.url === item.url || `${deltaItem.platform}:${deltaItem.handle}` === `${item.platform}:${item.handle}`)), + }))]; + } + if (dateKeys.includes(key)) { return [key, new Date(value)]; } @@ -300,6 +323,17 @@ const curatedRevisions = computed(() => revisions.value.map((revision) => { }; } + if (delta.key === 'socials') { + // new socials don't have IDs yet, so we need to compare the values + return { + ...delta, + value: delta.value.map((social) => ({ + ...social, + modified: !revision.base[delta.key].some((baseItem) => baseItem.url === social.url || `${baseItem.platform}:${baseItem.handle}` === `${social.platform}:${social.handle}`), + })), + }; + } + if (dateKeys.includes(delta.key)) { return { ...delta, diff --git a/components/edit/socials.vue b/components/edit/socials.vue new file mode 100644 index 0000000..3d004b2 --- /dev/null +++ b/components/edit/socials.vue @@ -0,0 +1,289 @@ + + + + + diff --git a/config/default.cjs b/config/default.cjs index 4f6d127..7d0507b 100755 --- a/config/default.cjs +++ b/config/default.cjs @@ -66,6 +66,23 @@ module.exports = { bans: { defaultExpiry: 60 * 24 * 3, // in minutes, 3 days }, + socials: { + urls: { + cashapp: 'https://cash.app/${handle}', // eslint-disable-line no-template-curly-in-string + fansly: 'https://fansly.com/{handle}', + linktree: 'https://linktr.ee/{handle}', + loyalfans: 'https://www.loyalfans.com/{handle}', + manyvids: 'https://www.manyvids.com/Profile/{handle}/slug/Store/Videos', + onlyfans: 'https://onlyfans.com/{handle}', + pornhub: 'https://www.pornhub.com/model/{handle}', + twitter: 'https://x.com/{handle}', + }, + prefix: { + default: '@', + cashapp: '$', + reddit: 'u/', + }, + }, apiAccess: { graphqlEnabled: true, keySize: 24, // bytes diff --git a/package-lock.json b/package-lock.json index 7b238fc..4a217f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,7 @@ "redis": "^4.6.12", "sharp": "^0.32.6", "sirv": "^2.0.3", + "template-format": "^1.2.5", "unprint": "^0.14.1", "video.js": "^8.10.0", "vike": "^0.4.150", @@ -10316,6 +10317,11 @@ "node": ">=8.0.0" } }, + "node_modules/template-format": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/template-format/-/template-format-1.2.5.tgz", + "integrity": "sha512-ZZqSfqYBMfPjouADYSRN9iaYlLr2PPVFYgULcV8cGMrJbifNXKvP7qx5PBFQjXg5mh1Gwkk+LTgdsZ8bmSvBdw==" + }, "node_modules/text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", @@ -18962,6 +18968,11 @@ "resolved": "https://registry.npmjs.org/tarn/-/tarn-3.0.2.tgz", "integrity": "sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==" }, + "template-format": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/template-format/-/template-format-1.2.5.tgz", + "integrity": "sha512-ZZqSfqYBMfPjouADYSRN9iaYlLr2PPVFYgULcV8cGMrJbifNXKvP7qx5PBFQjXg5mh1Gwkk+LTgdsZ8bmSvBdw==" + }, "text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", diff --git a/package.json b/package.json index 342f804..fc3115d 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "redis": "^4.6.12", "sharp": "^0.32.6", "sirv": "^2.0.3", + "template-format": "^1.2.5", "unprint": "^0.14.1", "video.js": "^8.10.0", "vike": "^0.4.150", diff --git a/pages/actors/@actorId/edit/+Page.vue b/pages/actors/@actorId/edit/+Page.vue index 24bc5e4..530629c 100644 --- a/pages/actors/@actorId/edit/+Page.vue +++ b/pages/actors/@actorId/edit/+Page.vue @@ -217,47 +217,12 @@
    - + [ : null, inline: true, }, - /* { key: 'socials', - type: 'list', - value: actor.value.socials.map((social) => ({ - url: social.url, - icon: social.platform, - })), + type: 'socials', + value: actor.value.socials, }, - */ { key: 'origin', type: 'place', @@ -647,7 +608,7 @@ async function submit() { actorId: actor.value.id, edits: { ...Object.fromEntries(Array.from(editing.value).flatMap((key) => { - if (edits.value[key] && typeof edits.value[key] === 'object') { + if (edits.value[key] && typeof edits.value[key] === 'object' && !Array.isArray(edits.value[key])) { return Object.entries(edits.value[key]).map(([valueKey, value]) => [keyMap[key]?.[valueKey] || valueKey, value]); } @@ -844,61 +805,6 @@ async function submit() { } } - .list { - &.disabled { - opacity: .5; - } - } - - .list-item { - display: flex; - align-items: center; - border-radius: .25rem; - background: var(--background); - box-shadow: 0 0 3px var(--shadow-weak-30); - - .icon-social { - margin: 0 .5rem; - } - - &.deleted { - color: var(--glass); - text-decoration: line-through; - - .icon.icon-social { - fill: var(--glass-weak-10); - } - } - - .add, - .remove { - padding: .25rem .3rem; - margin-left: .5rem; - border-radius: .25rem; - - &:hover { - fill: var(--text-light); - cursor: pointer; - } - } - - .add { - fill: var(--success); - - &:hover { - background: var(--success); - } - } - - .remove { - fill: var(--error); - - &:hover { - background: var(--error); - } - } - } - .avatars { width: 100%; display: flex; diff --git a/src/actors.js b/src/actors.js index 238ab5e..9b99323 100644 --- a/src/actors.js +++ b/src/actors.js @@ -55,6 +55,8 @@ const keyMap = { isCircumcised: 'circumcised', }; +const socialsOrder = ['onlyfans', 'twitter']; + export function curateActor(actor, context = {}) { return { id: actor.id, @@ -115,9 +117,11 @@ export function curateActor(actor, context = {}) { agency: actor.agency, avatar: curateMedia(actor.avatar), socials: context.socials?.map((social) => ({ + id: social.id, url: social.url, platform: social.platform, - })), + handle: social.handle, + })).toSorted((socialA, socialB) => socialsOrder.indexOf(socialB.platform) - socialsOrder.indexOf(socialA.platform)), profiles: context.profiles?.map((profile) => ({ id: profile.id, description: profile.description, @@ -216,7 +220,7 @@ export async function fetchActorsById(actorIds, options = {}, reqUser) { .leftJoin('media', 'media.id', 'actors_avatars.media_id') .groupBy('media.id', 'actors_avatars.actor_id') .orderBy(knex.raw('max(actors_avatars.created_at)'), 'desc'), - knex('actors_social') + knex('actors_socials') .whereIn('actor_id', actorIds), reqUser ? knex('stashes_actors') @@ -527,14 +531,14 @@ export async function fetchActorRevisions(revisionId, filters = {}, reqUser) { } async function applyActorValueDelta(profileId, delta, trx) { - return knex('actors_profiles') + await knex('actors_profiles') .where('id', profileId) .update(keyMap[delta.key] || delta.key, delta.value) .transacting(trx); } async function applyActorDirectDelta(actorId, delta, trx) { - return knex('actors') + await knex('actors') .where('id', actorId) .update(keyMap[delta.key] || delta.key, delta.value) .modify((builder) => { @@ -545,6 +549,22 @@ async function applyActorDirectDelta(actorId, delta, trx) { .transacting(trx); } +async function applyActorSocialsDelta(actorId, delta, trx) { + await knex('actors_socials') + .where('actor_id', actorId) + .delete() + .transacting(trx); + + await knex('actors_socials') + .insert(delta.value.map((social) => ({ + actor_id: actorId, + platform: social.platform, + handle: social.handle, + url: social.url, + }))) + .transacting(trx); +} + async function fetchMainProfile(actorId, wasCreated = false) { const profileEntry = await knex('actors_profiles') .where('actor_id', actorId) @@ -623,6 +643,10 @@ async function applyActorRevision(revisionIds, reqUser) { return applyActorValueDelta(mainProfile.id, delta, trx); } + if (delta.key === 'socials') { + return applyActorSocialsDelta(revision.actor_id, delta, trx); + } + if (delta.key === 'name' && reqUser.role === 'admin') { return applyActorDirectDelta(revision.actor_id, delta, trx); } @@ -767,35 +791,59 @@ function convertWeight(weight, units) { return Number(weight) || null; } -export async function createActorRevision(actorId, { - edits, - comment, - apply, - ...options -}, reqUser) { - const [ - [actor], - openRevisions, - ] = await Promise.all([ - fetchActorsById([actorId], { - reqUser, - includeAssets: true, - includePartOf: true, - }), - knex('actors_revisions') - .where('user_id', reqUser.id) - .whereNull('approved'), - ]); +const platformsByHostname = Object.fromEntries(Object.entries(config.socials.urls).map(([platform, url]) => { + const { hostname, pathname } = new URL(url); - if (!actor) { - throw new HttpError(`No actor with ID ${actorId} found to update`, 404); - } + return [hostname, { + platform, + pathname: decodeURIComponent(pathname), + url, + }]; +})); - if (openRevisions.length >= config.revisions.unapprovedLimit && reqUser.role !== 'admin') { - throw new HttpError(`You have ${config.revisions.unapprovedLimit} unapproved revisions, please wait for approval before submitting another revision.`, 429); - } +function curateSocials(socials) { + return socials.map((social) => { + if (!social.handle && !social.url) { + throw new Error('No social handle or website URL specified'); + } - const baseActor = Object.fromEntries(Object.entries(actor).map(([key, values]) => { + if (social.handle && !social.platform) { + throw new Error('No platform specified for social handle'); + } + + if (social.handle && social.platform && /[\w-]+/.test(social.handle) && /[a-z]+/i.test(social.platform)) { + return { + platform: social.platform.toLowerCase(), + handle: social.handle, + }; + } + + if (social.url) { + const { hostname, pathname } = new URL(social.url); + const platform = platformsByHostname[hostname]; + + if (platform) { + const handle = pathname.match(new RegExp(platform.pathname.replace('{handle}', '([\\w-]+)')))?.[1]; + + if (handle) { + return { + platform: platform.platform, + handle, + }; + } + } + + return { + url: social.url, + }; + } + + throw new Error('Invalid social'); + }).filter(Boolean); +} + +function getBaseActor(actor) { + return Object.fromEntries(Object.entries(actor).map(([key, values]) => { if ([ 'scenes', 'likes', @@ -805,11 +853,11 @@ export async function createActorRevision(actorId, { return null; } - /* avatar should return id - if (values?.hash) { - return [key, values.hash]; + if ([ + 'socials', + ].includes(key)) { + return [key, values]; } - */ if (values?.id) { return [key, values.id]; @@ -825,8 +873,10 @@ export async function createActorRevision(actorId, { return [key, values]; }).filter(Boolean)); +} - const deltas = await Promise.all(Object.entries(edits).map(async ([key, value]) => { +function getDeltas(edits, baseActor, options) { + return Promise.all(Object.entries(edits).map(async ([key, value]) => { if (baseActor[key] === value || typeof value === 'undefined') { return null; } @@ -890,6 +940,24 @@ export async function createActorRevision(actorId, { ]; } + if (key === 'socials') { + const convertedSocials = curateSocials(value); + + const convertedUrls = value + .filter((social) => social.url && !convertedSocials.some((convertedSocial) => convertedSocial.url === social.url)) + .map((social) => social.url); + + const conversionComment = convertedUrls.length > 0 + ? `curated URLs ${convertedUrls.join(', ')} as social handles` + : null; + + return { + key, + value: convertedSocials, + comment: conversionComment, + }; + } + if (['cup', 'bust', 'waist', 'hip'].includes(key)) { const convertedValue = convertFigure(key, value, options.figureUnits); @@ -967,6 +1035,38 @@ export async function createActorRevision(actorId, { return { key, value }; })).then((rawDeltas) => rawDeltas.flat().filter(Boolean)); +} + +export async function createActorRevision(actorId, { + edits, + comment, + apply, + ...options +}, reqUser) { + const [ + [actor], + openRevisions, + ] = await Promise.all([ + fetchActorsById([actorId], { + reqUser, + includeAssets: true, + includePartOf: true, + }), + knex('actors_revisions') + .where('user_id', reqUser.id) + .whereNull('approved'), + ]); + + if (!actor) { + throw new HttpError(`No actor with ID ${actorId} found to update`, 404); + } + + if (openRevisions.length >= config.revisions.unapprovedLimit && reqUser.role !== 'admin') { + throw new HttpError(`You have ${config.revisions.unapprovedLimit} unapproved revisions, please wait for approval before submitting another revision.`, 429); + } + + const baseActor = getBaseActor(actor); + const deltas = await getDeltas(edits, baseActor, options); const deltaComments = deltas.map((delta) => delta.comment); const curatedComment = [comment, ...deltaComments].filter(Boolean).join(' | '); diff --git a/src/web/main.js b/src/web/main.js index 2823eef..007aa35 100644 --- a/src/web/main.js +++ b/src/web/main.js @@ -42,6 +42,7 @@ export default async function mainHandler(req, res, next) { media: config.media, psa: config.psa, links: config.links, + socials: config.socials, }, meta: { now: new Date().toISOString(),