mute territories

This commit is contained in:
keyan 2023-12-15 12:10:29 -06:00
parent baee771d67
commit 214e863458
16 changed files with 280 additions and 144 deletions

View File

@ -191,8 +191,12 @@ const activeOrMine = (me) => {
export const muteClause = me => export const muteClause = me =>
me ? `NOT EXISTS (SELECT 1 FROM "Mute" WHERE "Mute"."muterId" = ${me.id} AND "Mute"."mutedId" = "Item"."userId")` : '' me ? `NOT EXISTS (SELECT 1 FROM "Mute" WHERE "Mute"."muterId" = ${me.id} AND "Mute"."mutedId" = "Item"."userId")` : ''
const subClause = (sub, num, table) => { const subClause = (sub, num, table, me) => {
return sub ? `${table ? `"${table}".` : ''}"subName" = $${num}` : '' return sub
? `${table ? `"${table}".` : ''}"subName" = $${num}`
: me
? `NOT EXISTS (SELECT 1 FROM "MuteSub" WHERE "MuteSub"."userId" = ${me.id} AND "MuteSub"."subName" = ${table ? `"${table}".` : ''}"subName")`
: ''
} }
export async function filterClause (me, models, type) { export async function filterClause (me, models, type) {
@ -341,7 +345,7 @@ export default {
${relationClause(type)} ${relationClause(type)}
${whereClause( ${whereClause(
'"Item".created_at <= $1', '"Item".created_at <= $1',
subClause(sub, 4, subClauseTable(type)), subClause(sub, 4, subClauseTable(type), me),
activeOrMine(me), activeOrMine(me),
await filterClause(me, models, type), await filterClause(me, models, type),
typeClause(type), typeClause(type),
@ -366,7 +370,7 @@ export default {
${whereClause( ${whereClause(
'"Item"."pinId" IS NULL', '"Item"."pinId" IS NULL',
'"Item"."deletedAt" IS NULL', '"Item"."deletedAt" IS NULL',
subClause(sub, 5, subClauseTable(type)), subClause(sub, 5, subClauseTable(type), me),
typeClause(type), typeClause(type),
whenClause(when, 'Item'), whenClause(when, 'Item'),
await filterClause(me, models, type), await filterClause(me, models, type),
@ -386,7 +390,7 @@ export default {
${whereClause( ${whereClause(
'"Item"."pinId" IS NULL', '"Item"."pinId" IS NULL',
'"Item"."deletedAt" IS NULL', '"Item"."deletedAt" IS NULL',
subClause(sub, 5, subClauseTable(type)), subClause(sub, 5, subClauseTable(type), me),
typeClause(type), typeClause(type),
whenClause(when, 'Item'), whenClause(when, 'Item'),
await filterClause(me, models, type), await filterClause(me, models, type),
@ -443,7 +447,7 @@ export default {
'"Item"."deletedAt" IS NULL', '"Item"."deletedAt" IS NULL',
'"Item"."parentId" IS NULL', '"Item"."parentId" IS NULL',
'"Item".bio = false', '"Item".bio = false',
subClause(sub, 3, 'Item', true), subClause(sub, 3, 'Item', me),
muteClause(me))} muteClause(me))}
ORDER BY rank DESC ORDER BY rank DESC
OFFSET $1 OFFSET $1
@ -460,7 +464,7 @@ export default {
${SELECT} ${SELECT}
FROM "Item" FROM "Item"
${whereClause( ${whereClause(
subClause(sub, 3, 'Item', true), subClause(sub, 3, 'Item', me),
muteClause(me), muteClause(me),
'"Item"."pinId" IS NULL', '"Item"."pinId" IS NULL',
'"Item"."deletedAt" IS NULL', '"Item"."deletedAt" IS NULL',
@ -858,6 +862,10 @@ export default {
return null return null
} }
if (item.sub) {
return item.sub
}
return await models.sub.findUnique({ where: { name: item.subName || item.root?.subName } }) return await models.sub.findUnique({ where: { name: item.subName || item.root?.subName } })
}, },
position: async (item, args, { models }) => { position: async (item, args, { models }) => {

View File

@ -1,5 +1,4 @@
import { GraphQLError } from 'graphql' import { GraphQLError } from 'graphql'
import { dayMonthYearToDate } from '../../lib/time'
// this function makes america more secure apparently // this function makes america more secure apparently
export default async function assertGofacYourself ({ models, headers, ip }) { export default async function assertGofacYourself ({ models, headers, ip }) {

View File

@ -65,30 +65,33 @@ export default {
sub: async (parent, { name }, { models, me }) => { sub: async (parent, { name }, { models, me }) => {
if (!name) return null if (!name) return null
if (me && name === 'jobs') {
models.user.update({
where: {
id: me.id
},
data: {
lastCheckedJobs: new Date()
}
}).catch(console.log)
}
return await models.sub.findUnique({ return await models.sub.findUnique({
where: { where: {
name name
},
include: {
MuteSub: {
where: {
userId: Number(me.id)
}
}
} }
}) })
}, },
subs: async (parent, args, { models }) => { subs: async (parent, args, { models, me }) => {
return await models.sub.findMany({ return await models.sub.findMany({
where: { where: {
status: { status: {
not: 'STOPPED' not: 'STOPPED'
} }
}, },
include: {
MuteSub: {
where: {
userId: Number(me.id)
}
}
},
orderBy: { orderBy: {
name: 'asc' name: 'asc'
} }
@ -158,6 +161,22 @@ export default {
queries, queries,
{ models, lnd, hash, hmac, me, enforceFee: sub.billingCost }) { models, lnd, hash, hmac, me, enforceFee: sub.billingCost })
return results[1] return results[1]
},
toggleMuteSub: async (parent, { name }, { me, models }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
}
const lookupData = { userId: Number(me.id), subName: name }
const where = { userId_subName: lookupData }
const existing = await models.muteSub.findUnique({ where })
if (existing) {
await models.muteSub.delete({ where })
return false
} else {
await models.muteSub.create({ data: { ...lookupData } })
return true
}
} }
}, },
Sub: { Sub: {
@ -166,6 +185,9 @@ export default {
return sub.user return sub.user
} }
return await models.user.findUnique({ where: { id: sub.userId } }) return await models.user.findUnique({ where: { id: sub.userId } })
},
meMuteSub: async (sub, args, { models }) => {
return sub.MuteSub.length > 0
} }
} }
} }

View File

@ -13,6 +13,7 @@ export default gql`
billingType: String!, billingAutoRenew: Boolean!, billingType: String!, billingAutoRenew: Boolean!,
moderated: Boolean!, hash: String, hmac: String): Sub moderated: Boolean!, hash: String, hmac: String): Sub
paySub(name: String!, hash: String, hmac: String): Sub paySub(name: String!, hash: String, hmac: String): Sub
toggleMuteSub(name: String!): Boolean!
} }
type Sub { type Sub {
@ -33,5 +34,6 @@ export default gql`
status: String! status: String!
moderated: Boolean! moderated: Boolean!
moderatedCount: Int! moderatedCount: Int!
meMuteSub: Boolean!
} }
` `

View File

@ -40,7 +40,7 @@ export function WelcomeBanner () {
setHidden(me?.privates?.hideWelcomeBanner || (!me && window.localStorage.getItem('hideWelcomeBanner'))) setHidden(me?.privates?.hideWelcomeBanner || (!me && window.localStorage.getItem('hideWelcomeBanner')))
}, [me?.privates?.hideWelcomeBanner]) }, [me?.privates?.hideWelcomeBanner])
if (hidden) return if (hidden) return null
return ( return (
<Alert className={styles.banner} key='info' variant='info' onClose={handleClose} dismissible> <Alert className={styles.banner} key='info' variant='info' onClose={handleClose} dismissible>

View File

@ -197,7 +197,7 @@ export default function Comment ({
/>)} />)}
{topLevel && ( {topLevel && (
<span className='d-flex ms-auto align-items-center'> <span className='d-flex ms-auto align-items-center'>
<Share item={item} /> <Share title={item?.title} path={`/items/${item?.id}`} />
</span> </span>
)} )}
</div> </div>

View File

@ -133,7 +133,7 @@ function TopLevelItem ({ item, noReply, ...props }) {
right={ right={
!noReply && !noReply &&
<> <>
<Share item={item} /> <Share title={item?.title} path={`/items/${item?.id}`} />
<Toc text={item.text} /> <Toc text={item.text} />
</> </>
} }

View File

@ -13,7 +13,7 @@ import { useMe } from './me'
import DontLikeThisDropdownItem, { OutlawDropdownItem } from './dont-link-this' import DontLikeThisDropdownItem, { OutlawDropdownItem } from './dont-link-this'
import BookmarkDropdownItem from './bookmark' import BookmarkDropdownItem from './bookmark'
import SubscribeDropdownItem from './subscribe' import SubscribeDropdownItem from './subscribe'
import { CopyLinkDropdownItem } from './share' import { CopyLinkDropdownItem, CrosspostDropdownItem } from './share'
import Hat from './hat' import Hat from './hat'
import { AD_USER_ID } from '../lib/constants' import { AD_USER_ID } from '../lib/constants'
import ActionDropdown from './action-dropdown' import ActionDropdown from './action-dropdown'
@ -149,11 +149,13 @@ export default function ItemInfo ({
<Link href={`/items/${item.id}/ots`} className='text-reset dropdown-item'> <Link href={`/items/${item.id}/ots`} className='text-reset dropdown-item'>
opentimestamp opentimestamp
</Link>} </Link>}
{me && item?.noteId && ( {item?.noteId && (
<Dropdown.Item onClick={() => window.open(`https://nostr.com/${item.noteId}`, '_blank', 'noopener')}> <Dropdown.Item onClick={() => window.open(`https://nostr.com/${item.noteId}`, '_blank', 'noopener')}>
nostr note nostr note
</Dropdown.Item> </Dropdown.Item>
)} )}
{item?.mine && !item?.noteId &&
<CrosspostDropdownItem item={item} />}
{me && !item.position && {me && !item.position &&
!item.mine && !item.deletedAt && !item.mine && !item.deletedAt &&
(item.meDontLikeSats > meTotalSats (item.meDontLikeSats > meTotalSats

View File

@ -73,7 +73,7 @@ export default function ItemJob ({ item, toc, rank, children }) {
</div> </div>
{toc && {toc &&
<> <>
<Share item={item} /> <Share title={item?.title} path={`/items/${item?.id}`} />
<Toc text={item.text} /> <Toc text={item.text} />
</>} </>}
</div> </div>

View File

@ -8,42 +8,29 @@ import { useToast } from './toast'
import { SSR } from '../lib/constants' import { SSR } from '../lib/constants'
import { callWithTimeout } from '../lib/nostr' import { callWithTimeout } from '../lib/nostr'
const getShareUrl = (item, me) => { const referrurl = (ipath, me) => {
const path = `/items/${item?.id}${me ? `/r/${me.name}` : ''}` const path = `${ipath}${me ? `/r/${me.name}` : ''}`
if (!SSR) { if (!SSR) {
return `${window.location.protocol}//${window.location.host}${path}` return `${window.location.protocol}//${window.location.host}${path}`
} }
return `https://stacker.news${path}` return `https://stacker.news${path}`
} }
export default function Share ({ item }) { export default function Share ({ path, title, className = '' }) {
const me = useMe() const me = useMe()
const crossposter = useCrossposter()
const toaster = useToast() const toaster = useToast()
const url = getShareUrl(item, me) const url = referrurl(path, me)
const mine = item?.user?.id === me?.id
const [updateNoteId] = useMutation(
gql`
mutation updateNoteId($id: ID!, $noteId: String!) {
updateNoteId(id: $id, noteId: $noteId) {
id
noteId
}
}`
)
return !SSR && navigator?.share return !SSR && navigator?.share
? ( ? (
<div className='ms-auto pointer d-flex align-items-center'> <div className='ms-auto pointer d-flex align-items-center'>
<ShareIcon <ShareIcon
width={20} height={20} width={20} height={20}
className='mx-2 fill-grey theme' className={`mx-2 fill-grey theme ${className}`}
onClick={async () => { onClick={async () => {
try { try {
await navigator.share({ await navigator.share({
title: item.title || '', title: title || '',
text: '', text: '',
url url
}) })
@ -57,9 +44,8 @@ export default function Share ({ item }) {
: ( : (
<Dropdown align='end' className='ms-auto pointer d-flex align-items-center' as='span'> <Dropdown align='end' className='ms-auto pointer d-flex align-items-center' as='span'>
<Dropdown.Toggle variant='success' id='dropdown-basic' as='a'> <Dropdown.Toggle variant='success' id='dropdown-basic' as='a'>
<ShareIcon width={20} height={20} className='mx-2 fill-grey theme' /> <ShareIcon width={20} height={20} className={`mx-2 fill-grey theme ${className}`} />
</Dropdown.Toggle> </Dropdown.Toggle>
<Dropdown.Menu> <Dropdown.Menu>
<Dropdown.Item <Dropdown.Item
onClick={async () => { onClick={async () => {
@ -74,7 +60,54 @@ export default function Share ({ item }) {
> >
copy link copy link
</Dropdown.Item> </Dropdown.Item>
{mine && !item?.noteId && ( </Dropdown.Menu>
</Dropdown>)
}
export function CopyLinkDropdownItem ({ item }) {
const me = useMe()
const toaster = useToast()
const url = referrurl(`/items/${item.id}`, me)
return (
<Dropdown.Item
onClick={async () => {
try {
if (navigator.share) {
await navigator.share({
title: item.title || '',
text: '',
url
})
} else {
await copy(url)
}
toaster.success('link copied')
} catch (err) {
console.error(err)
toaster.danger('failed to copy link')
}
}}
>
copy link
</Dropdown.Item>
)
}
export function CrosspostDropdownItem ({ item }) {
const crossposter = useCrossposter()
const toaster = useToast()
const [updateNoteId] = useMutation(
gql`
mutation updateNoteId($id: ID!, $noteId: String!) {
updateNoteId(id: $id, noteId: $noteId) {
id
noteId
}
}`
)
return (
<Dropdown.Item <Dropdown.Item
onClick={async () => { onClick={async () => {
try { try {
@ -110,36 +143,5 @@ export default function Share ({ item }) {
> >
crosspost to nostr crosspost to nostr
</Dropdown.Item> </Dropdown.Item>
)}
</Dropdown.Menu>
</Dropdown>)
}
export function CopyLinkDropdownItem ({ item }) {
const me = useMe()
const toaster = useToast()
const url = getShareUrl(item, me)
return (
<Dropdown.Item
onClick={async () => {
try {
if (navigator.share) {
await navigator.share({
title: item.title || '',
text: '',
url
})
} else {
await copy(url)
}
toaster.success('link copied')
} catch (err) {
console.error(err)
toaster.danger('failed to copy link')
}
}}
>
copy link
</Dropdown.Item>
) )
} }

View File

@ -0,0 +1,92 @@
import { Badge, Button, CardFooter } from 'react-bootstrap'
import { AccordianCard } from './accordian-item'
import TerritoryPaymentDue, { TerritoryBillingLine } from './territory-payment-due'
import Link from 'next/link'
import Text from './text'
import { numWithUnits } from '../lib/format'
import styles from './item.module.css'
import Hat from './hat'
import { useMe } from './me'
import Share from './share'
import { gql, useMutation } from '@apollo/client'
import { useToast } from './toast'
export default function TerritoryHeader ({ sub }) {
const me = useMe()
const toaster = useToast()
const [toggleMuteSub] = useMutation(
gql`
mutation toggleMuteSub($name: String!) {
toggleMuteSub(name: $name)
}`, {
update (cache, { data: { toggleMuteSub } }) {
cache.modify({
id: `Sub:{"name":"${sub.name}"}`,
fields: {
meMuteSub: () => toggleMuteSub.meMuteSub
}
})
}
}
)
return (
<>
<TerritoryPaymentDue sub={sub} />
<div className='mb-3'>
<div>
<AccordianCard
header={
<small className='text-muted fw-bold align-items-center d-flex'>
territory details
{sub.status === 'STOPPED' && <Badge className='ms-2' bg='danger'>archived</Badge>}
{(sub.moderated || sub.moderatedCount > 0) && <Badge className='ms-2' bg='secondary'>moderated{sub.moderatedCount && ` ${sub.moderatedCount}`}</Badge>}
</small>
}
>
<div className='py-2'>
<Text>{sub.desc}</Text>
</div>
<CardFooter className={`py-1 ${styles.other}`}>
<div className='text-muted'>
<span>founded by </span>
<Link href={`/${sub.user.name}`}>
@{sub.user.name}<span> </span><Hat className='fill-grey' user={sub.user} height={12} width={12} />
</Link>
</div>
<div className='text-muted'>
<span>post cost </span>
<span className='fw-bold'>{numWithUnits(sub.baseCost)}</span>
</div>
<TerritoryBillingLine sub={sub} />
</CardFooter>
</AccordianCard>
</div>
<div className='d-flex my-2 justify-content-end'>
<Share path={`/~${sub.name}`} title={`~${sub.name} stacker news territory`} className='mx-3' />
{Number(sub.userId) === Number(me?.id)
? (
<Link href={`/~${sub.name}/edit`} className='d-flex align-items-center'>
<Button variant='outline-grey border-2 rounded py-0' size='sm'>edit territory</Button>
</Link>)
: (
<Button
variant='outline-grey border-2 py-0 rounded'
size='sm'
onClick={async () => {
try {
await toggleMuteSub({ variables: { name: sub.name } })
} catch {
toaster.danger(`failed to ${sub.meMuteSub ? 'join' : 'mute'} territory`)
return
}
toaster.success(`${sub.meMuteSub ? 'joined' : 'muted'} territory`)
}}
>{sub.meMuteSub ? 'join' : 'mute'} territory
</Button>)}
</div>
</div>
</>
)
}

View File

@ -18,6 +18,7 @@ export const SUB_FIELDS = gql`
status status
moderated moderated
moderatedCount moderatedCount
meMuteSub
}` }`
export const SUB_FULL_FIELDS = gql` export const SUB_FULL_FIELDS = gql`

View File

@ -5,19 +5,9 @@ import Layout from '../../components/layout'
import { SUB_FULL, SUB_ITEMS } from '../../fragments/subs' import { SUB_FULL, SUB_ITEMS } from '../../fragments/subs'
import Snl from '../../components/snl' import Snl from '../../components/snl'
import { WelcomeBanner } from '../../components/banners' import { WelcomeBanner } from '../../components/banners'
import { AccordianCard } from '../../components/accordian-item'
import Text from '../../components/text'
import { useMe } from '../../components/me'
import Gear from '../../svgs/settings-5-fill.svg'
import Link from 'next/link'
import { useQuery } from '@apollo/client' import { useQuery } from '@apollo/client'
import PageLoading from '../../components/page-loading' import PageLoading from '../../components/page-loading'
import CardFooter from 'react-bootstrap/CardFooter' import TerritoryHeader from '../../components/territory-header'
import Hat from '../../components/hat'
import styles from '../../components/item.module.css'
import TerritoryPaymentDue, { TerritoryBillingLine } from '../../components/territory-payment-due'
import Badge from 'react-bootstrap/Badge'
import { numWithUnits } from '../../lib/format'
export const getServerSideProps = getGetServerSideProps({ export const getServerSideProps = getGetServerSideProps({
query: SUB_ITEMS, query: SUB_ITEMS,
@ -26,7 +16,6 @@ export const getServerSideProps = getGetServerSideProps({
export default function Sub ({ ssrData }) { export default function Sub ({ ssrData }) {
const router = useRouter() const router = useRouter()
const me = useMe()
const variables = { ...router.query } const variables = { ...router.query }
const { data } = useQuery(SUB_FULL, { variables }) const { data } = useQuery(SUB_FULL, { variables })
@ -36,44 +25,7 @@ export default function Sub ({ ssrData }) {
return ( return (
<Layout sub={variables.sub}> <Layout sub={variables.sub}>
{sub {sub
? ( ? <TerritoryHeader sub={sub} />
<>
<TerritoryPaymentDue sub={sub} />
<div className='mb-3 d-flex'>
<div className='flex-grow-1'>
<AccordianCard
header={
<small className='text-muted fw-bold align-items-center d-flex'>
territory details
{sub.status === 'STOPPED' && <Badge className='ms-2' bg='danger'>archived</Badge>}
{(sub.moderated || sub.moderatedCount) && <Badge className='ms-2' bg='secondary'>moderated{sub.moderatedCount && ` ${sub.moderatedCount}`}</Badge>}
</small>
}
>
<div className='py-2'>
<Text>{sub.desc}</Text>
</div>
<CardFooter className={`py-1 ${styles.other}`}>
<div className='text-muted'>
<span>founded by </span>
<Link href={`/${sub.user.name}`}>
@{sub.user.name}<span> </span><Hat className='fill-grey' user={sub.user} height={12} width={12} />
</Link>
</div>
<div className='text-muted'>
<span>post cost </span>
<span className='fw-bold'>{numWithUnits(sub.baseCost)}</span>
</div>
<TerritoryBillingLine sub={sub} />
</CardFooter>
</AccordianCard>
</div>
{Number(sub.userId) === Number(me?.id) &&
<Link href={`/~${sub.name}/edit`} className='d-flex align-items-center flex-shrink-1 ps-2'>
<Gear className='fill-grey' width={22} height={22} />
</Link>}
</div>
</>)
: ( : (
<> <>
<Snl /> <Snl />

View File

@ -0,0 +1,36 @@
/*
Warnings:
- You are about to drop the `Subscription` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "Subscription" DROP CONSTRAINT "Subscription_subName_fkey";
-- DropForeignKey
ALTER TABLE "Subscription" DROP CONSTRAINT "Subscription_userId_fkey";
-- DropTable
DROP TABLE "Subscription";
-- CreateTable
CREATE TABLE "MuteSub" (
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"subName" CITEXT NOT NULL,
"userId" INTEGER NOT NULL,
CONSTRAINT "MuteSub_pkey" PRIMARY KEY ("userId","subName")
);
-- CreateIndex
CREATE INDEX "MuteSub_subName_idx" ON "MuteSub"("subName");
-- CreateIndex
CREATE INDEX "MuteSub_created_at_idx" ON "MuteSub"("created_at");
-- AddForeignKey
ALTER TABLE "MuteSub" ADD CONSTRAINT "MuteSub_subName_fkey" FOREIGN KEY ("subName") REFERENCES "Sub"("name") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MuteSub" ADD CONSTRAINT "MuteSub_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -77,7 +77,6 @@ model User {
PushSubscriptions PushSubscription[] PushSubscriptions PushSubscription[]
ReferralAct ReferralAct[] ReferralAct ReferralAct[]
Streak Streak[] Streak Streak[]
Subscriptions Subscription[]
ThreadSubscriptions ThreadSubscription[] ThreadSubscriptions ThreadSubscription[]
Upload Upload[] @relation("Uploads") Upload Upload[] @relation("Uploads")
nostrRelays UserNostrRelay[] nostrRelays UserNostrRelay[]
@ -102,6 +101,7 @@ model User {
ArcIn Arc[] @relation("toUser") ArcIn Arc[] @relation("toUser")
Sub Sub[] Sub Sub[]
SubAct SubAct[] SubAct SubAct[]
MuteSub MuteSub[]
@@index([photoId]) @@index([photoId])
@@index([createdAt], map: "users.created_at_index") @@index([createdAt], map: "users.created_at_index")
@ -423,8 +423,8 @@ model Sub {
children Sub[] @relation("ParentChildren") children Sub[] @relation("ParentChildren")
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
Item Item[] Item Item[]
Subscription Subscription[]
SubAct SubAct[] SubAct SubAct[]
MuteSub MuteSub[]
@@index([parentName]) @@index([parentName])
@@index([createdAt]) @@index([createdAt])
@ -451,14 +451,17 @@ model SubAct {
@@index([userId, createdAt, type]) @@index([userId, createdAt, type])
} }
model Subscription { model MuteSub {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
subName String @db.Citext subName String @db.Citext
userId Int userId Int
sub Sub @relation(fields: [subName], references: [name], onDelete: Cascade) sub Sub @relation(fields: [subName], references: [name], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([userId, subName])
@@index([subName])
@@index([createdAt])
} }
model Pin { model Pin {

View File

@ -187,6 +187,23 @@ $accordion-button-active-icon-dark: $accordion-button-icon;
} }
} }
.btn-outline-grey {
--bs-btn-color: var(--theme-grey);
--bs-btn-border-color: var(--theme-grey);
--bs-btn-hover-color: #fff;
--bs-btn-hover-bg: var(--theme-grey);
--bs-btn-hover-border-color: var(--theme-grey);
--bs-btn-focus-shadow-rgb: 233, 236, 239;
--bs-btn-active-color: #fff;
--bs-btn-active-bg: var(--theme-grey);
--bs-btn-active-border-color: var(--theme-grey);
--bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
--bs-btn-disabled-color: var(--theme-grey);
--bs-btn-disabled-bg: transparent;
--bs-btn-disabled-border-color: var(--theme-grey);
--bs-gradient: none;
}
.text-monospace { .text-monospace {
font-family: monospace; font-family: monospace;
} }