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 @@
-
- -
-
-
- {{ value.url }}
-
- {{ value.value || value }}
-
-
-
- listIndex !== index)"
- />
-
-
+ edits.socials = socials"
+ />
[
: 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(),