@novacraft-engineering/mailbox 0.4.27 → 0.4.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/api/mail/inbox/counts/route.ts +13 -3
- package/app/api/mail/send/route.ts +2 -1
- package/app/api/mail/threads/route.ts +4 -3
- package/app/layout.tsx +19 -4
- package/app/mail/InboxFilters.tsx +154 -0
- package/app/mail/page.module.css +170 -0
- package/app/mail/page.tsx +67 -25
- package/app/manifest.ts +2 -0
- package/app/robots.ts +2 -0
- package/lib/brand.client.ts +46 -25
- package/lib/color-scheme.test.ts +51 -0
- package/lib/color-scheme.ts +233 -0
- package/lib/emails/index.ts +11 -10
- package/lib/inbox-filters.test.ts +54 -0
- package/lib/inbox-filters.ts +111 -0
- package/lib/mail-provider.ts +3 -1
- package/lib/mailbox.ts +57 -17
- package/lib/receive.ts +35 -3
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
2
|
import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
|
|
3
3
|
import { scopeFor } from '@/lib/scope'
|
|
4
|
-
import { countFolders, countFoldersCached } from '@/lib/mailbox'
|
|
4
|
+
import { countFilteredThreads, countFolders, countFoldersCached, inboxFiltersFor } from '@/lib/mailbox'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Counts come from the durable cache, which every write that moves mail clears. An extra
|
|
@@ -22,6 +22,16 @@ export async function GET(req: Request) {
|
|
|
22
22
|
// Counts describe the signed-in mailbox, matching what the list will actually show.
|
|
23
23
|
const account = await resolveAccount(req)
|
|
24
24
|
const fresh = new URL(req.url).searchParams.get('fresh') === '1'
|
|
25
|
-
const
|
|
26
|
-
|
|
25
|
+
const owner = scopeFor(account, new URL(req.url).searchParams.get('mailbox'))
|
|
26
|
+
const [counts, filtered] = await Promise.all([
|
|
27
|
+
cachedCounts(owner, fresh),
|
|
28
|
+
inboxFiltersFor(account.email || 'local@dev').then(filters => countFilteredThreads(owner, filters)),
|
|
29
|
+
])
|
|
30
|
+
// The cache is per mailbox and the rules are per person, so the rules come off afterwards.
|
|
31
|
+
const conversations = counts.conversations && {
|
|
32
|
+
...counts.conversations,
|
|
33
|
+
inbox: Math.max(0, counts.conversations.inbox - filtered.total),
|
|
34
|
+
unread: Math.max(0, counts.conversations.unread - filtered.unread),
|
|
35
|
+
}
|
|
36
|
+
return NextResponse.json({ ok: true, counts: { ...counts, conversations, filtered } })
|
|
27
37
|
}
|
|
@@ -7,6 +7,7 @@ import { presign } from '@/lib/r2'
|
|
|
7
7
|
import { recordContact, recordPixel, recordSentMeta, recordSentMessage } from '@/lib/mailbox'
|
|
8
8
|
import { scheduleSend } from '@/lib/scheduled'
|
|
9
9
|
import { absoluteUrls, stripOwnPixel } from '@/lib/email-html'
|
|
10
|
+
import { adaptiveEmail } from '@/lib/color-scheme'
|
|
10
11
|
import { publicOrigin } from '@/lib/public-url'
|
|
11
12
|
|
|
12
13
|
export const runtime = 'nodejs'
|
|
@@ -146,7 +147,7 @@ export async function POST(req: Request) {
|
|
|
146
147
|
const origin = publicOrigin(req)
|
|
147
148
|
const pixelId = randomUUID()
|
|
148
149
|
const trackedHtml = body.html?.trim()
|
|
149
|
-
? `${stripOwnPixel(absoluteUrls(body.html.trim(), origin))}<img src="${origin}/api/mail/pixel/${pixelId}" alt="" width="1" height="1" style="display:none;width:1px;height:1px;border:0" />`
|
|
150
|
+
? adaptiveEmail(`${stripOwnPixel(absoluteUrls(body.html.trim(), origin))}<img src="${origin}/api/mail/pixel/${pixelId}" alt="" width="1" height="1" style="display:none;width:1px;height:1px;border:0" />`)
|
|
150
151
|
: null
|
|
151
152
|
|
|
152
153
|
// Thread the reply: In-Reply-To/References point at the inbound Message-ID so the
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
2
|
import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
|
|
3
3
|
import { scopeFor } from '@/lib/scope'
|
|
4
|
-
import { listThreads, threadsLive, type ThreadFolder } from '@/lib/mailbox'
|
|
4
|
+
import { inboxFiltersFor, listThreads, threadsLive, type ThreadFolder } from '@/lib/mailbox'
|
|
5
5
|
|
|
6
6
|
export const runtime = 'nodejs'
|
|
7
7
|
|
|
@@ -12,8 +12,9 @@ export async function GET(req: Request) {
|
|
|
12
12
|
const account = await resolveAccount(req)
|
|
13
13
|
const url = new URL(req.url)
|
|
14
14
|
const folderParam = url.searchParams.get('folder')
|
|
15
|
-
const folder: ThreadFolder = (['inbox', 'archive', 'trash', 'starred', 'snoozed', 'spam'] as const).find(f => f === folderParam) ?? 'inbox'
|
|
15
|
+
const folder: ThreadFolder = (['inbox', 'archive', 'trash', 'starred', 'snoozed', 'spam', 'filtered'] as const).find(f => f === folderParam) ?? 'inbox'
|
|
16
16
|
const limit = Math.min(Number(url.searchParams.get('limit') ?? 500) || 500, 1000)
|
|
17
|
-
const
|
|
17
|
+
const filters = folder === 'inbox' || folder === 'filtered' ? await inboxFiltersFor(account.email || 'local@dev') : []
|
|
18
|
+
const page = await listThreads(scopeFor(account, url.searchParams.get('mailbox')), folder, limit, url.searchParams.get('cursor'), filters)
|
|
18
19
|
return NextResponse.json({ ok: true, threads: page.rows, nextCursor: page.nextCursor })
|
|
19
20
|
}
|
package/app/layout.tsx
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { BRAND } from '@/lib/brand'
|
|
2
|
+
import { BRAND_ENV_KEYS } from '@/lib/brand.client'
|
|
2
3
|
import type { Metadata, Viewport } from 'next'
|
|
3
4
|
import type { CSSProperties, ReactNode } from 'react'
|
|
4
5
|
import './globals.css'
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
// One image serves every deployment, so nothing that names the brand may be settled at
|
|
8
|
+
// build time: every page is rendered per request, from the environment it runs in.
|
|
9
|
+
export const dynamic = 'force-dynamic'
|
|
10
|
+
|
|
11
|
+
export const generateMetadata = (): Metadata => ({
|
|
7
12
|
title: `${BRAND.name} Mail`,
|
|
8
13
|
description: `${BRAND.name} team mailbox`,
|
|
9
14
|
robots: { index: false, follow: false, nocache: true, googleBot: { index: false, follow: false } },
|
|
@@ -14,20 +19,30 @@ export const metadata: Metadata = {
|
|
|
14
19
|
applicationName: `${BRAND.name} Mail`,
|
|
15
20
|
appleWebApp: { capable: true, title: `${BRAND.name} Mail`, statusBarStyle: 'black-translucent' },
|
|
16
21
|
formatDetection: { telephone: false },
|
|
17
|
-
}
|
|
22
|
+
})
|
|
18
23
|
|
|
19
|
-
export const
|
|
24
|
+
export const generateViewport = (): Viewport => ({
|
|
20
25
|
themeColor: BRAND.colors.accent,
|
|
21
26
|
viewportFit: 'cover',
|
|
22
27
|
width: 'device-width',
|
|
23
28
|
initialScale: 1,
|
|
24
29
|
maximumScale: 1,
|
|
25
30
|
userScalable: false,
|
|
26
|
-
}
|
|
31
|
+
})
|
|
27
32
|
|
|
28
33
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
|
29
34
|
return (
|
|
30
35
|
<html lang="en">
|
|
36
|
+
<head>
|
|
37
|
+
<script
|
|
38
|
+
// Only the public brand settings, and escaped so a value can never close the tag.
|
|
39
|
+
dangerouslySetInnerHTML={{
|
|
40
|
+
__html: `window.__MAILBOX_BRAND_ENV__=${JSON.stringify(
|
|
41
|
+
Object.fromEntries(BRAND_ENV_KEYS.flatMap(key => (process.env[key] ? [[key, process.env[key]]] : []))),
|
|
42
|
+
).replace(/</g, '\\u003c')}`,
|
|
43
|
+
}}
|
|
44
|
+
/>
|
|
45
|
+
</head>
|
|
31
46
|
<body
|
|
32
47
|
style={{ ['--brand-mark' as string]: `url('${BRAND.chromeMarkUrl}')` } as CSSProperties}
|
|
33
48
|
>
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from 'react'
|
|
4
|
+
import styles from './page.module.css'
|
|
5
|
+
import MailSelect from './MailSelect'
|
|
6
|
+
import { INBOX_FILTER_KINDS, normalizeInboxFilters, type InboxFilter, type InboxFilterKind } from '@/lib/inbox-filters'
|
|
7
|
+
|
|
8
|
+
const FILTER_ICON = (
|
|
9
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
10
|
+
<path d="M22 3H2l8 9.46V19l4 2v-8.54L22 3z" />
|
|
11
|
+
</svg>
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
const labelOf = (kind: InboxFilterKind) => INBOX_FILTER_KINDS.find(option => option.kind === kind)?.label ?? kind
|
|
15
|
+
|
|
16
|
+
export default function InboxFilters({
|
|
17
|
+
filters,
|
|
18
|
+
counts,
|
|
19
|
+
showing,
|
|
20
|
+
onShowingChange,
|
|
21
|
+
onFiltersChange,
|
|
22
|
+
}: {
|
|
23
|
+
filters: InboxFilter[]
|
|
24
|
+
counts: { total: number; unread: number } | null
|
|
25
|
+
showing: boolean
|
|
26
|
+
onShowingChange: (showing: boolean) => void
|
|
27
|
+
onFiltersChange: (filters: InboxFilter[]) => void
|
|
28
|
+
}) {
|
|
29
|
+
const [open, setOpen] = useState(false)
|
|
30
|
+
const [kind, setKind] = useState<InboxFilterKind>('senderPrefix')
|
|
31
|
+
const [value, setValue] = useState('')
|
|
32
|
+
const wrapRef = useRef<HTMLDivElement>(null)
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!open) return
|
|
36
|
+
const onPointer = (event: PointerEvent) => {
|
|
37
|
+
const target = event.target as HTMLElement
|
|
38
|
+
if (wrapRef.current?.contains(target) || target.closest('[role="listbox"]')) return
|
|
39
|
+
setOpen(false)
|
|
40
|
+
}
|
|
41
|
+
const onKey = (event: KeyboardEvent) => {
|
|
42
|
+
if (event.key === 'Escape') setOpen(false)
|
|
43
|
+
}
|
|
44
|
+
document.addEventListener('pointerdown', onPointer)
|
|
45
|
+
document.addEventListener('keydown', onKey)
|
|
46
|
+
return () => {
|
|
47
|
+
document.removeEventListener('pointerdown', onPointer)
|
|
48
|
+
document.removeEventListener('keydown', onKey)
|
|
49
|
+
}
|
|
50
|
+
}, [open])
|
|
51
|
+
|
|
52
|
+
const total = counts?.total ?? 0
|
|
53
|
+
const unread = counts?.unread ?? 0
|
|
54
|
+
const placeholder = INBOX_FILTER_KINDS.find(option => option.kind === kind)?.placeholder ?? ''
|
|
55
|
+
|
|
56
|
+
const addRule = () => {
|
|
57
|
+
const next = normalizeInboxFilters([...filters, { kind, value }])
|
|
58
|
+
if (next.length === filters.length) return
|
|
59
|
+
onFiltersChange(next)
|
|
60
|
+
setValue('')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const summary = total
|
|
64
|
+
? `${total.toLocaleString()} filtered out of your Inbox${unread ? `, ${unread.toLocaleString()} unread` : ''}`
|
|
65
|
+
: 'Filtered out of your Inbox'
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div className={styles.filterWrap} ref={wrapRef}>
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
className={`${styles.filterChip} ${showing ? styles.filterChipOn : ''}`}
|
|
72
|
+
onClick={() => setOpen(current => !current)}
|
|
73
|
+
aria-expanded={open}
|
|
74
|
+
aria-label={summary}
|
|
75
|
+
title={summary}
|
|
76
|
+
>
|
|
77
|
+
{FILTER_ICON}
|
|
78
|
+
<span className={styles.filterChipLabel}>Filtered</span>
|
|
79
|
+
{total > 0 && (
|
|
80
|
+
<span className={styles.filterChipCount}>
|
|
81
|
+
{total.toLocaleString()}
|
|
82
|
+
{unread > 0 && <span className={styles.filterChipUnread}>{unread.toLocaleString()} new</span>}
|
|
83
|
+
</span>
|
|
84
|
+
)}
|
|
85
|
+
</button>
|
|
86
|
+
{open && (
|
|
87
|
+
<div className={styles.filterMenu} role="dialog" aria-label="Filtered out of your Inbox">
|
|
88
|
+
<div className={styles.filterMenuHead}>
|
|
89
|
+
<span>Filtered out of your Inbox</span>
|
|
90
|
+
<span className={styles.filterMenuHint}>Matching mail skips the Inbox and never notifies. Nothing is deleted.</span>
|
|
91
|
+
</div>
|
|
92
|
+
<button
|
|
93
|
+
type="button"
|
|
94
|
+
className={styles.filterMenuView}
|
|
95
|
+
onClick={() => {
|
|
96
|
+
onShowingChange(!showing)
|
|
97
|
+
setOpen(false)
|
|
98
|
+
}}
|
|
99
|
+
>
|
|
100
|
+
{showing ? 'Back to Inbox' : 'Show filtered mail'}
|
|
101
|
+
{!showing && total > 0 && (
|
|
102
|
+
<span className={styles.filterChipCount}>
|
|
103
|
+
{total.toLocaleString()}
|
|
104
|
+
{unread > 0 && <span className={styles.filterChipUnread}>{unread.toLocaleString()} new</span>}
|
|
105
|
+
</span>
|
|
106
|
+
)}
|
|
107
|
+
</button>
|
|
108
|
+
<div className={styles.filterRules}>
|
|
109
|
+
{filters.length === 0 && <div className={styles.filterEmpty}>No rules. Everything reaches your Inbox.</div>}
|
|
110
|
+
{filters.map(filter => (
|
|
111
|
+
<div key={`${filter.kind}:${filter.value}`} className={styles.filterRule}>
|
|
112
|
+
<span className={styles.filterRuleKind}>{labelOf(filter.kind)}</span>
|
|
113
|
+
<span className={styles.filterRuleValue}>{filter.value}</span>
|
|
114
|
+
<button
|
|
115
|
+
type="button"
|
|
116
|
+
className={styles.filterRuleRemove}
|
|
117
|
+
aria-label={`Remove ${labelOf(filter.kind).toLowerCase()} ${filter.value}`}
|
|
118
|
+
onClick={() => onFiltersChange(filters.filter(entry => entry !== filter))}
|
|
119
|
+
>
|
|
120
|
+
×
|
|
121
|
+
</button>
|
|
122
|
+
</div>
|
|
123
|
+
))}
|
|
124
|
+
</div>
|
|
125
|
+
<form
|
|
126
|
+
className={styles.filterAdd}
|
|
127
|
+
onSubmit={event => {
|
|
128
|
+
event.preventDefault()
|
|
129
|
+
addRule()
|
|
130
|
+
}}
|
|
131
|
+
>
|
|
132
|
+
<MailSelect
|
|
133
|
+
value={kind}
|
|
134
|
+
options={INBOX_FILTER_KINDS.map(option => ({ value: option.kind, label: option.label }))}
|
|
135
|
+
ariaLabel="Rule type"
|
|
136
|
+
buttonClassName={styles.filterAddKind}
|
|
137
|
+
onChange={next => setKind(next as InboxFilterKind)}
|
|
138
|
+
/>
|
|
139
|
+
<input
|
|
140
|
+
className={styles.filterAddValue}
|
|
141
|
+
value={value}
|
|
142
|
+
placeholder={placeholder}
|
|
143
|
+
aria-label="Rule value"
|
|
144
|
+
onChange={event => setValue(event.target.value)}
|
|
145
|
+
/>
|
|
146
|
+
<button type="submit" className={styles.filterAddBtn} disabled={!value.trim()}>
|
|
147
|
+
Add
|
|
148
|
+
</button>
|
|
149
|
+
</form>
|
|
150
|
+
</div>
|
|
151
|
+
)}
|
|
152
|
+
</div>
|
|
153
|
+
)
|
|
154
|
+
}
|
package/app/mail/page.module.css
CHANGED
|
@@ -559,6 +559,176 @@
|
|
|
559
559
|
transition: color var(--dur-fast) var(--nc-ease-out), border-color var(--dur-fast) var(--nc-ease-out);
|
|
560
560
|
}
|
|
561
561
|
.refreshBtn:hover { color: var(--nc-fg-1); border-color: var(--nc-line-2); }
|
|
562
|
+
.filterWrap {
|
|
563
|
+
position: relative;
|
|
564
|
+
margin-left: auto;
|
|
565
|
+
}
|
|
566
|
+
.filterWrap + .refreshBtn { margin-left: 0; }
|
|
567
|
+
.filterChip {
|
|
568
|
+
display: inline-flex;
|
|
569
|
+
align-items: center;
|
|
570
|
+
gap: 6px;
|
|
571
|
+
height: 30px;
|
|
572
|
+
padding: 0 9px;
|
|
573
|
+
border: 1px solid var(--nc-line-1);
|
|
574
|
+
border-radius: 8px;
|
|
575
|
+
background: transparent;
|
|
576
|
+
color: var(--nc-fg-3);
|
|
577
|
+
font: 500 12px/1 var(--nc-font-sans);
|
|
578
|
+
cursor: pointer;
|
|
579
|
+
transition: color var(--dur-fast) var(--nc-ease-out), border-color var(--dur-fast) var(--nc-ease-out);
|
|
580
|
+
}
|
|
581
|
+
.filterChip svg {
|
|
582
|
+
width: 14px;
|
|
583
|
+
height: 14px;
|
|
584
|
+
flex: none;
|
|
585
|
+
}
|
|
586
|
+
.filterChip:hover { color: var(--nc-fg-1); border-color: var(--nc-line-2); }
|
|
587
|
+
.filterChipOn {
|
|
588
|
+
color: var(--nc-fg-1);
|
|
589
|
+
border-color: var(--nc-violet-500);
|
|
590
|
+
}
|
|
591
|
+
.filterChipCount {
|
|
592
|
+
display: inline-flex;
|
|
593
|
+
align-items: center;
|
|
594
|
+
gap: 5px;
|
|
595
|
+
font-family: var(--nc-font-mono);
|
|
596
|
+
color: var(--nc-fg-2);
|
|
597
|
+
}
|
|
598
|
+
.filterChipUnread {
|
|
599
|
+
padding: 2px 5px;
|
|
600
|
+
border-radius: 999px;
|
|
601
|
+
background: var(--nc-violet-500);
|
|
602
|
+
color: #fff;
|
|
603
|
+
font: 600 10px/1 var(--nc-font-sans);
|
|
604
|
+
}
|
|
605
|
+
.filterMenu {
|
|
606
|
+
position: absolute;
|
|
607
|
+
top: calc(100% + 8px);
|
|
608
|
+
right: 0;
|
|
609
|
+
z-index: 30;
|
|
610
|
+
width: min(330px, calc(100vw - 32px));
|
|
611
|
+
padding: 8px;
|
|
612
|
+
display: flex;
|
|
613
|
+
flex-direction: column;
|
|
614
|
+
gap: 6px;
|
|
615
|
+
border: 1px solid var(--nc-line-2);
|
|
616
|
+
border-radius: var(--radius-card);
|
|
617
|
+
background: var(--nc-ink-2);
|
|
618
|
+
box-shadow: 0 18px 44px rgba(4, 2, 8, .42);
|
|
619
|
+
animation: ncFadeDown .16s var(--nc-ease-out);
|
|
620
|
+
}
|
|
621
|
+
.filterMenuHead {
|
|
622
|
+
display: flex;
|
|
623
|
+
flex-direction: column;
|
|
624
|
+
gap: 4px;
|
|
625
|
+
padding: 4px 6px 2px;
|
|
626
|
+
color: var(--nc-fg-1);
|
|
627
|
+
font: 600 13px/1.2 var(--nc-font-sans);
|
|
628
|
+
}
|
|
629
|
+
.filterMenuHint {
|
|
630
|
+
color: var(--nc-fg-3);
|
|
631
|
+
font: 400 12px/1.4 var(--nc-font-sans);
|
|
632
|
+
}
|
|
633
|
+
.filterMenuView {
|
|
634
|
+
display: flex;
|
|
635
|
+
align-items: center;
|
|
636
|
+
justify-content: space-between;
|
|
637
|
+
gap: 8px;
|
|
638
|
+
padding: 9px 10px;
|
|
639
|
+
border: 1px solid var(--nc-line-1);
|
|
640
|
+
border-radius: 8px;
|
|
641
|
+
background: var(--nc-ink-3);
|
|
642
|
+
color: var(--nc-fg-1);
|
|
643
|
+
font: 600 12px/1 var(--nc-font-sans);
|
|
644
|
+
cursor: pointer;
|
|
645
|
+
text-align: left;
|
|
646
|
+
}
|
|
647
|
+
.filterMenuView:hover { border-color: var(--nc-violet-500); }
|
|
648
|
+
.filterRules {
|
|
649
|
+
display: flex;
|
|
650
|
+
flex-direction: column;
|
|
651
|
+
max-height: 220px;
|
|
652
|
+
overflow-y: auto;
|
|
653
|
+
}
|
|
654
|
+
.filterRule {
|
|
655
|
+
display: grid;
|
|
656
|
+
grid-template-columns: minmax(0, auto) minmax(0, 1fr) auto;
|
|
657
|
+
align-items: center;
|
|
658
|
+
gap: 8px;
|
|
659
|
+
padding: 7px 6px;
|
|
660
|
+
border-bottom: 1px solid var(--nc-line-1);
|
|
661
|
+
}
|
|
662
|
+
.filterRule:last-child { border-bottom: none; }
|
|
663
|
+
.filterRuleKind {
|
|
664
|
+
color: var(--nc-fg-3);
|
|
665
|
+
font: 500 11px/1.2 var(--nc-font-sans);
|
|
666
|
+
white-space: nowrap;
|
|
667
|
+
}
|
|
668
|
+
.filterRuleValue {
|
|
669
|
+
overflow: hidden;
|
|
670
|
+
color: var(--nc-fg-1);
|
|
671
|
+
font: 500 12px/1.2 var(--nc-font-mono);
|
|
672
|
+
text-overflow: ellipsis;
|
|
673
|
+
white-space: nowrap;
|
|
674
|
+
}
|
|
675
|
+
.filterRuleRemove {
|
|
676
|
+
width: 22px;
|
|
677
|
+
height: 22px;
|
|
678
|
+
border: none;
|
|
679
|
+
border-radius: 6px;
|
|
680
|
+
background: transparent;
|
|
681
|
+
color: var(--nc-fg-3);
|
|
682
|
+
font: 400 16px/1 var(--nc-font-sans);
|
|
683
|
+
cursor: pointer;
|
|
684
|
+
}
|
|
685
|
+
.filterRuleRemove:hover { color: var(--nc-danger); background: var(--nc-ink-4); }
|
|
686
|
+
.filterEmpty {
|
|
687
|
+
padding: 8px 6px;
|
|
688
|
+
color: var(--nc-fg-3);
|
|
689
|
+
font: 400 12px/1.4 var(--nc-font-sans);
|
|
690
|
+
}
|
|
691
|
+
.filterAdd {
|
|
692
|
+
display: grid;
|
|
693
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
694
|
+
gap: 6px;
|
|
695
|
+
padding-top: 6px;
|
|
696
|
+
border-top: 1px solid var(--nc-line-1);
|
|
697
|
+
}
|
|
698
|
+
.filterAdd > :first-child { grid-column: 1 / -1; }
|
|
699
|
+
.filterAddKind {
|
|
700
|
+
width: 100%;
|
|
701
|
+
min-width: 0;
|
|
702
|
+
}
|
|
703
|
+
.filterAddValue {
|
|
704
|
+
min-width: 0;
|
|
705
|
+
height: 32px;
|
|
706
|
+
padding: 0 9px;
|
|
707
|
+
border: 1px solid var(--nc-line-1);
|
|
708
|
+
border-radius: 8px;
|
|
709
|
+
background: var(--nc-ink-1);
|
|
710
|
+
color: var(--nc-fg-1);
|
|
711
|
+
font: 400 12px/1 var(--nc-font-sans);
|
|
712
|
+
}
|
|
713
|
+
.filterAddValue:focus { outline: none; border-color: var(--nc-violet-500); }
|
|
714
|
+
.filterAddBtn {
|
|
715
|
+
height: 32px;
|
|
716
|
+
padding: 0 12px;
|
|
717
|
+
border: none;
|
|
718
|
+
border-radius: 8px;
|
|
719
|
+
background: var(--nc-violet-500);
|
|
720
|
+
color: #fff;
|
|
721
|
+
font: 600 12px/1 var(--nc-font-sans);
|
|
722
|
+
cursor: pointer;
|
|
723
|
+
}
|
|
724
|
+
.filterAddBtn:disabled {
|
|
725
|
+
opacity: .45;
|
|
726
|
+
cursor: default;
|
|
727
|
+
}
|
|
728
|
+
.listHead { container-type: inline-size; }
|
|
729
|
+
@container (max-width: 440px) {
|
|
730
|
+
.filterChipLabel { display: none; }
|
|
731
|
+
}
|
|
562
732
|
.crossFolderHint {
|
|
563
733
|
align-self: flex-start;
|
|
564
734
|
margin: 0 16px 8px;
|