Compare commits
121
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfc02040ca | ||
|
|
1e65b74b8d | ||
|
|
a0f91e4b93 | ||
|
|
6ead6e6410 | ||
|
|
0df4ab5a74 | ||
|
|
afa7e0fa7d | ||
|
|
120a1b88fb | ||
|
|
86b0ee68b9 | ||
|
|
a44bae5edd | ||
|
|
6950dec056 | ||
|
|
6e9d6fe7d4 | ||
|
|
5e115f1896 | ||
|
|
4b557e909c | ||
|
|
8c3d677af8 | ||
|
|
6687d842db | ||
|
|
4a969ad9ad | ||
|
|
0b1acdf136 | ||
|
|
13a98ca7e5 | ||
|
|
015c44af80 | ||
|
|
1ebfd522a3 | ||
|
|
82b47a3e76 | ||
|
|
0f59913c14 | ||
|
|
d5c7a6d109 | ||
|
|
c394f623eb | ||
|
|
adde340bbd | ||
|
|
32275c6068 | ||
|
|
ef2f06f824 | ||
|
|
8d5568b807 | ||
|
|
eaabf16b2c | ||
|
|
3c7e4f93d0 | ||
|
|
3f95f1c118 | ||
|
|
996a40ceb9 | ||
|
|
6c1f5e261d | ||
|
|
dc0de8a8dd | ||
|
|
898313f5a5 | ||
|
|
a279392ba9 | ||
|
|
6951b2d92a | ||
|
|
76444e2992 | ||
|
|
47c256a53c | ||
|
|
a0570a18ca | ||
|
|
58d31905ef | ||
|
|
b8c6bb093f | ||
|
|
24b22dc1df | ||
|
|
c9b249c969 | ||
|
|
f677204bc2 | ||
|
|
71b243ad9b | ||
|
|
7216556de2 | ||
|
|
223d81adbf | ||
|
|
e19ae608d9 | ||
|
|
be9c7dedae | ||
|
|
9bdbe333ad | ||
|
|
f1a686e39d | ||
|
|
d3bd1802cd | ||
|
|
3ae1dbb678 | ||
|
|
58476317a5 | ||
|
|
c670f29273 | ||
|
|
996d5f0e21 | ||
|
|
0dd914a87d | ||
|
|
febb9322a7 | ||
|
|
661014c893 | ||
|
|
53d328c2d9 | ||
|
|
5f81333e6e | ||
|
|
47922d48d7 | ||
|
|
3794b3eeff | ||
|
|
8feb0e2d6e | ||
|
|
4e0969ab71 | ||
|
|
3c20959060 | ||
|
|
4145145b74 | ||
|
|
9d7b8bf8f2 | ||
|
|
2425a8933b | ||
|
|
d1a3603837 | ||
|
|
70beab2c5e | ||
|
|
e5dab063db | ||
|
|
c7c1f59649 | ||
|
|
7fd0d7bdfe | ||
|
|
cf1e783d8f | ||
|
|
7542ffe53a | ||
|
|
ae45df36b8 | ||
|
|
1571168300 | ||
|
|
3f351a0900 | ||
|
|
5df098f630 | ||
|
|
6d7d88431b | ||
|
|
ebb928916c | ||
|
|
ce2b40daad | ||
|
|
e591d6cea1 | ||
|
|
ebd0e9f367 | ||
|
|
931bb5a0ba | ||
|
|
03a6a09bb0 | ||
|
|
548b0f51f3 | ||
|
|
c5fd6bbfa6 | ||
|
|
f97f5e9164 | ||
|
|
3a75915740 | ||
|
|
4dd6981e48 | ||
|
|
01aa7f4306 | ||
|
|
75a5795882 | ||
|
|
2709049f68 | ||
|
|
e81d4118aa | ||
|
|
c9638ebd9f | ||
|
|
47b0564ad6 | ||
|
|
fcc3fd4d92 | ||
|
|
db70856fb0 | ||
|
|
df1fbfda77 | ||
|
|
4d29ecc1c4 | ||
|
|
a8981b4950 | ||
|
|
9e0cc88a4a | ||
|
|
e0eaaa5b3d | ||
|
|
07f56a1b51 | ||
|
|
a3108dc5fa | ||
|
|
c2e4adc30b | ||
|
|
c6a95059bd | ||
|
|
ba9bd0c45c | ||
|
|
2c6f390ddc | ||
|
|
4237049af5 | ||
|
|
9386dce3ee | ||
|
|
bd109580ff | ||
|
|
5d041f20f8 | ||
|
|
8a874e10e0 | ||
|
|
7c50bf6242 | ||
|
|
fb51399469 | ||
|
|
053394901c | ||
|
|
5e7ff9fa29 |
@@ -0,0 +1,177 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
name: frontend-design
|
||||
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics.
|
||||
license: Complete terms in LICENSE.txt
|
||||
---
|
||||
|
||||
This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
|
||||
|
||||
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
|
||||
|
||||
## Design Thinking
|
||||
|
||||
Before coding, understand the context and commit to a BOLD aesthetic direction:
|
||||
- **Purpose**: What problem does this interface solve? Who uses it?
|
||||
- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
|
||||
- **Constraints**: Technical requirements (framework, performance, accessibility).
|
||||
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
|
||||
|
||||
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
|
||||
|
||||
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
|
||||
- Production-grade and functional
|
||||
- Visually striking and memorable
|
||||
- Cohesive with a clear aesthetic point-of-view
|
||||
- Meticulously refined in every detail
|
||||
|
||||
## Frontend Aesthetics Guidelines
|
||||
|
||||
Focus on:
|
||||
- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
|
||||
- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
|
||||
- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
|
||||
- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
|
||||
- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
|
||||
|
||||
NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
|
||||
|
||||
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
|
||||
|
||||
**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
|
||||
|
||||
Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
name: postgres
|
||||
description: PostgreSQL best practices, query optimization, connection troubleshooting, and performance improvement. Load when working with Postgres databases.
|
||||
license: MIT
|
||||
metadata:
|
||||
author: planetscale
|
||||
version: "1.0.0"
|
||||
---
|
||||
|
||||
# PlanetScale Postgres
|
||||
|
||||
## Generic Postgres
|
||||
|
||||
| Topic | Reference | Use for |
|
||||
| ---------------------- | ---------------------------------------------------------------- | --------------------------------------------------------- |
|
||||
| Schema Design | [references/schema-design.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/schema-design.md) | Tables, primary keys, data types, foreign keys |
|
||||
| Indexing | [references/indexing.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/indexing.md) | Index types, composite indexes, performance |
|
||||
| Index Optimization | [references/index-optimization.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/index-optimization.md) | Unused/duplicate index queries, index audit |
|
||||
| Partitioning | [references/partitioning.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/partitioning.md) | Large tables, time-series, data retention |
|
||||
| Query Patterns | [references/query-patterns.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/query-patterns.md) | SQL anti-patterns, JOINs, pagination, batch queries |
|
||||
| Optimization Checklist | [references/optimization-checklist.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/optimization-checklist.md) | Pre-optimization audit, cleanup, readiness checks |
|
||||
| MVCC and VACUUM | [references/mvcc-vacuum.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/mvcc-vacuum.md) | Dead tuples, long transactions, xid wraparound prevention |
|
||||
|
||||
## Operations and Architecture
|
||||
|
||||
| Topic | Reference | Use for |
|
||||
| ---------------------- | ---------------------------------------------------------------------------- | --------------------------------------------------------------- |
|
||||
| Process Architecture | [references/process-architecture.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/process-architecture.md) | Multi-process model, connection pooling, auxiliary processes |
|
||||
| Memory Architecture | [references/memory-management-ops.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/memory-management-ops.md) | Shared/private memory layout, OS page cache, OOM prevention |
|
||||
| MVCC Transactions | [references/mvcc-transactions.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/mvcc-transactions.md) | Isolation levels, XID wraparound, serialization errors |
|
||||
| WAL and Checkpoints | [references/wal-operations.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/wal-operations.md) | WAL internals, checkpoint tuning, durability, crash recovery |
|
||||
| Replication | [references/replication.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/replication.md) | Streaming replication, slots, sync commit, failover |
|
||||
| Storage Layout | [references/storage-layout.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/storage-layout.md) | PGDATA structure, TOAST, fillfactor, tablespaces, disk mgmt |
|
||||
| Monitoring | [references/monitoring.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/monitoring.md) | pg_stat views, logging, pg_stat_statements, host metrics |
|
||||
| Backup and Recovery | [references/backup-recovery.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/backup-recovery.md) | pg_dump, pg_basebackup, PITR, WAL archiving, backup tools |
|
||||
|
||||
## PlanetScale-Specific
|
||||
|
||||
| Topic | Reference | Use for |
|
||||
| ------------------ | ---------------------------------------------------------------------------- | ----------------------------------------------------- |
|
||||
| Connection Pooling | [references/ps-connection-pooling.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/ps-connection-pooling.md) | PgBouncer, pool sizing, pooled vs direct |
|
||||
| Extensions | [references/ps-extensions.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/ps-extensions.md) | Supported extensions, compatibility |
|
||||
| Connections | [references/ps-connections.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/ps-connections.md) | Connection troubleshooting, drivers, SSL |
|
||||
| Insights | [references/ps-insights.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/ps-insights.md) | Slow queries, MCP server, pscale CLI |
|
||||
| CLI Commands | [references/ps-cli-commands.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/ps-cli-commands.md) | pscale CLI reference, branches, deploy requests, auth |
|
||||
| CLI API Insights | [references/ps-cli-api-insights.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/ps-cli-api-insights.md) | Query insights via `pscale api`, schema analysis |
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
title: Backup and Recovery
|
||||
description: Logical/physical backups, PITR, WAL archiving, backup tools, and recovery strategies
|
||||
tags: postgres, backup, recovery, pitr, pg_dump, pg_basebackup, wal-archiving, operations
|
||||
---
|
||||
|
||||
# Backup and Recovery
|
||||
|
||||
**FUNDAMENTAL RULE: Backups are useless until you've successfully tested recovery.**
|
||||
|
||||
## Logical Backups (pg_dump)
|
||||
Exports as SQL or custom format; portable across PG versions and architectures. Formats: `-Fp` (plain SQL), `-Fc` (custom compressed, selective restore), `-Fd` (directory, parallel with `-j`), `-Ft` (tar, avoid). Use `-Fd -j 4` for large DBs. Restore: `pg_restore -d dbname file.dump`; add `-j` for parallel restore. Selective table restore: `pg_restore -t tablename`. Slow for large DBs; RPO = backup frequency (typically 24h).
|
||||
|
||||
## Physical Backups (pg_basebackup)
|
||||
Copies raw PGDATA; same major version and platform required; cross-architecture works if same endianness (e.g., x86_64 ↔ ARM64). Faster for large clusters; includes all databases. Flags: `-Ft -z -P` for compressed tar with progress. Manual alternative: `pg_backup_start()` → copy PGDATA → `pg_backup_stop()` (complex; must write returned `backup_label`).
|
||||
|
||||
## PITR (Point-in-Time Recovery)
|
||||
Requires base backup + continuous WAL archiving. Restores to any timestamp, transaction, or named restore point. Without PITR: restore only to backup time (potentially lose hours). With PITR: RPO = minutes. `archive_command` must return 0 ONLY when file is safely stored—premature 0 = data loss risk. `wal_level` must be `replica` or `logical` (not `minimal`).
|
||||
|
||||
## WAL Archiving
|
||||
`archive_mode=on`, `archive_command='test ! -f /archive/%f && cp %p /archive/%f'`. **Test archive command as postgres user** (not root) since permission issues are common. Monitor `pg_stat_archiver` for `failed_count`, `last_archived_time`. Archive failures prevent WAL recycling → disk fills.
|
||||
|
||||
## Tool Comparison
|
||||
| Tool | Use case |
|
||||
|------|----------|
|
||||
| pg_dump | Small DBs, migrations, selective restore |
|
||||
| pg_basebackup | Basic PITR, built-in |
|
||||
| pgBackRest | Production—parallel, incremental, S3/GCS/Azure, retention |
|
||||
| Barman | Enterprise PITR, retention policies |
|
||||
| WAL-G | Cloud-native, S3/GCS/Azure |
|
||||
|
||||
## RPO/RTO
|
||||
Logical only: RPO = backup interval (hours); RTO = hours. PITR: RPO = minutes; RTO = hours. Synchronous replication: RPO = 0; RTO = seconds to minutes (failover).
|
||||
|
||||
## Operational Rules
|
||||
- Verify integrity with `pg_verifybackup` (PG 13+)
|
||||
- Test recovery / PITR regularly
|
||||
- Take backups from standby to avoid impacting primary
|
||||
- Retention: 7 daily, 4 weekly, 12 monthly
|
||||
- Monitor archive growth and backup age
|
||||
- **Never assume backups work without testing**
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
title: Index Optimization Queries
|
||||
description: Index audit queries
|
||||
tags: postgres, indexes, unused-indexes, duplicate-indexes, optimization
|
||||
---
|
||||
|
||||
# Index Optimization
|
||||
|
||||
## Identify Unused Indexes
|
||||
|
||||
Query to find unused indexes:
|
||||
|
||||
```sql
|
||||
-- indexes with 0 scans (check pg_stat_reset / pg_postmaster_start_time first)
|
||||
SELECT
|
||||
s.schemaname,
|
||||
s.relname AS table_name,
|
||||
s.indexrelname AS index_name,
|
||||
pg_size_pretty(pg_relation_size(s.indexrelid)) AS index_size
|
||||
FROM pg_catalog.pg_stat_user_indexes s
|
||||
JOIN pg_catalog.pg_index i ON s.indexrelid = i.indexrelid
|
||||
WHERE s.idx_scan = 0
|
||||
AND 0 <> ALL (i.indkey) -- exclude expression indexes
|
||||
AND NOT i.indisunique -- exclude UNIQUE indexes
|
||||
AND NOT EXISTS ( -- exclude constraint-backing indexes
|
||||
SELECT 1 FROM pg_catalog.pg_constraint c
|
||||
WHERE c.conindid = s.indexrelid
|
||||
)
|
||||
ORDER BY pg_relation_size(s.indexrelid) DESC;
|
||||
```
|
||||
|
||||
## Indexes Per Table Guidelines
|
||||
|
||||
- **< 5**: Normal
|
||||
- **5-10**: Monitor (Verify necessity)
|
||||
- **> 10**: Audit required (High write overhead)
|
||||
|
||||
```sql
|
||||
SELECT relname AS table, count(*) as index_count
|
||||
FROM pg_stat_user_indexes
|
||||
GROUP BY relname
|
||||
ORDER BY count(*) DESC;
|
||||
```
|
||||
|
||||
## Identify Unused Indexes
|
||||
|
||||
Indexes with identical definitions (after normalizing names) on the same table are duplicates:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
schemaname || '.' || tablename AS table,
|
||||
array_agg(indexname) AS duplicate_indexes,
|
||||
pg_size_pretty(sum(pg_relation_size((schemaname || '.' || indexname)::regclass))) AS total_size
|
||||
FROM pg_indexes
|
||||
WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
|
||||
GROUP BY schemaname, tablename,
|
||||
regexp_replace(indexdef, 'INDEX \S+ ON ', 'INDEX ON ')
|
||||
HAVING count(*) > 1;
|
||||
```
|
||||
|
||||
**Always confirm with a human before dropping or removing any indexes identified by the queries above.** Even indexes with 0 scans may be needed for infrequent but critical queries, and stats may have been reset recently.
|
||||
|
||||
## Per-table Index Count Guidelines
|
||||
|
||||
| Index Count | Recommendation |
|
||||
| ----------- | ------------------------------------------- |
|
||||
| <5 | Normal |
|
||||
| 5-10 | Review for unused/duplicates |
|
||||
| >10 | Audit required - significant write overhead |
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
title: Indexing Best Practices
|
||||
description: Index design guide
|
||||
tags: postgres, indexes, composite, partial, covering, gin, brin
|
||||
---
|
||||
|
||||
# Indexing Best Practices
|
||||
|
||||
## Core Rules
|
||||
|
||||
1. **Always index foreign key columns** — PostgreSQL does not auto-create these
|
||||
2. **Index columns in WHERE, JOIN, and ORDER BY** clauses
|
||||
3. **Don't over-index** — each index slows writes and uses storage
|
||||
4. **Verify with EXPLAIN ANALYZE** — confirm indexes are actually used
|
||||
|
||||
## Composite Indexes
|
||||
|
||||
Put equality columns first, then range/sort columns:
|
||||
|
||||
```sql
|
||||
-- WHERE status = 'active' AND created_at > '2026-01-01'
|
||||
CREATE INDEX order_status_created_idx ON order (status, created_at);
|
||||
```
|
||||
|
||||
A composite index on `(a, b)` supports queries on `a` + `b` and `a` alone, but not `b` alone.
|
||||
|
||||
## Partial Indexes
|
||||
|
||||
Reduce index size by filtering to common query patterns.
|
||||
Only use if index size is problematic but the index is needed for performance.
|
||||
|
||||
```sql
|
||||
CREATE INDEX order_active_idx ON order (customer_id)
|
||||
WHERE status = 'active';
|
||||
```
|
||||
|
||||
## Covering Indexes
|
||||
|
||||
Consider creating covering indexes for commonly executed query patterns that return only 1 or a small number of columns.
|
||||
|
||||
## Index Types
|
||||
|
||||
| Type | Use Case | Example |
|
||||
| --- | --- | --- |
|
||||
| B-tree (default) | Equality, range, sorting | `WHERE id = 1`, `ORDER BY date` |
|
||||
| GIN | Arrays, JSONB, full-text | `WHERE tags @> ARRAY['x']` |
|
||||
| GiST | Geometric, range types, full-text | PostGIS, `tsrange`, `tsvector` |
|
||||
| BRIN | Large sequential/time-series | Append-only logs, events (requires physical row order correlation) |
|
||||
|
||||
```sql
|
||||
CREATE INDEX metadata_idx ON order USING GIN (metadata); -- JSONB
|
||||
CREATE INDEX event_created_idx ON event USING BRIN (created_at); -- time-series
|
||||
```
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Name indexes consistently: `{table}_{column}_idx`
|
||||
- Review for unused indexes periodically
|
||||
- **Always confirm with a human before removing or dropping any indexes** — even unused ones may serve a purpose not reflected in recent stats
|
||||
- Use partial indexes for frequently filtered subsets
|
||||
- Use covering indexes on hot read paths
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
title: Memory Architecture and OOM Prevention
|
||||
description: PostgreSQL shared/private memory layout, OS page cache interaction, and OOM avoidance strategies
|
||||
tags: postgres, memory, shared_buffers, work_mem, oom, architecture, operations
|
||||
---
|
||||
|
||||
# Memory Architecture and OOM Prevention
|
||||
|
||||
## Memory Areas
|
||||
|
||||
- **Shared memory**: `shared_buffers` — main data cache, all processes, requires restart to change.
|
||||
- **Private per backend**: `work_mem` (sorts/hashes/joins, per-operation); `maintenance_work_mem` (VACUUM, CREATE INDEX, ALTER TABLE ADD FOREIGN KEY); `temp_buffers` (8MB default).
|
||||
- **Planner hint only**: `effective_cache_size` is NOT allocated — set to ~50–75% of total RAM.
|
||||
- **Hash multiplier**: `hash_mem_multiplier` (default 2.0) means hash ops use up to 2× `work_mem`.
|
||||
|
||||
## Memory Multiplication Danger
|
||||
|
||||
Maximum potential: `work_mem × operations_per_query × (parallel_workers + 1) × connections` (leader participates by default via `parallel_leader_participation = on`; hash operations use up to `hash_mem_multiplier × work_mem`, default 2.0). Example: 128MB work_mem, 3 ops (2 sorts + 1 hash join), 2 parallel workers, 100 connections → 2 sorts at 128MB = 256MB, 1 hash join at 128MB × 2.0 = 256MB, per process = 512MB, × 3 processes (2 workers + leader) = 1536MB/query, × 100 connections = **~150GB** worst case. This case is rare.
|
||||
Not all queries hit limits at once, but high concurrency + large datasets approach it. This is a common cause of OOM in containerized/Kubernetes deployments. Plan capacity with a 1.5–2× safety margin.
|
||||
|
||||
## OS Page Cache (Double Buffering)
|
||||
|
||||
Data exists in both `shared_buffers` and OS page cache. A miss in shared_buffers can still hit OS cache (avoiding disk I/O). Extremely large shared_buffers can hurt performance: less OS cache, slower startup, heavier checkpoints. Optimal split depends on workload (OLTP vs OLAP).
|
||||
|
||||
## OOM Prevention
|
||||
|
||||
- Implement connection pooling to reduce total backend count.
|
||||
- Reduce `work_mem` globally; use per-session overrides for heavy queries only.
|
||||
- Lower `max_parallel_workers_per_gather` in high-concurrency systems.
|
||||
- Set `statement_timeout` to kill runaway queries.
|
||||
- Monitor: `dmesg -T | grep "killed process"` and `temp_blks_written` in pg_stat_statements.
|
||||
|
||||
## Operational Rules
|
||||
|
||||
- Tune per-session first, global last.
|
||||
- Suspect OOM when memory spikes during high concurrency, dashboards, or large batch jobs.
|
||||
- Increase memory only after confirming spill behavior (`temp_blks_written > 0`).
|
||||
- `maintenance_work_mem` can be set much higher (1–2GB) — fewer processes use it. Cap autovacuum with `autovacuum_work_mem` to avoid `autovacuum_max_workers × maintenance_work_mem` memory spikes.
|
||||
- `shared_buffers` change requires full restart; `work_mem` is per-session changeable.
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
title: Monitoring
|
||||
description: Essential PostgreSQL monitoring views, pg_stat_statements, logging, host metrics, and statistics management
|
||||
tags: postgres, monitoring, pg_stat_statements, logging, pgbadger, metrics, operations
|
||||
---
|
||||
|
||||
# Monitoring
|
||||
|
||||
## Essential Views
|
||||
|
||||
- **pg_stat_activity**: First stop when something is wrong — running queries, states, wait events, locks.
|
||||
- **pg_stat_statements**: Execution stats for all SQL. Requires `shared_preload_libraries = 'pg_stat_statements'` and `CREATE EXTENSION pg_stat_statements`.
|
||||
- **pg_stat_database**: Cache hit ratio, temp files, deadlocks, connections per database.
|
||||
- **pg_stat_user_tables**: `seq_scan` vs `idx_scan`, dead tuples, last vacuum/analyze times.
|
||||
- **pg_stat_user_indexes**: Find unused indexes (`idx_scan = 0` with large size).
|
||||
- **pg_stat_bgwriter**: `buffers_clean`, `maxwritten_clean`, `buffers_alloc`. Pre-PG 17 also had `buffers_checkpoint`, `buffers_backend` (high = backends bypassing bgwriter). PG 17+ moved checkpoint stats to `pg_stat_checkpointer`.
|
||||
- **pg_stat_checkpointer** (PG 17+): Checkpoint frequency (`num_timed`, `num_requested`), write/sync time.
|
||||
|
||||
## Key Queries
|
||||
|
||||
```sql
|
||||
-- Slow queries (with cache hit ratio)
|
||||
SELECT query, calls, mean_exec_time,
|
||||
100.0 * shared_blks_hit / nullif(shared_blks_hit + shared_blks_read, 0) AS cache_hit_pct
|
||||
FROM pg_stat_statements ORDER BY mean_exec_time DESC LIMIT 10;
|
||||
|
||||
-- Connection counts / states
|
||||
SELECT state, count(*) FROM pg_stat_activity GROUP BY state;
|
||||
|
||||
-- Dead tuples (vacuum candidates)
|
||||
SELECT relname, n_dead_tup, last_autovacuum FROM pg_stat_user_tables ORDER BY n_dead_tup DESC;
|
||||
-- last_autovacuum = <null> means autovacuum has not run on this table
|
||||
```
|
||||
|
||||
Blocking: use `pg_blocking_pids(pid)` with `pg_stat_activity` to find blocked and blocking sessions.
|
||||
|
||||
## Logging — First Line of Defense
|
||||
|
||||
PostgreSQL is extremely vocal about problems. **Always check logs first**: `tail -f /var/log/postgresql/postgresql-*.log`.
|
||||
|
||||
Key settings: `log_min_duration_statement` (OLTP: 1–3s, analytics: 30–60s, dev: 100–500ms). Enable `log_checkpoints=on`, `log_connections=on`, `log_disconnections=on`, `log_lock_waits=on`, `log_temp_files=0`. Use CSV log format for pgBadger analysis; pgBadger generates HTML reports with query stats and performance graphs.
|
||||
|
||||
## pg_activity
|
||||
|
||||
Interactive top-like tool (pip install pg_activity). Run on DB host for OS metrics alongside PG metrics. Combines `pg_stat_activity` with CPU/memory/I/O context.
|
||||
|
||||
## Host Metrics — Critical
|
||||
|
||||
PostgreSQL cannot report these. **Monitor them yourself:**
|
||||
|
||||
- **CPU**: Steal time >10% in VMs bad; load average > core count; context switches >100k/sec.
|
||||
- **Memory**: Any swap = performance degradation. Check `dmesg` for OOM kills.
|
||||
- **Disk I/O**: `iostat -x` — `%util=100%` means saturated; `await` >10ms = high latency.
|
||||
- **Disk space**: >90% critical (VACUUM fails, writes fail). Check inode usage too.
|
||||
- **Network**: Packet loss >0% = problems; high retransmits = instability.
|
||||
|
||||
## Statistics Management
|
||||
|
||||
Stats accumulate since last reset or restart; check `stats_reset` timestamp. `pg_stat_statements_reset()` clears query stats; `pg_stat_reset()` clears database stats. Reset after major maintenance, config changes, or perf testing — not routinely. Prefer snapshotting stats to external monitoring (Prometheus, Datadog) over resetting. **Always confirm with a human before resetting statistics** — resetting destroys historical performance baselines and can make it harder to identify unused indexes or regressions.
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
title: MVCC Transactions and Concurrency
|
||||
description: Transaction isolation levels, XID wraparound prevention, serialization errors, and long-transaction impact
|
||||
tags: postgres, mvcc, transactions, isolation, xid-wraparound, concurrency, serialization
|
||||
---
|
||||
|
||||
# MVCC Transactions and Concurrency
|
||||
|
||||
## Transaction Isolation Levels
|
||||
|
||||
- **READ UNCOMMITTED** — treated as READ COMMITTED in PostgreSQL; no dirty reads ever.
|
||||
- **READ COMMITTED** (default): new snapshot per statement; can see different data within same tx.
|
||||
- **REPEATABLE READ**: snapshot at first query; can cause serialization errors on write conflicts.
|
||||
- **SERIALIZABLE**: strongest; transactions appear serial; requires retry logic in app code.
|
||||
|
||||
Readers never block writers; writers never block readers (only writer-writer conflicts on same row). No lock escalation — row locks never degrade to table locks.
|
||||
|
||||
## XID Wraparound
|
||||
|
||||
32-bit transaction IDs wrap at ~2 billion (2^31). `VACUUM FREEZE` replaces old XIDs with FrozenXID (value 2, always visible). Without freeze: after wraparound, old rows appear "in the future" and become **invisible**. Data physically exists but is invisible to all queries — looks like total data loss. PostgreSQL emergency shutdown at 2B XIDs to prevent this. XID wraparound should be avoided at all cost.
|
||||
|
||||
Warning messages start at ~1.4B XIDs; shutdown at 2B. Recovery requires single-user mode VACUUM — can take hours to days on large DBs. **Never disable autovacuum** — it's your protection against wraparound.
|
||||
|
||||
## XID Age Monitoring
|
||||
|
||||
```sql
|
||||
SELECT datname, age(datfrozenxid),
|
||||
ROUND(100.0 * age(datfrozenxid) / 2147483648, 2) AS pct
|
||||
FROM pg_database ORDER BY age(datfrozenxid) DESC;
|
||||
```
|
||||
|
||||
## Long Transaction Impact
|
||||
|
||||
A single long-running transaction blocks VACUUM from removing dead tuples across the **entire database**. Causes table bloat, increased disk, slower queries, cache pollution. `idle_in_transaction` connections are the #1 operational MVCC issue. Set `idle_in_transaction_session_timeout` (30s–5min). Dead tuples waste I/O on seq scans and cause useless heap lookups from indexes.
|
||||
|
||||
## Serialization Errors
|
||||
|
||||
Apps **must** handle "could not serialize access" with retry logic. More common in REPEATABLE READ and SERIALIZABLE. Smaller, faster transactions reduce conflict frequency.
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
title: MVCC and VACUUM
|
||||
description: MVCC internals, VACUUM/autovacuum tuning, and bloat prevention
|
||||
tags: postgres, mvcc, vacuum, autovacuum, xid, bloat, dead-tuples
|
||||
---
|
||||
|
||||
# MVCC and VACUUM
|
||||
|
||||
## MVCC
|
||||
|
||||
Every `UPDATE` creates a new tuple and marks the old one dead; `DELETE` marks tuples dead. Dead tuples accumulate until `VACUUM` reclaims space. Each transaction gets a 32-bit XID (2^32 ≈ 4B values, but modular comparison means the effective danger zone is 2^31 ≈ 2B). VACUUM must freeze old XIDs to prevent wraparound.
|
||||
|
||||
## VACUUM vs VACUUM FULL
|
||||
|
||||
`VACUUM` is non-blocking (ShareUpdateExclusive lock) and marks dead space reusable. `VACUUM FULL` rewrites the table and requires an AccessExclusive lock — use only as a last resort. For online bloat reduction prefer `pg_squeeze` or `pg_repack`.
|
||||
|
||||
## Autovacuum Tuning
|
||||
|
||||
Triggers when dead tuples > `Min(autovacuum_vacuum_max_threshold, autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor * reltuples)`. `autovacuum_vacuum_max_threshold` defaults to 100M (PG 18+), capping the threshold for very large tables. Also triggers on inserts exceeding `autovacuum_vacuum_insert_threshold + autovacuum_vacuum_insert_scale_factor * reltuples * pct_not_frozen` (ensures insert-only tables get frozen; PG 13+). For large/hot tables, set per-table overrides:
|
||||
|
||||
- `autovacuum_vacuum_scale_factor` — default 0.2; lower to 0.01–0.05 for large tables.
|
||||
- `autovacuum_vacuum_cost_delay` — default 2 ms; set to 0 on fast storage.
|
||||
- `autovacuum_vacuum_cost_limit` — default -1 (uses `vacuum_cost_limit`, effectively 200); raise to 1000–2000 on fast storage.
|
||||
- `autovacuum_freeze_max_age` — default 200M; triggers anti-wraparound vacuum.
|
||||
- `vacuum_failsafe_age` — default 1.6B; last-resort mode (PG 14+) that disables throttling and skips index vacuuming when wraparound is imminent.
|
||||
|
||||
## Key Monitoring Queries
|
||||
|
||||
Dead tuples: `SELECT relname, n_dead_tup, last_autovacuum FROM pg_stat_user_tables ORDER BY n_dead_tup DESC;`
|
||||
|
||||
XID age: `SELECT datname, age(datfrozenxid) AS xid_age FROM pg_database ORDER BY xid_age DESC;`
|
||||
|
||||
Long transactions: `SELECT pid, state, now() - xact_start AS tx_age FROM pg_stat_activity WHERE xact_start IS NOT NULL ORDER BY xact_start;`
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Keep transactions short; set `idle_in_transaction_session_timeout` (30s–5min).
|
||||
- Alert when `age(datfrozenxid)` exceeds 40–50% of wraparound (~800M–1B).
|
||||
- Tune autovacuum per-table for write-heavy tables; don't change global defaults first.
|
||||
- Fix application transaction scope before adjusting vacuum parameters.
|
||||
- Never disable autovacuum globally.
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
title: Database Optimization Checklist
|
||||
description: Optimize checklist
|
||||
tags: postgres, optimization, indexes, partitioning, maintenance
|
||||
---
|
||||
|
||||
# Optimization Checklist
|
||||
|
||||
When optimizing performance, check the following:
|
||||
|
||||
- Look for unused indexes (0 scans; exclude unique/primary indexes and verify stats age first)
|
||||
- Look for duplicate indexes
|
||||
- Archive audit/log tables >10GB
|
||||
- Review tables >500GB for partitioning (>100GB for time-series/logs)
|
||||
- Verify all extensions are supported
|
||||
- Check for circular foreign key dependencies
|
||||
- Consider alternatives to UUID primary keys for large tables
|
||||
- Configure connection pooling for OLTP workloads
|
||||
- **Always confirm with a human before removing any indexes, dropping partitions, archiving tables, or performing other destructive actions**
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
title: Table Partitioning Guide
|
||||
description: Partition guide
|
||||
tags: postgres, partitioning, range, list, pg_partman, data-retention
|
||||
---
|
||||
|
||||
# Table Partitioning
|
||||
|
||||
Plan partitioning upfront for tables expected to grow large. Retrofitting later requires a migration.
|
||||
|
||||
## When to Partition
|
||||
|
||||
Partitioning benefits maintenance (vacuum, index builds) and data retention more than pure query speed.
|
||||
|
||||
| Table Type | Size Threshold | Row Threshold |
|
||||
| --- | --- | --- |
|
||||
| General tables | >100 GB (or >RAM) | >20M rows |
|
||||
| Time-series / logs | >50 GB | >10M rows |
|
||||
|
||||
Use the lower thresholds for append-heavy, time-ordered data with retention needs (logs, events, audit trails, metrics).
|
||||
|
||||
## Range Partitioning (Most Common)
|
||||
|
||||
```sql
|
||||
-- EXAMPLE
|
||||
CREATE TABLE event (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY,
|
||||
event_type TEXT NOT NULL,
|
||||
payload JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (id, created_at) -- Partition key MUST be part of PK
|
||||
) PARTITION BY RANGE (created_at);
|
||||
|
||||
CREATE TABLE event_2026_01 PARTITION OF event
|
||||
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
|
||||
|
||||
CREATE TABLE event_2026_02 PARTITION OF event
|
||||
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
|
||||
```
|
||||
|
||||
## List Partitioning
|
||||
|
||||
Useful for partitioning by region, tenant, or category:
|
||||
|
||||
```sql
|
||||
-- EXAMPLE
|
||||
CREATE TABLE order (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY,
|
||||
region TEXT NOT NULL,
|
||||
total NUMERIC(10,2),
|
||||
PRIMARY KEY (id, region) -- Partition key MUST be part of PK
|
||||
) PARTITION BY LIST (region);
|
||||
|
||||
CREATE TABLE order_us PARTITION OF order FOR VALUES IN ('us');
|
||||
CREATE TABLE order_eu PARTITION OF order FOR VALUES IN ('eu');
|
||||
CREATE TABLE order_default PARTITION OF order DEFAULT; -- catches unmatched values
|
||||
```
|
||||
|
||||
## Partition Management
|
||||
|
||||
- Use `pg_partman` (extension) to automate partition creation and cleanup.
|
||||
- Use `DETACH PARTITION` to remove a partition while retaining it as a standalone table (e.g., for archiving).
|
||||
- Use `DETACH PARTITION ... CONCURRENTLY` (PG 14+) to avoid `ACCESS EXCLUSIVE` locks on the parent table.
|
||||
- Drop old partitions for data retention instead of `DELETE` to avoid vacuum overhead and bloat.
|
||||
- Create future partitions ahead of time to avoid insert failures.
|
||||
- **Always confirm with a human before detaching or dropping partitions.** These are destructive actions — detaching removes data from the partitioned table, and dropping permanently deletes the data.
|
||||
|
||||
```sql
|
||||
-- DESTRUCTIVE: confirm with a human before executing
|
||||
ALTER TABLE event DETACH PARTITION event_2025_01 CONCURRENTLY;
|
||||
DROP TABLE event_2025_01;
|
||||
```
|
||||
|
||||
## Guidelines & Limitations
|
||||
|
||||
- **Primary Keys**: Partition key columns MUST be included in the `PRIMARY KEY` and any `UNIQUE` constraints.
|
||||
- **Global Uniqueness**: Global unique constraints on non-partition columns are NOT supported.
|
||||
- **Indexes**: Indexes defined on the parent are automatically created on all partitions (and future ones).
|
||||
- **Pruning**: Ensure queries filter by the partition key to enable "partition pruning" (skipping unrelated partitions).
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
title: Process Architecture
|
||||
description: PostgreSQL multi-process model, connection management, and auxiliary processes
|
||||
tags: postgres, processes, connections, pooling, memory, operations
|
||||
---
|
||||
|
||||
# Process Architecture
|
||||
|
||||
PostgreSQL uses a **multi-process** model, not multi-threaded: one OS process per client connection. The postmaster is the parent; it spawns backend processes per connection. Each backend has some private memory (`work_mem`, temp buffers). 1000 connections = 1000 processes (~5–10MB base + query memory each). There is also a large buffer shared amongst all.
|
||||
|
||||
## Auxiliary Processes
|
||||
|
||||
WAL Writer, Background Writer, Checkpointer, Autovacuum Launcher/Workers, Archiver, WAL Summarizer (PG 17+). These run alongside backends and are not spawned per connection.
|
||||
|
||||
## Memory Risk
|
||||
|
||||
`work_mem` is per-operation, not per-query. Estimate: `work_mem × operations_per_query × parallel_workers × connections` can grow very large at high concurrency. Scale connections and parallelism before raising `work_mem`.
|
||||
|
||||
## Connection Pooling (Critical)
|
||||
|
||||
Each connection = OS process (fork overhead, context switching, memory). PgBouncer can multiplex many app connections to fewer DB connections. Typical: 1000 app connections → pooler → 20–50 backends. Implement pooling before raising `max_connections`; `max_connections` requires a full restart to change (default 100). Note: `superuser_reserved_connections` (default 3) reserves slots for emergency superuser access, so non-superusers are rejected before `max_connections` is fully reached.
|
||||
|
||||
## Monitoring
|
||||
|
||||
```sql
|
||||
SELECT state, count(*) FROM pg_stat_activity WHERE backend_type = 'client backend' GROUP BY state;
|
||||
```
|
||||
|
||||
```sql
|
||||
-- Show used and free connection slots
|
||||
SELECT count(*) AS used, max(max_conn) - count(*) AS free
|
||||
FROM pg_stat_activity, (SELECT setting::int AS max_conn FROM pg_settings WHERE name = 'max_connections') s
|
||||
WHERE backend_type = 'client backend';
|
||||
```
|
||||
|
||||
Use `pg_activity` for interactive top-like monitoring. Alert at 80% connection usage, critical at 95%. Count by state to find idle-in-transaction leaks — these hold locks and **block VACUUM** from reclaiming dead tuples.
|
||||
|
||||
## Common Problems
|
||||
|
||||
| Problem | Fix |
|
||||
| ------- | --- |
|
||||
| `too many clients already` | Implement pooling; find idle connections; check for connection leaks |
|
||||
| High memory / OOM | Reduce `work_mem`; add pooling; set `statement_timeout` |
|
||||
| Stuck process | `SELECT pg_cancel_backend(pid);` then `SELECT pg_terminate_backend(pid);` — **always confirm with a human before terminating backends**, as this may abort in-flight transactions and cause data issues for the application |
|
||||
|
||||
Prefer pooling + conservative `max_connections` over raising limits reactively.
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
title: CLI Query Insights API
|
||||
description: CLI insights usage
|
||||
tags: postgres, planetscale, cli, insights, query-patterns, api
|
||||
---
|
||||
|
||||
# Query Insights via pscale CLI
|
||||
|
||||
Analyze slow queries and missing indexes using `pscale api`. Endpoints may change—see https://planetscale.com/docs/api/reference/getting-started-with-planetscale-api for current API docs.
|
||||
|
||||
## Using pscale api
|
||||
|
||||
The `pscale api` command makes authenticated API calls using your current login or service token (see [ps-cli-commands.md](ps-cli-commands.md#service-token-cicd) for auth setup). No need to manage auth headers manually.
|
||||
|
||||
```bash
|
||||
pscale api "<endpoint>" [--method POST] [--field key=value] [--org <org>]
|
||||
```
|
||||
|
||||
## Query Patterns Reports
|
||||
|
||||
```bash
|
||||
# Create a new report
|
||||
pscale api "organizations/{org}/databases/{db}/branches/{branch}/query-patterns-reports" \
|
||||
--method POST --org my-org
|
||||
|
||||
# Check status (poll until state=complete)
|
||||
pscale api "organizations/{org}/databases/{db}/branches/{branch}/query-patterns-reports/{id}/status"
|
||||
|
||||
# Download completed report
|
||||
pscale api "organizations/{org}/databases/{db}/branches/{branch}/query-patterns-reports/{id}"
|
||||
|
||||
# List all reports
|
||||
pscale api "organizations/{org}/databases/{db}/branches/{branch}/query-patterns-reports"
|
||||
```
|
||||
|
||||
## Schema Analysis
|
||||
|
||||
```bash
|
||||
# Get branch schema
|
||||
pscale api "organizations/{org}/databases/{db}/branches/{branch}/schema"
|
||||
|
||||
# Lint schema for issues
|
||||
pscale api "organizations/{org}/databases/{db}/branches/{branch}/schema/lint"
|
||||
```
|
||||
|
||||
## What to Look For
|
||||
|
||||
| Metric | Indicates | Action |
|
||||
| -------------------------------- | --------------------- | ------------------------------- |
|
||||
| High `rows_read / rows_returned` | Missing or poor index | Add index on WHERE/JOIN columns |
|
||||
| High `total_time_s` | Heavy query | Optimize or cache |
|
||||
| High `count` with same pattern | N+1 queries | Batch or eager-load |
|
||||
| `indexed: false` | Full table scan | Add index |
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
title: PlanetScale CLI Reference
|
||||
description: CLI command guide
|
||||
tags: planetscale, cli, branches, deploy-requests, authentication
|
||||
---
|
||||
|
||||
# pscale CLI Commands
|
||||
|
||||
Full CLI reference: https://planetscale.com/docs/cli. Use `pscale <command> --help` for subcommands and flags.
|
||||
|
||||
## Authentication
|
||||
|
||||
```bash
|
||||
pscale auth login # Opens browser
|
||||
pscale auth logout
|
||||
pscale org list
|
||||
pscale org switch <name>
|
||||
```
|
||||
|
||||
### Service Token (CI/CD)
|
||||
|
||||
```bash
|
||||
# Create and configure
|
||||
pscale service-token create
|
||||
pscale service-token add-access <id> read_branch --database <db>
|
||||
# Use in CI/CD
|
||||
export PLANETSCALE_SERVICE_TOKEN_ID="<id>"
|
||||
export PLANETSCALE_SERVICE_TOKEN="<token>"
|
||||
```
|
||||
|
||||
## Core Commands
|
||||
|
||||
```bash
|
||||
# Databases
|
||||
pscale database list
|
||||
pscale database create <name>
|
||||
|
||||
# Branches
|
||||
pscale branch list <db>
|
||||
pscale branch create <db> <branch> [--from <parent>]
|
||||
pscale branch delete <db> <branch> # DESTRUCTIVE — always confirm with a human first
|
||||
pscale branch schema <db> <branch>
|
||||
|
||||
# Deploy requests (schema changes) — Vitess only
|
||||
pscale deploy-request create <db> <branch>
|
||||
pscale deploy-request list <db>
|
||||
pscale deploy-request deploy <db> <number>
|
||||
|
||||
# Connect
|
||||
pscale shell <db> <branch> # Opens psql (Postgres) or mysql (Vitess)
|
||||
pscale connect <db> <branch> # Proxy for GUI tools (secure tunnel) — Vitess only
|
||||
|
||||
# Credentials
|
||||
pscale role create <db> <branch> <name> # Postgres
|
||||
pscale password create <db> <branch> <name> # Vitess
|
||||
|
||||
# Other
|
||||
pscale ping # Check latency to regions
|
||||
pscale region list # Available regions
|
||||
pscale backup list <db> <branch>
|
||||
pscale backup create <db> <branch>
|
||||
```
|
||||
|
||||
## Useful Flags
|
||||
|
||||
```bash
|
||||
--format json # Output as JSON (also: csv, human)
|
||||
--org <name> # Specify organization
|
||||
--debug # Debug output
|
||||
```
|
||||
|
||||
For API calls via CLI, see [ps-cli-api-insights.md](ps-cli-api-insights.md).
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
title: PgBouncer Connection Pooling
|
||||
description: Pooling setup guide
|
||||
tags: postgres, pgbouncer, connection-pooling, performance, transactions
|
||||
---
|
||||
|
||||
# Connection Pooling with PgBouncer
|
||||
|
||||
PlanetScale provides PgBouncer for connection pooling. Connect on port `6432` instead of `5432`.
|
||||
|
||||
## When to Use PgBouncer (Port 6432)
|
||||
|
||||
All OLTP application workloads: web apps, APIs, high-concurrency read/write operations.
|
||||
|
||||
## When to Use Direct Connections (Port 5432)
|
||||
|
||||
- Schema changes (DDL)
|
||||
- Analytics, reporting, batch processing
|
||||
- Session-specific features (temp tables, session variables)
|
||||
- ETL, data streaming, `pg_dump`
|
||||
- Long-running admin transactions
|
||||
|
||||
## PgBouncer Types
|
||||
|
||||
PlanetScale offers three PgBouncer options. All use port `6432`.
|
||||
|
||||
| Type | Runs On | Routes To | Key Trait |
|
||||
| ---- | ------- | --------- | --------- |
|
||||
| **Local** | Same node as primary | Primary only | Included with every database; no replica routing |
|
||||
| **Dedicated Primary** | Separate node | Primary | Connections persist through resizes, upgrades, and most failovers |
|
||||
| **Dedicated Replica** | Separate node | Replicas | Read-only traffic; supports AZ affinity for lower latency |
|
||||
|
||||
- **Local PgBouncer** — use same credentials as direct, just change port to `6432`. Always routes to primary regardless of username.
|
||||
- **Dedicated Primary** — runs off-server for improved HA. Use for production OLTP write traffic.
|
||||
- **Dedicated Replica** — runs off-server for read-heavy workloads. Supports AZ affinity to prefer same-zone replicas. Multiple can be created for capacity or per-app isolation.
|
||||
|
||||
To connect to a dedicated PgBouncer, append `|pgbouncer-name` to the username (e.g., `postgres.xxx|write-pool` or `postgres.xxx|read-bouncer`).
|
||||
|
||||
## Transaction Pooling Limitations
|
||||
|
||||
PlanetScale PgBouncer uses **transaction pooling mode**. These features are unavailable:
|
||||
|
||||
- Prepared statements that persist across transactions
|
||||
- Temporary tables
|
||||
- `LISTEN`/`NOTIFY`
|
||||
- Session-level advisory locks
|
||||
- `SET` commands persisting beyond a transaction
|
||||
|
||||
## Recommended Patterns
|
||||
|
||||
- Size pools from observed concurrency, query memory behavior, and connection limits.
|
||||
- Keep pooled app traffic on `6432` and reserve direct connections for DDL/admin/long-running jobs.
|
||||
|
||||
## Avoid Patterns
|
||||
|
||||
- Avoid setting pool size with only `CPU_cores * N` while ignoring query-memory amplification.
|
||||
- Avoid running session-dependent workflows through transaction pooling.
|
||||
|
||||
## Connecting
|
||||
|
||||
```bash
|
||||
# Local PgBouncer (same credentials, port 6432)
|
||||
psql 'host=xxx.horizon.psdb.cloud port=6432 user=postgres.xxx password=pscale_pw_xxx dbname=mydb sslnegotiation=direct sslmode=verify-full sslrootcert=system'
|
||||
|
||||
# Dedicated primary PgBouncer (append |pgbouncer-name to user)
|
||||
psql 'host=xxx.horizon.psdb.cloud port=6432 user=postgres.xxx|write-pool password=pscale_pw_xxx dbname=mydb sslnegotiation=direct sslmode=verify-full sslrootcert=system'
|
||||
|
||||
# Dedicated replica PgBouncer (append |pgbouncer-name to user)
|
||||
psql 'host=xxx.horizon.psdb.cloud port=6432 user=postgres.xxx|read-bouncer password=pscale_pw_xxx dbname=mydb sslnegotiation=direct sslmode=verify-full sslrootcert=system'
|
||||
```
|
||||
|
||||
Docs: https://planetscale.com/docs/postgres/connecting/pgbouncer
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
title: PlanetScale Postgres Connections
|
||||
description: Connection guide for PlanetScale Postgres
|
||||
tags: planetscale, postgres, connections, ssl, troubleshooting
|
||||
---
|
||||
|
||||
# PlanetScale Postgres Connections
|
||||
|
||||
Postgres docs: https://planetscale.com/docs/postgres/connecting
|
||||
|
||||
| Protocol | Standard Port | Pooled Port | SSL |
|
||||
| -------- | ------------- | ----------------------- | -------- |
|
||||
| Postgres | 5432 | 6432 (PgBouncer) | Required |
|
||||
|
||||
Credentials (roles) are branch-specific and cannot be recovered after creation.
|
||||
|
||||
## Connection String
|
||||
|
||||
```
|
||||
postgresql://<user>:<password>@<host>.horizon.psdb.cloud:5432/<database>?sslmode=verify-full&sslrootcert=system&sslnegotiation=direct
|
||||
```
|
||||
|
||||
Use port **6432** for PgBouncer (applications/OLTP).
|
||||
Use port **5432** for DDL, admin tasks, and migrations.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Error | Fix |
|
||||
| -------------------------------- | --------------------------------------- |
|
||||
| `password authentication failed` | Check role format: `<role>.<branch_id>` |
|
||||
| `too many clients already` | Use PgBouncer (port 6432) |
|
||||
| `SSL connection is required` | Add `sslmode=verify-full&sslrootcert=system` |
|
||||
|
||||
**Best practices:**
|
||||
- Use the PlanetScale Postgres metrics page to monitor direct and PgBouncer connections
|
||||
- Route OLTP traffic to port 6432 and reserve 5432 for admin/migrations.
|
||||
- Avoid raising `max_connections` reactively instead of pooling.
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
title: PlanetScale PostgreSQL Extensions
|
||||
description: Extension reference
|
||||
tags: postgres, extensions
|
||||
---
|
||||
|
||||
# PostgreSQL Extensions on PlanetScale
|
||||
|
||||
Only use PlanetScale-supported extensions. For the complete and up-to-date list of available extensions, see: https://planetscale.com/docs/postgres/extensions
|
||||
|
||||
Do not rely on hard-coded extension lists — always check the documentation above for current availability.
|
||||
|
||||
## Enabling Extensions
|
||||
|
||||
Some extensions must first be **enabled in the PlanetScale Dashboard** (Clusters > Extensions) before they can be created in SQL. This often requires a database restart.
|
||||
|
||||
Once enabled in the dashboard, create the extension in SQL:
|
||||
|
||||
```sql
|
||||
CREATE EXTENSION IF NOT EXISTS <extension_name>;
|
||||
```
|
||||
|
||||
## Recommended Patterns
|
||||
|
||||
- Always check the [PlanetScale extensions docs](https://planetscale.com/docs/postgres/extensions) before assuming an extension is available.
|
||||
- Verify extension availability in PlanetScale configuration and docs before schema design depends on it.
|
||||
- Enable `pg_stat_statements` early for baseline query telemetry.
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
title: PlanetScale Query Insights
|
||||
description: Query insights guide
|
||||
tags: postgres, planetscale, insights, monitoring, optimization
|
||||
---
|
||||
|
||||
# PlanetScale Insights
|
||||
|
||||
## Fetch current documentation first
|
||||
|
||||
Prefer retrieval over pre-training knowledge. Docs: https://planetscale.com/docs
|
||||
|
||||
## MCP Server (Preferred)
|
||||
|
||||
When the PlanetScale MCP server is configured in your environment, prefer it over CLI. Key tools:
|
||||
|
||||
- `planetscale_get_branch_schema` — Get schema for a branch
|
||||
- `planetscale_execute_read_query` — Run SELECT, SHOW, DESCRIBE, EXPLAIN
|
||||
- `planetscale_get_insights` — Query performance insights
|
||||
- `planetscale_list_schema_recommendations` — Index and schema suggestions
|
||||
- `planetscale_search_documentation` — Search PlanetScale docs
|
||||
|
||||
MCP setup: https://planetscale.com/docs/connect/mcp
|
||||
|
||||
The MCP server is the ideal way to interact with insights from an AI agent.
|
||||
If not installed, prompt the user to install it to make the agent more effective.
|
||||
|
||||
## Query Insights (CLI)
|
||||
|
||||
Generating reports via CLI is a multi-step process (create → wait → download).
|
||||
|
||||
See [ps-cli-api-insights.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/ps-cli-api-insights.md) for how to use.
|
||||
|
||||
What to look for:
|
||||
|
||||
- High `rows_read / rows_returned` ratio → missing index
|
||||
- High `total_time_s` → optimization target
|
||||
|
||||
## Insights UI (Dashboard)
|
||||
|
||||
In the [PlanetScale dashboard](https://app.planetscale.com/), select your database and click **Insights**.
|
||||
|
||||
- **Filtering** — Pick a branch, choose primary or replica, and scroll through the last 7 days. Click-and-drag on graphs to zoom into a time window.
|
||||
- **Graphs** — Four tabs: Query latency (p50/p95/p99/p99.9), Queries per second, Rows read/s, and Rows written/s.
|
||||
- **Queries table** — All queries in the selected timeframe, normalized into patterns. Sortable and filterable by SQL, schema, table, latency, index usage, and more. Customizable columns (count, total time, latency percentiles, rows read/returned/affected, CPU/IO time, cache hit ratio, etc.). Enable sparklines for inline trend graphs. Orange icons flag full table scans.
|
||||
- **Query deep dive** — Click any query to see per-pattern graphs, summary stats, index usage breakdown, and a table of notable executions (>1 s, >10k rows read, or errors). Use "Summarize query" for an LLM-generated plain-English description.
|
||||
- **Anomalies tab** — Flags periods with elevated slow-running queries and surfaces the responsible patterns.
|
||||
- **Errors tab** — Surfaces queries that produced errors.
|
||||
- **pginsights settings** — `pginsights.raw_queries` enables full query text collection for notable queries; `pginsights.normalize_schema_names` groups identical patterns across schemas (useful for schema-per-tenant designs). Both configurable in the Extensions tab on the Clusters page.
|
||||
|
||||
More: [PlanetScale Insights docs](https://planetscale.com/docs/postgres/monitoring/query-insights)
|
||||
|
||||
## Optimization Checklist
|
||||
|
||||
- Remove unused indexes (0 scans)
|
||||
- Remove duplicate indexes
|
||||
- Archive audit/log tables >10 GB
|
||||
- Review tables >100 GB for partitioning
|
||||
|
||||
**Always confirm with a human before removing indexes, dropping tables/partitions, or archiving data.** These are destructive actions that cannot be easily undone.
|
||||
|
||||
More: [optimization-checklist.md](https://raw.githubusercontent.com/planetscale/database-skills/main/skills/postgres/references/optimization-checklist.md)
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
title: SQL Query Patterns
|
||||
description: Common SQL anti-patterns and optimized alternatives
|
||||
tags: postgres, sql, query-optimization, n-plus-one, pagination
|
||||
---
|
||||
|
||||
# SQL Query Patterns
|
||||
|
||||
## Query Structure
|
||||
|
||||
**SELECT specific columns** — avoids fetching unnecessary data and enables covering indexes:
|
||||
```sql
|
||||
-- Bad:
|
||||
SELECT * FROM user WHERE status = 'active';
|
||||
-- Good:
|
||||
SELECT id, name, email FROM user WHERE status = 'active';
|
||||
```
|
||||
|
||||
**Subqueries → JOINs** — correlated subqueries re-execute per row:
|
||||
```sql
|
||||
-- Bad
|
||||
SELECT id, (SELECT COUNT(*) FROM order WHERE order.user_id = user.id) FROM user;
|
||||
-- Good
|
||||
SELECT u.id, COUNT(o.id) FROM user u LEFT JOIN order o ON o.user_id = u.id GROUP BY u.id;
|
||||
```
|
||||
|
||||
**Always LIMIT unbounded queries** — prevent runaway result sets:
|
||||
```sql
|
||||
SELECT id, message FROM log WHERE level = 'error' ORDER BY created_at DESC LIMIT 100;
|
||||
```
|
||||
|
||||
**Avoid functions on indexed columns (SARGable)** — functions prevent index usage unless a functional index exists:
|
||||
```sql
|
||||
-- Bad: Full table scan
|
||||
SELECT * FROM user WHERE date_trunc('day', created_at) = '2023-01-01';
|
||||
-- Good: Index scan
|
||||
SELECT * FROM user WHERE created_at >= '2023-01-01' AND created_at < '2023-01-02';
|
||||
```
|
||||
|
||||
## N+1 Detection
|
||||
|
||||
**Queries inside loops → batch with ANY/IN:**
|
||||
```python
|
||||
# Bad
|
||||
for uid in user_ids:
|
||||
cursor.execute("SELECT name FROM user WHERE id = %s", (uid,))
|
||||
# Good (Postgres specific)
|
||||
cursor.execute("SELECT id, name FROM user WHERE id = ANY(%s)", (list(user_ids),))
|
||||
# Good (Standard SQL)
|
||||
# cursor.execute("SELECT id, name FROM user WHERE id IN %s", (tuple(user_ids),))
|
||||
```
|
||||
|
||||
**ORM lazy loading → eager loading:**
|
||||
```python
|
||||
# Bad: N+1 — each iteration fires a query
|
||||
for user in User.query.all():
|
||||
print(user.posts)
|
||||
# Good
|
||||
users = User.query.options(joinedload(User.posts)).all()
|
||||
```
|
||||
|
||||
## Query Rewrites
|
||||
|
||||
**UNION → UNION ALL** — skip deduplication when duplicates are impossible or acceptable.
|
||||
|
||||
**IN subquery → EXISTS** — EXISTS short-circuits on first match:
|
||||
```sql
|
||||
SELECT id, name FROM user u
|
||||
WHERE EXISTS (SELECT 1 FROM order o WHERE o.user_id = u.id AND o.total > 100);
|
||||
```
|
||||
|
||||
**OFFSET → cursor pagination** — OFFSET scans and discards rows, degrading at depth:
|
||||
```sql
|
||||
-- Bad: OFFSET 10000 scans 10020 rows
|
||||
SELECT id, title FROM article ORDER BY created_at DESC LIMIT 20 OFFSET 10000;
|
||||
-- Good: cursor-based (requires index on (created_at DESC, id DESC))
|
||||
SELECT id, title FROM article
|
||||
WHERE (created_at, id) < ('2025-06-15T12:00:00Z', 987654)
|
||||
ORDER BY created_at DESC, id DESC LIMIT 20;
|
||||
```
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
title: Replication
|
||||
description: Streaming replication, replication slots, synchronous commit levels, failover, and standby management
|
||||
tags: postgres, replication, streaming, slots, synchronous, failover, standby, operations
|
||||
---
|
||||
|
||||
# Replication
|
||||
|
||||
## Streaming Replication for followers
|
||||
|
||||
Use physical (byte-for-byte) replication via WAL stream from primary to standbys. Standbys are read-only (hot standby); same major PG version and architecture required (same minor recommended). Without replication slots, the primary may recycle WAL before the standby receives it → standby needs full resync via `pg_basebackup`. Use replication slots to guarantee WAL retention for specific standbys.
|
||||
|
||||
## Replication Slots
|
||||
|
||||
Postgres supports Physical slots (streaming) and logical slots (logical replication). Slots prevent WAL deletion even if standby is offline — can exhaust `pg_wal/` disk. Use `max_slot_wal_keep_size` to cap retained WAL per slot. Use `idle_replication_slot_timeout` (PG 17+) to auto-invalidate idle slots. `wal_keep_size` is a simpler alternative to slots for WAL retention. Drop inactive slots immediately to prevent disk exhaustion.
|
||||
|
||||
Slot lag (MB behind): `SELECT slot_name, pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)/1024/1024 AS mb_behind FROM pg_replication_slots;`
|
||||
|
||||
Drop inactive slot: `SELECT pg_drop_replication_slot('slot_name');`
|
||||
|
||||
**Always confirm with a human before dropping replication slots.** Dropping an active or needed slot can cause downstream issues.
|
||||
|
||||
## Synchronous Commit Levels
|
||||
|
||||
| Level | Behavior | Use Case |
|
||||
|-------|----------|----------|
|
||||
| `off` | Returns immediately, no wait | Non-critical writes; risks losing ~600ms of commits on crash (no inconsistency) |
|
||||
| `local` | Waits for local WAL fsync only | Local durability only; no standby wait |
|
||||
| `remote_write` | Waits for standby OS buffer | Data loss on standby OS crash |
|
||||
| `on` | Waits for standby WAL to disk when `synchronous_standby_names` is set; otherwise same as `local` | **Default. This level or higher recommended for HA** |
|
||||
| `remote_apply` | Waits for standby to apply WAL | Strongest; read-your-writes |
|
||||
|
||||
Configure with `synchronous_standby_names`. Use `ANY N` for quorum or `FIRST N` for priority-based sync.
|
||||
|
||||
## Quorum and Failure
|
||||
|
||||
`FIRST 2 (s1, s2, s3)` is priority-based: waits for the 2 highest-priority connected standbys (s1+s2; s3 takes over only if one disconnects). `ANY 2 (s1, s2, s3)` is quorum-based: waits for any 2. With either, if only 1 is healthy, commits hang. Provision at least N+1 standbys: need 2 confirmations → provision 3. PostgreSQL never commits unless required standbys confirm — no inconsistency, but clients may timeout.
|
||||
|
||||
## Failover
|
||||
|
||||
`pg_ctl promote` or `SELECT pg_promote()` (SQL function, PG 12+) converts standby to primary. One-way: promoted standby cannot rejoin as standby without rebuild. `pg_rewind` can resync old primary to new primary (requires `wal_log_hints=on` or data checksums) — faster than full rebuild. After promotion: update connection strings, rebuild old primary as standby, reconfigure other standbys.
|
||||
|
||||
## Monitoring
|
||||
|
||||
On the primary, query `pg_stat_replication` for each connected standby's `state` (`streaming` = healthy, `catchup` = behind), `sync_state` (`sync`/`async`), and LSN positions (`sent_lsn`, `write_lsn`, `flush_lsn`, `replay_lsn`) to compute lag. On standbys, `pg_stat_wal_receiver` shows the receiver process status and `flushed_lsn`; compare `pg_last_wal_receive_lsn()` vs `pg_last_wal_replay_lsn()` for local replay lag.
|
||||
|
||||
Replication lag (MB): `SELECT application_name, pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)/1024/1024 AS lag_mb FROM pg_stat_replication;`
|
||||
|
||||
Enable `wal_compression` (`pglz`, `lz4`, or `zstd`) to compress full page images in WAL (not all WAL data) — reduces WAL size for bandwidth-limited replication.
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
title: PostgreSQL Schema Design
|
||||
description: Schema design guide
|
||||
tags: postgres, schema, primary-keys, data-types, foreign-keys, naming
|
||||
---
|
||||
|
||||
# Schema Design
|
||||
|
||||
## Primary Keys
|
||||
|
||||
Prefer `BIGINT GENERATED ALWAYS AS IDENTITY`. Avoid random UUIDs (UUIDv4) as primary keys; use `uuidv7()` when you need UUIDs.
|
||||
|
||||
```sql
|
||||
CREATE TABLE user (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE
|
||||
);
|
||||
```
|
||||
|
||||
Random UUID PKs (v4) can cause index fragmentation; UUIDs are also larger (16 vs 8 bytes for BIGINT) and can slow joins.
|
||||
|
||||
## Data Types
|
||||
|
||||
| Use | Avoid |
|
||||
| --- | --- |
|
||||
| `TEXT`, `VARCHAR` | Extension-specific types |
|
||||
| `JSONB` | Custom ENUMs (use CHECK instead) |
|
||||
| `TIMESTAMPTZ` | `TIMESTAMP` without time zone |
|
||||
| `BIGINT`, `INTEGER` | Platform-specific types |
|
||||
|
||||
Prefer CHECK constraints over ENUM types — they're easier to modify:
|
||||
|
||||
```sql
|
||||
CREATE TABLE order (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'shipped', 'delivered'))
|
||||
);
|
||||
```
|
||||
|
||||
## Foreign Keys
|
||||
|
||||
- Always index FK columns (PostgreSQL does not auto-create these)
|
||||
- Avoid circular FK dependencies
|
||||
- Suggestion: use `ON DELETE CASCADE` or `ON DELETE SET NULL` explicitly
|
||||
|
||||
```sql
|
||||
CREATE TABLE order (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
customer_id BIGINT NOT NULL REFERENCES customer(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX order_customer_id_idx ON order (customer_id);
|
||||
```
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
- Tables: singular snake_case (`user_account`, `order_item`)
|
||||
- Columns: singular snake_case (`created_at`, `user_id`)
|
||||
- Indexes: `{table}_{column}_idx`
|
||||
- Constraints: `{table}_{column}_{type}` (e.g., `order_status_check`)
|
||||
|
||||
## General Guidelines
|
||||
|
||||
- Add `NOT NULL` to as many columns as possible
|
||||
- Add `created_at TIMESTAMPTZ DEFAULT NOW()` to all tables
|
||||
- Use `BIGINT` for all IDs and foreign keys, even on small tables
|
||||
- Keep tables normalized; denormalize only for proven hot read paths
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
title: Storage Layout and Tablespaces
|
||||
description: PGDATA directory structure, TOAST, fillfactor, tablespaces, and disk management
|
||||
tags: postgres, storage, pgdata, toast, fillfactor, tablespaces, disk, operations
|
||||
---
|
||||
|
||||
# Storage Layout and Tablespaces
|
||||
|
||||
## PGDATA Structure
|
||||
|
||||
- **base/** — database files (one subdirectory per database, named by OID)
|
||||
- **global/** — cluster-wide shared catalogs (pg_database, pg_authid, pg_tablespace)
|
||||
- **pg_wal/** — WAL files
|
||||
- **pg_xact/** — transaction commit status
|
||||
|
||||
"Cluster" in PostgreSQL = single instance with one PGDATA, not an HA cluster. Each table/index = one or more files, split into 1GB segments. Tables have companion **_fsm** (free space map) and **_vm** (visibility map); indexes have **_fsm** only (no _vm), except hash indexes.
|
||||
|
||||
## Visibility Map and Free Space Map
|
||||
|
||||
- **_vm** tracks all-visible pages — VACUUM skips these
|
||||
- **_fsm** tracks free space per page — INSERT uses this to find pages with room
|
||||
- Both are small files but critical for performance
|
||||
|
||||
## TOAST
|
||||
|
||||
TOAST triggers when a **row** exceeds ~2KB. Large values are compressed and/or moved out-of-line to `pg_toast.pg_toast_<oid>` tables. **Strategies:** PLAIN (no TOAST), EXTENDED (compress+out-of-line, default for text/bytea), EXTERNAL (out-of-line, no compression — use for pre-compressed data), MAIN (compress, avoid out-of-line). TOAST tables bloat like regular tables — they need VACUUM. `SELECT *` fetches all TOAST columns; always SELECT only needed columns. Move large rarely-accessed columns to separate tables.
|
||||
|
||||
## Fillfactor
|
||||
|
||||
Controls how full pages are packed (default 100%). Lower fillfactor (70–80%) leaves room for HOT (Heap-Only Tuple) updates, which avoid index entries and reduce bloat on UPDATE-heavy tables. Keep 100% for insert-only or read-mostly tables. `ALTER TABLE t SET (fillfactor = 70);`
|
||||
|
||||
## Tablespaces
|
||||
|
||||
`pg_default` (base/), `pg_global` (global/) are built-in. Custom tablespaces: symbolic links in **pg_tblspc/** to other filesystem locations. Use for separating hot data (SSD) from archives (HDD). Moving tablespaces requires exclusive lock on affected tables.
|
||||
|
||||
## Disk Monitoring
|
||||
|
||||
- `pg_database_size('dbname')`, `pg_total_relation_size('tablename')`, `pg_relation_size('tablename')`
|
||||
- Monitor disk usage: >80% = at risk; >90% = critical (VACUUM may fail if disk capacity is insufficient)
|
||||
- Check inode usage (`df -i`) — can run out even with free space
|
||||
- `pg_wal/` suddenly large = check replication slots and archiving
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
title: WAL and Checkpoint Operations
|
||||
description: Write-ahead log internals, checkpoint tuning, durability guarantees, and WAL disk management
|
||||
tags: postgres, wal, checkpoints, durability, crash-recovery, fsync, operations
|
||||
---
|
||||
|
||||
# WAL and Checkpoint Operations
|
||||
|
||||
## WAL Fundamentals
|
||||
|
||||
Write-Ahead Logging: logs changes to `pg_wal/` **before** modifying data files. WAL segments are 16MB (fixed at initdb). On COMMIT, PostgreSQL fsyncs WAL to disk and returns SUCCESS — data files are updated lazily. WAL records are written for all changes (including uncommitted transactions and rollbacks). **Never disable `fsync` in production** — power loss without fsync risks unrecoverable data loss.
|
||||
|
||||
`wal_level`: `minimal` (crash recovery only), `replica` (default; replication + archiving), `logical` (logical replication).
|
||||
|
||||
## Dirty Pages and Checkpoints
|
||||
|
||||
A dirty page is modified in shared_buffers but not yet written to data files. A checkpoint flushes all dirty pages to disk and writes a checkpoint record to WAL; recovery only replays WAL since the last checkpoint.
|
||||
|
||||
- `checkpoint_timeout` (default 5 min) and `max_wal_size` (default 1GB) — checkpoint on whichever triggers first.
|
||||
- `checkpoint_completion_target=0.9` spreads I/O over 90% of the interval; avoid spikes.
|
||||
- "Checkpoints are occurring too frequently" in logs → increase `max_wal_size`.
|
||||
- **Target: >90% of checkpoints should be time-based** (`num_timed` in `pg_stat_checkpointer`), not size-based (`num_requested`). If num_requested/(num_timed+num_requested) > 10%, tune `max_wal_size` up.
|
||||
|
||||
## WAL Disk Management
|
||||
|
||||
Replication slots prevent WAL deletion even when standbys are offline — they can fill disk. WAL archiving failures also block recycling. `max_wal_size` is a *soft* limit; WAL can grow beyond it under heavy load.
|
||||
|
||||
WAL size: `SELECT count(*) AS files, pg_size_pretty(sum(size)) AS total FROM pg_ls_waldir();`
|
||||
|
||||
Slot lag: `SELECT slot_name, pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) AS lag_bytes FROM pg_replication_slots;`
|
||||
|
||||
## Checkpoint Monitoring
|
||||
|
||||
PG17+ moved checkpoint stats from `pg_stat_bgwriter` to `pg_stat_checkpointer` and renamed columns.
|
||||
|
||||
`SELECT num_timed, num_requested, write_time, sync_time, buffers_written FROM pg_stat_checkpointer;`
|
||||
|
||||
Backend-direct writes (formerly `buffers_backend` in `pg_stat_bgwriter`) are now tracked in `pg_stat_io`: `SELECT writes FROM pg_stat_io WHERE backend_type = 'client backend' AND object = 'relation';`
|
||||
|
||||
## Crash Recovery
|
||||
|
||||
On crash, PostgreSQL replays WAL from the last checkpoint. Longer checkpoint intervals → more WAL to replay → longer recovery. Trade-off: frequent checkpoints (faster recovery, more I/O) vs infrequent (less I/O, slower recovery). For most workloads, `checkpoint_timeout=5min` and `max_wal_size` tuned to keep checkpoints time-based is the right balance.
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/frontend-design
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/postgres
|
||||
@@ -35,8 +35,11 @@ export async function POST(
|
||||
);
|
||||
}
|
||||
|
||||
const { content, filterDocumentId, filterDataroomDocumentIds } =
|
||||
validation.data;
|
||||
const {
|
||||
content,
|
||||
filterDocumentId,
|
||||
filterDataroomDocumentIds,
|
||||
} = validation.data;
|
||||
|
||||
const session = await getServerSession(authOptions);
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
@@ -133,16 +136,49 @@ export async function POST(
|
||||
}
|
||||
|
||||
// Send message and get streaming response
|
||||
const result = await sendMessage({
|
||||
const { result, referencesForStream } = await sendMessage({
|
||||
chatId,
|
||||
content,
|
||||
vectorStoreId: chat.vectorStoreId,
|
||||
filteredDataroomDocumentIds,
|
||||
filterDocumentId,
|
||||
userSelectedDataroomDocumentIds: filterDataroomDocumentIds,
|
||||
dataroomId: chat.dataroomId || undefined,
|
||||
linkId: chat.linkId || undefined,
|
||||
});
|
||||
|
||||
return result.toTextStreamResponse();
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
try {
|
||||
for await (const chunk of result.textStream) {
|
||||
controller.enqueue(encoder.encode(chunk));
|
||||
}
|
||||
|
||||
const referencesSection = await Promise.race([
|
||||
referencesForStream,
|
||||
new Promise<string>((resolve) =>
|
||||
setTimeout(() => resolve(""), 5000),
|
||||
),
|
||||
]);
|
||||
|
||||
if (referencesSection) {
|
||||
controller.enqueue(encoder.encode(referencesSection));
|
||||
}
|
||||
|
||||
controller.close();
|
||||
} catch (error) {
|
||||
console.error("Error streaming AI response:", error);
|
||||
controller.error(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error sending message:", error);
|
||||
return new Response(JSON.stringify({ error: "Internal server error" }), {
|
||||
|
||||
@@ -27,10 +27,12 @@ export async function GET(
|
||||
const userId = (session.user as CustomUser).id;
|
||||
|
||||
// Verify user is member of team
|
||||
const userTeam = await prisma.userTeam.findFirst({
|
||||
const userTeam = await prisma.userTeam.findUnique({
|
||||
where: {
|
||||
userId,
|
||||
teamId,
|
||||
userId_teamId: {
|
||||
userId,
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,10 +5,112 @@ import { verifyDataroomSession } from "@/lib/auth/dataroom-auth";
|
||||
import { DocumentData } from "@/lib/documents/create-document";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { sendDataroomUploadNotificationTask } from "@/lib/trigger/dataroom-upload-notification";
|
||||
import { sanitizePlainText } from "@/lib/utils/sanitize-html";
|
||||
import { supportsAdvancedExcelMode } from "@/lib/utils/get-content-type";
|
||||
import { runs } from "@trigger.dev/sdk/v3";
|
||||
import { waitUntil } from "@vercel/functions";
|
||||
|
||||
/**
|
||||
* GET /api/links/[id]/upload?dataroomId=xxx
|
||||
* Returns the viewer's previously uploaded documents for this dataroom.
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } },
|
||||
) {
|
||||
try {
|
||||
const linkId = params.id;
|
||||
const dataroomId = request.nextUrl.searchParams.get("dataroomId");
|
||||
|
||||
if (!linkId || !dataroomId) {
|
||||
return NextResponse.json(
|
||||
{ message: "Missing required parameters" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Verify the dataroom session
|
||||
const dataroomSession = await verifyDataroomSession(
|
||||
request,
|
||||
linkId,
|
||||
dataroomId,
|
||||
);
|
||||
|
||||
if (!dataroomSession || !dataroomSession.viewerId) {
|
||||
return NextResponse.json(
|
||||
{ message: "Unauthorized" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const { viewerId } = dataroomSession;
|
||||
|
||||
// Fetch the viewer's uploads for this dataroom
|
||||
const uploads = await prisma.documentUpload.findMany({
|
||||
where: {
|
||||
viewerId,
|
||||
dataroomId,
|
||||
linkId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
documentId: true,
|
||||
dataroomDocumentId: true,
|
||||
originalFilename: true,
|
||||
uploadedAt: true,
|
||||
document: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
versions: {
|
||||
where: { isPrimary: true },
|
||||
select: {
|
||||
id: true,
|
||||
hasPages: true,
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
dataroomDocument: {
|
||||
select: {
|
||||
folderId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { uploadedAt: "desc" },
|
||||
});
|
||||
|
||||
const formattedUploads = uploads.map((upload) => {
|
||||
const fileType = upload.document?.type ?? "";
|
||||
const hasPages = upload.document?.versions?.[0]?.hasPages ?? false;
|
||||
const needsProcessing = ["pdf", "docs", "slides"].includes(fileType);
|
||||
const isComplete = !needsProcessing || hasPages;
|
||||
|
||||
return {
|
||||
id: upload.id,
|
||||
documentId: upload.documentId,
|
||||
dataroomDocumentId: upload.dataroomDocumentId,
|
||||
documentVersionId: upload.document?.versions?.[0]?.id ?? null,
|
||||
name: upload.originalFilename ?? upload.document?.name ?? "Unknown",
|
||||
fileType,
|
||||
folderId: upload.dataroomDocument?.folderId ?? null,
|
||||
uploadedAt: upload.uploadedAt,
|
||||
status: isComplete ? "complete" : "processing",
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({ uploads: formattedUploads });
|
||||
} catch (error) {
|
||||
console.error("Error fetching viewer uploads:", error);
|
||||
return NextResponse.json(
|
||||
{ message: "Error fetching uploads" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } },
|
||||
@@ -94,9 +196,31 @@ export async function POST(
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof documentData.name !== "string") {
|
||||
return NextResponse.json(
|
||||
{ message: "Document name is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const sanitizedDocumentName = sanitizePlainText(documentData.name);
|
||||
if (!sanitizedDocumentName) {
|
||||
return NextResponse.json(
|
||||
{ message: "Document name is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (sanitizedDocumentName.length > 255) {
|
||||
return NextResponse.json(
|
||||
{ message: "Document name too long" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const updatedDocumentData = {
|
||||
...documentData,
|
||||
name: sanitizedDocumentName,
|
||||
enableExcelAdvancedMode: documentData.supportedFileType === "sheet" &&
|
||||
link.team?.enableExcelAdvancedMode &&
|
||||
supportsAdvancedExcelMode(documentData.contentType),
|
||||
@@ -144,7 +268,7 @@ export async function POST(
|
||||
viewId: viewId,
|
||||
linkId: linkId,
|
||||
originalFilename: document.name,
|
||||
fileSize: document.versions[0].fileSize,
|
||||
fileSize: documentData.fileSize ?? 0,
|
||||
numPages: document.numPages,
|
||||
mimeType: document.contentType,
|
||||
dataroomId: dataroomId,
|
||||
@@ -203,7 +327,20 @@ export async function POST(
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
// Return document data for optimistic UI rendering
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
document: {
|
||||
id: document.id,
|
||||
name: document.name,
|
||||
dataroomDocumentId: newDataroomDocument.id,
|
||||
documentVersionId: document.versions[0]?.id,
|
||||
folderId: dataroomFolderId,
|
||||
fileType: document.type,
|
||||
hasPages: (document.numPages ?? 0) > 0,
|
||||
createdAt: document.createdAt,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error uploading document:", error);
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { receiver } from "@/lib/cron";
|
||||
import { processDataroomDigest } from "@/lib/emails/process-dataroom-digest";
|
||||
import { log } from "@/lib/utils";
|
||||
|
||||
// Runs daily at 9 AM UTC (0 9 * * *)
|
||||
export const maxDuration = 300;
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = await req.json();
|
||||
if (process.env.VERCEL === "1") {
|
||||
const isValid = await receiver.verify({
|
||||
signature: req.headers.get("Upstash-Signature") || "",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!isValid) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await processDataroomDigest("daily");
|
||||
return NextResponse.json({ success: true, ...result });
|
||||
} catch (error) {
|
||||
await log({
|
||||
message: `Daily dataroom digest cron failed. \n\nError: ${(error as Error).message}`,
|
||||
type: "cron",
|
||||
mention: true,
|
||||
});
|
||||
return NextResponse.json({ error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { receiver } from "@/lib/cron";
|
||||
import { processDataroomDigest } from "@/lib/emails/process-dataroom-digest";
|
||||
import { log } from "@/lib/utils";
|
||||
|
||||
// Runs weekly on Monday at 9 AM UTC (0 9 * * 1)
|
||||
export const maxDuration = 300;
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = await req.json();
|
||||
if (process.env.VERCEL === "1") {
|
||||
const isValid = await receiver.verify({
|
||||
signature: req.headers.get("Upstash-Signature") || "",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!isValid) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await processDataroomDigest("weekly");
|
||||
return NextResponse.json({ success: true, ...result });
|
||||
} catch (error) {
|
||||
await log({
|
||||
message: `Weekly dataroom digest cron failed. \n\nError: ${(error as Error).message}`,
|
||||
type: "cron",
|
||||
mention: true,
|
||||
});
|
||||
return NextResponse.json({ error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
@@ -147,6 +147,15 @@ export async function POST(request: NextRequest) {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
visitorGroups: {
|
||||
select: {
|
||||
visitorGroup: {
|
||||
select: {
|
||||
emails: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -312,10 +321,18 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ message: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Build combined allow list from individual emails + visitor groups
|
||||
const visitorGroupEmails =
|
||||
link.visitorGroups?.flatMap((vg) => vg.visitorGroup.emails) || [];
|
||||
const combinedAllowList = [
|
||||
...(link.allowList || []),
|
||||
...visitorGroupEmails,
|
||||
];
|
||||
|
||||
// Check if email is allowed to visit the link
|
||||
if (link.allowList && link.allowList.length > 0) {
|
||||
if (combinedAllowList.length > 0) {
|
||||
// Determine if the email or its domain is allowed
|
||||
const isAllowed = link.allowList.some((allowed) =>
|
||||
const isAllowed = combinedAllowList.some((allowed) =>
|
||||
isEmailMatched(email, allowed),
|
||||
);
|
||||
|
||||
|
||||
+19
-2
@@ -123,6 +123,15 @@ export async function POST(request: NextRequest) {
|
||||
agentsEnabled: true,
|
||||
},
|
||||
},
|
||||
visitorGroups: {
|
||||
select: {
|
||||
visitorGroup: {
|
||||
select: {
|
||||
emails: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -246,10 +255,18 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ message: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Build combined allow list from individual emails + visitor groups
|
||||
const visitorGroupEmails =
|
||||
link.visitorGroups?.flatMap((vg) => vg.visitorGroup.emails) || [];
|
||||
const combinedAllowList = [
|
||||
...(link.allowList || []),
|
||||
...visitorGroupEmails,
|
||||
];
|
||||
|
||||
// Check if email is allowed to visit the link
|
||||
if (link.allowList && link.allowList.length > 0) {
|
||||
if (combinedAllowList.length > 0) {
|
||||
// Determine if the email or its domain is allowed
|
||||
const isAllowed = link.allowList.some((allowed) =>
|
||||
const isAllowed = combinedAllowList.some((allowed) =>
|
||||
isEmailMatched(email, allowed),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
User-Agent: *
|
||||
Disallow: /login
|
||||
Disallow: /register
|
||||
Disallow: /verify/
|
||||
Disallow: /auth/
|
||||
|
||||
@@ -11,16 +11,19 @@ const CustomTooltip = ({
|
||||
payload: any;
|
||||
active: boolean | undefined;
|
||||
}) => {
|
||||
const router = useRouter(); // Call useRouter at the top level
|
||||
const documentId = router.query.id as string;
|
||||
const router = useRouter();
|
||||
const routerDocumentId = router.query.id as string;
|
||||
|
||||
// Default pageNumber to 0 or a sensible default if payload is not available
|
||||
const pageNumber =
|
||||
payload && payload.length > 0 ? parseInt(payload[0].payload.pageNumber) : 0;
|
||||
|
||||
// Default versionNumber to 0 or a sensible default if payload is not available
|
||||
const documentId =
|
||||
payload && payload.length > 0 && payload[0].payload.documentId
|
||||
? (payload[0].payload.documentId as string)
|
||||
: routerDocumentId;
|
||||
|
||||
const versionNumber =
|
||||
payload && payload.length > 0
|
||||
payload && payload.length > 0 && !isNaN(parseInt(payload[0].payload.versionNumber))
|
||||
? parseInt(payload[0].payload.versionNumber)
|
||||
: 1;
|
||||
const { data, error } = useDocumentThumbnail(
|
||||
|
||||
@@ -24,13 +24,18 @@ const renameDummyDurationKey = (data: Data[]): TransformedData[] => {
|
||||
}, [] as TransformedData[]);
|
||||
};
|
||||
|
||||
const renameSumDurationKey = (data: SumData[], versionNumber?: number) => {
|
||||
const renameSumDurationKey = (
|
||||
data: SumData[],
|
||||
versionNumber?: number,
|
||||
documentId?: string,
|
||||
) => {
|
||||
return data.map((item) => {
|
||||
return {
|
||||
...item,
|
||||
"Time spent per page": item.sum_duration,
|
||||
sum_duration: undefined,
|
||||
versionNumber: versionNumber,
|
||||
versionNumber: versionNumber ?? 1,
|
||||
documentId: documentId,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -64,16 +69,18 @@ export default function BarChartComponent({
|
||||
isSum = false,
|
||||
isDummy = false,
|
||||
versionNumber,
|
||||
documentId,
|
||||
}: {
|
||||
data: any;
|
||||
isSum?: boolean;
|
||||
isDummy?: boolean;
|
||||
versionNumber?: number;
|
||||
documentId?: string;
|
||||
}) {
|
||||
const [, setValue] = useState<any>(null);
|
||||
|
||||
if (isSum) {
|
||||
const renamedData = renameSumDurationKey(data, versionNumber);
|
||||
const renamedData = renameSumDurationKey(data, versionNumber, documentId);
|
||||
|
||||
return (
|
||||
<BarChart
|
||||
|
||||
@@ -148,7 +148,7 @@ export function AddDataroomModal({
|
||||
},
|
||||
{
|
||||
id: "fund-management",
|
||||
name: "Fund Management",
|
||||
name: "Fundraising & Reporting",
|
||||
icon: BuildingIcon,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -191,7 +191,7 @@ export default function DataroomDocumentCard({
|
||||
};
|
||||
|
||||
const handleCardClick = (e: React.MouseEvent) => {
|
||||
if (isDragging) {
|
||||
if (isDragging || menuOpen) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
@@ -288,7 +288,7 @@ export default function DataroomDocumentCard({
|
||||
<DropdownMenu open={menuOpen} onOpenChange={handleMenuStateChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
// size="icon"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
variant="outline"
|
||||
className="z-10 h-8 w-8 border-gray-200 bg-transparent p-0 hover:bg-gray-200 dark:border-gray-700 hover:dark:bg-gray-700 lg:h-9 lg:w-9"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { memo, useMemo } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import { DataroomFolder } from "@prisma/client";
|
||||
import { HomeIcon } from "lucide-react";
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
} from "@/lib/utils/hierarchical-display";
|
||||
|
||||
import { FileTree } from "@/components/ui/nextra-filetree";
|
||||
import { useViewerSurfaceTheme } from "@/components/view/viewer/viewer-surface-theme";
|
||||
|
||||
import { buildNestedFolderStructureWithDocs } from "./utils";
|
||||
|
||||
@@ -182,15 +184,31 @@ const HomeLink = memo(
|
||||
folderId: string | null;
|
||||
setFolderId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
}) => {
|
||||
const { usesLightText, palette } = useViewerSurfaceTheme();
|
||||
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
"flex list-none",
|
||||
"rounded-md text-foreground transition-all duration-200 ease-in-out",
|
||||
"hover:bg-gray-100 hover:shadow-sm hover:dark:bg-muted",
|
||||
"rounded-md transition-all duration-200 ease-in-out",
|
||||
usesLightText
|
||||
? "text-[var(--viewer-text)] hover:bg-[var(--viewer-control-bg)]"
|
||||
: "text-foreground hover:bg-gray-100 hover:shadow-sm hover:dark:bg-muted",
|
||||
"px-3 py-1.5 leading-6",
|
||||
folderId === null && "bg-gray-100 font-semibold dark:bg-muted",
|
||||
folderId === null &&
|
||||
(usesLightText
|
||||
? "bg-[var(--viewer-panel-active)] font-semibold"
|
||||
: "bg-gray-100 font-semibold dark:bg-muted"),
|
||||
)}
|
||||
style={
|
||||
usesLightText
|
||||
? ({
|
||||
"--viewer-text": palette.textColor,
|
||||
"--viewer-control-bg": palette.controlBgColor,
|
||||
"--viewer-panel-active": palette.panelActiveBgColor,
|
||||
} as CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span
|
||||
className="inline-flex w-full cursor-pointer items-center"
|
||||
@@ -223,6 +241,8 @@ const SidebarFolders = ({
|
||||
setFolderId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
dataroomIndexEnabled?: boolean;
|
||||
}) => {
|
||||
const { usesLightText, palette } = useViewerSurfaceTheme();
|
||||
|
||||
const nestedFolders = useMemo(() => {
|
||||
if (folders) {
|
||||
return buildNestedFolderStructureWithDocs(folders, documents);
|
||||
@@ -243,10 +263,22 @@ const SidebarFolders = ({
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [folders, documents, folderId]);
|
||||
}, [nestedFolders, folderId]);
|
||||
|
||||
return (
|
||||
<FileTree>
|
||||
<FileTree
|
||||
prefersLightText={usesLightText}
|
||||
style={
|
||||
usesLightText
|
||||
? ({
|
||||
"--viewer-text": palette.textColor,
|
||||
"--viewer-muted-text": palette.mutedTextColor,
|
||||
"--viewer-control-bg": palette.controlBgColor,
|
||||
"--viewer-panel-active": palette.panelActiveBgColor,
|
||||
} as CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<HomeLink folderId={folderId} setFolderId={setFolderId} />
|
||||
{nestedFolders.map((folder) => (
|
||||
<FolderComponent
|
||||
|
||||
@@ -85,7 +85,7 @@ export function AddDocumentModal({
|
||||
}[]
|
||||
>([]);
|
||||
const teamInfo = useTeam();
|
||||
const { canAddDocuments } = useLimits();
|
||||
const { canAddDocuments, limits } = useLimits();
|
||||
const { plan, isFree, isTrial, isPaused } = usePlan();
|
||||
const { dataroom } = useDataroom();
|
||||
const teamId = teamInfo?.currentTeam?.id as string;
|
||||
@@ -229,7 +229,18 @@ export function AddDocumentModal({
|
||||
}
|
||||
|
||||
if (!canAddDocuments) {
|
||||
toast.error("You have reached the maximum number of documents.");
|
||||
toast.error(
|
||||
limits?.documents
|
||||
? `You've reached your plan's document limit (${limits.usage?.documents}/${limits.documents} documents). Upgrade your plan to upload more.`
|
||||
: "You have reached the maximum number of documents.",
|
||||
{
|
||||
action: {
|
||||
label: "Upgrade",
|
||||
onClick: () => router.push("/settings/billing"),
|
||||
},
|
||||
duration: 8000,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -415,7 +426,18 @@ export function AddDocumentModal({
|
||||
}
|
||||
|
||||
if (!canAddDocuments) {
|
||||
toast.error("You have reached the maximum number of documents.");
|
||||
toast.error(
|
||||
limits?.documents
|
||||
? `You've reached your plan's document limit (${limits.usage?.documents}/${limits.documents} documents). Upgrade your plan to upload more.`
|
||||
: "You have reached the maximum number of documents.",
|
||||
{
|
||||
action: {
|
||||
label: "Upgrade",
|
||||
onClick: () => router.push("/settings/billing"),
|
||||
},
|
||||
duration: 8000,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -566,7 +588,18 @@ export function AddDocumentModal({
|
||||
}
|
||||
|
||||
if (!canAddDocuments) {
|
||||
toast.error("You have reached the maximum number of documents.");
|
||||
toast.error(
|
||||
limits?.documents
|
||||
? `You've reached your plan's document limit (${limits.usage?.documents}/${limits.documents} documents). Upgrade your plan to upload more.`
|
||||
: "You have reached the maximum number of documents.",
|
||||
{
|
||||
action: {
|
||||
label: "Upgrade",
|
||||
onClick: () => router.push("/settings/billing"),
|
||||
},
|
||||
duration: 8000,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ export default function FolderCard({
|
||||
};
|
||||
|
||||
const handleCardClick = (e: React.MouseEvent) => {
|
||||
if (isDragging) {
|
||||
if (isDragging || menuOpen) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
@@ -258,7 +258,7 @@ export default function FolderCard({
|
||||
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
// size="icon"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
variant="outline"
|
||||
className="z-10 h-8 w-8 border-gray-200 bg-transparent p-0 hover:bg-gray-200 dark:border-gray-700 hover:dark:bg-gray-700 lg:h-9 lg:w-9"
|
||||
>
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { useEffect, useRef, useState, type ElementType } from "react";
|
||||
import { type ElementType, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useTeam } from "@/context/team-context";
|
||||
import { PlanEnum } from "@/ee/stripe/constants";
|
||||
import { LinkType } from "@prisma/client";
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
CircleCheckIcon,
|
||||
InfoIcon,
|
||||
} from "lucide-react";
|
||||
import { AlertTriangleIcon, CircleCheckIcon, InfoIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useDebounce } from "use-debounce";
|
||||
|
||||
@@ -211,25 +207,21 @@ export function AddDomainModal({
|
||||
|
||||
if (saveDisabled) {
|
||||
return toast.error(
|
||||
statusMessageOverride ??
|
||||
"Please enter a valid domain before adding.",
|
||||
statusMessageOverride ?? "Please enter a valid domain before adding.",
|
||||
);
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/teams/${teamId}/domains`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
domain: normalizedDomain,
|
||||
}),
|
||||
const response = await fetch(`/api/teams/${teamId}/domains`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
);
|
||||
body: JSON.stringify({
|
||||
domain: normalizedDomain,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const { message } = await response.json();
|
||||
@@ -370,9 +362,7 @@ export function AddDomainModal({
|
||||
"Enter a valid domain to check availability."
|
||||
)}
|
||||
</p>
|
||||
{StatusIcon && (
|
||||
<StatusIcon className="h-5 w-5 shrink-0" />
|
||||
)}
|
||||
{StatusIcon && <StatusIcon className="h-5 w-5 shrink-0" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTeam } from "@/context/team-context";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
CircleCheckIcon,
|
||||
ExternalLinkIcon,
|
||||
FlagIcon,
|
||||
GlobeIcon,
|
||||
MoreVertical,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { toast } from "sonner";
|
||||
import { mutate } from "swr";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -24,6 +26,7 @@ import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
|
||||
import { useDeleteDomainModal } from "./delete-domain-modal";
|
||||
@@ -33,15 +36,21 @@ import { useDomainStatus } from "./use-domain-status";
|
||||
export default function DomainCard({
|
||||
domain,
|
||||
isDefault,
|
||||
redirectUrl: initialRedirectUrl,
|
||||
redirectsAllowed,
|
||||
onDelete,
|
||||
}: {
|
||||
domain: string;
|
||||
isDefault: boolean;
|
||||
redirectUrl?: string | null;
|
||||
redirectsAllowed: boolean;
|
||||
onDelete: (deletedDomain: string) => void;
|
||||
}) {
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [groupHover, setGroupHover] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [redirectUrl, setRedirectUrl] = useState(initialRedirectUrl || "");
|
||||
const [savingRedirect, setSavingRedirect] = useState(false);
|
||||
|
||||
const domainRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -65,6 +74,34 @@ export default function DomainCard({
|
||||
onDelete,
|
||||
});
|
||||
|
||||
const handleSaveRedirectUrl = async () => {
|
||||
setSavingRedirect(true);
|
||||
try {
|
||||
const response = await fetch(`/api/teams/${teamId}/domains/${domain}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ redirectUrl: redirectUrl || null }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
toast.error(data?.message || "Failed to save redirect URL");
|
||||
return;
|
||||
}
|
||||
|
||||
mutate(`/api/teams/${teamId}/domains`);
|
||||
toast.success(
|
||||
redirectUrl
|
||||
? "Root redirect URL saved"
|
||||
: "Root redirect URL removed",
|
||||
);
|
||||
} catch {
|
||||
toast.error("Failed to save redirect URL");
|
||||
} finally {
|
||||
setSavingRedirect(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMakeDefault = async () => {
|
||||
const response = await fetch(`/api/teams/${teamId}/domains/${domain}`, {
|
||||
method: "PATCH",
|
||||
@@ -232,6 +269,45 @@ export default function DomainCard({
|
||||
) : (
|
||||
<div className="mt-6 h-6 w-32 animate-pulse rounded-md bg-gray-200 dark:bg-gray-400" />
|
||||
)}
|
||||
|
||||
{/* Root domain redirect */}
|
||||
<div className="mt-4 rounded-lg border border-gray-200 p-4 dark:border-gray-400">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<ExternalLinkIcon className="h-4 w-4" />
|
||||
Root Domain Redirect
|
||||
</div>
|
||||
{redirectsAllowed ? (
|
||||
<>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Redirect visitors who land on{" "}
|
||||
<span className="font-medium">{domain}</span> to a specific
|
||||
URL. Leave empty to redirect to papermark.com.
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="https://example.com"
|
||||
value={redirectUrl}
|
||||
onChange={(e) => setRedirectUrl(e.target.value)}
|
||||
className="h-9 flex-1"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveRedirectUrl}
|
||||
disabled={savingRedirect}
|
||||
className="h-9 shrink-0"
|
||||
>
|
||||
{savingRedirect ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Root domain redirects require a{" "}
|
||||
<span className="font-semibold">Business</span> plan or higher.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
<DeleteDomainModal />
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
|
||||
type DocumentChange = {
|
||||
documentName: string;
|
||||
};
|
||||
|
||||
export default function DataroomDigestNotification({
|
||||
dataroomName = "Example Data Room",
|
||||
documents = [
|
||||
{ documentName: "Document A" },
|
||||
{ documentName: "Document B" },
|
||||
{ documentName: "Document C" },
|
||||
],
|
||||
senderEmail = "example@example.com",
|
||||
url = "https://app.papermark.com/datarooms/123",
|
||||
preferencesUrl = "https://app.papermark.com/notification-preferences?token=abc",
|
||||
frequency = "daily",
|
||||
}: {
|
||||
dataroomName: string;
|
||||
documents: DocumentChange[];
|
||||
senderEmail: string;
|
||||
url: string;
|
||||
preferencesUrl: string;
|
||||
frequency: "daily" | "weekly";
|
||||
}) {
|
||||
const count = documents.length;
|
||||
const periodLabel = frequency === "daily" ? "today" : "this week";
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>
|
||||
{`${count} new document${count !== 1 ? "s" : ""} in ${dataroomName} ${periodLabel}`}
|
||||
</Preview>
|
||||
<Tailwind>
|
||||
<Body className="mx-auto my-auto bg-white font-sans">
|
||||
<Container className="mx-auto my-10 w-[465px] p-5">
|
||||
<Text className="mb-8 mt-4 text-center text-2xl font-normal">
|
||||
<span className="font-bold tracking-tighter">Papermark</span>
|
||||
</Text>
|
||||
<Text className="font-semibold mb-8 mt-4 text-center text-xl">
|
||||
{`${count} new document${count !== 1 ? "s" : ""} in ${dataroomName}`}
|
||||
</Text>
|
||||
<Text className="text-sm leading-6 text-black">
|
||||
The following document{count !== 1 ? "s have" : " has"} been added
|
||||
to <span className="font-semibold">{dataroomName}</span>{" "}
|
||||
{periodLabel}:
|
||||
</Text>
|
||||
<Section className="my-4">
|
||||
{documents.map((doc, i) => (
|
||||
<Text
|
||||
key={i}
|
||||
className="my-1 text-sm leading-6 text-black"
|
||||
>
|
||||
• <span className="font-semibold">{doc.documentName}</span>
|
||||
</Text>
|
||||
))}
|
||||
</Section>
|
||||
<Section className="my-8 text-center">
|
||||
<Button
|
||||
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
|
||||
href={url}
|
||||
style={{ padding: "12px 20px" }}
|
||||
>
|
||||
View the dataroom
|
||||
</Button>
|
||||
</Section>
|
||||
<Text className="text-sm text-black">
|
||||
or copy and paste this URL into your browser: <br />
|
||||
{url}
|
||||
</Text>
|
||||
<Text className="text-sm text-gray-400">Papermark</Text>
|
||||
|
||||
<Hr />
|
||||
<Section className="text-gray-400">
|
||||
<Text className="text-xs">
|
||||
© {new Date().getFullYear()} Papermark, Inc. All rights
|
||||
reserved.
|
||||
</Text>
|
||||
<Text className="text-xs">
|
||||
You received this {frequency} digest from{" "}
|
||||
<span className="font-semibold">{senderEmail}</span> because you
|
||||
viewed the dataroom{" "}
|
||||
<span className="font-semibold">{dataroomName}</span> on
|
||||
Papermark. If you have any feedback or questions about this
|
||||
email, simply reply to it.{" "}
|
||||
<a
|
||||
href={preferencesUrl}
|
||||
className="text-gray-400 underline underline-offset-2"
|
||||
>
|
||||
Manage your notification preferences
|
||||
</a>
|
||||
.
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
@@ -72,13 +72,12 @@ export default function DataroomNotification({
|
||||
viewed the dataroom{" "}
|
||||
<span className="font-semibold">{dataroomName}</span> on
|
||||
Papermark. If you have any feedback or questions about this
|
||||
email, simply reply to it. To unsubscribe from updates about
|
||||
this dataroom,{" "}
|
||||
email, simply reply to it.{" "}
|
||||
<a
|
||||
href={unsubscribeUrl}
|
||||
className="text-gray-400 underline underline-offset-2"
|
||||
>
|
||||
click here
|
||||
Manage your notification preferences
|
||||
</a>
|
||||
.
|
||||
</Text>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useState } from "react";
|
||||
|
||||
import { useTeam } from "@/context/team-context";
|
||||
import { PlanEnum } from "@/ee/stripe/constants";
|
||||
import slugify from "@sindresorhus/slugify";
|
||||
import { toast } from "sonner";
|
||||
import { mutate } from "swr";
|
||||
import { z } from "zod";
|
||||
@@ -15,6 +14,7 @@ import {
|
||||
FolderColorId,
|
||||
FolderIconId,
|
||||
} from "@/lib/constants/folder-constants";
|
||||
import { safeSlugify } from "@/lib/utils";
|
||||
import { useAnalytics } from "@/lib/analytics";
|
||||
import { usePlan } from "@/lib/swr/use-billing";
|
||||
|
||||
@@ -65,8 +65,8 @@ export function AddFolderModal({
|
||||
|
||||
const folderPath =
|
||||
isDataroom && dataroomId
|
||||
? `/datarooms/${dataroomId}/documents/${currentFolderPath ? currentFolderPath?.join("/") : ""}/${"/" + slugify(folderName.trim())}`
|
||||
: `/documents/tree/${currentFolderPath ? currentFolderPath?.join("/") : ""}${"/" + slugify(folderName.trim())}`;
|
||||
? `/datarooms/${dataroomId}/documents/${currentFolderPath ? currentFolderPath?.join("/") : ""}/${"/" + safeSlugify(folderName.trim())}`
|
||||
: `/documents/tree/${currentFolderPath ? currentFolderPath?.join("/") : ""}${"/" + safeSlugify(folderName.trim())}`;
|
||||
|
||||
const addFolderSchema = z.object({
|
||||
name: z
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { LinkPreset } from "@prisma/client";
|
||||
import { CheckIcon, UsersIcon, XIcon } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
import { FADE_IN_ANIMATION_SETTINGS } from "@/lib/constants";
|
||||
import useVisitorGroups from "@/lib/swr/use-visitor-groups";
|
||||
import { sanitizeList } from "@/lib/utils";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
import { DEFAULT_LINK_TYPE } from ".";
|
||||
@@ -30,25 +39,29 @@ export default function AllowListSection({
|
||||
}: LinkUpgradeOptions) => void;
|
||||
presets: LinkPreset | null;
|
||||
}) {
|
||||
const { emailProtected, allowList } = data;
|
||||
const { emailProtected, allowList, visitorGroupIds } = data;
|
||||
const { visitorGroups } = useVisitorGroups();
|
||||
|
||||
// Initialize enabled state based on whether allowList is not null and not empty
|
||||
// or if visitor groups are selected
|
||||
const [enabled, setEnabled] = useState<boolean>(
|
||||
!!allowList && allowList.length > 0,
|
||||
(!!allowList && allowList.length > 0) ||
|
||||
(!!visitorGroupIds && visitorGroupIds.length > 0),
|
||||
);
|
||||
const [allowListInput, setAllowListInput] = useState<string>(
|
||||
allowList?.join("\n") || "",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Update the allowList in the data state when their inputs change
|
||||
const newAllowList = sanitizeList(allowListInput);
|
||||
setEnabled((prevEnabled) => prevEnabled && emailProtected);
|
||||
setData((prevData) => ({
|
||||
...prevData,
|
||||
allowList: emailProtected && enabled ? newAllowList : [],
|
||||
}));
|
||||
}, [allowListInput, emailProtected, enabled, setData]);
|
||||
if (!emailProtected && enabled) {
|
||||
setEnabled(false);
|
||||
setData((prevData) => ({
|
||||
...prevData,
|
||||
allowList: [],
|
||||
visitorGroupIds: [],
|
||||
}));
|
||||
}
|
||||
}, [emailProtected, enabled, setData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAllowed && presets?.allowList && presets.allowList.length > 0) {
|
||||
@@ -72,6 +85,7 @@ export default function AllowListSection({
|
||||
setData((prevData) => ({
|
||||
...prevData,
|
||||
allowList: [],
|
||||
visitorGroupIds: [],
|
||||
}));
|
||||
}
|
||||
};
|
||||
@@ -79,16 +93,46 @@ export default function AllowListSection({
|
||||
const handleAllowListChange = (
|
||||
event: React.ChangeEvent<HTMLTextAreaElement>,
|
||||
) => {
|
||||
setAllowListInput(event.target.value);
|
||||
const updatedAllowListInput = event.target.value;
|
||||
setAllowListInput(updatedAllowListInput);
|
||||
|
||||
if (emailProtected && enabled) {
|
||||
setData((prevData) => ({
|
||||
...prevData,
|
||||
allowList: sanitizeList(updatedAllowListInput),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleVisitorGroup = (groupId: string) => {
|
||||
setData((prevData) => {
|
||||
const currentIds = prevData.visitorGroupIds || [];
|
||||
const newIds = currentIds.includes(groupId)
|
||||
? currentIds.filter((id) => id !== groupId)
|
||||
: [...currentIds, groupId];
|
||||
return { ...prevData, visitorGroupIds: newIds };
|
||||
});
|
||||
};
|
||||
|
||||
const removeVisitorGroup = (groupId: string) => {
|
||||
setData((prevData) => ({
|
||||
...prevData,
|
||||
visitorGroupIds: (prevData.visitorGroupIds || []).filter(
|
||||
(id) => id !== groupId,
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const selectedGroups =
|
||||
visitorGroups?.filter((g) => visitorGroupIds?.includes(g.id)) || [];
|
||||
|
||||
return (
|
||||
<div className="pb-5">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<LinkItem
|
||||
title="Allow specified viewers"
|
||||
link="https://www.papermark.com/help/article/allow-list"
|
||||
tooltipContent="Restrict access to a selected group of viewers. Enter allowed emails or domains."
|
||||
tooltipContent={`Restrict access to a selected group of viewers. Enter allowed emails or domains${visitorGroups && visitorGroups.length > 0 ? ", or select visitor groups" : ""}.`}
|
||||
enabled={enabled}
|
||||
isAllowed={isAllowed}
|
||||
action={handleEnableAllowList}
|
||||
@@ -105,9 +149,108 @@ export default function AllowListSection({
|
||||
|
||||
{enabled && (
|
||||
<motion.div
|
||||
className="mt-1 block w-full"
|
||||
className="mt-1 block w-full space-y-3"
|
||||
{...FADE_IN_ANIMATION_SETTINGS}
|
||||
>
|
||||
{/* Visitor Groups Selector */}
|
||||
{visitorGroups && visitorGroups.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-1.5 flex items-center gap-1.5">
|
||||
<UsersIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Visitor Groups
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Selected groups as badges */}
|
||||
{selectedGroups.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{selectedGroups.map((group) => (
|
||||
<Badge
|
||||
key={group.id}
|
||||
variant="secondary"
|
||||
className="gap-1 pr-1"
|
||||
>
|
||||
{group.name}
|
||||
<span className="text-muted-foreground">
|
||||
({group.emails.length})
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeVisitorGroup(group.id)}
|
||||
className="ml-0.5 rounded-full p-0.5 hover:bg-muted"
|
||||
>
|
||||
<XIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start text-muted-foreground"
|
||||
>
|
||||
<UsersIcon className="mr-2 h-3.5 w-3.5" />
|
||||
{selectedGroups.length > 0
|
||||
? `${selectedGroups.length} group${selectedGroups.length > 1 ? "s" : ""} selected`
|
||||
: "Select visitor groups..."}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72 p-1" align="start">
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
{visitorGroups.map((group) => {
|
||||
const isSelected = visitorGroupIds?.includes(group.id);
|
||||
return (
|
||||
<button
|
||||
key={group.id}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-muted"
|
||||
onClick={() => toggleVisitorGroup(group.id)}
|
||||
>
|
||||
<div
|
||||
className={`flex h-4 w-4 items-center justify-center rounded-sm border ${
|
||||
isSelected
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-muted-foreground/30"
|
||||
}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<CheckIcon className="h-3 w-3" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium">
|
||||
{group.name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{group.emails.length}{" "}
|
||||
{group.emails.length === 1
|
||||
? "entry"
|
||||
: "entries"}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<div className="my-2 flex items-center gap-2">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
plus individual emails
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
className="focus:ring-inset"
|
||||
rows={5}
|
||||
|
||||
@@ -40,14 +40,14 @@ export default function DenyListSection({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Update the denyList in the data state when their inputs change
|
||||
const newDenyList = sanitizeList(denyListInput);
|
||||
setEnabled((prevEnabled) => prevEnabled && emailProtected);
|
||||
setData((prevData) => ({
|
||||
...prevData,
|
||||
denyList: emailProtected && enabled ? newDenyList : [],
|
||||
}));
|
||||
}, [denyListInput, enabled, emailProtected, setData]);
|
||||
if (!emailProtected && enabled) {
|
||||
setEnabled(false);
|
||||
setData((prevData) => ({
|
||||
...prevData,
|
||||
denyList: [],
|
||||
}));
|
||||
}
|
||||
}, [emailProtected, enabled, setData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAllowed && presets?.denyList && presets.denyList.length > 0) {
|
||||
@@ -78,7 +78,15 @@ export default function DenyListSection({
|
||||
const handleDenyListChange = (
|
||||
event: React.ChangeEvent<HTMLTextAreaElement>,
|
||||
) => {
|
||||
setDenyListInput(event.target.value);
|
||||
const updatedDenyListInput = event.target.value;
|
||||
setDenyListInput(updatedDenyListInput);
|
||||
|
||||
if (emailProtected && enabled) {
|
||||
setData((prevData) => ({
|
||||
...prevData,
|
||||
denyList: sanitizeList(updatedDenyListInput),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -65,6 +65,7 @@ export const DEFAULT_LINK_PROPS = (
|
||||
allowDownload: false,
|
||||
allowList: [],
|
||||
denyList: [],
|
||||
visitorGroupIds: [],
|
||||
enableNotification: true,
|
||||
enableFeedback: false,
|
||||
enableScreenshotProtection: false,
|
||||
@@ -109,6 +110,7 @@ export type DEFAULT_LINK_TYPE = {
|
||||
allowDownload: boolean;
|
||||
allowList: string[];
|
||||
denyList: string[];
|
||||
visitorGroupIds: string[];
|
||||
enableNotification: boolean;
|
||||
enableFeedback: boolean;
|
||||
enableScreenshotProtection: boolean;
|
||||
|
||||
@@ -146,6 +146,7 @@ export const LinkOptions = ({
|
||||
{data.audienceType === LinkAudienceType.GENERAL ? (
|
||||
<>
|
||||
<AllowListSection
|
||||
key={`allow-list-${data.id ?? "new"}`}
|
||||
{...{ data, setData }}
|
||||
isAllowed={
|
||||
isTrial ||
|
||||
@@ -158,6 +159,7 @@ export const LinkOptions = ({
|
||||
presets={currentPreset}
|
||||
/>
|
||||
<DenyListSection
|
||||
key={`deny-list-${data.id ?? "new"}`}
|
||||
{...{ data, setData }}
|
||||
isAllowed={
|
||||
isTrial ||
|
||||
|
||||
@@ -369,6 +369,10 @@ export default function LinksTable({
|
||||
allowDownload: link.allowDownload ? link.allowDownload : false,
|
||||
allowList: link.allowList,
|
||||
denyList: link.denyList,
|
||||
visitorGroupIds:
|
||||
link.visitorGroups?.map(
|
||||
(vg: { visitorGroupId: string }) => vg.visitorGroupId,
|
||||
) || [],
|
||||
enableNotification: link.enableNotification
|
||||
? link.enableNotification
|
||||
: false,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import {
|
||||
memo,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -26,6 +27,8 @@ type SearchBoxProps = {
|
||||
onChangeDebounced?: (value: string) => void;
|
||||
debounceTimeoutMs?: number;
|
||||
inputClassName?: string;
|
||||
leftIconClassName?: string;
|
||||
clearIconClassName?: string;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
@@ -39,6 +42,8 @@ const SearchBox = forwardRef(
|
||||
onChangeDebounced,
|
||||
debounceTimeoutMs = 500,
|
||||
inputClassName,
|
||||
leftIconClassName,
|
||||
clearIconClassName,
|
||||
placeholder = "Search...",
|
||||
}: SearchBoxProps,
|
||||
forwardedRef,
|
||||
@@ -77,14 +82,16 @@ const SearchBox = forwardRef(
|
||||
{loading && value.length > 0 ? (
|
||||
<LoadingSpinner className="h-4 w-4" />
|
||||
) : (
|
||||
<SearchIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<SearchIcon
|
||||
className={cn("h-4 w-4 text-muted-foreground", leftIconClassName)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className={cn(
|
||||
"peer w-full rounded-md border border-border bg-white px-10 text-foreground outline-none placeholder:text-muted-foreground dark:bg-gray-800 sm:text-sm",
|
||||
"peer w-full cursor-text rounded-md border border-border bg-white px-10 text-foreground outline-none placeholder:text-muted-foreground dark:bg-gray-800 sm:text-sm",
|
||||
"transition-all focus:border-gray-500 focus:ring-0",
|
||||
inputClassName,
|
||||
)}
|
||||
@@ -104,7 +111,9 @@ const SearchBox = forwardRef(
|
||||
}}
|
||||
className="pointer-events-auto absolute inset-y-0 right-0 flex items-center pr-4"
|
||||
>
|
||||
<CircleXIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<CircleXIcon
|
||||
className={cn("h-4 w-4 text-muted-foreground", clearIconClassName)}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -112,6 +121,7 @@ const SearchBox = forwardRef(
|
||||
},
|
||||
);
|
||||
SearchBox.displayName = "SearchBox";
|
||||
const MemoizedSearchBox = memo(SearchBox);
|
||||
|
||||
export function SearchBoxPersisted({
|
||||
urlParam = "search",
|
||||
@@ -148,6 +158,9 @@ export function SearchBoxPersisted({
|
||||
isCustomDomain ? `/${router.query.slug}` : undefined, // Preserve custom domain URL
|
||||
{ shallow: true },
|
||||
);
|
||||
// This is intentionally keyed only by debounced input value.
|
||||
// Adding router/query deps can cause feedback loops with shallow routing.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedValue]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -157,10 +170,12 @@ export function SearchBoxPersisted({
|
||||
setValue(queryValue);
|
||||
setDebouncedValue(queryValue);
|
||||
}
|
||||
// Keep this tied to the specific URL param only.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [queryParams[urlParam]]);
|
||||
|
||||
return (
|
||||
<SearchBox
|
||||
<MemoizedSearchBox
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onChangeDebounced={setDebouncedValue}
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useTeam } from "@/context/team-context";
|
||||
import { CheckCircleIcon, PencilIcon, XIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
const DEAL_TYPE_OPTIONS = [
|
||||
{ value: "startup-fundraising", label: "Startup Fundraising" },
|
||||
{ value: "fund-management", label: "Fundraising & Reporting" },
|
||||
{ value: "mergers-acquisitions", label: "Mergers & Acquisitions" },
|
||||
{ value: "financial-operations", label: "Financial Operations" },
|
||||
{ value: "real-estate", label: "Real Estate" },
|
||||
{ value: "project-management", label: "Project Management" },
|
||||
];
|
||||
|
||||
const DEAL_SIZE_OPTIONS = [
|
||||
{ value: "0-500k", label: "$0 - $500K" },
|
||||
{ value: "500k-5m", label: "$500K - $5M" },
|
||||
{ value: "5m-10m", label: "$5M - $10M" },
|
||||
{ value: "10m-100m", label: "$10M - $100M" },
|
||||
{ value: "100m+", label: "$100M+" },
|
||||
];
|
||||
|
||||
export function SurveySettings() {
|
||||
const teamInfo = useTeam();
|
||||
const teamId = teamInfo?.currentTeam?.id;
|
||||
|
||||
const [dealType, setDealType] = useState<string | null>(null);
|
||||
const [dealTypeOther, setDealTypeOther] = useState<string | null>(null);
|
||||
const [dealSize, setDealSize] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [step, setStep] = useState<1 | 2 | 3>(1);
|
||||
const [editDealType, setEditDealType] = useState<string | null>(null);
|
||||
const [editDealTypeOther, setEditDealTypeOther] = useState<string>("");
|
||||
const [showOtherInput, setShowOtherInput] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSurvey = async () => {
|
||||
if (!teamId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/teams/${teamId}/survey`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setDealType(data.dealType);
|
||||
setDealTypeOther(data.dealTypeOther);
|
||||
setDealSize(data.dealSize);
|
||||
|
||||
// Set initial step based on existing data
|
||||
if (data.dealType && (data.dealSize || data.dealType === "project-management")) {
|
||||
setStep(3); // Show completed state
|
||||
} else if (data.dealType) {
|
||||
setEditDealType(data.dealType);
|
||||
setEditDealTypeOther(data.dealTypeOther || "");
|
||||
setStep(2); // Show deal size question
|
||||
} else {
|
||||
setStep(1); // Show deal type question
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch survey data:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSurvey();
|
||||
}, [teamId]);
|
||||
|
||||
const getDealTypeLabel = (value: string | null, otherText?: string | null) => {
|
||||
if (!value) return null;
|
||||
if (value === "other" && otherText) {
|
||||
return `Other: ${otherText}`;
|
||||
}
|
||||
return DEAL_TYPE_OPTIONS.find((opt) => opt.value === value)?.label || value;
|
||||
};
|
||||
|
||||
const getDealSizeLabel = (value: string | null) => {
|
||||
if (!value) return null;
|
||||
return DEAL_SIZE_OPTIONS.find((opt) => opt.value === value)?.label || value;
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
setEditDealType(dealType);
|
||||
setEditDealTypeOther(dealTypeOther || "");
|
||||
setShowOtherInput(false);
|
||||
setStep(1);
|
||||
};
|
||||
|
||||
const handleDealTypeSelect = async (value: string) => {
|
||||
setEditDealType(value);
|
||||
setShowOtherInput(false);
|
||||
|
||||
if (value === "project-management") {
|
||||
// Project management doesn't need deal size - save directly
|
||||
await handleSave(value, null, null);
|
||||
} else {
|
||||
setStep(2);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOtherSubmit = () => {
|
||||
if (!editDealTypeOther.trim()) return;
|
||||
setEditDealType("other");
|
||||
setShowOtherInput(false);
|
||||
setStep(2);
|
||||
};
|
||||
|
||||
const handleDealSizeSelect = async (value: string) => {
|
||||
await handleSave(editDealType, value, editDealTypeOther || null);
|
||||
};
|
||||
|
||||
const handleSave = async (
|
||||
type?: string | null,
|
||||
size?: string | null,
|
||||
otherText?: string | null,
|
||||
) => {
|
||||
const finalDealType = type || editDealType;
|
||||
if (!finalDealType || !teamId) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const response = await fetch(`/api/teams/${teamId}/survey`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
dealType: finalDealType,
|
||||
dealTypeOther: otherText,
|
||||
dealSize: size,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to save");
|
||||
}
|
||||
|
||||
setDealType(finalDealType);
|
||||
setDealTypeOther(otherText || null);
|
||||
setDealSize(size || null);
|
||||
setStep(3);
|
||||
toast.success("Survey answers saved!");
|
||||
} catch (error) {
|
||||
console.error("Failed to save survey:", error);
|
||||
toast.error("Failed to save changes");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getDealSizeQuestion = () => {
|
||||
switch (editDealType) {
|
||||
case "startup-fundraising":
|
||||
case "fund-management":
|
||||
return "How much are you raising?";
|
||||
case "mergers-acquisitions":
|
||||
case "real-estate":
|
||||
case "financial-operations":
|
||||
return "What's the deal size?";
|
||||
default:
|
||||
return "What's the typical deal size?";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card id="team-survey">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Team Survey</CardTitle>
|
||||
<CardDescription>
|
||||
This will help us tailor your Papermark experience
|
||||
</CardDescription>
|
||||
</div>
|
||||
{step === 3 && (
|
||||
<Button variant="outline" size="sm" onClick={handleEdit}>
|
||||
<PencilIcon className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
) : step === 1 ? (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold">What do you use Papermark for?</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
{DEAL_TYPE_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => handleDealTypeSelect(option.value)}
|
||||
disabled={isSaving}
|
||||
className="flex items-center justify-between rounded-lg border border-border px-3 py-2 text-left text-sm transition-all hover:border-primary/50 hover:bg-muted/50"
|
||||
>
|
||||
<span className="font-medium">{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
{/* Other - inline input */}
|
||||
{showOtherInput ? (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editDealTypeOther}
|
||||
onChange={(e) => setEditDealTypeOther(e.target.value)}
|
||||
placeholder="Please specify..."
|
||||
className="flex-1 rounded-lg border border-border bg-background px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && editDealTypeOther.trim()) {
|
||||
handleOtherSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleOtherSubmit}
|
||||
disabled={!editDealTypeOther.trim() || isSaving}
|
||||
className="rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowOtherInput(true)}
|
||||
disabled={isSaving}
|
||||
className="flex items-center justify-between rounded-lg border border-border px-3 py-2 text-left text-sm transition-all hover:border-primary/50 hover:bg-muted/50"
|
||||
>
|
||||
<span className="font-medium">Other</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : step === 2 ? (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{getDealSizeQuestion()}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
{DEAL_SIZE_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => handleDealSizeSelect(option.value)}
|
||||
disabled={isSaving}
|
||||
className="flex items-center justify-between rounded-lg border border-border px-3 py-2 text-left text-sm transition-all hover:border-primary/50 hover:bg-muted/50"
|
||||
>
|
||||
<span className="font-medium">{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<CheckCircleIcon className="h-6 w-6 text-green-500" />
|
||||
<h3 className="text-lg font-semibold">Thanks for sharing!</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between rounded-lg border border-border px-3 py-2">
|
||||
<span className="text-sm text-muted-foreground">Use case</span>
|
||||
<span className="text-sm font-medium">
|
||||
{getDealTypeLabel(dealType, dealTypeOther)}
|
||||
</span>
|
||||
</div>
|
||||
{dealSize && (
|
||||
<div className="flex items-center justify-between rounded-lg border border-border px-3 py-2">
|
||||
<span className="text-sm text-muted-foreground">Deal size</span>
|
||||
<span className="text-sm font-medium">
|
||||
{getDealSizeLabel(dealSize)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useTeam } from "@/context/team-context";
|
||||
import { CheckCircleIcon, XIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const DEAL_TYPE_OPTIONS = [
|
||||
{ value: "startup-fundraising", label: "Startup Fundraising" },
|
||||
{ value: "fund-management", label: "Fundraising & Reporting" },
|
||||
{ value: "mergers-acquisitions", label: "Mergers & Acquisitions" },
|
||||
{ value: "financial-operations", label: "Financial Operations" },
|
||||
{ value: "real-estate", label: "Real Estate" },
|
||||
{ value: "project-management", label: "Project Management" },
|
||||
];
|
||||
|
||||
const DEAL_SIZE_OPTIONS = [
|
||||
{ value: "0-500k", label: "$0 - $500K" },
|
||||
{ value: "500k-5m", label: "$500K - $5M" },
|
||||
{ value: "5m-10m", label: "$5M - $10M" },
|
||||
{ value: "10m-100m", label: "$10M - $100M" },
|
||||
{ value: "100m+", label: "$100M+" },
|
||||
];
|
||||
|
||||
export function DealflowPopup() {
|
||||
const router = useRouter();
|
||||
const teamInfo = useTeam();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [step, setStep] = useState(1);
|
||||
const [dealType, setDealType] = useState<string | null>(null);
|
||||
const [dealTypeOther, setDealTypeOther] = useState<string>("");
|
||||
const [showOtherInput, setShowOtherInput] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const teamId = teamInfo?.currentTeam?.id;
|
||||
const storageKey = `dealflow-survey-dismissed-${teamId}`;
|
||||
|
||||
// Check if we're on onboarding/welcome pages
|
||||
const isOnboarding = router.pathname.startsWith("/welcome");
|
||||
|
||||
useEffect(() => {
|
||||
if (isOnboarding) return;
|
||||
if (!teamId) return;
|
||||
|
||||
const dismissed = localStorage.getItem(storageKey);
|
||||
if (dismissed) return;
|
||||
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
|
||||
const checkSurvey = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/teams/${teamId}/survey`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
if (data.dismissed) {
|
||||
localStorage.setItem(storageKey, "true");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data.dealType) {
|
||||
timeoutId = setTimeout(() => {
|
||||
setStep(1);
|
||||
setIsOpen(true);
|
||||
}, 2000);
|
||||
} else if (!data.dealSize && data.dealType !== "project-management") {
|
||||
timeoutId = setTimeout(() => {
|
||||
setDealType(data.dealType);
|
||||
setDealTypeOther(data.dealTypeOther || "");
|
||||
setStep(2);
|
||||
setIsOpen(true);
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check survey status:", error);
|
||||
}
|
||||
};
|
||||
|
||||
checkSurvey();
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [teamId, storageKey, isOnboarding]);
|
||||
|
||||
const handleDealTypeSelect = (value: string) => {
|
||||
setDealType(value);
|
||||
setShowOtherInput(false);
|
||||
if (value === "project-management") {
|
||||
// Project management doesn't need a second step - go straight to thank you
|
||||
handleSubmit(value, null, null);
|
||||
} else {
|
||||
setStep(2);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOtherSubmit = () => {
|
||||
if (!dealTypeOther.trim()) return;
|
||||
setDealType("other");
|
||||
setShowOtherInput(false);
|
||||
// After entering "Other" text, go to deal size question
|
||||
setStep(2);
|
||||
};
|
||||
|
||||
const handleDealSizeSelect = async (value: string) => {
|
||||
await handleSubmit(dealType, value, dealTypeOther || null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (
|
||||
type?: string | null,
|
||||
size?: string | null,
|
||||
otherText?: string | null,
|
||||
) => {
|
||||
const finalDealType = type || dealType;
|
||||
if (!finalDealType) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/teams/${teamInfo?.currentTeam?.id}/survey`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
dealType: finalDealType,
|
||||
dealTypeOther:
|
||||
otherText !== undefined
|
||||
? otherText
|
||||
: dealTypeOther || null,
|
||||
dealSize: size ?? null,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to save response");
|
||||
}
|
||||
|
||||
localStorage.setItem(storageKey, "true");
|
||||
setStep(3); // Go to thank you screen
|
||||
} catch (error) {
|
||||
console.error("Failed to save survey response:", error);
|
||||
toast.error("Failed to save your response");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleDismiss = async () => {
|
||||
localStorage.setItem(storageKey, "true");
|
||||
setIsOpen(false);
|
||||
|
||||
toast("Survey dismissed", {
|
||||
description: "You can complete it anytime from Settings → General.",
|
||||
action: {
|
||||
label: "Go to Settings",
|
||||
onClick: () => router.push("/settings/general#team-survey"),
|
||||
},
|
||||
});
|
||||
|
||||
if (teamId) {
|
||||
try {
|
||||
await fetch(`/api/teams/${teamId}/survey`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
dismissed: true,
|
||||
dismissedAt: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to save survey dismissal:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getDealSizeQuestion = () => {
|
||||
switch (dealType) {
|
||||
case "startup-fundraising":
|
||||
case "fund-management":
|
||||
return "How much are you raising?";
|
||||
case "mergers-acquisitions":
|
||||
case "real-estate":
|
||||
case "financial-operations":
|
||||
return "What's the deal size?";
|
||||
default:
|
||||
return "What's the typical deal size?";
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 w-full max-w-sm animate-in fade-in slide-in-from-bottom-4 duration-300">
|
||||
<div className="rounded-lg border-2 border-black bg-background p-4 shadow-lg">
|
||||
{step === 1 ? (
|
||||
<>
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">What do you use Papermark for?</h3>
|
||||
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="rounded-full p-1 hover:bg-muted"
|
||||
>
|
||||
<XIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
{DEAL_TYPE_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => handleDealTypeSelect(option.value)}
|
||||
disabled={isSubmitting}
|
||||
className="flex items-center justify-between rounded-lg border border-border px-3 py-2 text-left text-sm transition-all hover:border-primary/50 hover:bg-muted/50"
|
||||
>
|
||||
<span className="font-medium">{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
{/* Other - inline input */}
|
||||
{showOtherInput ? (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={dealTypeOther}
|
||||
onChange={(e) => setDealTypeOther(e.target.value)}
|
||||
placeholder="Please specify..."
|
||||
className="flex-1 rounded-lg border border-border bg-background px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && dealTypeOther.trim()) {
|
||||
handleOtherSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleOtherSubmit}
|
||||
disabled={!dealTypeOther.trim() || isSubmitting}
|
||||
className="rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowOtherInput(true)}
|
||||
disabled={isSubmitting}
|
||||
className="flex items-center justify-between rounded-lg border border-border px-3 py-2 text-left text-sm transition-all hover:border-primary/50 hover:bg-muted/50"
|
||||
>
|
||||
<span className="font-medium">Other</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
This will help us tailor your Papermark experience
|
||||
</p>
|
||||
</>
|
||||
) : step === 2 ? (
|
||||
<>
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">
|
||||
{getDealSizeQuestion()}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="rounded-full p-1 hover:bg-muted"
|
||||
>
|
||||
<XIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
{DEAL_SIZE_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => handleDealSizeSelect(option.value)}
|
||||
disabled={isSubmitting}
|
||||
className="flex items-center justify-between rounded-lg border border-border px-3 py-2 text-left text-sm transition-all hover:border-primary/50 hover:bg-muted/50"
|
||||
>
|
||||
<span className="font-medium">{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
This will help us tailor your Papermark experience
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircleIcon className="h-6 w-6 text-green-500" />
|
||||
<h3 className="text-lg font-semibold">Thanks for sharing!</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="rounded-full p-1 hover:bg-muted"
|
||||
>
|
||||
<XIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
You can find and update your responses in settings.
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
router.push("/settings/general#team-survey");
|
||||
handleClose();
|
||||
}}
|
||||
className="w-full rounded-lg border border-black bg-white px-3 py-2 text-sm font-medium text-black transition-colors hover:bg-gray-50"
|
||||
>
|
||||
Go to Team Survey
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
export default function EyeOff({ className }: { className?: string }) {
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
export default function EyeOff({
|
||||
className,
|
||||
...props
|
||||
}: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width="14"
|
||||
@@ -11,6 +16,7 @@ export default function EyeOff({ className }: { className?: string }) {
|
||||
strokeLinejoin="round"
|
||||
shapeRendering="geometricPrecision"
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" />
|
||||
<path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68" />
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export default function Eye({ className }: { className?: string }) {
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
export default function Eye({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width="14"
|
||||
@@ -11,6 +13,7 @@ export default function Eye({ className }: { className?: string }) {
|
||||
strokeLinejoin="round"
|
||||
shapeRendering="geometricPrecision"
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
|
||||
@@ -17,7 +17,7 @@ export const Gauge = ({
|
||||
xs: {
|
||||
width: "24",
|
||||
height: "24",
|
||||
textSize: "text-xs",
|
||||
textSize: "text-[10px]",
|
||||
},
|
||||
small: {
|
||||
width: "36",
|
||||
|
||||
@@ -4,7 +4,7 @@ import { OTPInput, OTPInputContext } from "input-otp";
|
||||
import { Dot } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { determineTextColor } from "@/lib/utils/determine-text-color";
|
||||
import { createAdaptiveSurfacePalette } from "@/lib/utils/create-adaptive-surface-palette";
|
||||
|
||||
// Create a context to pass accentColor to InputOTPSlot components
|
||||
const AccentColorContext = React.createContext<string | null | undefined>(
|
||||
@@ -50,7 +50,8 @@ const InputOTPSlot = React.forwardRef<
|
||||
const inputOTPContext = React.useContext(OTPInputContext);
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
|
||||
const accentColor = React.useContext(AccentColorContext);
|
||||
const textColor = determineTextColor(accentColor);
|
||||
const otpPalette = createAdaptiveSurfacePalette(accentColor);
|
||||
const textColor = otpPalette.textColor;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*
|
||||
*/
|
||||
import React, {
|
||||
CSSProperties,
|
||||
createContext,
|
||||
memo,
|
||||
useCallback,
|
||||
@@ -25,11 +26,16 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ctx = createContext(0);
|
||||
const prefersLightTextCtx = createContext(false);
|
||||
|
||||
function useIndent() {
|
||||
return useContext(ctx);
|
||||
}
|
||||
|
||||
export function usePrefersLightText() {
|
||||
return useContext(prefersLightTextCtx);
|
||||
}
|
||||
|
||||
interface FolderProps {
|
||||
name: string;
|
||||
label?: ReactElement;
|
||||
@@ -50,11 +56,24 @@ interface FileProps {
|
||||
onToggle?: (active: boolean) => void;
|
||||
}
|
||||
|
||||
function Tree({ children }: { children: ReactNode }): ReactElement {
|
||||
function Tree({
|
||||
children,
|
||||
prefersLightText,
|
||||
style,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
prefersLightText?: boolean;
|
||||
style?: CSSProperties;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<div className={cn("nextra-filetree !mt-0 w-full select-none text-sm")}>
|
||||
<div className="block space-y-1 rounded-lg">{children}</div>
|
||||
</div>
|
||||
<prefersLightTextCtx.Provider value={prefersLightText ?? false}>
|
||||
<div
|
||||
className={cn("nextra-filetree !mt-0 w-full select-none text-sm")}
|
||||
style={style}
|
||||
>
|
||||
<div className="block space-y-1 rounded-lg">{children}</div>
|
||||
</div>
|
||||
</prefersLightTextCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -83,6 +102,7 @@ const Folder = memo<FolderProps>(
|
||||
disable,
|
||||
}) => {
|
||||
const indent = useIndent();
|
||||
const prefersLightText = usePrefersLightText();
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen || childActive);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -121,9 +141,15 @@ const Folder = memo<FolderProps>(
|
||||
title={name}
|
||||
className={cn(
|
||||
"inline-flex w-full cursor-pointer items-center overflow-hidden",
|
||||
"rounded-md text-foreground duration-100 hover:bg-gray-100 hover:dark:bg-muted",
|
||||
"rounded-md duration-100",
|
||||
prefersLightText
|
||||
? "text-[var(--viewer-text)] hover:bg-[var(--viewer-control-bg)]"
|
||||
: "text-foreground hover:bg-gray-100 hover:dark:bg-muted",
|
||||
"px-3 py-1.5 leading-6",
|
||||
active && "bg-gray-100 font-semibold dark:bg-muted",
|
||||
active &&
|
||||
(prefersLightText
|
||||
? "bg-[var(--viewer-panel-active)] font-semibold"
|
||||
: "bg-gray-100 font-semibold dark:bg-muted"),
|
||||
disable && "pointer-events-none cursor-auto opacity-50",
|
||||
className,
|
||||
)}
|
||||
@@ -169,17 +195,24 @@ Folder.displayName = "Folder";
|
||||
|
||||
const File = memo<FileProps>(({ label, name, active, onToggle }) => {
|
||||
const indent = useIndent();
|
||||
const prefersLightText = usePrefersLightText();
|
||||
const toggle = useCallback(() => {
|
||||
onToggle?.(!active);
|
||||
}, [onToggle]);
|
||||
}, [active, onToggle]);
|
||||
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
"flex list-none",
|
||||
"rounded-md text-foreground duration-100 hover:bg-gray-100 hover:dark:bg-muted",
|
||||
"rounded-md duration-100",
|
||||
prefersLightText
|
||||
? "text-[var(--viewer-muted-text)] hover:bg-[var(--viewer-control-bg)]"
|
||||
: "text-foreground hover:bg-gray-100 hover:dark:bg-muted",
|
||||
"px-3 py-1.5 leading-6",
|
||||
active && "bg-gray-100 font-semibold dark:bg-muted",
|
||||
active &&
|
||||
(prefersLightText
|
||||
? "bg-[var(--viewer-panel-active)] text-[var(--viewer-text)] font-semibold"
|
||||
: "bg-gray-100 font-semibold dark:bg-muted"),
|
||||
)}
|
||||
>
|
||||
<span
|
||||
|
||||
@@ -100,9 +100,10 @@ export default function UploadZone({
|
||||
const teamInfo = useTeam();
|
||||
const { data: session } = useSession();
|
||||
const { limits, canAddDocuments, isPaused } = useLimits();
|
||||
const remainingDocuments = limits?.documents
|
||||
? limits?.documents - limits?.usage?.documents
|
||||
: 0;
|
||||
const hasDocumentLimit = limits?.documents != null && limits.documents > 0;
|
||||
const remainingDocuments = hasDocumentLimit
|
||||
? limits.documents - (limits?.usage?.documents ?? 0)
|
||||
: Infinity;
|
||||
|
||||
// Fetch team settings with proper revalidation - ensures settings stay fresh across tabs
|
||||
const { settings: teamSettings } = useTeamSettings(teamInfo?.currentTeam?.id);
|
||||
@@ -113,6 +114,7 @@ export default function UploadZone({
|
||||
// Using promise-lock pattern to prevent race conditions during concurrent folder creation
|
||||
const dataroomFolderPathRef = useRef<string | null>(null);
|
||||
const dataroomFolderCreationPromiseRef = useRef<Promise<string> | null>(null);
|
||||
const fileLimitTruncatedRef = useRef(false);
|
||||
|
||||
// Reset the cached dataroom folder path when the replication setting changes
|
||||
// This ensures we don't use stale cached paths if the setting is toggled
|
||||
@@ -222,6 +224,25 @@ export default function UploadZone({
|
||||
|
||||
const onDropRejected = useCallback(
|
||||
(rejectedFiles: FileRejection[]) => {
|
||||
const hasTooManyFiles = rejectedFiles.some(({ errors }) =>
|
||||
errors.some(({ code }) => code === "too-many-files"),
|
||||
);
|
||||
|
||||
if (hasTooManyFiles) {
|
||||
const maxFiles = fileSizeLimits.maxFiles ?? 150;
|
||||
toast.error(
|
||||
`You're trying to upload ${rejectedFiles.length} files, but you can only upload up to ${maxFiles} files at once. Please upload in smaller batches.`,
|
||||
{ duration: 8000 },
|
||||
);
|
||||
onUploadRejected([
|
||||
{
|
||||
fileName: `${rejectedFiles.length} files selected`,
|
||||
message: `Maximum ${maxFiles} files per upload`,
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const rejected = rejectedFiles.map(({ file, errors }) => {
|
||||
let message = "";
|
||||
if (errors.find(({ code }) => code === "file-too-large")) {
|
||||
@@ -256,13 +277,55 @@ export default function UploadZone({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canAddDocuments && acceptedFiles.length > remainingDocuments) {
|
||||
toast.error("You have reached the maximum number of documents.");
|
||||
if (hasDocumentLimit && remainingDocuments <= 0) {
|
||||
toast.error(
|
||||
`You've reached your plan's document limit (${limits?.usage?.documents}/${limits?.documents} documents). Upgrade your plan to upload more.`,
|
||||
{
|
||||
action: {
|
||||
label: "Upgrade",
|
||||
onClick: () => router.push("/settings/billing"),
|
||||
},
|
||||
duration: 8000,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let filesToUpload = acceptedFiles;
|
||||
|
||||
if (fileLimitTruncatedRef.current) {
|
||||
// Folder traversal was already capped at remainingDocuments –
|
||||
// no extra folders were created, just show the warning.
|
||||
fileLimitTruncatedRef.current = false;
|
||||
toast.warning(
|
||||
`Your upload was limited to ${acceptedFiles.length} file${acceptedFiles.length === 1 ? "" : "s"} because your plan only allows ${remainingDocuments} more document${remainingDocuments === 1 ? "" : "s"} (${limits?.usage?.documents}/${limits?.documents} used).`,
|
||||
{
|
||||
action: {
|
||||
label: "Upgrade",
|
||||
onClick: () => router.push("/settings/billing"),
|
||||
},
|
||||
duration: 10000,
|
||||
},
|
||||
);
|
||||
} else if (hasDocumentLimit && acceptedFiles.length > remainingDocuments) {
|
||||
// Safety net for the file-picker path (no folder traversal) or
|
||||
// race conditions where the cap was slightly exceeded.
|
||||
const skippedCount = acceptedFiles.length - remainingDocuments;
|
||||
toast.warning(
|
||||
`You're trying to upload ${acceptedFiles.length} files, but your plan only allows ${remainingDocuments} more document${remainingDocuments === 1 ? "" : "s"} (${limits?.usage?.documents}/${limits?.documents} used). ${skippedCount} file${skippedCount === 1 ? "" : "s"} will be skipped.`,
|
||||
{
|
||||
action: {
|
||||
label: "Upgrade",
|
||||
onClick: () => router.push("/settings/billing"),
|
||||
},
|
||||
duration: 10000,
|
||||
},
|
||||
);
|
||||
filesToUpload = acceptedFiles.slice(0, remainingDocuments);
|
||||
}
|
||||
|
||||
// Validate files and separate into valid and invalid
|
||||
const validatedFiles = acceptedFiles.reduce<{
|
||||
const validatedFiles = filesToUpload.reduce<{
|
||||
valid: FileWithPaths[];
|
||||
invalid: { fileName: string; message: string }[];
|
||||
}>(
|
||||
@@ -530,6 +593,8 @@ export default function UploadZone({
|
||||
isFree,
|
||||
isTrial,
|
||||
isPaused,
|
||||
hasDocumentLimit,
|
||||
remainingDocuments,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -540,6 +605,18 @@ export default function UploadZone({
|
||||
return [];
|
||||
}
|
||||
|
||||
fileLimitTruncatedRef.current = false;
|
||||
const fileLimit =
|
||||
hasDocumentLimit && isFinite(remainingDocuments)
|
||||
? Math.max(0, remainingDocuments)
|
||||
: Infinity;
|
||||
let collectedFileCount = 0;
|
||||
|
||||
// Early check: skip folder traversal (and folder creation) if document limit is already reached
|
||||
if (fileLimit <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let filesToBePassedToOnDrop: FileWithPaths[] = [];
|
||||
|
||||
/** *********** START OF `traverseFolder` *********** */
|
||||
@@ -561,6 +638,10 @@ export default function UploadZone({
|
||||
return files;
|
||||
}
|
||||
|
||||
if (collectedFileCount >= fileLimit) {
|
||||
return files;
|
||||
}
|
||||
|
||||
if (entry.isDirectory) {
|
||||
/**
|
||||
* Let's create the folder.
|
||||
@@ -706,6 +787,10 @@ export default function UploadZone({
|
||||
return files;
|
||||
}
|
||||
|
||||
if (collectedFileCount >= fileLimit) {
|
||||
return files;
|
||||
}
|
||||
|
||||
let file = await new Promise<FileWithPaths>((resolve) =>
|
||||
(entry as FileSystemFileEntry).file(resolve),
|
||||
);
|
||||
@@ -760,6 +845,7 @@ export default function UploadZone({
|
||||
file.dataroomUploadPath = dataroomParentPath;
|
||||
|
||||
files.push(file);
|
||||
collectedFileCount++;
|
||||
}
|
||||
|
||||
return files;
|
||||
@@ -805,6 +891,10 @@ export default function UploadZone({
|
||||
}
|
||||
}
|
||||
|
||||
if (isFinite(fileLimit) && collectedFileCount >= fileLimit) {
|
||||
fileLimitTruncatedRef.current = true;
|
||||
}
|
||||
|
||||
return filesToBePassedToOnDrop;
|
||||
},
|
||||
[
|
||||
@@ -817,6 +907,8 @@ export default function UploadZone({
|
||||
setRejectedFiles,
|
||||
acceptableDropZoneFileTypes,
|
||||
getOrCreateDataroomFolder,
|
||||
hasDocumentLimit,
|
||||
remainingDocuments,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { createAdaptiveSurfacePalette } from "@/lib/utils/create-adaptive-surface-palette";
|
||||
|
||||
export type AccessFormTheme = ReturnType<typeof createAdaptiveSurfacePalette>;
|
||||
|
||||
const defaultTheme = createAdaptiveSurfacePalette(undefined);
|
||||
|
||||
const AccessFormThemeContext = createContext<AccessFormTheme>(defaultTheme);
|
||||
|
||||
export function createAccessFormTheme(accentColor: string | null | undefined) {
|
||||
return createAdaptiveSurfacePalette(accentColor || "#000000");
|
||||
}
|
||||
|
||||
export function AccessFormThemeProvider({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: AccessFormTheme;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<AccessFormThemeContext.Provider value={value}>
|
||||
{children}
|
||||
</AccessFormThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAccessFormTheme() {
|
||||
return useContext(AccessFormThemeContext);
|
||||
}
|
||||
@@ -2,11 +2,10 @@ import { Dispatch, SetStateAction } from "react";
|
||||
|
||||
import { Brand, DataroomBrand } from "@prisma/client";
|
||||
|
||||
import { determineTextColor } from "@/lib/utils/determine-text-color";
|
||||
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
|
||||
import { DEFAULT_ACCESS_FORM_TYPE } from ".";
|
||||
import { useAccessFormTheme } from "./access-form-theme";
|
||||
|
||||
export default function AgreementSection({
|
||||
data,
|
||||
@@ -25,9 +24,15 @@ export default function AgreementSection({
|
||||
brand?: Partial<Brand> | Partial<DataroomBrand> | null;
|
||||
useCustomAccessForm?: boolean;
|
||||
}) {
|
||||
const theme = useAccessFormTheme();
|
||||
const isChecked = !!data.hasConfirmedAgreement;
|
||||
|
||||
const handleCheckChange = (checked: boolean) => {
|
||||
setData((prevData) => ({ ...prevData, hasConfirmedAgreement: checked }));
|
||||
};
|
||||
const toggleAgreement = () => {
|
||||
handleCheckChange(!isChecked);
|
||||
};
|
||||
|
||||
const isTextContent = agreementContentType === "TEXT";
|
||||
|
||||
@@ -35,39 +40,47 @@ export default function AgreementSection({
|
||||
<div className="relative flex items-start space-x-2 pt-5">
|
||||
<Checkbox
|
||||
id="agreement"
|
||||
checked={isChecked}
|
||||
onCheckedChange={handleCheckChange}
|
||||
className="border border-gray-400 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-gray-300 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-[var(--dynamic-accent-color)] data-[state=checked]:bg-[var(--dynamic-accent-color)] data-[state=checked]:text-[var(--dynamic-accent-color)]"
|
||||
className="border border-gray-400 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-gray-300 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-[var(--agreement-checked-bg)] data-[state=checked]:bg-[var(--agreement-checked-bg)] data-[state=checked]:text-[var(--agreement-check-color)]"
|
||||
style={
|
||||
{
|
||||
borderColor: determineTextColor(brand?.accentColor),
|
||||
color: brand?.accentColor || undefined,
|
||||
"--dynamic-accent-color": determineTextColor(brand?.accentColor),
|
||||
borderColor: theme.controlBorderStrongColor,
|
||||
color: theme.backgroundColor || undefined,
|
||||
"--agreement-checked-bg": theme.textColor,
|
||||
"--agreement-check-color": theme.inverseTextColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
<label
|
||||
className="text-sm font-normal leading-5 text-white peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
style={{
|
||||
color: determineTextColor(brand?.accentColor),
|
||||
}}
|
||||
style={{ color: theme.textColor }}
|
||||
>
|
||||
{isTextContent ? (
|
||||
<span className="whitespace-pre-line">{agreementContent}</span>
|
||||
<span
|
||||
className="cursor-pointer whitespace-pre-line"
|
||||
onClick={toggleAgreement}
|
||||
>
|
||||
{agreementContent}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
I have reviewed and agree to the terms of this{" "}
|
||||
<span className="cursor-pointer" onClick={toggleAgreement}>
|
||||
I have reviewed and agree to the terms of this{" "}
|
||||
</span>
|
||||
<a
|
||||
href={`${agreementContent}`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="underline hover:text-gray-200"
|
||||
style={{
|
||||
color: determineTextColor(brand?.accentColor),
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ color: theme.textColor }}
|
||||
>
|
||||
{agreementName}
|
||||
</a>
|
||||
.
|
||||
<span className="cursor-pointer" onClick={toggleAgreement}>
|
||||
.
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
|
||||
@@ -2,12 +2,12 @@ import { Brand, CustomField, DataroomBrand } from "@prisma/client";
|
||||
import { E164Number } from "libphonenumber-js";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { determineTextColor } from "@/lib/utils/determine-text-color";
|
||||
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { PhoneInput } from "@/components/ui/phone-input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useAccessFormTheme } from "./access-form-theme";
|
||||
|
||||
export default function CustomFieldsSection({
|
||||
fields,
|
||||
@@ -20,6 +20,8 @@ export default function CustomFieldsSection({
|
||||
setData: (data: { [key: string]: string }) => void;
|
||||
brand?: Partial<Brand> | Partial<DataroomBrand> | null;
|
||||
}) {
|
||||
const theme = useAccessFormTheme();
|
||||
|
||||
const handleInputChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
identifier: string,
|
||||
@@ -53,9 +55,7 @@ export default function CustomFieldsSection({
|
||||
<label
|
||||
htmlFor={field.identifier}
|
||||
className="block text-sm font-medium leading-6 text-white"
|
||||
style={{
|
||||
color: determineTextColor(brand?.accentColor),
|
||||
}}
|
||||
style={{ color: theme.textColor }}
|
||||
>
|
||||
{field.label}
|
||||
</label>
|
||||
@@ -73,17 +73,13 @@ export default function CustomFieldsSection({
|
||||
"border-gray-600 data-[state=checked]:bg-gray-300 data-[state=checked]:text-black",
|
||||
)}
|
||||
style={{
|
||||
borderColor: brand?.accentColor
|
||||
? determineTextColor(brand.accentColor)
|
||||
: undefined,
|
||||
borderColor: theme.controlBorderColor,
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor={field.identifier}
|
||||
className="cursor-pointer text-sm font-medium leading-none text-white peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
style={{
|
||||
color: determineTextColor(brand?.accentColor),
|
||||
}}
|
||||
style={{ color: theme.textColor }}
|
||||
>
|
||||
{field.label}
|
||||
{field.required && (
|
||||
@@ -103,15 +99,13 @@ export default function CustomFieldsSection({
|
||||
disabled={field.disabled}
|
||||
translate="no"
|
||||
className={cn(
|
||||
"notranslate flex w-full rounded-md border-0 bg-black text-gray-500 placeholder:text-gray-400 sm:text-sm sm:leading-6",
|
||||
"notranslate flex w-full cursor-text rounded-md border-0 bg-black text-gray-500 placeholder:text-[var(--access-placeholder)] sm:text-sm sm:leading-6",
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--phone-input-bg":
|
||||
brand && brand.accentColor ? brand.accentColor : "black",
|
||||
"--phone-input-color": determineTextColor(
|
||||
brand?.accentColor,
|
||||
),
|
||||
"--phone-input-bg": theme.controlBgColor,
|
||||
"--phone-input-color": theme.textColor,
|
||||
"--access-placeholder": theme.controlPlaceholderColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
@@ -144,16 +138,19 @@ export default function CustomFieldsSection({
|
||||
disabled={field.disabled}
|
||||
translate="no"
|
||||
className={cn(
|
||||
"notranslate flex w-full rounded-md border-0 bg-black py-1.5 text-gray-500 shadow-sm ring-1 ring-inset ring-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-gray-300 sm:text-sm sm:leading-6",
|
||||
"notranslate flex w-full cursor-text rounded-md border-0 bg-black py-1.5 text-gray-500 shadow-sm ring-1 ring-inset ring-gray-600 placeholder:text-[var(--access-placeholder)] focus:ring-2 focus:ring-inset focus:ring-[var(--access-input-focus)] sm:text-sm sm:leading-6",
|
||||
isLongText && "min-h-[100px] resize-none",
|
||||
)}
|
||||
style={{
|
||||
backgroundColor:
|
||||
brand && brand.accentColor
|
||||
? brand.accentColor
|
||||
: "black",
|
||||
color: determineTextColor(brand?.accentColor),
|
||||
}}
|
||||
style={
|
||||
{
|
||||
backgroundColor: theme.controlBgColor,
|
||||
borderColor: theme.controlBorderColor,
|
||||
"--access-placeholder":
|
||||
theme.controlPlaceholderColor,
|
||||
"--access-input-focus": theme.controlBorderStrongColor,
|
||||
color: theme.textColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
value={value}
|
||||
placeholder={field.placeholder || ""}
|
||||
onChange={(e) => handleInputChange(e, field.identifier!)}
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { Dispatch, SetStateAction, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
} from "react";
|
||||
|
||||
import { Brand, DataroomBrand } from "@prisma/client";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { determineTextColor } from "@/lib/utils/determine-text-color";
|
||||
import { validateEmail } from "@/lib/utils/validate-email";
|
||||
|
||||
import { DEFAULT_ACCESS_FORM_TYPE } from ".";
|
||||
import { useAccessFormTheme } from "./access-form-theme";
|
||||
|
||||
export default function EmailSection({
|
||||
data,
|
||||
@@ -25,6 +32,7 @@ export default function EmailSection({
|
||||
onValidationChange: (isValid: boolean) => void;
|
||||
}) {
|
||||
const { email } = data;
|
||||
const theme = useAccessFormTheme();
|
||||
const [emailError, setEmailError] = useState<string | null>(null);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
|
||||
@@ -95,9 +103,7 @@ export default function EmailSection({
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium leading-6 text-white"
|
||||
style={{
|
||||
color: determineTextColor(brand?.accentColor),
|
||||
}}
|
||||
style={{ color: theme.textColor }}
|
||||
>
|
||||
Email address
|
||||
</label>
|
||||
@@ -111,16 +117,18 @@ export default function EmailSection({
|
||||
required
|
||||
translate="no"
|
||||
className={cn(
|
||||
"notranslate flex w-full rounded-md border-0 bg-black py-1.5 text-gray-500 shadow-sm ring-1 ring-inset ring-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-gray-300 sm:text-sm sm:leading-6",
|
||||
"notranslate flex w-full cursor-text rounded-md border-0 bg-black py-1.5 text-gray-500 shadow-sm ring-1 ring-inset ring-gray-600 placeholder:text-[var(--access-placeholder)] focus:ring-2 focus:ring-inset focus:ring-[var(--access-input-focus)] sm:text-sm sm:leading-6",
|
||||
emailError && isDirty && "ring-red-500",
|
||||
)}
|
||||
style={{
|
||||
backgroundColor:
|
||||
brand && brand.accentColor ? brand.accentColor : "black",
|
||||
backgroundColor: theme.controlBgColor,
|
||||
borderColor: theme.controlBorderColor,
|
||||
"--access-placeholder": theme.controlPlaceholderColor,
|
||||
"--access-input-focus": theme.controlBorderStrongColor,
|
||||
color: disableEditEmail
|
||||
? "hsl(var(--muted-foreground))"
|
||||
: determineTextColor(brand?.accentColor),
|
||||
}}
|
||||
? theme.subtleTextColor
|
||||
: theme.textColor,
|
||||
} as CSSProperties}
|
||||
value={email || ""}
|
||||
placeholder="Enter email"
|
||||
onChange={handleEmailChange}
|
||||
@@ -136,14 +144,12 @@ export default function EmailSection({
|
||||
<p
|
||||
id="email-error"
|
||||
className="mt-1 text-sm text-red-500"
|
||||
style={{
|
||||
color: determineTextColor(brand?.accentColor),
|
||||
}}
|
||||
style={{ color: theme.textColor }}
|
||||
>
|
||||
{emailError}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-gray-500">
|
||||
<p className="text-sm" style={{ color: theme.subtleTextColor }}>
|
||||
{useCustomAccessForm
|
||||
? "This data will be shared with the content provider."
|
||||
: "This data will be shared with the sender."}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useEffect, useState } from "react";
|
||||
|
||||
import { Brand, DataroomBrand } from "@prisma/client";
|
||||
|
||||
import { determineTextColor } from "@/lib/utils/determine-text-color";
|
||||
import { useMediaQuery } from "@/lib/utils/use-media-query";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
InputOTPSlot,
|
||||
} from "@/components/ui/input-otp";
|
||||
import { DEFAULT_ACCESS_FORM_TYPE } from "@/components/view/access-form";
|
||||
import { createAccessFormTheme } from "@/components/view/access-form/access-form-theme";
|
||||
|
||||
const REGEXP_ONLY_DIGITS = "^\\d+$";
|
||||
|
||||
@@ -35,6 +35,7 @@ export default function EmailVerificationMessage({
|
||||
brand?: Partial<Brand> | Partial<DataroomBrand> | null;
|
||||
}) {
|
||||
const { isMobile } = useMediaQuery();
|
||||
const theme = createAccessFormTheme(brand?.accentColor);
|
||||
const [isResendLoading, setIsResendLoading] = useState(false);
|
||||
const [delaySeconds, setDelaySeconds] = useState(60);
|
||||
|
||||
@@ -54,24 +55,19 @@ export default function EmailVerificationMessage({
|
||||
<div
|
||||
className="flex h-screen flex-1 flex-col px-6 py-12 lg:px-8"
|
||||
style={{
|
||||
backgroundColor:
|
||||
brand && brand.accentColor ? brand.accentColor : "black",
|
||||
backgroundColor: theme.backgroundColor,
|
||||
}}
|
||||
>
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<h2
|
||||
className="mt-10 text-2xl font-bold leading-9 tracking-tight"
|
||||
style={{
|
||||
color: determineTextColor(brand?.accentColor),
|
||||
}}
|
||||
style={{ color: theme.textColor }}
|
||||
>
|
||||
Verify your email address
|
||||
</h2>
|
||||
<p
|
||||
className="text-pretty text-sm leading-6"
|
||||
style={{
|
||||
color: determineTextColor(brand?.accentColor),
|
||||
}}
|
||||
style={{ color: theme.textColor }}
|
||||
>
|
||||
Enter the six digit verification code sent to{" "}
|
||||
<strong className="font-medium" title={data.email ?? ""}>
|
||||
@@ -110,8 +106,8 @@ export default function EmailVerificationMessage({
|
||||
loading={isLoading && !isResendLoading}
|
||||
className="hover:opacity-90"
|
||||
style={{
|
||||
backgroundColor: determineTextColor(brand?.accentColor),
|
||||
color: brand && brand.accentColor ? brand.accentColor : "black",
|
||||
backgroundColor: theme.ctaBgColor,
|
||||
color: theme.ctaTextColor,
|
||||
}}
|
||||
>
|
||||
{isLoading && !isResendLoading ? "Verifying..." : "Continue"}
|
||||
@@ -122,12 +118,7 @@ export default function EmailVerificationMessage({
|
||||
<div className="flex items-center">
|
||||
<p
|
||||
className="text-xs"
|
||||
style={{
|
||||
color:
|
||||
determineTextColor(brand?.accentColor) === "white"
|
||||
? "rgb(75, 85, 99)"
|
||||
: "rgb(107, 114, 128)",
|
||||
}}
|
||||
style={{ color: theme.subtleTextColor }}
|
||||
>
|
||||
Didn't receive the email?
|
||||
</p>{" "}
|
||||
@@ -135,12 +126,7 @@ export default function EmailVerificationMessage({
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="text-xs font-normal"
|
||||
style={{
|
||||
color:
|
||||
determineTextColor(brand?.accentColor) === "white"
|
||||
? "rgb(107, 114, 128)"
|
||||
: "rgb(156, 163, 175)",
|
||||
}}
|
||||
style={{ color: theme.mutedTextColor }}
|
||||
disabled={isLoading || delaySeconds > 0}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -3,11 +3,13 @@ import { useEffect, useState } from "react";
|
||||
import { Brand, CustomField, DataroomBrand } from "@prisma/client";
|
||||
import { ArrowUpRightIcon } from "lucide-react";
|
||||
|
||||
import { determineTextColor } from "@/lib/utils/determine-text-color";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import AgreementSection from "./agreement-section";
|
||||
import {
|
||||
AccessFormThemeProvider,
|
||||
createAccessFormTheme,
|
||||
} from "./access-form-theme";
|
||||
import CustomFieldsSection from "./custom-fields-section";
|
||||
import EmailSection from "./email-section";
|
||||
import NameSection from "./name-section";
|
||||
@@ -68,6 +70,7 @@ export default function AccessForm({
|
||||
linkWelcomeMessage?: string | null;
|
||||
}) {
|
||||
const [isEmailValid, setIsEmailValid] = useState(true);
|
||||
const accessFormTheme = createAccessFormTheme(brand?.accentColor);
|
||||
|
||||
useEffect(() => {
|
||||
const userEmail = email;
|
||||
@@ -77,7 +80,7 @@ export default function AccessForm({
|
||||
email: userEmail || prevData.email,
|
||||
}));
|
||||
}
|
||||
}, [email]);
|
||||
}, [email, setData]);
|
||||
|
||||
const isFormValid = () => {
|
||||
if (requireEmail) {
|
||||
@@ -115,13 +118,14 @@ export default function AccessForm({
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full min-h-dvh flex-col justify-between pb-4"
|
||||
style={{
|
||||
backgroundColor:
|
||||
brand && brand.accentColor ? brand.accentColor : "black",
|
||||
}}
|
||||
>
|
||||
<AccessFormThemeProvider value={accessFormTheme}>
|
||||
<div
|
||||
className="flex h-full min-h-dvh flex-col justify-between pb-4"
|
||||
style={{
|
||||
backgroundColor: accessFormTheme.backgroundColor,
|
||||
color: accessFormTheme.textColor,
|
||||
}}
|
||||
>
|
||||
{/* Light Navbar */}
|
||||
{logoOnAccessForm && brand && brand.logo && (
|
||||
<nav
|
||||
@@ -144,9 +148,7 @@ export default function AccessForm({
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<h1
|
||||
className="mt-10 text-2xl font-bold leading-9 tracking-tight text-white"
|
||||
style={{
|
||||
color: determineTextColor(brand?.accentColor),
|
||||
}}
|
||||
style={{ color: accessFormTheme.textColor }}
|
||||
>
|
||||
{linkWelcomeMessage ||
|
||||
(brand && "welcomeMessage" in brand && brand.welcomeMessage) ||
|
||||
@@ -195,9 +197,8 @@ export default function AccessForm({
|
||||
className="w-1/3 min-w-fit bg-white text-gray-950 hover:bg-white/90"
|
||||
loading={isLoading}
|
||||
style={{
|
||||
backgroundColor: determineTextColor(brand?.accentColor),
|
||||
color:
|
||||
brand && brand.accentColor ? brand.accentColor : "black",
|
||||
backgroundColor: accessFormTheme.ctaBgColor,
|
||||
color: accessFormTheme.ctaTextColor,
|
||||
}}
|
||||
>
|
||||
Continue
|
||||
@@ -208,25 +209,33 @@ export default function AccessForm({
|
||||
</div>
|
||||
{!useCustomAccessForm ? (
|
||||
<div className="flex flex-col items-center gap-0.5">
|
||||
<p className="text-center text-sm tracking-tight text-gray-500">
|
||||
<p
|
||||
className="text-center text-sm tracking-tight"
|
||||
style={{ color: accessFormTheme.subtleTextColor }}
|
||||
>
|
||||
This document is securely shared with you using{" "}
|
||||
<a
|
||||
href="https://www.papermark.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium hover:text-gray-600"
|
||||
className="font-medium"
|
||||
style={{ color: accessFormTheme.mutedTextColor }}
|
||||
>
|
||||
Papermark
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<p className="text-center text-sm tracking-tight text-gray-500">
|
||||
<p
|
||||
className="text-center text-sm tracking-tight"
|
||||
style={{ color: accessFormTheme.subtleTextColor }}
|
||||
>
|
||||
See how we protect your data in our{" "}
|
||||
<a
|
||||
href={`${process.env.NEXT_PUBLIC_MARKETING_URL}/privacy`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-0.5 hover:text-gray-600"
|
||||
className="inline-flex items-center gap-0.5"
|
||||
style={{ color: accessFormTheme.mutedTextColor }}
|
||||
>
|
||||
<span>Privacy Policy</span>
|
||||
<ArrowUpRightIcon className="h-3 w-3" />
|
||||
@@ -234,6 +243,7 @@ export default function AccessForm({
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</AccessFormThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Dispatch, SetStateAction, useEffect } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import { Brand, DataroomBrand } from "@prisma/client";
|
||||
|
||||
import { determineTextColor } from "@/lib/utils/determine-text-color";
|
||||
|
||||
import { DEFAULT_ACCESS_FORM_TYPE } from ".";
|
||||
import { useAccessFormTheme } from "./access-form-theme";
|
||||
|
||||
export default function NameSection({
|
||||
data,
|
||||
@@ -16,6 +16,7 @@ export default function NameSection({
|
||||
brand?: Partial<Brand> | Partial<DataroomBrand> | null;
|
||||
}) {
|
||||
const { name } = data;
|
||||
const theme = useAccessFormTheme();
|
||||
|
||||
useEffect(() => {
|
||||
// Load name from localStorage when the component mounts
|
||||
@@ -41,9 +42,7 @@ export default function NameSection({
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-sm font-medium leading-6 text-white"
|
||||
style={{
|
||||
color: determineTextColor(brand?.accentColor),
|
||||
}}
|
||||
style={{ color: theme.textColor }}
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
@@ -55,12 +54,14 @@ export default function NameSection({
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
translate="no"
|
||||
className="notranslate flex w-full rounded-md border-0 bg-black py-1.5 text-white shadow-sm ring-1 ring-inset ring-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-gray-300 sm:text-sm sm:leading-6"
|
||||
className="notranslate flex w-full cursor-text rounded-md border-0 bg-black py-1.5 text-white shadow-sm ring-1 ring-inset ring-gray-600 placeholder:text-[var(--access-placeholder)] focus:ring-2 focus:ring-inset focus:ring-[var(--access-input-focus)] sm:text-sm sm:leading-6"
|
||||
style={{
|
||||
backgroundColor:
|
||||
brand && brand.accentColor ? brand.accentColor : "black",
|
||||
color: determineTextColor(brand?.accentColor),
|
||||
}}
|
||||
backgroundColor: theme.controlBgColor,
|
||||
borderColor: theme.controlBorderColor,
|
||||
"--access-placeholder": theme.controlPlaceholderColor,
|
||||
"--access-input-focus": theme.controlBorderStrongColor,
|
||||
color: theme.textColor,
|
||||
} as CSSProperties}
|
||||
value={name || ""}
|
||||
placeholder="Enter your full name"
|
||||
onChange={handleNameChange}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Dispatch, SetStateAction, useState } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import { Brand, DataroomBrand } from "@prisma/client";
|
||||
|
||||
import { determineTextColor } from "@/lib/utils/determine-text-color";
|
||||
|
||||
import Eye from "@/components/shared/icons/eye";
|
||||
import EyeOff from "@/components/shared/icons/eye-off";
|
||||
|
||||
import { DEFAULT_ACCESS_FORM_TYPE } from ".";
|
||||
import { useAccessFormTheme } from "./access-form-theme";
|
||||
|
||||
export default function PasswordSection({
|
||||
data,
|
||||
@@ -19,6 +19,7 @@ export default function PasswordSection({
|
||||
brand?: Partial<Brand> | Partial<DataroomBrand> | null;
|
||||
}) {
|
||||
const { password } = data;
|
||||
const theme = useAccessFormTheme();
|
||||
const [showPassword, setShowPassword] = useState<boolean>(false);
|
||||
|
||||
return (
|
||||
@@ -26,9 +27,7 @@ export default function PasswordSection({
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium leading-6 text-white"
|
||||
style={{
|
||||
color: determineTextColor(brand?.accentColor),
|
||||
}}
|
||||
style={{ color: theme.textColor }}
|
||||
>
|
||||
Passcode
|
||||
</label>
|
||||
@@ -40,12 +39,14 @@ export default function PasswordSection({
|
||||
autoCorrect="off"
|
||||
autoComplete="off"
|
||||
translate="no"
|
||||
className="notranslate flex w-full rounded-md border-0 bg-black py-1.5 text-white shadow-sm ring-1 ring-inset ring-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-gray-300 sm:text-sm sm:leading-6"
|
||||
className="notranslate flex w-full cursor-text rounded-md border-0 bg-black py-1.5 text-white shadow-sm ring-1 ring-inset ring-gray-600 placeholder:text-[var(--access-placeholder)] focus:ring-2 focus:ring-inset focus:ring-[var(--access-input-focus)] sm:text-sm sm:leading-6"
|
||||
style={{
|
||||
backgroundColor:
|
||||
brand && brand.accentColor ? brand.accentColor : "black",
|
||||
color: determineTextColor(brand?.accentColor),
|
||||
}}
|
||||
backgroundColor: theme.controlBgColor,
|
||||
borderColor: theme.controlBorderColor,
|
||||
"--access-placeholder": theme.controlPlaceholderColor,
|
||||
"--access-input-focus": theme.controlBorderStrongColor,
|
||||
color: theme.textColor,
|
||||
} as CSSProperties}
|
||||
value={password || ""}
|
||||
placeholder="Enter passcode"
|
||||
onChange={(e) => {
|
||||
@@ -60,9 +61,17 @@ export default function PasswordSection({
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3"
|
||||
>
|
||||
{showPassword ? (
|
||||
<Eye className="h-4 w-4 text-gray-400" aria-hidden="true" />
|
||||
<Eye
|
||||
className="h-4 w-4"
|
||||
style={{ color: theme.controlIconColor }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<EyeOff className="h-4 w-4 text-gray-400" aria-hidden="true" />
|
||||
<EyeOff
|
||||
className="h-4 w-4"
|
||||
style={{ color: theme.controlIconColor }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Brand, DataroomBrand } from "@prisma/client";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
|
||||
import { useViewerAnnotations } from "@/lib/swr/use-annotations";
|
||||
import { determineTextColor } from "@/lib/utils/determine-text-color";
|
||||
import { createAdaptiveSurfacePalette } from "@/lib/utils/create-adaptive-surface-palette";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -41,6 +41,7 @@ export function AnnotationPanel({
|
||||
isVisible,
|
||||
onResize,
|
||||
}: AnnotationPanelProps) {
|
||||
const palette = createAdaptiveSurfacePalette(brand?.accentColor);
|
||||
const { annotations, loading } = useViewerAnnotations(
|
||||
linkId,
|
||||
documentId,
|
||||
@@ -182,7 +183,7 @@ export function AnnotationPanel({
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<div
|
||||
className="text-xs opacity-70"
|
||||
style={{ color: determineTextColor(brand?.accentColor) }}
|
||||
style={{ color: palette.textColor }}
|
||||
>
|
||||
Loading annotations...
|
||||
</div>
|
||||
@@ -191,7 +192,7 @@ export function AnnotationPanel({
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||
<p
|
||||
className="mb-1 text-xs opacity-70"
|
||||
style={{ color: determineTextColor(brand?.accentColor) }}
|
||||
style={{ color: palette.textColor }}
|
||||
>
|
||||
No annotations on this page
|
||||
</p>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { DataroomBrand } from "@prisma/client";
|
||||
import Cookies from "js-cookie";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { PendingUploadsProvider } from "@/context/pending-uploads-context";
|
||||
import { useAnalytics } from "@/lib/analytics";
|
||||
import { SUPPORTED_DOCUMENT_SIMPLE_TYPES } from "@/lib/constants";
|
||||
import { useDisablePrint } from "@/lib/hooks/use-disable-print";
|
||||
@@ -111,6 +112,11 @@ export default function DataroomView({
|
||||
|
||||
const [code, setCode] = useState<string | null>(null);
|
||||
const [isInvalidCode, setIsInvalidCode] = useState<boolean>(false);
|
||||
const shouldApplyAccentToDataroomView = !!(brand as any)
|
||||
?.applyAccentColorToDataroomView;
|
||||
const dataroomViewBackgroundColor = shouldApplyAccentToDataroomView
|
||||
? brand?.accentColor
|
||||
: "#ffffff";
|
||||
|
||||
const handleSubmission = async (): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
@@ -280,36 +286,44 @@ export default function DataroomView({
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className="bg-gray-950">
|
||||
<DataroomViewer
|
||||
accessControls={link.accessControls || group?.accessControls || []}
|
||||
brand={brand!}
|
||||
viewId={viewData.viewId}
|
||||
isPreview={viewData.isPreview}
|
||||
linkId={link.id}
|
||||
dataroom={dataroom}
|
||||
allowDownload={link.allowDownload!}
|
||||
enableIndexFile={link.enableIndexFile}
|
||||
folderId={folderId}
|
||||
setFolderId={setFolderId}
|
||||
viewerId={viewData.viewerId}
|
||||
viewData={viewData}
|
||||
isEmbedded={isEmbedded}
|
||||
dataroomIndexEnabled={dataroomIndexEnabled}
|
||||
viewerEmail={
|
||||
viewData.viewerEmail ??
|
||||
data.email ??
|
||||
verifiedEmail ??
|
||||
userEmail ??
|
||||
undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<PendingUploadsProvider linkId={link.id} dataroomId={dataroom?.id}>
|
||||
<div
|
||||
className="min-h-screen bg-white"
|
||||
style={{ backgroundColor: dataroomViewBackgroundColor ?? undefined }}
|
||||
>
|
||||
<DataroomViewer
|
||||
accessControls={link.accessControls || group?.accessControls || []}
|
||||
brand={brand!}
|
||||
viewId={viewData.viewId}
|
||||
isPreview={viewData.isPreview}
|
||||
linkId={link.id}
|
||||
dataroom={dataroom}
|
||||
allowDownload={link.allowDownload!}
|
||||
enableIndexFile={link.enableIndexFile}
|
||||
folderId={folderId}
|
||||
setFolderId={setFolderId}
|
||||
viewerId={viewData.viewerId}
|
||||
viewData={viewData}
|
||||
isEmbedded={isEmbedded}
|
||||
dataroomIndexEnabled={dataroomIndexEnabled}
|
||||
viewerEmail={
|
||||
viewData.viewerEmail ??
|
||||
data.email ??
|
||||
verifiedEmail ??
|
||||
userEmail ??
|
||||
undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</PendingUploadsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gray-950">
|
||||
<div
|
||||
className="min-h-screen bg-white"
|
||||
style={{ backgroundColor: dataroomViewBackgroundColor ?? undefined }}
|
||||
>
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<LoadingSpinner className="h-20 w-20" />
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useViewerSurfaceTheme } from "@/components/view/viewer/viewer-surface-theme";
|
||||
|
||||
import { DocumentVersion } from "../viewer/dataroom-viewer";
|
||||
|
||||
@@ -57,6 +58,7 @@ export default function DocumentCard({
|
||||
showLastUpdated = true,
|
||||
}: DocumentsCardProps) {
|
||||
const { theme, systemTheme } = useTheme();
|
||||
const { palette } = useViewerSurfaceTheme();
|
||||
const canDownload = document.canDownload && allowDownload;
|
||||
|
||||
const isLight =
|
||||
@@ -182,9 +184,25 @@ export default function DocumentCard({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group/row relative flex items-center justify-between rounded-lg border-0 p-3 ring-1 ring-gray-200 transition-all hover:bg-secondary hover:ring-gray-300 dark:bg-secondary dark:ring-gray-700 hover:dark:ring-gray-500 sm:p-4",
|
||||
"group/row relative flex items-center justify-between rounded-lg border p-3 transition-all sm:p-4",
|
||||
"bg-[var(--viewer-panel-bg)] hover:bg-[var(--viewer-panel-bg-hover)]",
|
||||
"border-[var(--viewer-panel-border)] hover:border-[var(--viewer-panel-border-hover)]",
|
||||
isProcessing && "cursor-not-allowed opacity-60",
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--viewer-panel-bg": palette.panelBgColor,
|
||||
"--viewer-panel-bg-hover": palette.panelHoverBgColor,
|
||||
"--viewer-panel-border": palette.panelBorderColor,
|
||||
"--viewer-panel-border-hover": palette.panelBorderHoverColor,
|
||||
"--viewer-text": palette.textColor,
|
||||
"--viewer-muted-text": palette.mutedTextColor,
|
||||
"--viewer-control-bg": palette.controlBgColor,
|
||||
"--viewer-control-border": palette.controlBorderColor,
|
||||
"--viewer-control-border-strong": palette.controlBorderStrongColor,
|
||||
"--viewer-control-icon": palette.controlIconColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{/* Click target - outside of text hierarchy to fix Safari truncation issue */}
|
||||
<button
|
||||
@@ -205,19 +223,23 @@ export default function DocumentCard({
|
||||
<div className="min-w-0 flex-1 flex-col">
|
||||
<div className="flex items-center">
|
||||
<h2
|
||||
className="truncate text-sm font-semibold leading-6 text-foreground"
|
||||
className="truncate text-sm font-semibold leading-6 text-[var(--viewer-text)]"
|
||||
style={HIERARCHICAL_DISPLAY_STYLE}
|
||||
>
|
||||
{displayName}
|
||||
{isProcessing && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
<span
|
||||
className="ml-2 text-xs text-[var(--viewer-muted-text)]"
|
||||
>
|
||||
(Processing...)
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
{showLastUpdated && (
|
||||
<div className="mt-1 flex items-center space-x-1 text-xs leading-5 text-muted-foreground">
|
||||
<div
|
||||
className="mt-1 flex items-center space-x-1 text-xs leading-5 text-[var(--viewer-muted-text)]"
|
||||
>
|
||||
<p className="truncate">
|
||||
Updated {timeAgo(document.versions[0].updatedAt)}
|
||||
</p>
|
||||
@@ -232,7 +254,11 @@ export default function DocumentCard({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 p-0 text-gray-500 ring-1 ring-gray-100 hover:bg-gray-200 group-hover/row:text-foreground group-hover/row:ring-gray-300"
|
||||
className={cn(
|
||||
"h-8 w-8 border bg-transparent p-0",
|
||||
"text-[var(--viewer-control-icon)] border-[var(--viewer-control-border)] hover:bg-[var(--viewer-control-bg)]",
|
||||
"group-hover/row:text-[var(--viewer-text)] group-hover/row:border-[var(--viewer-control-border-strong)]",
|
||||
)}
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<MoreVerticalIcon className="h-4 w-4" />
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
// components/view/dataroom/document-upload-button.tsx
|
||||
import { useState } from "react";
|
||||
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import {
|
||||
CheckCircle2,
|
||||
FolderIcon,
|
||||
PlusIcon,
|
||||
UploadIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
@@ -18,13 +21,33 @@ export function DocumentUploadModal({
|
||||
dataroomId,
|
||||
viewerId,
|
||||
folderId,
|
||||
folderName,
|
||||
}: {
|
||||
linkId: string;
|
||||
dataroomId: string;
|
||||
viewerId: string;
|
||||
folderId?: string;
|
||||
/** Display name of the current folder (undefined = root) */
|
||||
folderName?: string;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [uploadSuccess, setUploadSuccess] = useState(false);
|
||||
|
||||
const handleUploadSuccess = () => {
|
||||
setUploadSuccess(true);
|
||||
// Auto-close the dialog after a short delay to show success message
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
setUploadSuccess(false);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setIsOpen(open);
|
||||
if (!open) {
|
||||
setUploadSuccess(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -39,25 +62,54 @@ export function DocumentUploadModal({
|
||||
<span>Add Document</span>
|
||||
</Button>
|
||||
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Upload Document to Dataroom</DialogTitle>
|
||||
<DialogDescription>
|
||||
The data room manager will receive a notification when the
|
||||
document is uploaded and approve the document. Only then, it will
|
||||
be visible to all visitors.
|
||||
</DialogDescription>
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-h-[85vh] max-w-2xl overflow-hidden border-0 p-0 shadow-2xl sm:max-w-xl sm:rounded-2xl">
|
||||
<DialogHeader className="border-b border-gray-100 bg-gray-50 px-6 py-5 dark:border-gray-800 dark:bg-gray-900">
|
||||
<DialogTitle className="flex items-center gap-2 text-lg font-semibold">
|
||||
<UploadIcon className="h-5 w-5 text-muted-foreground" />
|
||||
Upload Document to Dataroom
|
||||
</DialogTitle>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Your document will appear immediately in the dataroom and will be
|
||||
processed in the background.
|
||||
</p>
|
||||
{folderName && (
|
||||
<div className="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<FolderIcon className="h-3.5 w-3.5" />
|
||||
<span>
|
||||
Uploading to:{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{folderName}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</DialogHeader>
|
||||
<ViewerUploadComponent
|
||||
viewerData={{
|
||||
id: viewerId,
|
||||
linkId,
|
||||
dataroomId,
|
||||
}}
|
||||
teamId="visitor-upload"
|
||||
folderId={folderId}
|
||||
/>
|
||||
|
||||
<div className="px-6 py-5">
|
||||
{uploadSuccess ? (
|
||||
<div className="flex flex-col items-center justify-center py-8">
|
||||
<CheckCircle2 className="h-12 w-12 text-green-500" />
|
||||
<p className="mt-3 text-sm font-medium text-foreground">
|
||||
Document uploaded successfully!
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Your document is now visible in the dataroom.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ViewerUploadComponent
|
||||
viewerData={{
|
||||
id: viewerId,
|
||||
linkId,
|
||||
dataroomId,
|
||||
}}
|
||||
teamId="visitor-upload"
|
||||
folderId={folderId}
|
||||
onUploadSuccess={handleUploadSuccess}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import { DataroomFolder } from "@prisma/client";
|
||||
import { Download, MoreVerticalIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getFolderColorClasses, getFolderIcon } from "@/lib/constants/folder-constants";
|
||||
import { timeAgo } from "@/lib/utils";
|
||||
import { cn, timeAgo } from "@/lib/utils";
|
||||
import {
|
||||
HIERARCHICAL_DISPLAY_STYLE,
|
||||
getHierarchicalDisplayName,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useViewerSurfaceTheme } from "@/components/view/viewer/viewer-surface-theme";
|
||||
|
||||
type FolderCardProps = {
|
||||
folder: DataroomFolder;
|
||||
@@ -43,6 +45,7 @@ export default function FolderCard({
|
||||
showLastUpdated = true,
|
||||
}: FolderCardProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { palette } = useViewerSurfaceTheme();
|
||||
|
||||
// Get hierarchical display name
|
||||
const displayName = getHierarchicalDisplayName(
|
||||
@@ -68,7 +71,27 @@ export default function FolderCard({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group/row relative flex items-center justify-between rounded-lg border-0 p-3 ring-1 ring-gray-400 transition-all hover:bg-secondary hover:ring-gray-500 dark:bg-secondary dark:ring-gray-500 hover:dark:ring-gray-400 sm:p-4">
|
||||
<div
|
||||
className={cn(
|
||||
"group/row relative flex items-center justify-between rounded-lg border p-3 transition-all sm:p-4",
|
||||
"bg-[var(--viewer-panel-bg)] hover:bg-[var(--viewer-panel-bg-hover)]",
|
||||
"border-[var(--viewer-panel-border)] hover:border-[var(--viewer-panel-border-hover)]",
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--viewer-panel-bg": palette.panelBgColor,
|
||||
"--viewer-panel-bg-hover": palette.panelHoverBgColor,
|
||||
"--viewer-panel-border": palette.panelBorderColor,
|
||||
"--viewer-panel-border-hover": palette.panelBorderHoverColor,
|
||||
"--viewer-text": palette.textColor,
|
||||
"--viewer-muted-text": palette.mutedTextColor,
|
||||
"--viewer-control-bg": palette.controlBgColor,
|
||||
"--viewer-control-border": palette.controlBorderColor,
|
||||
"--viewer-control-border-strong": palette.controlBorderStrongColor,
|
||||
"--viewer-control-icon": palette.controlIconColor,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{/* Click target - outside of text hierarchy to fix Safari truncation issue */}
|
||||
<button
|
||||
onClick={() => setFolderId(folder.id)}
|
||||
@@ -92,14 +115,16 @@ export default function FolderCard({
|
||||
<div className="min-w-0 flex-1 flex-col">
|
||||
<div className="flex items-center">
|
||||
<h2
|
||||
className="truncate text-sm font-semibold leading-6 text-foreground"
|
||||
className="truncate text-sm font-semibold leading-6 text-[var(--viewer-text)]"
|
||||
style={HIERARCHICAL_DISPLAY_STYLE}
|
||||
>
|
||||
{displayName}
|
||||
</h2>
|
||||
</div>
|
||||
{showLastUpdated && (
|
||||
<div className="mt-1 flex items-center space-x-1 text-xs leading-5 text-muted-foreground">
|
||||
<div
|
||||
className="mt-1 flex items-center space-x-1 text-xs leading-5 text-[var(--viewer-muted-text)]"
|
||||
>
|
||||
<p className="truncate">Updated {timeAgo(folder.updatedAt)}</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -112,7 +137,11 @@ export default function FolderCard({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 p-0 text-gray-500 ring-1 ring-gray-100 hover:bg-gray-200 group-hover/row:text-foreground group-hover/row:ring-gray-300"
|
||||
className={cn(
|
||||
"h-8 w-8 border bg-transparent p-0",
|
||||
"text-[var(--viewer-control-icon)] border-[var(--viewer-control-border)] hover:bg-[var(--viewer-control-bg)]",
|
||||
"group-hover/row:text-[var(--viewer-text)] group-hover/row:border-[var(--viewer-control-border-strong)]",
|
||||
)}
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<MoreVerticalIcon className="h-4 w-4" />
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
FileIcon,
|
||||
FolderIcon,
|
||||
Loader2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { toast } from "sonner";
|
||||
import useSWRImmutable from "swr/immutable";
|
||||
|
||||
import {
|
||||
PendingUploadDocument,
|
||||
usePendingUploads,
|
||||
} from "@/context/pending-uploads-context";
|
||||
import { cn, fetcher } from "@/lib/utils";
|
||||
import { fileIcon } from "@/lib/utils/get-file-icon";
|
||||
import { useDocumentProgressStatus } from "@/lib/utils/use-progress-status";
|
||||
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { useViewerSurfaceTheme } from "@/components/view/viewer/viewer-surface-theme";
|
||||
|
||||
type FolderInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
parentId: string | null;
|
||||
};
|
||||
|
||||
type PendingDocumentCardProps = {
|
||||
pendingUpload: PendingUploadDocument;
|
||||
/** Flat list of all folders for resolving folder paths */
|
||||
folders?: FolderInfo[];
|
||||
/** Link ID for building document URLs */
|
||||
linkId?: string;
|
||||
/** Callback to navigate to a folder */
|
||||
onNavigateToFolder?: (folderId: string | null) => void;
|
||||
/** Whether to show the folder path (used in My Uploads tab) */
|
||||
showFolderPath?: boolean;
|
||||
};
|
||||
|
||||
/** Build a breadcrumb path like "Home > Company Info > Financials > Q4" */
|
||||
function getFolderPath(
|
||||
folderId: string | null,
|
||||
folders: FolderInfo[],
|
||||
): string {
|
||||
if (!folderId) return "Home";
|
||||
|
||||
const parts: string[] = ["Home"];
|
||||
const folderParts: string[] = [];
|
||||
let current = folders.find((f) => f.id === folderId);
|
||||
while (current) {
|
||||
folderParts.unshift(current.name);
|
||||
current = current.parentId
|
||||
? folders.find((f) => f.id === current!.parentId)
|
||||
: undefined;
|
||||
}
|
||||
return [...parts, ...folderParts].join(" > ");
|
||||
}
|
||||
|
||||
export default function PendingDocumentCard({
|
||||
pendingUpload,
|
||||
folders = [],
|
||||
linkId,
|
||||
onNavigateToFolder,
|
||||
showFolderPath = false,
|
||||
}: PendingDocumentCardProps) {
|
||||
const { theme, systemTheme } = useTheme();
|
||||
const router = useRouter();
|
||||
const { updatePendingUpload } = usePendingUploads();
|
||||
const { palette } = useViewerSurfaceTheme();
|
||||
const isLight =
|
||||
theme === "light" || (theme === "system" && systemTheme === "light");
|
||||
|
||||
const { previewToken, domain, slug } = router.query as {
|
||||
previewToken?: string;
|
||||
domain?: string;
|
||||
slug?: string;
|
||||
};
|
||||
|
||||
// Fetch trigger public access token for processing documents
|
||||
const needsTriggerTracking =
|
||||
pendingUpload.status === "processing" && pendingUpload.documentVersionId;
|
||||
|
||||
const { data: tokenData } = useSWRImmutable<{ publicAccessToken: string }>(
|
||||
needsTriggerTracking
|
||||
? `/api/progress-token?documentVersionId=${pendingUpload.documentVersionId}`
|
||||
: null,
|
||||
fetcher,
|
||||
);
|
||||
|
||||
// Subscribe to Trigger.dev realtime for processing status
|
||||
const { status: triggerStatus } = useDocumentProgressStatus(
|
||||
pendingUpload.documentVersionId ?? "",
|
||||
tokenData?.publicAccessToken,
|
||||
);
|
||||
|
||||
// When trigger reports COMPLETED, update the upload status
|
||||
useEffect(() => {
|
||||
if (
|
||||
needsTriggerTracking &&
|
||||
triggerStatus.state === "COMPLETED" &&
|
||||
pendingUpload.status === "processing"
|
||||
) {
|
||||
updatePendingUpload(pendingUpload.id, { status: "complete" });
|
||||
}
|
||||
}, [
|
||||
triggerStatus.state,
|
||||
needsTriggerTracking,
|
||||
pendingUpload.status,
|
||||
pendingUpload.id,
|
||||
updatePendingUpload,
|
||||
]);
|
||||
|
||||
const isError = pendingUpload.status === "error";
|
||||
const isUploading = pendingUpload.status === "uploading";
|
||||
const isProcessing = pendingUpload.status === "processing";
|
||||
const isComplete = pendingUpload.status === "complete";
|
||||
const isClickable = isComplete && pendingUpload.dataroomDocumentId && linkId;
|
||||
|
||||
const getStatusText = () => {
|
||||
switch (pendingUpload.status) {
|
||||
case "uploading":
|
||||
return `Uploading... ${pendingUpload.progress}%`;
|
||||
case "processing":
|
||||
// Use trigger realtime text if available
|
||||
if (triggerStatus.state === "EXECUTING" && triggerStatus.text) {
|
||||
return triggerStatus.text;
|
||||
}
|
||||
if (triggerStatus.state === "QUEUED") {
|
||||
return "Queued for processing...";
|
||||
}
|
||||
return "Processing document...";
|
||||
case "error":
|
||||
return pendingUpload.errorMessage || "Upload failed";
|
||||
case "complete":
|
||||
return "Ready";
|
||||
default:
|
||||
return "Pending";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (pendingUpload.status) {
|
||||
case "uploading":
|
||||
return <Upload className="h-4 w-4 animate-pulse text-blue-500" />;
|
||||
case "processing":
|
||||
return <Loader2 className="h-4 w-4 animate-spin text-orange-500" />;
|
||||
case "error":
|
||||
return <AlertCircle className="h-4 w-4 text-red-500" />;
|
||||
case "complete":
|
||||
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Real processing progress from trigger (0-100)
|
||||
const processingProgress =
|
||||
isProcessing && triggerStatus.state === "EXECUTING"
|
||||
? triggerStatus.progress
|
||||
: undefined;
|
||||
|
||||
const handleDocumentClick = (e: React.MouseEvent) => {
|
||||
if (!isClickable) {
|
||||
if (isProcessing) {
|
||||
e.preventDefault();
|
||||
toast.error(
|
||||
"Document is still processing. Please wait a moment and try again.",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
if (domain && slug) {
|
||||
window.open(
|
||||
`/${slug}/d/${pendingUpload.dataroomDocumentId}`,
|
||||
"_blank",
|
||||
);
|
||||
} else if (linkId) {
|
||||
window.open(
|
||||
`/view/${linkId}/d/${pendingUpload.dataroomDocumentId}${
|
||||
previewToken ? `?previewToken=${previewToken}&preview=1` : ""
|
||||
}`,
|
||||
"_blank",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFolderClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (onNavigateToFolder) {
|
||||
onNavigateToFolder(pendingUpload.folderId);
|
||||
}
|
||||
};
|
||||
|
||||
const folderPath =
|
||||
showFolderPath && folders.length > 0
|
||||
? getFolderPath(pendingUpload.folderId, folders)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group/row relative flex items-center justify-between rounded-lg border p-3 transition-all sm:p-4",
|
||||
"bg-[var(--viewer-panel-bg)] hover:bg-[var(--viewer-panel-bg-hover)]",
|
||||
"border-[var(--viewer-panel-border)] hover:border-[var(--viewer-panel-border-hover)]",
|
||||
isError && "border-red-400/40",
|
||||
(isUploading || isProcessing) && "opacity-80",
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--viewer-panel-bg": palette.panelBgColor,
|
||||
"--viewer-panel-bg-hover": palette.panelHoverBgColor,
|
||||
"--viewer-panel-border": palette.panelBorderColor,
|
||||
"--viewer-panel-border-hover": palette.panelBorderHoverColor,
|
||||
"--viewer-text": palette.textColor,
|
||||
"--viewer-muted-text": palette.mutedTextColor,
|
||||
"--viewer-subtle-text": palette.subtleTextColor,
|
||||
"--viewer-control-bg": palette.controlBgColor,
|
||||
"--viewer-control-border": palette.controlBorderColor,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{/* Clickable overlay for opening documents */}
|
||||
{isClickable && (
|
||||
<button
|
||||
onClick={handleDocumentClick}
|
||||
className="absolute inset-0 z-0 cursor-pointer"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* pointer-events-none so clicks fall through to the button overlay above */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 shrink items-center space-x-2 sm:space-x-4",
|
||||
isClickable && "pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<div className="mx-0.5 flex w-8 items-center justify-center text-center sm:mx-1">
|
||||
{pendingUpload.fileType ? (
|
||||
fileIcon({
|
||||
fileType: pendingUpload.fileType,
|
||||
className: "h-8 w-8 opacity-60",
|
||||
isLight,
|
||||
})
|
||||
) : (
|
||||
<FileIcon className="h-8 w-8 opacity-60" style={{ color: palette.mutedTextColor }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="min-w-0 max-w-[300px] truncate text-sm font-semibold leading-6 text-[var(--viewer-text)] sm:max-w-lg">
|
||||
{pendingUpload.name}
|
||||
</h2>
|
||||
{getStatusIcon()}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs leading-5",
|
||||
isError
|
||||
? "text-red-500"
|
||||
: isComplete
|
||||
? "text-green-500"
|
||||
: "text-[var(--viewer-muted-text)]",
|
||||
)}
|
||||
>
|
||||
{getStatusText()}
|
||||
</p>
|
||||
{folderPath && isComplete && (
|
||||
<>
|
||||
<span className="text-xs text-[var(--viewer-subtle-text)]">
|
||||
·
|
||||
</span>
|
||||
<button
|
||||
onClick={handleFolderClick}
|
||||
className="pointer-events-auto z-10 flex items-center gap-1 text-xs text-[var(--viewer-muted-text)] transition-colors hover:text-[var(--viewer-text)]"
|
||||
>
|
||||
<FolderIcon className="h-3 w-3" />
|
||||
<span className="max-w-[200px] truncate sm:max-w-[300px]">
|
||||
{folderPath}
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{folderPath && !isComplete && (
|
||||
<>
|
||||
<span className="text-xs text-[var(--viewer-subtle-text)]">
|
||||
·
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-xs text-[var(--viewer-muted-text)]">
|
||||
<FolderIcon className="h-3 w-3" />
|
||||
<span className="max-w-[200px] truncate sm:max-w-[300px]">
|
||||
{folderPath}
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{(isUploading || isProcessing) && (
|
||||
<Progress
|
||||
value={
|
||||
isProcessing
|
||||
? (processingProgress ?? 0)
|
||||
: pendingUpload.progress
|
||||
}
|
||||
className={cn(
|
||||
"mt-1.5 h-1 w-full max-w-[200px]",
|
||||
isProcessing && !processingProgress && "animate-pulse",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { determineTextColor } from "@/lib/utils/determine-text-color";
|
||||
import { createAdaptiveSurfacePalette } from "@/lib/utils/create-adaptive-surface-palette";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -117,6 +117,7 @@ export default function Nav({
|
||||
} = navData;
|
||||
|
||||
const [showConversations, setShowConversations] = useState(false);
|
||||
const navColorPalette = createAdaptiveSurfacePalette(brand?.brandColor);
|
||||
|
||||
// Extract the dataroom path from the URL
|
||||
// This regex captures everything before "/d/" in the path
|
||||
@@ -272,7 +273,7 @@ export default function Nav({
|
||||
className="cursor-pointer underline underline-offset-4 hover:font-medium"
|
||||
href={`${dataroomPath}${isPreview ? "?previewToken=" + previewToken + "&preview=" + preview : ""}`}
|
||||
style={{
|
||||
color: determineTextColor(brand?.brandColor),
|
||||
color: navColorPalette.textColor,
|
||||
}}
|
||||
>
|
||||
Home
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import { ThumbsDownIcon, ThumbsUpIcon } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
import { STAGGER_CHILD_VARIANTS } from "@/lib/constants";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { determineTextColor } from "@/lib/utils/determine-text-color";
|
||||
import { createAdaptiveSurfacePalette } from "@/lib/utils/create-adaptive-surface-palette";
|
||||
|
||||
export default function Question({
|
||||
feedback,
|
||||
@@ -23,6 +24,7 @@ export default function Question({
|
||||
accentColor?: string | null;
|
||||
}) {
|
||||
const [answer, setAnswer] = useState<"yes" | "no" | "">("");
|
||||
const palette = createAdaptiveSurfacePalette(accentColor);
|
||||
|
||||
const handleQuestionSubmit = async (answer: string) => {
|
||||
if (answer === "") return;
|
||||
@@ -75,9 +77,7 @@ export default function Question({
|
||||
>
|
||||
<h1
|
||||
className="font-display max-w-lg text-3xl font-semibold transition-colors sm:text-4xl"
|
||||
style={{
|
||||
color: accentColor ? determineTextColor(accentColor) : "white",
|
||||
}}
|
||||
style={{ color: palette.textColor }}
|
||||
>
|
||||
Thanks for your feedback!
|
||||
</h1>
|
||||
@@ -110,9 +110,7 @@ export default function Question({
|
||||
>
|
||||
<h1
|
||||
className="font-display max-w-xl text-3xl font-semibold transition-colors sm:text-4xl"
|
||||
style={{
|
||||
color: accentColor ? determineTextColor(accentColor) : "white",
|
||||
}}
|
||||
style={{ color: palette.textColor }}
|
||||
>
|
||||
{feedback.data.question}
|
||||
</h1>
|
||||
@@ -121,25 +119,22 @@ export default function Question({
|
||||
variants={STAGGER_CHILD_VARIANTS}
|
||||
className="grid w-full max-w-sm grid-cols-1 divide-y rounded-md border border-border md:grid-cols-2 md:divide-x md:divide-y-0"
|
||||
style={{
|
||||
color: accentColor ? determineTextColor(accentColor) : "white",
|
||||
borderColor: accentColor
|
||||
? determineTextColor(accentColor)
|
||||
: "hsl(var(--border))",
|
||||
color: palette.textColor,
|
||||
borderColor: palette.panelBorderColor,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => handleQuestionSubmit("yes")}
|
||||
className={cn(
|
||||
"flex min-h-[200px] flex-col items-center justify-center space-y-5 overflow-hidden p-5 transition-colors md:p-10",
|
||||
determineTextColor(accentColor) === "black"
|
||||
? "hover:bg-gray-800 hover:text-gray-200"
|
||||
: "hover:bg-gray-200 hover:text-gray-800",
|
||||
answer === "yes"
|
||||
? determineTextColor(accentColor) === "black"
|
||||
? "bg-gray-800 text-gray-200"
|
||||
: "bg-gray-200 text-gray-800"
|
||||
: "",
|
||||
"flex min-h-[200px] flex-col items-center justify-center space-y-5 overflow-hidden p-5 transition-colors hover:bg-[var(--feedback-hover-bg)] md:p-10",
|
||||
answer === "yes" ? "bg-[var(--feedback-active-bg)]" : "",
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--feedback-hover-bg": palette.panelHoverBgColor,
|
||||
"--feedback-active-bg": palette.panelActiveBgColor,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<ThumbsUpIcon
|
||||
className="pointer-events-none h-auto w-12 sm:w-12"
|
||||
@@ -150,16 +145,15 @@ export default function Question({
|
||||
<button
|
||||
onClick={() => handleQuestionSubmit("no")}
|
||||
className={cn(
|
||||
"flex min-h-[200px] flex-col items-center justify-center space-y-5 overflow-hidden p-5 transition-colors md:p-10",
|
||||
determineTextColor(accentColor) === "black"
|
||||
? "hover:bg-gray-800 hover:text-gray-200"
|
||||
: "hover:bg-gray-200 hover:text-gray-800",
|
||||
answer === "no"
|
||||
? determineTextColor(accentColor) === "black"
|
||||
? "bg-gray-800 text-gray-200"
|
||||
: "bg-gray-200 text-gray-800"
|
||||
: "",
|
||||
"flex min-h-[200px] flex-col items-center justify-center space-y-5 overflow-hidden p-5 transition-colors hover:bg-[var(--feedback-hover-bg)] md:p-10",
|
||||
answer === "no" ? "bg-[var(--feedback-active-bg)]" : "",
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--feedback-hover-bg": palette.panelHoverBgColor,
|
||||
"--feedback-active-bg": palette.panelActiveBgColor,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<ThumbsDownIcon
|
||||
className="pointer-events-none h-auto w-12 sm:w-12"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import React from "react";
|
||||
|
||||
import { ViewerChatPanel } from "@/ee/features/ai/components/viewer-chat-panel";
|
||||
@@ -16,8 +16,9 @@ import {
|
||||
ViewerGroupAccessControls,
|
||||
} from "@prisma/client";
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||
import { PanelLeftIcon, XIcon } from "lucide-react";
|
||||
import { PanelLeftIcon, UploadIcon, XIcon } from "lucide-react";
|
||||
|
||||
import { usePendingUploads } from "@/context/pending-uploads-context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
HIERARCHICAL_DISPLAY_STYLE,
|
||||
@@ -53,6 +54,11 @@ import {
|
||||
IntroductionProvider,
|
||||
} from "../dataroom/introduction-modal";
|
||||
import DataroomNav from "../dataroom/nav-dataroom";
|
||||
import PendingDocumentCard from "../dataroom/pending-document-card";
|
||||
import {
|
||||
ViewerSurfaceThemeProvider,
|
||||
createViewerSurfaceTheme,
|
||||
} from "./viewer-surface-theme";
|
||||
|
||||
const ViewerBreadcrumbItem = ({
|
||||
folder,
|
||||
@@ -73,7 +79,10 @@ const ViewerBreadcrumbItem = ({
|
||||
|
||||
if (isLast) {
|
||||
return (
|
||||
<BreadcrumbPage className="capitalize" style={HIERARCHICAL_DISPLAY_STYLE}>
|
||||
<BreadcrumbPage
|
||||
className="capitalize text-[var(--viewer-text)]"
|
||||
style={HIERARCHICAL_DISPLAY_STYLE}
|
||||
>
|
||||
{displayName}
|
||||
</BreadcrumbPage>
|
||||
);
|
||||
@@ -82,7 +91,7 @@ const ViewerBreadcrumbItem = ({
|
||||
return (
|
||||
<BreadcrumbLink
|
||||
onClick={() => setFolderId(folder.id)}
|
||||
className="cursor-pointer capitalize"
|
||||
className="cursor-pointer capitalize text-[var(--viewer-muted-text)] hover:text-[var(--viewer-text)]"
|
||||
style={HIERARCHICAL_DISPLAY_STYLE}
|
||||
>
|
||||
{displayName}
|
||||
@@ -175,6 +184,21 @@ export default function DataroomViewer({
|
||||
const router = useRouter();
|
||||
const searchQuery = (router.query.search as string)?.toLowerCase() || "";
|
||||
|
||||
// Tab state: "documents" (normal view) or "my-uploads" (visitor's uploads)
|
||||
const [activeTab, setActiveTab] = useState<"documents" | "my-uploads">(
|
||||
"documents",
|
||||
);
|
||||
|
||||
// Get pending uploads (in-flight + persisted from server)
|
||||
const {
|
||||
getPendingUploadsForFolder,
|
||||
getAllUploads,
|
||||
hasUploads,
|
||||
updatePendingUpload,
|
||||
} = usePendingUploads();
|
||||
const pendingUploadsForFolder = getPendingUploadsForFolder(folderId);
|
||||
const allUploads = getAllUploads();
|
||||
|
||||
const breadcrumbFolders = useMemo(
|
||||
() => getParentFolders(folderId, folders),
|
||||
[folderId, folders],
|
||||
@@ -342,6 +366,40 @@ export default function DataroomViewer({
|
||||
searchQuery,
|
||||
]);
|
||||
|
||||
const filteredPendingUploads = useMemo(
|
||||
() =>
|
||||
pendingUploadsForFolder.filter((u) => {
|
||||
if (!u.documentId) return true;
|
||||
return !mixedItems.some(
|
||||
(item) => "versions" in item && item.id === u.documentId,
|
||||
);
|
||||
}),
|
||||
[pendingUploadsForFolder, mixedItems],
|
||||
);
|
||||
|
||||
// Fallback reconciliation: if the document is already visible and ready,
|
||||
// mark its pending upload as complete even if realtime status was missed.
|
||||
useEffect(() => {
|
||||
allUploads.forEach((upload) => {
|
||||
if (upload.status !== "processing" || !upload.documentId) return;
|
||||
|
||||
const matchingDocument = documents.find((doc) => doc.id === upload.documentId);
|
||||
if (!matchingDocument) return;
|
||||
|
||||
const primaryVersion = matchingDocument.versions[0];
|
||||
if (!primaryVersion) return;
|
||||
|
||||
const needsProcessing = ["pdf", "docs", "slides"].includes(
|
||||
primaryVersion.type,
|
||||
);
|
||||
const isReady = !needsProcessing || primaryVersion.hasPages;
|
||||
|
||||
if (isReady) {
|
||||
updatePendingUpload(upload.id, { status: "complete" });
|
||||
}
|
||||
});
|
||||
}, [allUploads, documents, updatePendingUpload]);
|
||||
|
||||
const renderItem = (item: FolderOrDocument) => {
|
||||
if ("versions" in item) {
|
||||
const isProcessing =
|
||||
@@ -379,6 +437,24 @@ export default function DataroomViewer({
|
||||
);
|
||||
};
|
||||
|
||||
const viewerSurfaceTheme = useMemo(
|
||||
() =>
|
||||
createViewerSurfaceTheme(
|
||||
(brand as any)?.applyAccentColorToDataroomView
|
||||
? brand?.accentColor
|
||||
: "#ffffff",
|
||||
),
|
||||
[brand],
|
||||
);
|
||||
const mobileTreeTheme = useMemo(
|
||||
() => ({
|
||||
...viewerSurfaceTheme,
|
||||
textTone: "dark" as const,
|
||||
usesLightText: false,
|
||||
}),
|
||||
[viewerSurfaceTheme],
|
||||
);
|
||||
|
||||
// Prepare documents for chat context
|
||||
const documentsForChat = documents.map((doc) => ({
|
||||
dataroomDocumentId: doc.dataroomDocumentId,
|
||||
@@ -413,11 +489,47 @@ export default function DataroomViewer({
|
||||
conversationsEnabled={viewData.conversationsEnabled}
|
||||
isTeamMember={viewData.isTeamMember}
|
||||
/>
|
||||
<ViewerChatLayout>
|
||||
<div className="relative flex flex-1 items-center overflow-hidden bg-white dark:bg-black">
|
||||
<div className="relative mx-auto flex h-full w-full items-start justify-center">
|
||||
<ViewerSurfaceThemeProvider value={viewerSurfaceTheme}>
|
||||
<ViewerChatLayout>
|
||||
<div
|
||||
className="relative flex flex-1 items-center overflow-hidden bg-white dark:bg-black"
|
||||
style={
|
||||
viewerSurfaceTheme.palette.backgroundColor
|
||||
? { backgroundColor: viewerSurfaceTheme.palette.backgroundColor }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="relative mx-auto flex h-full w-full items-start justify-center"
|
||||
style={
|
||||
{
|
||||
"--viewer-text": viewerSurfaceTheme.palette.textColor,
|
||||
"--viewer-muted-text": viewerSurfaceTheme.palette.mutedTextColor,
|
||||
"--viewer-subtle-text":
|
||||
viewerSurfaceTheme.palette.subtleTextColor,
|
||||
"--viewer-panel-bg": viewerSurfaceTheme.palette.panelBgColor,
|
||||
"--viewer-panel-bg-hover":
|
||||
viewerSurfaceTheme.palette.panelHoverBgColor,
|
||||
"--viewer-panel-border":
|
||||
viewerSurfaceTheme.palette.panelBorderColor,
|
||||
"--viewer-panel-border-hover":
|
||||
viewerSurfaceTheme.palette.panelBorderHoverColor,
|
||||
"--viewer-control-bg": viewerSurfaceTheme.palette.controlBgColor,
|
||||
"--viewer-control-border":
|
||||
viewerSurfaceTheme.palette.controlBorderColor,
|
||||
"--viewer-control-border-strong":
|
||||
viewerSurfaceTheme.palette.controlBorderStrongColor,
|
||||
"--viewer-control-icon":
|
||||
viewerSurfaceTheme.palette.controlIconColor,
|
||||
"--viewer-placeholder":
|
||||
viewerSurfaceTheme.palette.controlPlaceholderColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{/* Tree view */}
|
||||
<div className="hidden h-full w-1/4 space-y-8 overflow-auto px-3 pb-4 pt-4 md:flex md:px-6 md:pt-6 lg:px-8 lg:pt-9 xl:px-14">
|
||||
<div
|
||||
className="hidden h-full w-1/4 space-y-8 overflow-auto px-3 pb-4 pt-4 md:flex md:px-6 md:pt-6 lg:px-8 lg:pt-9 xl:px-14"
|
||||
>
|
||||
<ScrollArea showScrollbar className="w-full">
|
||||
<ViewFolderTree
|
||||
folders={folders}
|
||||
@@ -436,13 +548,18 @@ export default function DataroomViewer({
|
||||
showScrollbar
|
||||
className="h-full flex-grow overflow-auto"
|
||||
>
|
||||
<div className="h-full px-3 pb-4 pt-4 md:px-6 md:pt-6 lg:px-8 lg:pt-9 xl:px-14">
|
||||
<div
|
||||
className="h-full px-3 pb-4 pt-4 md:px-6 md:pt-6 lg:px-8 lg:pt-9 xl:px-14"
|
||||
>
|
||||
<div className="flex items-center gap-x-2">
|
||||
{/* sidebar for mobile */}
|
||||
<div className="flex md:hidden">
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<button className="text-muted-foreground lg:hidden">
|
||||
<button className={cn(
|
||||
"lg:hidden",
|
||||
"text-[var(--viewer-subtle-text)]",
|
||||
)}>
|
||||
<PanelLeftIcon
|
||||
className="h-5 w-5"
|
||||
aria-hidden="true"
|
||||
@@ -459,13 +576,15 @@ export default function DataroomViewer({
|
||||
)}
|
||||
>
|
||||
<div className="mt-8 h-full space-y-8 overflow-auto px-2 py-3">
|
||||
<ViewFolderTree
|
||||
folders={folders}
|
||||
documents={documents}
|
||||
setFolderId={setFolderId}
|
||||
folderId={folderId}
|
||||
dataroomIndexEnabled={dataroomIndexEnabled}
|
||||
/>
|
||||
<ViewerSurfaceThemeProvider value={mobileTreeTheme}>
|
||||
<ViewFolderTree
|
||||
folders={folders}
|
||||
documents={documents}
|
||||
setFolderId={setFolderId}
|
||||
folderId={folderId}
|
||||
dataroomIndexEnabled={dataroomIndexEnabled}
|
||||
/>
|
||||
</ViewerSurfaceThemeProvider>
|
||||
</div>
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<XIcon className="h-4 w-4" />
|
||||
@@ -478,11 +597,11 @@ export default function DataroomViewer({
|
||||
|
||||
<div className="flex flex-1 items-center justify-between gap-x-2">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbList className="text-[var(--viewer-muted-text)]">
|
||||
<BreadcrumbItem key={"root"}>
|
||||
<BreadcrumbLink
|
||||
onClick={() => setFolderId(null)}
|
||||
className="cursor-pointer"
|
||||
className="cursor-pointer text-[var(--viewer-muted-text)] hover:text-[var(--viewer-text)]"
|
||||
>
|
||||
Home
|
||||
</BreadcrumbLink>
|
||||
@@ -490,7 +609,9 @@ export default function DataroomViewer({
|
||||
|
||||
{breadcrumbFolders.map((folder, index) => (
|
||||
<React.Fragment key={folder.id}>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbSeparator
|
||||
className="text-[var(--viewer-subtle-text)]"
|
||||
/>
|
||||
<BreadcrumbItem>
|
||||
<ViewerBreadcrumbItem
|
||||
folder={folder}
|
||||
@@ -506,7 +627,11 @@ export default function DataroomViewer({
|
||||
|
||||
<div className="flex items-center gap-x-2">
|
||||
<IntroductionInfoButton />
|
||||
<SearchBoxPersisted inputClassName="h-9" />
|
||||
<SearchBoxPersisted
|
||||
inputClassName="h-9 border-[var(--viewer-control-border)] bg-[var(--viewer-control-bg)] text-[var(--viewer-text)] placeholder:text-[var(--viewer-placeholder)] shadow-sm hover:border-[var(--viewer-control-border-strong)] focus:border-[var(--viewer-control-border-strong)]"
|
||||
leftIconClassName="text-[var(--viewer-control-icon)]"
|
||||
clearIconClassName="text-[var(--viewer-control-icon)] hover:text-[var(--viewer-text)]"
|
||||
/>
|
||||
{enableIndexFile && viewId && viewerId && (
|
||||
<IndexFileDialog
|
||||
linkId={linkId}
|
||||
@@ -523,20 +648,66 @@ export default function DataroomViewer({
|
||||
dataroomId={dataroom?.id}
|
||||
viewerId={viewerId}
|
||||
folderId={folderId ?? undefined}
|
||||
folderName={
|
||||
folderId
|
||||
? folders.find((f) => f.id === folderId)?.name
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs: Documents / My Uploads */}
|
||||
{viewData?.enableVisitorUpload && hasUploads && (
|
||||
<div
|
||||
className="mt-4 flex items-center gap-1 border-b"
|
||||
style={{ borderColor: viewerSurfaceTheme.palette.panelBorderColor }}
|
||||
>
|
||||
<button
|
||||
onClick={() => setActiveTab("documents")}
|
||||
className={cn(
|
||||
"-mb-px border-b-2 px-3 py-2 text-sm font-medium transition-colors",
|
||||
activeTab === "documents"
|
||||
? "border-[var(--viewer-text)] text-[var(--viewer-text)]"
|
||||
: "border-transparent text-[var(--viewer-subtle-text)] hover:border-[var(--viewer-panel-border-hover)] hover:text-[var(--viewer-text)]",
|
||||
)}
|
||||
>
|
||||
Documents
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("my-uploads")}
|
||||
className={cn(
|
||||
"-mb-px flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm font-medium transition-colors",
|
||||
activeTab === "my-uploads"
|
||||
? "border-[var(--viewer-text)] text-[var(--viewer-text)]"
|
||||
: "border-transparent text-[var(--viewer-subtle-text)] hover:border-[var(--viewer-panel-border-hover)] hover:text-[var(--viewer-text)]",
|
||||
)}
|
||||
>
|
||||
<UploadIcon className="h-3.5 w-3.5" />
|
||||
My Uploads
|
||||
<span
|
||||
className="inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: viewerSurfaceTheme.palette.controlBgColor,
|
||||
color: viewerSurfaceTheme.palette.mutedTextColor,
|
||||
}}
|
||||
>
|
||||
{allUploads.length}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search results banner */}
|
||||
{searchQuery && (
|
||||
<div className="mt-4 rounded-md border border-muted/50 bg-muted px-4 py-3">
|
||||
{searchQuery && activeTab === "documents" && (
|
||||
<div className="mt-4 rounded-md border border-[var(--viewer-panel-border)] bg-[var(--viewer-control-bg)] px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
<div className="text-sm font-medium text-[var(--viewer-muted-text)]">
|
||||
Search results for "{searchQuery}"
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="text-xs text-[var(--viewer-subtle-text)]">
|
||||
({mixedItems.length} result
|
||||
{mixedItems.length !== 1 ? "s" : ""} across all folders)
|
||||
</div>
|
||||
@@ -544,26 +715,72 @@ export default function DataroomViewer({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul role="list" className="-mx-4 space-y-4 overflow-auto p-4">
|
||||
{mixedItems.length === 0 ? (
|
||||
<li className="py-6 text-center text-muted-foreground">
|
||||
{searchQuery
|
||||
? "No documents match your search."
|
||||
: "No items available."}
|
||||
</li>
|
||||
) : (
|
||||
mixedItems.map((item) => (
|
||||
<li key={item.id}>{renderItem(item)}</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
{activeTab === "my-uploads" ? (
|
||||
/* My Uploads tab - show all uploads across all folders */
|
||||
<ul
|
||||
role="list"
|
||||
className="-mx-4 space-y-4 overflow-auto p-4"
|
||||
>
|
||||
{allUploads.length === 0 ? (
|
||||
<li className="py-6 text-center text-[var(--viewer-subtle-text)]">
|
||||
No uploads yet. Upload documents using the "Add
|
||||
Document" button.
|
||||
</li>
|
||||
) : (
|
||||
allUploads.map((pendingUpload) => (
|
||||
<li key={pendingUpload.id}>
|
||||
<PendingDocumentCard
|
||||
pendingUpload={pendingUpload}
|
||||
folders={folders}
|
||||
linkId={linkId}
|
||||
showFolderPath
|
||||
onNavigateToFolder={(id) => {
|
||||
setFolderId(id);
|
||||
setActiveTab("documents");
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
) : (
|
||||
/* Documents tab - normal folder view */
|
||||
<ul
|
||||
role="list"
|
||||
className="-mx-4 space-y-4 overflow-auto p-4"
|
||||
>
|
||||
{!searchQuery &&
|
||||
filteredPendingUploads.map((pendingUpload) => (
|
||||
<li key={pendingUpload.id}>
|
||||
<PendingDocumentCard
|
||||
pendingUpload={pendingUpload}
|
||||
linkId={linkId}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
|
||||
{mixedItems.length === 0 &&
|
||||
filteredPendingUploads.length === 0 ? (
|
||||
<li className="py-6 text-center text-[var(--viewer-subtle-text)]">
|
||||
{searchQuery
|
||||
? "No documents match your search."
|
||||
: "No items available."}
|
||||
</li>
|
||||
) : (
|
||||
mixedItems.map((item) => (
|
||||
<li key={item.id}>{renderItem(item)}</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<ScrollBar orientation="vertical" />
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</ViewerChatLayout>
|
||||
</div>
|
||||
</ViewerChatLayout>
|
||||
</ViewerSurfaceThemeProvider>
|
||||
|
||||
{/* AI Chat Components */}
|
||||
<ViewerChatPanel />
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useSafePageViewTracker } from "@/lib/tracking/safe-page-view-tracker";
|
||||
import { getTrackingOptions } from "@/lib/tracking/tracking-config";
|
||||
import { NotionTheme } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { determineTextColor } from "@/lib/utils/determine-text-color";
|
||||
import { createAdaptiveSurfacePalette } from "@/lib/utils/create-adaptive-surface-palette";
|
||||
|
||||
import {
|
||||
BreadcrumbItem,
|
||||
@@ -139,6 +139,7 @@ export const NotionPage = ({
|
||||
navData: TNavData;
|
||||
}) => {
|
||||
const { isPreview, linkId, documentId, viewId, brand } = navData;
|
||||
const navPalette = createAdaptiveSurfacePalette(brand?.brandColor);
|
||||
const [pageNumber, setPageNumber] = useState<number>(1); // start on first page
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [subPageId, setSubPageId] = useQueryState("pageid", {
|
||||
@@ -511,7 +512,7 @@ export const NotionPage = ({
|
||||
className="cursor-pointer underline underline-offset-4"
|
||||
onClick={() => setSubPageId(null)}
|
||||
style={{
|
||||
color: determineTextColor(brand?.brandColor),
|
||||
color: navPalette.textColor,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
@@ -526,7 +527,7 @@ export const NotionPage = ({
|
||||
<BreadcrumbPage
|
||||
className="font-medium"
|
||||
style={{
|
||||
color: determineTextColor(brand?.brandColor),
|
||||
color: navPalette.textColor,
|
||||
}}
|
||||
>
|
||||
{subTitle}
|
||||
|
||||
@@ -633,8 +633,16 @@ export default function PagesHorizontalViewer({
|
||||
<div
|
||||
className="mx-auto"
|
||||
style={{
|
||||
width: scaledWidthPx ? `${scaledWidthPx}px` : "100%",
|
||||
height: scaledHeightPx ? `${scaledHeightPx}px` : "auto",
|
||||
// Keep default zoom responsive to viewport changes.
|
||||
// Only lock dimensions when zoomed in to preserve a stable scroll area.
|
||||
width:
|
||||
scale > 1 && scaledWidthPx
|
||||
? `${scaledWidthPx}px`
|
||||
: "100%",
|
||||
height:
|
||||
scale > 1 && scaledHeightPx
|
||||
? `${scaledHeightPx}px`
|
||||
: "auto",
|
||||
}}
|
||||
>
|
||||
{/* Content is scaled; origin set to top-left so it grows into the sizer */}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import {
|
||||
AdaptiveSurfacePalette,
|
||||
createAdaptiveSurfacePalette,
|
||||
} from "@/lib/utils/create-adaptive-surface-palette";
|
||||
|
||||
type ViewerSurfaceTextTone = "light" | "dark";
|
||||
|
||||
export type ViewerSurfaceTheme = {
|
||||
palette: AdaptiveSurfacePalette;
|
||||
textTone: ViewerSurfaceTextTone;
|
||||
usesLightText: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_VIEWER_SURFACE_THEME: ViewerSurfaceTheme =
|
||||
createViewerSurfaceTheme(null);
|
||||
|
||||
const ViewerSurfaceThemeContext = createContext<ViewerSurfaceTheme>(
|
||||
DEFAULT_VIEWER_SURFACE_THEME,
|
||||
);
|
||||
|
||||
export function createViewerSurfaceTheme(
|
||||
backgroundColor: string | null | undefined,
|
||||
): ViewerSurfaceTheme {
|
||||
const palette = createAdaptiveSurfacePalette(backgroundColor || "#ffffff");
|
||||
|
||||
return {
|
||||
palette,
|
||||
textTone: palette.usesLightText ? "light" : "dark",
|
||||
usesLightText: palette.usesLightText,
|
||||
};
|
||||
}
|
||||
|
||||
export function ViewerSurfaceThemeProvider({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: ViewerSurfaceTheme;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<ViewerSurfaceThemeContext.Provider value={value}>
|
||||
{children}
|
||||
</ViewerSurfaceThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useViewerSurfaceTheme() {
|
||||
return useContext(ViewerSurfaceThemeContext);
|
||||
}
|
||||
@@ -1,43 +1,118 @@
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { FileUp } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { usePendingUploads } from "@/context/pending-uploads-context";
|
||||
import { DocumentData } from "@/lib/documents/create-document";
|
||||
import { newId } from "@/lib/id-helper";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import ViewerUploadZone from "@/components/viewer-upload-zone";
|
||||
|
||||
export function ViewerUploadComponent({
|
||||
viewerData,
|
||||
teamId,
|
||||
folderId,
|
||||
onUploadSuccess,
|
||||
}: {
|
||||
viewerData: { id: string; linkId: string; dataroomId?: string };
|
||||
teamId: string;
|
||||
folderId?: string;
|
||||
onUploadSuccess?: () => void;
|
||||
}) {
|
||||
const [uploads, setUploads] = useState<
|
||||
{ fileName: string; progress: number }[]
|
||||
{ uploadId: string; fileName: string; progress: number }[]
|
||||
>([]);
|
||||
const [rejectedFiles, setRejectedFiles] = useState<
|
||||
{ fileName: string; message: string }[]
|
||||
>([]);
|
||||
|
||||
const handleUploadStart = (
|
||||
uploads: { fileName: string; progress: number }[],
|
||||
) => {
|
||||
setUploads(uploads);
|
||||
const { addPendingUpload, updatePendingUpload } = usePendingUploads();
|
||||
|
||||
// Map each active upload item to its pending upload record
|
||||
const pendingUploadIds = useRef<Map<string, string>>(new Map());
|
||||
const activeUploadIds = useRef<Set<string>>(new Set());
|
||||
const failedCountRef = useRef(0);
|
||||
|
||||
const finalizeSessionIfIdle = () => {
|
||||
if (activeUploadIds.current.size > 0) return;
|
||||
const hasFailures = failedCountRef.current > 0;
|
||||
failedCountRef.current = 0;
|
||||
|
||||
if (!hasFailures) {
|
||||
onUploadSuccess?.();
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadProgress = (index: number, progress: number) => {
|
||||
const handleUploadStart = (
|
||||
newUploads: { uploadId: string; fileName: string; progress: number }[],
|
||||
) => {
|
||||
const isStartingFreshSession = activeUploadIds.current.size === 0;
|
||||
if (isStartingFreshSession) {
|
||||
failedCountRef.current = 0;
|
||||
setRejectedFiles([]);
|
||||
}
|
||||
setUploads((prev) => [...prev, ...newUploads]);
|
||||
|
||||
newUploads.forEach((upload) => {
|
||||
activeUploadIds.current.add(upload.uploadId);
|
||||
const pendingId = newId("pending");
|
||||
pendingUploadIds.current.set(upload.uploadId, pendingId);
|
||||
|
||||
addPendingUpload({
|
||||
id: pendingId,
|
||||
name: upload.fileName,
|
||||
folderId: folderId ?? null,
|
||||
uploadedAt: new Date(),
|
||||
status: "uploading",
|
||||
progress: 0,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleUploadProgress = (uploadId: string, progress: number) => {
|
||||
setUploads((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[index] = { ...updated[index], progress };
|
||||
const updated = prev.map((upload) =>
|
||||
upload.uploadId === uploadId ? { ...upload, progress } : upload,
|
||||
);
|
||||
|
||||
const pendingId = pendingUploadIds.current.get(uploadId);
|
||||
if (pendingId) {
|
||||
updatePendingUpload(pendingId, { progress });
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const handleUploadComplete = async (documentData: DocumentData) => {
|
||||
// Call your API to add the document to the dataroom or store it for viewer uploads
|
||||
const settleUpload = (uploadId: string, failed: boolean) => {
|
||||
if (failed) {
|
||||
failedCountRef.current += 1;
|
||||
}
|
||||
|
||||
activeUploadIds.current.delete(uploadId);
|
||||
pendingUploadIds.current.delete(uploadId);
|
||||
setUploads((prev) => prev.filter((upload) => upload.uploadId !== uploadId));
|
||||
finalizeSessionIfIdle();
|
||||
};
|
||||
|
||||
const handleUploadComplete = async (
|
||||
documentData: DocumentData,
|
||||
uploadId: string,
|
||||
) => {
|
||||
const pendingId = pendingUploadIds.current.get(uploadId);
|
||||
|
||||
// Update status to processing (file uploaded to S3, now being processed by backend)
|
||||
if (pendingId) {
|
||||
updatePendingUpload(pendingId, {
|
||||
status: "processing",
|
||||
progress: 100,
|
||||
});
|
||||
}
|
||||
|
||||
// Call the API to add the document to the dataroom
|
||||
try {
|
||||
const response = await fetch(`/api/links/${viewerData.linkId}/upload`, {
|
||||
method: "POST",
|
||||
@@ -56,66 +131,118 @@ export function ViewerUploadComponent({
|
||||
throw new Error(errorData.message || "Failed to process upload");
|
||||
}
|
||||
|
||||
// Optional: You might want to update UI or fetch updated document list
|
||||
const result = await response.json();
|
||||
if (!result.document) {
|
||||
throw new Error("Upload response missing document metadata");
|
||||
}
|
||||
|
||||
// Determine if the file needs trigger processing (PDF, docs, slides)
|
||||
// Images and Excel/CSV files are ready immediately after upload
|
||||
const FILE_TYPES_NEEDING_PROCESSING = ["pdf", "docs", "slides"];
|
||||
const needsProcessing = FILE_TYPES_NEEDING_PROCESSING.includes(
|
||||
result.document.fileType,
|
||||
);
|
||||
|
||||
// Update pending upload with real document data
|
||||
if (pendingId) {
|
||||
updatePendingUpload(pendingId, {
|
||||
status: needsProcessing ? "processing" : "complete",
|
||||
documentId: result.document.id,
|
||||
dataroomDocumentId: result.document.dataroomDocumentId,
|
||||
documentVersionId: result.document.documentVersionId,
|
||||
fileType: result.document.fileType,
|
||||
});
|
||||
}
|
||||
|
||||
settleUpload(uploadId, false);
|
||||
} catch (error) {
|
||||
console.error("Error processing upload:", error);
|
||||
toast.error((error as Error).message || "Failed to upload document");
|
||||
|
||||
if (pendingId) {
|
||||
updatePendingUpload(pendingId, {
|
||||
status: "error",
|
||||
errorMessage: (error as Error).message || "Failed to upload document",
|
||||
});
|
||||
}
|
||||
|
||||
settleUpload(uploadId, true);
|
||||
}
|
||||
};
|
||||
|
||||
const isUploading = uploads.length > 0;
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h1 className="mb-4 text-xl font-bold">Upload Documents</h1>
|
||||
|
||||
<ViewerUploadZone
|
||||
onUploadStart={handleUploadStart}
|
||||
onUploadProgress={handleUploadProgress}
|
||||
onUploadComplete={handleUploadComplete}
|
||||
onUploadRejected={(rejected) => setRejectedFiles(rejected)}
|
||||
viewerData={viewerData}
|
||||
teamId={teamId}
|
||||
>
|
||||
<div className="rounded-lg border-2 border-dashed border-gray-300 p-8 text-center">
|
||||
<p>Drag & drop files here, or click to select files</p>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
Supported file types: PDF, Word, Excel, CSV
|
||||
</p>
|
||||
|
||||
{/* Display current uploads */}
|
||||
{uploads.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h3 className="font-medium">Uploads</h3>
|
||||
<ul className="mt-2 space-y-2">
|
||||
{uploads.map((upload, index) => (
|
||||
<li key={index} className="text-sm">
|
||||
{upload.fileName} - {upload.progress}%
|
||||
<div className="mt-1 h-1 rounded-full bg-gray-200">
|
||||
<div
|
||||
className="h-1 rounded-full bg-blue-500"
|
||||
style={{ width: `${upload.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<ViewerUploadZone
|
||||
onUploadStart={handleUploadStart}
|
||||
onUploadProgress={handleUploadProgress}
|
||||
onUploadComplete={handleUploadComplete}
|
||||
onUploadRejected={(rejected) => setRejectedFiles(rejected)}
|
||||
viewerData={viewerData}
|
||||
teamId={teamId}
|
||||
>
|
||||
{isUploading ? (
|
||||
<div className="space-y-3">
|
||||
{uploads.map((upload) => (
|
||||
<div
|
||||
key={upload.uploadId}
|
||||
className="flex items-center gap-3 rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<FileUp className="h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{upload.fileName}
|
||||
</p>
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<Progress
|
||||
value={upload.progress}
|
||||
className="h-1.5 flex-1"
|
||||
/>
|
||||
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
|
||||
{upload.progress}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Display rejected files */}
|
||||
{rejectedFiles.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h3 className="font-medium text-red-500">Rejected Files</h3>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{rejectedFiles.map((file, index) => (
|
||||
<li key={index} className="text-sm text-red-500">
|
||||
{file.fileName}: {file.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</ViewerUploadZone>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center rounded-xl border-2 border-dashed px-6 py-10 text-center transition-colors",
|
||||
"border-gray-300 hover:border-gray-400 dark:border-gray-600 dark:hover:border-gray-500",
|
||||
)}
|
||||
>
|
||||
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-full bg-gray-100 dark:bg-gray-800">
|
||||
<FileUp className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Drag & drop files here, or click to select files
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Supported file types: PDF, Excel, CSV, Images
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Display rejected files */}
|
||||
{rejectedFiles.length > 0 && (
|
||||
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-800 dark:bg-red-950/30">
|
||||
<p className="text-xs font-medium text-red-600 dark:text-red-400">
|
||||
Some files were rejected:
|
||||
</p>
|
||||
<ul className="mt-1.5 space-y-0.5">
|
||||
{rejectedFiles.map((file, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="text-xs text-red-500 dark:text-red-400"
|
||||
>
|
||||
{file.fileName}: {file.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</ViewerUploadZone>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { DocumentStorageType } from "@prisma/client";
|
||||
import { FileRejection, useDropzone } from "react-dropzone";
|
||||
@@ -7,6 +7,7 @@ import { toast } from "sonner";
|
||||
import { VIEWER_ACCEPTED_FILE_TYPES } from "@/lib/constants";
|
||||
import { DocumentData } from "@/lib/documents/create-document";
|
||||
import { viewerUpload } from "@/lib/files/viewer-tus-upload";
|
||||
import { newId } from "@/lib/id-helper";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getSupportedContentType } from "@/lib/utils/get-content-type";
|
||||
import { getPagesCount } from "@/lib/utils/get-page-number-count";
|
||||
@@ -23,11 +24,14 @@ export default function ViewerUploadZone({
|
||||
viewerData,
|
||||
teamId,
|
||||
maxFileSize = 30, // 30 MB default
|
||||
disabled = false,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onUploadStart: (uploads: { fileName: string; progress: number }[]) => void;
|
||||
onUploadProgress: (index: number, progress: number) => void;
|
||||
onUploadComplete: (documentData: DocumentData) => void;
|
||||
onUploadStart: (
|
||||
uploads: { uploadId: string; fileName: string; progress: number }[],
|
||||
) => void;
|
||||
onUploadProgress: (uploadId: string, progress: number) => void;
|
||||
onUploadComplete: (documentData: DocumentData, uploadId: string) => void;
|
||||
onUploadRejected: (rejected: { fileName: string; message: string }[]) => void;
|
||||
viewerData: {
|
||||
id: string;
|
||||
@@ -36,10 +40,8 @@ export default function ViewerUploadZone({
|
||||
};
|
||||
teamId: string;
|
||||
maxFileSize?: number;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [progress, setProgress] = useState<number>(0);
|
||||
const uploadProgress = useRef<number[]>([]);
|
||||
|
||||
const onDropRejected = useCallback(
|
||||
(rejectedFiles: FileRejection[]) => {
|
||||
const rejected = rejectedFiles.map(({ file, errors }) => {
|
||||
@@ -58,14 +60,20 @@ export default function ViewerUploadZone({
|
||||
|
||||
const onDrop = useCallback(
|
||||
async (acceptedFiles: File[]) => {
|
||||
const newUploads = acceptedFiles.map((file) => ({
|
||||
const trackedFiles = acceptedFiles.map((file) => ({
|
||||
uploadId: newId("pending"),
|
||||
file,
|
||||
}));
|
||||
|
||||
const newUploads = trackedFiles.map(({ uploadId, file }) => ({
|
||||
uploadId,
|
||||
fileName: file.name,
|
||||
progress: 0,
|
||||
}));
|
||||
|
||||
onUploadStart(newUploads);
|
||||
|
||||
const uploadPromises = acceptedFiles.map(async (file, index) => {
|
||||
const uploadPromises = trackedFiles.map(async ({ uploadId, file }) => {
|
||||
// count the number of pages in the file
|
||||
let numPages = 1;
|
||||
if (file.type === "application/pdf") {
|
||||
@@ -76,18 +84,11 @@ export default function ViewerUploadZone({
|
||||
const { complete } = await viewerUpload({
|
||||
file,
|
||||
onProgress: (bytesUploaded, bytesTotal) => {
|
||||
uploadProgress.current[index] = (bytesUploaded / bytesTotal) * 100;
|
||||
onUploadProgress(
|
||||
index,
|
||||
Math.min(Math.round(uploadProgress.current[index]), 99),
|
||||
const progress = Math.min(
|
||||
Math.round((bytesUploaded / bytesTotal) * 100),
|
||||
99,
|
||||
);
|
||||
|
||||
const _progress = uploadProgress.current.reduce(
|
||||
(acc, progress) => acc + progress,
|
||||
0,
|
||||
);
|
||||
|
||||
setProgress(Math.round(_progress / acceptedFiles.length));
|
||||
onUploadProgress(uploadId, progress);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Upload error:", error);
|
||||
@@ -134,10 +135,9 @@ export default function ViewerUploadZone({
|
||||
numPages: numPages,
|
||||
};
|
||||
|
||||
// Complete the upload by calling the provided callback
|
||||
onUploadComplete(documentData);
|
||||
onUploadComplete(documentData, uploadId);
|
||||
|
||||
onUploadProgress(index, 100); // Mark upload as complete
|
||||
onUploadProgress(uploadId, 100); // Mark upload as complete
|
||||
|
||||
return uploadResult;
|
||||
});
|
||||
@@ -159,6 +159,7 @@ export default function ViewerUploadZone({
|
||||
maxSize: maxFileSize * 1024 * 1024,
|
||||
onDrop,
|
||||
onDropRejected,
|
||||
disabled,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -179,16 +180,14 @@ export default function ViewerUploadZone({
|
||||
/>
|
||||
|
||||
{isDragActive && (
|
||||
<div className="sticky top-1/2 z-50 -translate-y-1/2 px-2">
|
||||
<div className="flex justify-center">
|
||||
<div className="inline-flex flex-col rounded-lg bg-background/95 px-6 py-4 text-center ring-1 ring-gray-900/5 dark:bg-gray-900/95 dark:ring-white/10">
|
||||
<span className="font-medium text-foreground">
|
||||
Drop your file(s) here
|
||||
</span>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
Only *.pdf, *.doc, *.docx, *.xls, *.xlsx, *.csv, *.ods files
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="inline-flex flex-col rounded-lg bg-background/95 px-6 py-4 text-center ring-1 ring-gray-900/5 dark:bg-gray-900/95 dark:ring-white/10">
|
||||
<span className="font-medium text-foreground">
|
||||
Drop your file(s) here
|
||||
</span>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
Only *.pdf, *.xls, *.xlsx, *.csv, *.tsv, *.ods files
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
ChevronRightIcon,
|
||||
DownloadCloudIcon,
|
||||
FileCheckIcon,
|
||||
FileIcon,
|
||||
UploadCloudIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useDataroomVisitHistory } from "@/lib/swr/use-dataroom";
|
||||
import {
|
||||
DocumentViewStats,
|
||||
useDataroomViewDocumentStats,
|
||||
} from "@/lib/swr/use-dataroom-view-document-stats";
|
||||
import { cn, durationFormat, timeAgo } from "@/lib/utils";
|
||||
|
||||
import { Gauge } from "@/components/ui/gauge";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { TableCell, TableRow } from "@/components/ui/table";
|
||||
import { TimestampTooltip } from "@/components/ui/timestamp-tooltip";
|
||||
|
||||
import { DocumentPageChart } from "./document-view-stats";
|
||||
|
||||
export function DataroomViewStats({
|
||||
viewId,
|
||||
dataroomId,
|
||||
isExpanded,
|
||||
}: {
|
||||
viewId: string;
|
||||
dataroomId: string;
|
||||
isExpanded: boolean;
|
||||
}) {
|
||||
const { documentViews, uploadedDocumentViews } = useDataroomVisitHistory({
|
||||
viewId,
|
||||
dataroomId,
|
||||
});
|
||||
|
||||
const { documentStats, loading: statsLoading } = useDataroomViewDocumentStats(
|
||||
{
|
||||
dataroomId,
|
||||
dataroomViewId: viewId,
|
||||
enabled: isExpanded,
|
||||
},
|
||||
);
|
||||
|
||||
const statsMap = new Map<string, DocumentViewStats>();
|
||||
documentStats?.forEach((s) => {
|
||||
statsMap.set(s.viewId, s);
|
||||
});
|
||||
|
||||
const groupedViews = documentViews
|
||||
? documentViews.reduce(
|
||||
(acc, view) => {
|
||||
if (view.downloadType === "BULK" || view.downloadType === "FOLDER") {
|
||||
const key = `${view.downloadType}-${new Date(view.viewedAt).toISOString()}`;
|
||||
if (!acc.bulkDownloads[key]) {
|
||||
acc.bulkDownloads[key] = {
|
||||
type: view.downloadType,
|
||||
viewedAt: view.viewedAt,
|
||||
downloadedAt: view.downloadedAt,
|
||||
metadata: view.downloadMetadata,
|
||||
documents: [],
|
||||
};
|
||||
}
|
||||
acc.bulkDownloads[key].documents.push(view);
|
||||
} else {
|
||||
acc.individualViews.push(view);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
individualViews: [] as typeof documentViews,
|
||||
bulkDownloads: {} as Record<
|
||||
string,
|
||||
{
|
||||
type: string;
|
||||
viewedAt: string;
|
||||
downloadedAt: string;
|
||||
metadata?: {
|
||||
folderName?: string;
|
||||
folderPath?: string;
|
||||
dataroomName?: string;
|
||||
documentCount?: number;
|
||||
documents?: {
|
||||
id: string;
|
||||
name: string;
|
||||
}[];
|
||||
};
|
||||
documents: typeof documentViews;
|
||||
}
|
||||
>,
|
||||
},
|
||||
)
|
||||
: { individualViews: [], bulkDownloads: {} };
|
||||
|
||||
const allEvents: Array<{
|
||||
type: "upload" | "view" | "download" | "bulk-download";
|
||||
timestamp: Date;
|
||||
data: any;
|
||||
}> = [];
|
||||
|
||||
uploadedDocumentViews?.forEach((upload) => {
|
||||
allEvents.push({
|
||||
type: "upload",
|
||||
timestamp: new Date(upload.uploadedAt),
|
||||
data: upload,
|
||||
});
|
||||
});
|
||||
|
||||
Object.entries(groupedViews.bulkDownloads).forEach(([_key, bulkGroup]) => {
|
||||
allEvents.push({
|
||||
type: "bulk-download",
|
||||
timestamp: new Date(bulkGroup.downloadedAt),
|
||||
data: bulkGroup,
|
||||
});
|
||||
});
|
||||
|
||||
groupedViews.individualViews.forEach((view) => {
|
||||
const viewedAtTime = new Date(view.viewedAt).getTime();
|
||||
const downloadedAtTime = view.downloadedAt
|
||||
? new Date(view.downloadedAt).getTime()
|
||||
: null;
|
||||
const isDownloadOnly =
|
||||
downloadedAtTime && Math.abs(viewedAtTime - downloadedAtTime) < 1000;
|
||||
|
||||
if (!isDownloadOnly) {
|
||||
allEvents.push({
|
||||
type: "view",
|
||||
timestamp: new Date(view.viewedAt),
|
||||
data: view,
|
||||
});
|
||||
}
|
||||
|
||||
if (view.downloadedAt) {
|
||||
allEvents.push({
|
||||
type: "download",
|
||||
timestamp: new Date(view.downloadedAt),
|
||||
data: view,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
allEvents.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
||||
|
||||
return (
|
||||
<>
|
||||
{allEvents.length > 0 ? (
|
||||
allEvents.map((event, index) => {
|
||||
if (event.type === "upload") {
|
||||
return (
|
||||
<UploadRow
|
||||
key={`upload-${event.data.documentId}-${index}`}
|
||||
upload={event.data}
|
||||
timestamp={event.timestamp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (event.type === "bulk-download") {
|
||||
return (
|
||||
<BulkDownloadRow
|
||||
key={`bulk-${index}`}
|
||||
bulkGroup={event.data}
|
||||
timestamp={event.timestamp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (event.type === "view") {
|
||||
const view = event.data;
|
||||
const stats = statsMap.get(view.id);
|
||||
return (
|
||||
<DocumentViewRow
|
||||
key={`view-${view.id}`}
|
||||
view={view}
|
||||
stats={stats}
|
||||
statsLoading={statsLoading}
|
||||
dataroomId={dataroomId}
|
||||
dataroomViewId={viewId}
|
||||
timestamp={event.timestamp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (event.type === "download") {
|
||||
const view = event.data;
|
||||
return (
|
||||
<DownloadRow
|
||||
key={`download-${view.id}`}
|
||||
view={view}
|
||||
timestamp={event.timestamp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
) : documentViews === undefined ? (
|
||||
<TableRow className="[&>td]:py-3">
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-24" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-16" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-6 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-16" />
|
||||
</TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DocumentViewRow({
|
||||
view,
|
||||
stats,
|
||||
statsLoading,
|
||||
dataroomId,
|
||||
dataroomViewId,
|
||||
timestamp,
|
||||
}: {
|
||||
view: any;
|
||||
stats: DocumentViewStats | undefined;
|
||||
statsLoading: boolean;
|
||||
dataroomId: string;
|
||||
dataroomViewId: string;
|
||||
timestamp: Date;
|
||||
}) {
|
||||
const [showPageByPage, setShowPageByPage] = useState(false);
|
||||
const hasPages = stats && stats.totalPages > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableRow className="[&>td]:py-3">
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-x-4 overflow-visible">
|
||||
<FileCheckIcon className="h-5 w-5 shrink-0 text-[#fb7a00]" />
|
||||
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span className="truncate">Viewed {view.document.name}</span>
|
||||
<Link
|
||||
href={`/documents/${view.document.id}`}
|
||||
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<ArrowUpRightIcon className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
{hasPages && (
|
||||
<button
|
||||
onClick={() => setShowPageByPage((prev) => !prev)}
|
||||
className="flex shrink-0 items-center gap-0.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<ChevronRightIcon
|
||||
className={cn(
|
||||
"h-3 w-3 transition-transform",
|
||||
showPageByPage && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
<span className="hidden sm:inline">page-by-page</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{statsLoading ? (
|
||||
<Skeleton className="h-4 w-14" />
|
||||
) : stats ? (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{durationFormat(stats.totalDuration)}
|
||||
</span>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{statsLoading ? (
|
||||
<Skeleton className="h-6 w-6 rounded-full" />
|
||||
) : stats ? (
|
||||
<Gauge value={stats.completionRate} size={"xs"} showValue={true} />
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TimestampTooltip
|
||||
timestamp={timestamp}
|
||||
side="right"
|
||||
rows={["local", "utc", "unix"]}
|
||||
>
|
||||
<time
|
||||
className="select-none truncate text-sm text-muted-foreground"
|
||||
dateTime={timestamp.toISOString()}
|
||||
>
|
||||
{timeAgo(timestamp)}
|
||||
</time>
|
||||
</TimestampTooltip>
|
||||
</TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
{showPageByPage && hasPages && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="p-0 px-4 pb-3 pt-0">
|
||||
<DocumentPageChart
|
||||
dataroomId={dataroomId}
|
||||
dataroomViewId={dataroomViewId}
|
||||
documentViewId={view.id}
|
||||
documentId={view.document.id}
|
||||
totalPages={stats.totalPages}
|
||||
downloadType={view.downloadType}
|
||||
downloadMetadata={view.downloadMetadata}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UploadRow({ upload, timestamp }: { upload: any; timestamp: Date }) {
|
||||
return (
|
||||
<TableRow className="[&>td]:py-3">
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-x-4 overflow-visible">
|
||||
<UploadCloudIcon className="h-5 w-5 text-[#fb7a00]" />
|
||||
<span className="truncate">Uploaded {upload.originalFilename}</span>
|
||||
<Link
|
||||
href={`/documents/${upload.documentId}`}
|
||||
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<ArrowUpRightIcon className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell />
|
||||
<TableCell />
|
||||
<TableCell>
|
||||
<TimestampTooltip
|
||||
timestamp={timestamp}
|
||||
side="right"
|
||||
rows={["local", "utc", "unix"]}
|
||||
>
|
||||
<time
|
||||
className="select-none truncate text-sm text-muted-foreground"
|
||||
dateTime={timestamp.toISOString()}
|
||||
>
|
||||
{timeAgo(timestamp)}
|
||||
</time>
|
||||
</TimestampTooltip>
|
||||
</TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function DownloadRow({ view, timestamp }: { view: any; timestamp: Date }) {
|
||||
return (
|
||||
<TableRow className="[&>td]:py-3">
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-x-4 overflow-visible">
|
||||
<DownloadCloudIcon className="h-5 w-5 text-[#fb7a00]" />
|
||||
<span className="truncate">Downloaded {view.document.name}</span>
|
||||
<Link
|
||||
href={`/documents/${view.document.id}`}
|
||||
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<ArrowUpRightIcon className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell />
|
||||
<TableCell />
|
||||
<TableCell>
|
||||
<TimestampTooltip
|
||||
timestamp={timestamp}
|
||||
side="right"
|
||||
rows={["local", "utc", "unix"]}
|
||||
>
|
||||
<time
|
||||
className="select-none truncate text-sm text-muted-foreground"
|
||||
dateTime={timestamp.toISOString()}
|
||||
>
|
||||
{timeAgo(timestamp)}
|
||||
</time>
|
||||
</TimestampTooltip>
|
||||
</TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function BulkDownloadRow({
|
||||
bulkGroup,
|
||||
timestamp,
|
||||
}: {
|
||||
bulkGroup: any;
|
||||
timestamp: Date;
|
||||
}) {
|
||||
const documentCount =
|
||||
bulkGroup.metadata?.documentCount || bulkGroup.documents.length;
|
||||
const hasDocumentList =
|
||||
bulkGroup.metadata?.documents && bulkGroup.metadata.documents.length > 0;
|
||||
|
||||
return (
|
||||
<TableRow className="[&>td]:py-3">
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-x-4 overflow-visible">
|
||||
<DownloadCloudIcon className="h-5 w-5 text-[#fb7a00]" />
|
||||
<span>
|
||||
Downloaded{" "}
|
||||
{hasDocumentList ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="underline decoration-dotted hover:text-primary">
|
||||
{documentCount} document
|
||||
{documentCount !== 1 ? "s" : ""}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium leading-none">
|
||||
{bulkGroup.type === "FOLDER" &&
|
||||
bulkGroup.metadata?.folderName
|
||||
? `Files in ${bulkGroup.metadata.folderName}`
|
||||
: `Files in ${bulkGroup.metadata?.dataroomName || "dataroom"}`}
|
||||
</h4>
|
||||
<ScrollArea className="h-[200px] w-full rounded-md border p-2">
|
||||
<div className="space-y-1">
|
||||
{bulkGroup.metadata.documents!.map(
|
||||
(doc: { id: string; name: string }) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<FileIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="truncate">{doc.name}</span>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<>
|
||||
{documentCount} document
|
||||
{documentCount !== 1 ? "s" : ""}
|
||||
</>
|
||||
)}{" "}
|
||||
{bulkGroup.type === "FOLDER" && bulkGroup.metadata?.folderName ? (
|
||||
<>
|
||||
from folder{" "}
|
||||
<span className="font-medium">
|
||||
{bulkGroup.metadata.folderName}
|
||||
</span>
|
||||
</>
|
||||
) : bulkGroup.type === "BULK" &&
|
||||
bulkGroup.metadata?.dataroomName ? (
|
||||
<>
|
||||
from{" "}
|
||||
<span className="font-medium">
|
||||
{bulkGroup.metadata.dataroomName}
|
||||
</span>{" "}
|
||||
dataroom
|
||||
</>
|
||||
) : (
|
||||
"via bulk download"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell />
|
||||
<TableCell />
|
||||
<TableCell>
|
||||
<TimestampTooltip
|
||||
timestamp={timestamp}
|
||||
side="right"
|
||||
rows={["local", "utc", "unix"]}
|
||||
>
|
||||
<time
|
||||
className="select-none truncate text-sm text-muted-foreground"
|
||||
dateTime={timestamp.toISOString()}
|
||||
>
|
||||
{timeAgo(timestamp)}
|
||||
</time>
|
||||
</TimestampTooltip>
|
||||
</TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
BadgeCheckIcon,
|
||||
BadgeInfoIcon,
|
||||
@@ -27,7 +29,7 @@ import {
|
||||
import { TimestampTooltip } from "@/components/ui/timestamp-tooltip";
|
||||
import { BadgeTooltip } from "@/components/ui/tooltip";
|
||||
|
||||
import DataroomVisitHistory from "./dataroom-visitors-history";
|
||||
import { DataroomViewStats } from "./dataroom-view-stats";
|
||||
import { VisitorAvatar } from "./visitor-avatar";
|
||||
|
||||
export default function DataroomViewersTable({
|
||||
@@ -36,6 +38,21 @@ export default function DataroomViewersTable({
|
||||
dataroomId: string;
|
||||
}) {
|
||||
const { viewers } = useDataroomViewers({ dataroomId });
|
||||
const [expandedViewerIds, setExpandedViewerIds] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const handleOpenChange = (viewerId: string, open: boolean) => {
|
||||
setExpandedViewerIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (open) {
|
||||
next.add(viewerId);
|
||||
} else {
|
||||
next.delete(viewerId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
@@ -43,14 +60,18 @@ export default function DataroomViewersTable({
|
||||
<h2 className="mb-2 md:mb-4">All dataroom visitors</h2>
|
||||
</div>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<Table className="table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow className="*:whitespace-nowrap *:font-medium hover:bg-transparent">
|
||||
<TableHead>Name</TableHead>
|
||||
{/* <TableHead>Visit Duration</TableHead> */}
|
||||
{/* <TableHead>Last Viewed Document</TableHead> */}
|
||||
<TableHead>Last Viewed</TableHead>
|
||||
<TableHead className="text-center sm:text-right"></TableHead>
|
||||
<TableHead className="w-[120px]">
|
||||
{expandedViewerIds.size > 0 ? "View Duration" : null}
|
||||
</TableHead>
|
||||
<TableHead className="w-[140px]">
|
||||
{expandedViewerIds.size > 0 ? "View Completion" : null}
|
||||
</TableHead>
|
||||
<TableHead className="w-[120px]">Last Viewed</TableHead>
|
||||
<TableHead className="w-[48px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -65,7 +86,11 @@ export default function DataroomViewersTable({
|
||||
)}
|
||||
{viewers ? (
|
||||
viewers.map((viewer) => (
|
||||
<Collapsible key={viewer.id} asChild>
|
||||
<Collapsible
|
||||
key={viewer.id}
|
||||
asChild
|
||||
onOpenChange={(open) => handleOpenChange(viewer.id, open)}
|
||||
>
|
||||
<>
|
||||
<TableRow key={viewer.id} className="group/row">
|
||||
{/* Name */}
|
||||
@@ -112,29 +137,12 @@ export default function DataroomViewersTable({
|
||||
{viewer.email}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground/60 sm:text-sm">
|
||||
{/* {view.link.name ? view.link.name : view.linkId} */}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* Duration */}
|
||||
{/* <TableCell className="">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{durationFormat(view.totalDuration)}
|
||||
</div>
|
||||
</TableCell> */}
|
||||
{/* Completion */}
|
||||
{/* <TableCell className="flex justify-start">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<Gauge
|
||||
value={view.completionRate}
|
||||
size={"small"}
|
||||
showValue={true}
|
||||
/>
|
||||
</div>
|
||||
</TableCell> */}
|
||||
<TableCell />
|
||||
<TableCell />
|
||||
{/* Last Viewed */}
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{viewer.lastViewedAt ? (
|
||||
@@ -172,14 +180,15 @@ export default function DataroomViewersTable({
|
||||
? viewer.views.map((view: any) => (
|
||||
<CollapsibleContent asChild key={view.id}>
|
||||
<>
|
||||
<TableRow key={view.id}>
|
||||
<TableRow key={view.id} className="[&>td]:py-3">
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-x-4 overflow-visible">
|
||||
<MailOpenIcon className="h-5 w-5 text-[#fb7a00]" />
|
||||
Accessed {viewer.dataroomName} dataroom
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell />
|
||||
<TableCell />
|
||||
<TableCell>
|
||||
<TimestampTooltip
|
||||
timestamp={view.viewedAt}
|
||||
@@ -196,18 +205,22 @@ export default function DataroomViewersTable({
|
||||
</time>
|
||||
</TimestampTooltip>
|
||||
</TableCell>
|
||||
<TableCell className="table-cell"></TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
|
||||
{view.downloadedAt ? (
|
||||
<TableRow key={`download-${view.id}`}>
|
||||
<TableRow
|
||||
key={`download-${view.id}`}
|
||||
className="[&>td]:py-3"
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-x-4 overflow-visible">
|
||||
<DownloadCloudIcon className="h-5 w-5 text-cyan-500 hover:text-cyan-600" />
|
||||
Downloaded {viewer.dataroomName} dataroom
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell />
|
||||
<TableCell />
|
||||
<TableCell>
|
||||
<TimestampTooltip
|
||||
timestamp={view.downloadedAt}
|
||||
@@ -224,13 +237,14 @@ export default function DataroomViewersTable({
|
||||
</time>
|
||||
</TimestampTooltip>
|
||||
</TableCell>
|
||||
<TableCell className="table-cell"></TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
) : null}
|
||||
|
||||
<DataroomVisitHistory
|
||||
<DataroomViewStats
|
||||
viewId={view.id}
|
||||
dataroomId={dataroomId}
|
||||
isExpanded={expandedViewerIds.has(viewer.id)}
|
||||
/>
|
||||
</>
|
||||
</CollapsibleContent>
|
||||
@@ -244,14 +258,17 @@ export default function DataroomViewersTable({
|
||||
<TableCell className="min-w-[100px]">
|
||||
<Skeleton className="h-6 w-full" />
|
||||
</TableCell>
|
||||
<TableCell className="min-w-[450px]">
|
||||
<Skeleton className="h-6 w-full" />
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-14" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-6 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell className="min-w-[100px]">
|
||||
<Skeleton className="h-6 w-24" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-24" />
|
||||
<Skeleton className="h-6 w-6" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
@@ -38,9 +38,9 @@ import { TimestampTooltip } from "@/components/ui/timestamp-tooltip";
|
||||
import { BadgeTooltip } from "@/components/ui/tooltip";
|
||||
|
||||
import { ExportVisitsModal } from "../datarooms/export-visits-modal";
|
||||
import { DataroomViewStats } from "./dataroom-view-stats";
|
||||
import DataroomVisitorCustomFields from "./dataroom-visitor-custom-fields";
|
||||
import { DataroomVisitorUserAgent } from "./dataroom-visitor-useragent";
|
||||
import DataroomVisitHistory from "./dataroom-visitors-history";
|
||||
import { VisitorAvatar } from "./visitor-avatar";
|
||||
|
||||
export default function DataroomVisitorsTable({
|
||||
@@ -61,11 +61,26 @@ export default function DataroomVisitorsTable({
|
||||
const { dataroom } = useDataroom();
|
||||
const { isPaused } = usePlan();
|
||||
const [exportModalOpen, setExportModalOpen] = useState(false);
|
||||
const [expandedViewIds, setExpandedViewIds] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const exportVisitCounts = () => {
|
||||
setExportModalOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenChange = (viewId: string, open: boolean) => {
|
||||
setExpandedViewIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (open) {
|
||||
next.add(viewId);
|
||||
} else {
|
||||
next.delete(viewId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="mb-2 flex items-center justify-between md:mb-4">
|
||||
@@ -76,14 +91,18 @@ export default function DataroomVisitorsTable({
|
||||
</Button>
|
||||
</div>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<Table className="table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow className="*:whitespace-nowrap *:font-medium hover:bg-transparent">
|
||||
<TableHead>Name</TableHead>
|
||||
{/* <TableHead>Visit Duration</TableHead> */}
|
||||
{/* <TableHead>Last Viewed Document</TableHead> */}
|
||||
<TableHead>Last Viewed</TableHead>
|
||||
<TableHead className="text-center sm:text-right"></TableHead>
|
||||
<TableHead className="w-[120px]">
|
||||
{expandedViewIds.size > 0 ? "View Duration" : null}
|
||||
</TableHead>
|
||||
<TableHead className="w-[140px]">
|
||||
{expandedViewIds.size > 0 ? "View Completion" : null}
|
||||
</TableHead>
|
||||
<TableHead className="w-[120px]">Last Viewed</TableHead>
|
||||
<TableHead className="w-[48px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -99,7 +118,7 @@ export default function DataroomVisitorsTable({
|
||||
{isPaused && hiddenFromPause > 0 && (
|
||||
<>
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-left sm:text-center">
|
||||
<TableCell colSpan={5} className="text-left sm:text-center">
|
||||
<div className="flex flex-col items-start justify-center gap-2 sm:flex-row sm:items-center">
|
||||
<span className="flex items-center gap-x-1">
|
||||
<AlertTriangleIcon className="inline-block h-4 w-4 text-orange-500" />
|
||||
@@ -124,7 +143,11 @@ export default function DataroomVisitorsTable({
|
||||
)}
|
||||
{views ? (
|
||||
views.map((view) => (
|
||||
<Collapsible key={view.id} asChild>
|
||||
<Collapsible
|
||||
key={view.id}
|
||||
asChild
|
||||
onOpenChange={(open) => handleOpenChange(view.id, open)}
|
||||
>
|
||||
<>
|
||||
<TableRow key={view.id} className="group/row">
|
||||
{/* Name */}
|
||||
@@ -186,22 +209,8 @@ export default function DataroomVisitorsTable({
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* Duration */}
|
||||
{/* <TableCell className="">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{durationFormat(view.totalDuration)}
|
||||
</div>
|
||||
</TableCell> */}
|
||||
{/* Completion */}
|
||||
{/* <TableCell className="flex justify-start">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<Gauge
|
||||
value={view.completionRate}
|
||||
size={"small"}
|
||||
showValue={true}
|
||||
/>
|
||||
</div>
|
||||
</TableCell> */}
|
||||
<TableCell />
|
||||
<TableCell />
|
||||
{/* Last Viewed */}
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
<TimestampTooltip
|
||||
@@ -230,7 +239,7 @@ export default function DataroomVisitorsTable({
|
||||
<CollapsibleContent asChild>
|
||||
<>
|
||||
<TableRow>
|
||||
<TableCell colSpan={3}>
|
||||
<TableCell colSpan={5}>
|
||||
<DataroomVisitorCustomFields
|
||||
viewId={view.id}
|
||||
teamId={view.teamId!}
|
||||
@@ -239,14 +248,15 @@ export default function DataroomVisitorsTable({
|
||||
<DataroomVisitorUserAgent viewId={view.id} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow key={view.id}>
|
||||
<TableRow key={view.id} className="[&>td]:py-3">
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-x-4 overflow-visible">
|
||||
<MailOpenIcon className="h-5 w-5 text-[#fb7a00]" />
|
||||
Accessed {view.dataroomName} dataroom
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell />
|
||||
<TableCell />
|
||||
<TableCell>
|
||||
<TimestampTooltip
|
||||
timestamp={view.viewedAt}
|
||||
@@ -261,18 +271,22 @@ export default function DataroomVisitorsTable({
|
||||
</time>
|
||||
</TimestampTooltip>
|
||||
</TableCell>
|
||||
<TableCell className="table-cell"></TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
|
||||
{view.downloadedAt ? (
|
||||
<TableRow key={`download-item-${view.id}`}>
|
||||
<TableRow
|
||||
key={`download-item-${view.id}`}
|
||||
className="[&>td]:py-3"
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-x-4 overflow-visible">
|
||||
<DownloadCloudIcon className="h-5 w-5 text-cyan-500 hover:text-cyan-600" />
|
||||
Downloaded {view.dataroomName} dataroom
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell />
|
||||
<TableCell />
|
||||
<TableCell>
|
||||
<TimestampTooltip
|
||||
timestamp={view.downloadedAt}
|
||||
@@ -289,13 +303,14 @@ export default function DataroomVisitorsTable({
|
||||
</time>
|
||||
</TimestampTooltip>
|
||||
</TableCell>
|
||||
<TableCell className="table-cell"></TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
) : null}
|
||||
|
||||
<DataroomVisitHistory
|
||||
<DataroomViewStats
|
||||
viewId={view.id}
|
||||
dataroomId={dataroomId}
|
||||
isExpanded={expandedViewIds.has(view.id)}
|
||||
/>
|
||||
</>
|
||||
</CollapsibleContent>
|
||||
@@ -307,14 +322,17 @@ export default function DataroomVisitorsTable({
|
||||
<TableCell className="min-w-[100px]">
|
||||
<Skeleton className="h-6 w-full" />
|
||||
</TableCell>
|
||||
<TableCell className="min-w-[450px]">
|
||||
<Skeleton className="h-6 w-full" />
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-14" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-6 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell className="min-w-[100px]">
|
||||
<Skeleton className="h-6 w-24" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-24" />
|
||||
<Skeleton className="h-6 w-6" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
@@ -336,7 +354,6 @@ export default function DataroomVisitorsTable({
|
||||
);
|
||||
}
|
||||
|
||||
// create a component for a blurred view of the visitor
|
||||
const VisitorBlurred = () => {
|
||||
return (
|
||||
<TableRow className="blur-sm">
|
||||
@@ -355,6 +372,8 @@ const VisitorBlurred = () => {
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell />
|
||||
<TableCell />
|
||||
{/* Last Viewed */}
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
<time
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { DownloadCloudIcon } from "lucide-react";
|
||||
|
||||
import BarChartComponent from "@/components/charts/bar-chart";
|
||||
import StatsChartSkeleton from "@/components/documents/stats-chart-skeleton";
|
||||
|
||||
import { useDataroomDocumentPageStats } from "@/lib/swr/use-dataroom-view-document-stats";
|
||||
|
||||
export function DocumentPageChart({
|
||||
dataroomId,
|
||||
dataroomViewId,
|
||||
documentViewId,
|
||||
documentId,
|
||||
totalPages,
|
||||
downloadType,
|
||||
downloadMetadata,
|
||||
}: {
|
||||
dataroomId: string;
|
||||
dataroomViewId: string;
|
||||
documentViewId: string;
|
||||
documentId: string;
|
||||
totalPages: number;
|
||||
downloadType?: "SINGLE" | "BULK" | "FOLDER" | null;
|
||||
downloadMetadata?: {
|
||||
folderName?: string;
|
||||
folderPath?: string;
|
||||
dataroomName?: string;
|
||||
documentCount?: number;
|
||||
documents?: { id: string; name: string }[];
|
||||
} | null;
|
||||
}) {
|
||||
const [fetchEnabled, setFetchEnabled] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
useEffect(() => {
|
||||
timerRef.current = setTimeout(() => {
|
||||
setFetchEnabled(true);
|
||||
}, 150);
|
||||
return () => clearTimeout(timerRef.current);
|
||||
}, []);
|
||||
|
||||
const { duration, loading } = useDataroomDocumentPageStats({
|
||||
dataroomId,
|
||||
dataroomViewId,
|
||||
documentViewId,
|
||||
documentId,
|
||||
enabled: fetchEnabled,
|
||||
});
|
||||
|
||||
if (loading || !duration) {
|
||||
return <StatsChartSkeleton className="border-none px-0" />;
|
||||
}
|
||||
|
||||
const hasViewData = duration.data.some((item) => item.sum_duration > 0);
|
||||
|
||||
if (!hasViewData && downloadType) {
|
||||
let downloadMessage = "";
|
||||
if (downloadType === "FOLDER" && downloadMetadata?.folderName) {
|
||||
downloadMessage = `Downloaded without viewing via folder "${downloadMetadata.folderName}"`;
|
||||
} else if (downloadType === "BULK") {
|
||||
downloadMessage = "Downloaded without viewing via bulk download";
|
||||
} else {
|
||||
downloadMessage = "Downloaded without viewing";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-2 text-sm text-muted-foreground">
|
||||
<DownloadCloudIcon className="h-4 w-4" />
|
||||
<span>{downloadMessage}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let durationData = Array.from({ length: totalPages }, (_, i) => ({
|
||||
pageNumber: (i + 1).toString(),
|
||||
sum_duration: 0,
|
||||
}));
|
||||
|
||||
durationData = durationData.map((item) => {
|
||||
const match = duration.data.find((d) => d.pageNumber === item.pageNumber);
|
||||
return match || item;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="pb-0.5 pl-0.5 md:pb-1 md:pl-1">
|
||||
<BarChartComponent data={durationData} isSum={true} documentId={documentId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { useTeam } from "@/context/team-context";
|
||||
import { LinkType } from "@prisma/client";
|
||||
import {
|
||||
ExternalLinkIcon,
|
||||
FileTextIcon,
|
||||
FolderKanbanIcon,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { mutate } from "swr";
|
||||
import useSWR from "swr";
|
||||
|
||||
import { fetcher, sanitizeList } from "@/lib/utils";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
import { VisitorGroupWithCount } from "@/lib/swr/use-visitor-groups";
|
||||
|
||||
type VisitorGroupLink = {
|
||||
id: string;
|
||||
link: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
linkType: LinkType;
|
||||
documentId: string | null;
|
||||
dataroomId: string | null;
|
||||
document: { id: string; name: string } | null;
|
||||
dataroom: { id: string; name: string } | null;
|
||||
};
|
||||
};
|
||||
|
||||
type VisitorGroupDetail = VisitorGroupWithCount & {
|
||||
links: VisitorGroupLink[];
|
||||
};
|
||||
|
||||
export function VisitorGroupModal({
|
||||
open,
|
||||
setOpen,
|
||||
existingGroup,
|
||||
}: {
|
||||
open: boolean;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
existingGroup?: VisitorGroupWithCount | null;
|
||||
}) {
|
||||
const [groupName, setGroupName] = useState<string>("");
|
||||
const [emailsInput, setEmailsInput] = useState<string>("");
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const teamInfo = useTeam();
|
||||
const teamId = teamInfo?.currentTeam?.id;
|
||||
const { data: existingGroupDetails, isLoading: loadingGroupDetails } =
|
||||
useSWR<VisitorGroupDetail>(
|
||||
open && existingGroup && teamId
|
||||
? `/api/teams/${teamId}/visitor-groups/${existingGroup.id}`
|
||||
: null,
|
||||
fetcher,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (existingGroup) {
|
||||
setGroupName(existingGroup.name);
|
||||
setEmailsInput(existingGroup.emails.join("\n"));
|
||||
} else {
|
||||
setGroupName("");
|
||||
setEmailsInput("");
|
||||
}
|
||||
}, [existingGroup, open]);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (!groupName || groupName.trim().length < 2) {
|
||||
toast.error("Please provide a group name with at least 2 characters.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const emails = sanitizeList(emailsInput);
|
||||
|
||||
try {
|
||||
const url = existingGroup
|
||||
? `/api/teams/${teamId}/visitor-groups/${existingGroup.id}`
|
||||
: `/api/teams/${teamId}/visitor-groups`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: existingGroup ? "PUT" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: groupName.trim(), emails }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
toast.error(data.error || "Failed to save visitor group.");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(
|
||||
existingGroup
|
||||
? "Visitor group updated successfully!"
|
||||
: "Visitor group created successfully!",
|
||||
);
|
||||
|
||||
mutate(
|
||||
`/api/teams/${teamId}/visitor-groups`,
|
||||
);
|
||||
if (existingGroup) {
|
||||
mutate(`/api/teams/${teamId}/visitor-groups/${existingGroup.id}`);
|
||||
}
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error("Error saving visitor group. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader className="text-start">
|
||||
<DialogTitle>
|
||||
{existingGroup ? "Edit Visitor Group" : "Create Visitor Group"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{existingGroup
|
||||
? "Update the group name and email list. Changes will apply to all links using this group."
|
||||
: "Create a named group of emails and domains. You can then apply this group to document and data room links."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="group-name" className="opacity-80">
|
||||
Group Name
|
||||
</Label>
|
||||
<Input
|
||||
id="group-name"
|
||||
placeholder="e.g., Series A Investors"
|
||||
className="mt-1 w-full"
|
||||
value={groupName}
|
||||
onChange={(e) => setGroupName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="group-emails" className="opacity-80">
|
||||
Emails & Domains
|
||||
</Label>
|
||||
<Textarea
|
||||
id="group-emails"
|
||||
className="mt-1 w-full focus:ring-inset"
|
||||
rows={6}
|
||||
placeholder={`Enter emails or domains, one per line, e.g.
|
||||
investor@fund.com
|
||||
partner@vc.com
|
||||
@example.org`}
|
||||
value={emailsInput}
|
||||
onChange={(e) => setEmailsInput(e.target.value)}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Use @domain.com to allow all emails from a domain.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{existingGroup && (
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
Linked Documents & Datarooms
|
||||
</p>
|
||||
{loadingGroupDetails ? (
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
Loading...
|
||||
</p>
|
||||
) : existingGroupDetails?.links?.length ? (
|
||||
<div className="mt-1.5 space-y-1">
|
||||
{existingGroupDetails.links.map((groupLink) => {
|
||||
const isDataroom =
|
||||
groupLink.link.linkType === "DATAROOM_LINK";
|
||||
const entity = isDataroom
|
||||
? groupLink.link.dataroom
|
||||
: groupLink.link.document;
|
||||
const href = isDataroom
|
||||
? groupLink.link.dataroomId
|
||||
? `/datarooms/${groupLink.link.dataroomId}/documents`
|
||||
: null
|
||||
: groupLink.link.documentId
|
||||
? `/documents/${groupLink.link.documentId}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={groupLink.id}
|
||||
className="flex items-center justify-between rounded-md border border-gray-200 px-2.5 py-1.5 dark:border-gray-800"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{isDataroom ? (
|
||||
<FolderKanbanIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<FileTextIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="truncate text-xs font-medium text-foreground">
|
||||
{entity?.name ||
|
||||
(isDataroom
|
||||
? "Untitled dataroom"
|
||||
: "Untitled document")}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||
via {groupLink.link.name || "Untitled link"}
|
||||
</span>
|
||||
</div>
|
||||
{href && (
|
||||
<Link
|
||||
href={href}
|
||||
className="ml-2 shrink-0 text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<ExternalLinkIcon className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
Not linked to any documents or datarooms yet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className="mt-4">
|
||||
<Button type="submit" loading={loading} className="h-9 w-full">
|
||||
{existingGroup ? "Update Group" : "Create Group"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { useTeam } from "@/context/team-context";
|
||||
import type { LinkType } from "@prisma/client";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ExternalLinkIcon,
|
||||
FileIcon,
|
||||
GlobeIcon,
|
||||
LinkIcon,
|
||||
MailIcon,
|
||||
MoreVerticalIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
ServerIcon,
|
||||
TrashIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { mutate } from "swr";
|
||||
import useSWR from "swr";
|
||||
|
||||
import useVisitorGroups, {
|
||||
VisitorGroupWithCount,
|
||||
} from "@/lib/swr/use-visitor-groups";
|
||||
import { cn, fetcher } from "@/lib/utils";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
import { VisitorGroupModal } from "./visitor-group-modal";
|
||||
|
||||
type VisitorGroupLink = {
|
||||
id: string;
|
||||
link: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
linkType: LinkType;
|
||||
documentId: string | null;
|
||||
dataroomId: string | null;
|
||||
document: { id: string; name: string } | null;
|
||||
dataroom: { id: string; name: string } | null;
|
||||
};
|
||||
};
|
||||
|
||||
type VisitorGroupDetail = VisitorGroupWithCount & {
|
||||
links: VisitorGroupLink[];
|
||||
};
|
||||
|
||||
const splitMembers = (members: string[]) => {
|
||||
const domainMembers = members.filter((member) => member.startsWith("@"));
|
||||
const emailMembers = members.filter((member) => !member.startsWith("@"));
|
||||
return { emailMembers, domainMembers };
|
||||
};
|
||||
|
||||
function VisitorGroupCard({
|
||||
group,
|
||||
teamId,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
group: VisitorGroupWithCount;
|
||||
teamId?: string;
|
||||
onEdit: (group: VisitorGroupWithCount) => void;
|
||||
onDelete: (group: VisitorGroupWithCount) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
const { data: groupDetails, isLoading: loadingDetails } =
|
||||
useSWR<VisitorGroupDetail>(
|
||||
expanded && teamId
|
||||
? `/api/teams/${teamId}/visitor-groups/${group.id}`
|
||||
: null,
|
||||
fetcher,
|
||||
);
|
||||
|
||||
const members = groupDetails?.emails ?? group.emails ?? [];
|
||||
const memberCount = members.length;
|
||||
const linkCount = groupDetails?._count.links ?? group._count.links;
|
||||
const { emailMembers, domainMembers } = splitMembers(members);
|
||||
const previewMembers = members.slice(0, 5);
|
||||
const extraMembersCount = Math.max(members.length - previewMembers.length, 0);
|
||||
|
||||
const links = groupDetails?.links ?? [];
|
||||
const documentLinks = links.filter(
|
||||
(item) => item.link.linkType !== "DATAROOM_LINK",
|
||||
);
|
||||
const dataroomLinks = links.filter(
|
||||
(item) => item.link.linkType === "DATAROOM_LINK",
|
||||
);
|
||||
|
||||
const hasLinkedItems =
|
||||
loadingDetails || documentLinks.length > 0 || dataroomLinks.length > 0;
|
||||
|
||||
const toggle = () => setExpanded((prev) => !prev);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-xl border border-gray-200 transition-colors dark:border-gray-800",
|
||||
expanded
|
||||
? "bg-gray-50/80 dark:bg-gray-900/70"
|
||||
: "bg-white hover:bg-gray-50/50 dark:bg-gray-900 dark:hover:bg-gray-800/40",
|
||||
)}
|
||||
>
|
||||
{/* Clickable header */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={toggle}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
}}
|
||||
className="cursor-pointer px-4 pt-3.5 sm:px-5 sm:pt-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-gray-200 bg-gradient-to-t from-gray-100 dark:border-gray-700 dark:from-gray-800">
|
||||
<UsersIcon className="h-3.5 w-3.5 text-foreground" />
|
||||
</div>
|
||||
<p className="truncate text-sm font-semibold text-foreground">
|
||||
{group.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center gap-1.5">
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-gray-100 px-2 py-0.5 text-xs font-medium text-muted-foreground dark:bg-gray-800">
|
||||
<UsersIcon className="h-3 w-3" />
|
||||
{memberCount} {memberCount === 1 ? "member" : "members"}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-gray-100 px-2 py-0.5 text-xs font-medium text-muted-foreground dark:bg-gray-800">
|
||||
<LinkIcon className="h-3 w-3" />
|
||||
{linkCount} {linkCount === 1 ? "link" : "links"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!expanded && previewMembers.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
{previewMembers.map((member) => (
|
||||
<span
|
||||
key={`${group.id}-${member}`}
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-md px-2 py-0.5 text-xs",
|
||||
member.startsWith("@")
|
||||
? "bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-300"
|
||||
: "bg-gray-100 text-foreground dark:bg-gray-800",
|
||||
)}
|
||||
>
|
||||
{member}
|
||||
</span>
|
||||
))}
|
||||
{extraMembersCount > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
+{extraMembersCount} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-7 w-7 border-gray-200 bg-transparent p-0 hover:bg-gray-100 dark:border-gray-700 dark:hover:bg-gray-800"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreVerticalIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(group);
|
||||
}}
|
||||
>
|
||||
<PencilIcon className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(group);
|
||||
}}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<TrashIcon className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded content */}
|
||||
{expanded && (
|
||||
<div className="px-4 pb-3 pt-3 sm:px-5 sm:pb-4">
|
||||
<div className="space-y-3">
|
||||
{emailMembers.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
Email Members ({emailMembers.length})
|
||||
</p>
|
||||
<div className="mt-1.5 space-y-0.5">
|
||||
{emailMembers.map((email) => (
|
||||
<div
|
||||
key={`${group.id}-email-${email}`}
|
||||
className="flex items-center gap-2 py-0.5 text-xs text-foreground"
|
||||
>
|
||||
<MailIcon className="h-3 w-3 text-muted-foreground" />
|
||||
<span>{email}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{domainMembers.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
Domain Access ({domainMembers.length})
|
||||
</p>
|
||||
<div className="mt-1.5 space-y-0.5">
|
||||
{domainMembers.map((domain) => (
|
||||
<div
|
||||
key={`${group.id}-domain-${domain}`}
|
||||
className="flex items-center gap-2 py-0.5 text-xs text-foreground"
|
||||
>
|
||||
<GlobeIcon className="h-3 w-3 text-muted-foreground" />
|
||||
<span>{domain}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasLinkedItems && (
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
Links ({documentLinks.length + dataroomLinks.length})
|
||||
</p>
|
||||
|
||||
{loadingDetails ? (
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
Loading...
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-1.5 space-y-1">
|
||||
{documentLinks.map((groupLink: VisitorGroupLink) => {
|
||||
const documentId = groupLink.link.documentId;
|
||||
const content = (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<FileIcon className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-xs font-medium text-foreground">
|
||||
{groupLink.link.document?.name ||
|
||||
"Untitled document"}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||
via {groupLink.link.name || "Untitled link"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
return documentId ? (
|
||||
<Link
|
||||
key={groupLink.id}
|
||||
href={`/documents/${documentId}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="group/link flex items-center justify-between rounded-md border border-gray-200 bg-white px-2.5 py-1.5 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50 dark:hover:bg-gray-800"
|
||||
>
|
||||
{content}
|
||||
<ExternalLinkIcon className="ml-2 h-3 w-3 shrink-0 text-muted-foreground transition-colors group-hover/link:text-foreground" />
|
||||
</Link>
|
||||
) : (
|
||||
<div
|
||||
key={groupLink.id}
|
||||
className="flex items-center justify-between rounded-md border border-gray-200 bg-white px-2.5 py-1.5 dark:border-gray-700 dark:bg-gray-800/50"
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{dataroomLinks.map((groupLink) => {
|
||||
const dataroomId = groupLink.link.dataroomId;
|
||||
const content = (
|
||||
<>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ServerIcon className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-xs font-medium text-foreground">
|
||||
{groupLink.link.dataroom?.name ||
|
||||
"Untitled dataroom"}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||
via {groupLink.link.name || "Untitled link"}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
return dataroomId ? (
|
||||
<Link
|
||||
key={groupLink.id}
|
||||
href={`/datarooms/${dataroomId}/permissions`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="group/link flex items-center justify-between rounded-md border border-gray-200 bg-white px-2.5 py-1.5 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50 dark:hover:bg-gray-800"
|
||||
>
|
||||
{content}
|
||||
<ExternalLinkIcon className="ml-2 h-3 w-3 shrink-0 text-muted-foreground transition-colors group-hover/link:text-foreground" />
|
||||
</Link>
|
||||
) : (
|
||||
<div
|
||||
key={groupLink.id}
|
||||
className="flex items-center justify-between rounded-md border border-gray-200 bg-white px-2.5 py-1.5 dark:border-gray-700 dark:bg-gray-800/50"
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chevron toggle at center bottom */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={toggle}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
}}
|
||||
className="flex cursor-pointer justify-center py-1 transition-colors hover:bg-gray-100/60 dark:hover:bg-gray-800/40"
|
||||
>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"h-4 w-4 text-muted-foreground transition-transform",
|
||||
expanded && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function VisitorGroupsSection() {
|
||||
const { visitorGroups, loading } = useVisitorGroups();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingGroup, setEditingGroup] =
|
||||
useState<VisitorGroupWithCount | null>(null);
|
||||
const teamInfo = useTeam();
|
||||
const teamId = teamInfo?.currentTeam?.id;
|
||||
|
||||
const handleEdit = (group: VisitorGroupWithCount) => {
|
||||
setEditingGroup(group);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingGroup(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (group: VisitorGroupWithCount) => {
|
||||
if (
|
||||
!confirm(
|
||||
`Are you sure you want to delete "${group.name}"? This cannot be undone.`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/teams/${teamInfo?.currentTeam?.id}/visitor-groups/${group.id}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
toast.error(data.error || "Failed to delete visitor group.");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("Visitor group deleted successfully.");
|
||||
mutate(`/api/teams/${teamInfo?.currentTeam?.id}/visitor-groups`);
|
||||
} catch (error) {
|
||||
toast.error("Error deleting visitor group. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
Visitor Groups
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create groups of emails and domains, then apply them to link allow
|
||||
lists.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleCreate} size="sm" className="gap-1.5">
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Create Group
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-20 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : !visitorGroups || visitorGroups.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-gray-300 p-8 text-center dark:border-gray-700">
|
||||
<UsersIcon className="mx-auto h-10 w-10 text-muted-foreground/50" />
|
||||
<h4 className="mt-2 text-sm font-medium text-foreground">
|
||||
No visitor groups yet
|
||||
</h4>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Create your first visitor group to manage access across multiple
|
||||
links.
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-4 gap-1.5"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Create Group
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{visitorGroups.map((group) => (
|
||||
<VisitorGroupCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
teamId={teamId}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<VisitorGroupModal
|
||||
open={modalOpen}
|
||||
setOpen={setModalOpen}
|
||||
existingGroup={editingGroup}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,7 @@ export default function DataroomTrial() {
|
||||
|
||||
const [useCase, setUseCase] = useState<string>("");
|
||||
const [customUseCase, setCustomUseCase] = useState<string>("");
|
||||
const [dealSize, setDealSize] = useState<string>("");
|
||||
const [companySize, setCompanySize] = useState<string>("");
|
||||
const [name, setName] = useState<string>("");
|
||||
const [companyName, setCompanyName] = useState<string>("");
|
||||
@@ -40,6 +41,21 @@ export default function DataroomTrial() {
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
// Map use case to deal type for survey
|
||||
const useCaseToDealType: Record<string, string> = {
|
||||
"mergers-and-acquisitions": "mergers-acquisitions",
|
||||
"startup-fundraising": "startup-fundraising",
|
||||
"fund-management": "fund-management",
|
||||
sales: "sales",
|
||||
"project-management": "project-management",
|
||||
operations: "financial-operations",
|
||||
"real-estate": "real-estate",
|
||||
};
|
||||
|
||||
// Check if use case needs deal size question
|
||||
const needsDealSize =
|
||||
!!useCase && useCase !== "project-management";
|
||||
|
||||
// Helper function to convert use case to proper dataroom name
|
||||
const getDataroomName = (useCaseValue: string, customValue: string = "") => {
|
||||
if (useCaseValue === "other" && customValue) {
|
||||
@@ -47,12 +63,13 @@ export default function DataroomTrial() {
|
||||
}
|
||||
|
||||
const useCaseNames: Record<string, string> = {
|
||||
"mergers-and-acquisitions": "Mergers and Acquisitions Data Room",
|
||||
"mergers-and-acquisitions": "Mergers & Acquisitions Data Room",
|
||||
"startup-fundraising": "Startup Fundraising Data Room",
|
||||
"fund-management": "Fund Management & Fundraising Data Room",
|
||||
"fund-management": "Fundraising & Reporting Data Room",
|
||||
sales: "Sales Data Room",
|
||||
"project-management": "Project Management Data Room",
|
||||
operations: "Operations Data Room",
|
||||
operations: "Financial Operations Data Room",
|
||||
"real-estate": "Real Estate Data Room",
|
||||
};
|
||||
|
||||
return useCaseNames[useCaseValue] || "Data Room";
|
||||
@@ -67,11 +84,31 @@ export default function DataroomTrial() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if deal size is required but not filled
|
||||
if (needsDealSize && !dealSize) {
|
||||
toast.error("Please select a deal size.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const dataroomName = getDataroomName(useCase, customUseCase.trim());
|
||||
|
||||
try {
|
||||
// Save survey data to team
|
||||
const dealType = useCase === "other" ? "other" : useCaseToDealType[useCase];
|
||||
if (dealType) {
|
||||
await fetch(`/api/teams/${teamInfo?.currentTeam?.id}/survey`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
dealType,
|
||||
dealTypeOther: useCase === "other" ? customUseCase.trim() : null,
|
||||
dealSize: dealSize || null,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`/api/teams/${teamInfo?.currentTeam?.id}/datarooms/trial`,
|
||||
{
|
||||
@@ -107,6 +144,7 @@ export default function DataroomTrial() {
|
||||
dataroomName: dataroomName,
|
||||
useCase: useCase === "other" ? customUseCase.trim() : useCase,
|
||||
companySize,
|
||||
dealSize,
|
||||
dataroomId,
|
||||
});
|
||||
toast.success("Dataroom successfully created! 🎉");
|
||||
@@ -250,6 +288,8 @@ export default function DataroomTrial() {
|
||||
if (value !== "other") {
|
||||
setCustomUseCase("");
|
||||
}
|
||||
// Reset deal size when use case changes
|
||||
setDealSize("");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -257,19 +297,20 @@ export default function DataroomTrial() {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="mergers-and-acquisitions">
|
||||
Mergers and Acquisitions
|
||||
Mergers & Acquisitions
|
||||
</SelectItem>
|
||||
<SelectItem value="startup-fundraising">
|
||||
Startup Fundraising
|
||||
</SelectItem>
|
||||
<SelectItem value="fund-management">
|
||||
Fund management & Fundraising
|
||||
Fundraising & Reporting
|
||||
</SelectItem>
|
||||
<SelectItem value="sales">Sales</SelectItem>
|
||||
<SelectItem value="project-management">
|
||||
Project management
|
||||
Project Management
|
||||
</SelectItem>
|
||||
<SelectItem value="operations">Operations</SelectItem>
|
||||
<SelectItem value="operations">Financial Operations</SelectItem>
|
||||
<SelectItem value="real-estate">Real Estate</SelectItem>
|
||||
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
@@ -285,6 +326,29 @@ export default function DataroomTrial() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Deal Size - shown after use case is selected (except project-management and operations) */}
|
||||
{needsDealSize && (
|
||||
<div className="space-y-1">
|
||||
<Label className="opacity-80">
|
||||
{useCase === "startup-fundraising" || useCase === "fund-management"
|
||||
? "How much are you raising?*"
|
||||
: "What's the deal size?*"}
|
||||
</Label>
|
||||
<Select onValueChange={(value) => setDealSize(value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select deal size" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0-500k">$0 - $500K</SelectItem>
|
||||
<SelectItem value="500k-5m">$500K - $5M</SelectItem>
|
||||
<SelectItem value="5m-10m">$5M - $10M</SelectItem>
|
||||
<SelectItem value="10m-100m">$10M - $100M</SelectItem>
|
||||
<SelectItem value="100m+">$100M+</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="tools" className="opacity-80">
|
||||
Tools*
|
||||
@@ -318,7 +382,8 @@ export default function DataroomTrial() {
|
||||
!useCase ||
|
||||
!name ||
|
||||
!companyName ||
|
||||
(useCase === "other" && !customUseCase.trim())
|
||||
(useCase === "other" && !customUseCase.trim()) ||
|
||||
(needsDealSize && !dealSize)
|
||||
}
|
||||
loading={loading}
|
||||
>
|
||||
|
||||
+263
-55
@@ -1,5 +1,8 @@
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { useTeam } from "@/context/team-context";
|
||||
import { FileChartPieIcon, FileIcon, PresentationIcon } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
@@ -7,8 +10,143 @@ import { STAGGER_CHILD_VARIANTS } from "@/lib/constants";
|
||||
|
||||
import NotionIcon from "@/components/shared/icons/files/notion";
|
||||
|
||||
export default function Next() {
|
||||
const DEAL_TYPE_OPTIONS = [
|
||||
{ value: "startup-fundraising", label: "Startup Fundraising" },
|
||||
{ value: "fund-management", label: "Fundraising & Reporting" },
|
||||
{ value: "mergers-acquisitions", label: "Mergers & Acquisitions" },
|
||||
{ value: "financial-operations", label: "Financial Operations" },
|
||||
{ value: "real-estate", label: "Real Estate" },
|
||||
{ value: "project-management", label: "Project Management" },
|
||||
];
|
||||
|
||||
const DEAL_SIZE_OPTIONS = [
|
||||
{ value: "0-500k", label: "$0-500K" },
|
||||
{ value: "500k-5m", label: "$500K-5M" },
|
||||
{ value: "5m-10m", label: "$5M-10M" },
|
||||
{ value: "10m-100m", label: "$10M-100M" },
|
||||
{ value: "100m+", label: "$100M+" },
|
||||
];
|
||||
|
||||
export default function Select() {
|
||||
const router = useRouter();
|
||||
const teamInfo = useTeam();
|
||||
const [selectedDoc, setSelectedDoc] = useState<string | null>(null);
|
||||
const [dealType, setDealType] = useState<string | null>(null);
|
||||
const [dealTypeOther, setDealTypeOther] = useState<string>("");
|
||||
const [showOtherInput, setShowOtherInput] = useState(false);
|
||||
const [dealSize, setDealSize] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Determine which survey step to show based on document type
|
||||
const needsDealTypeQuestion =
|
||||
selectedDoc === "notion" || selectedDoc === "document";
|
||||
const needsDealSizeQuestion =
|
||||
selectedDoc === "pitchdeck" ||
|
||||
selectedDoc === "sales-document" ||
|
||||
(needsDealTypeQuestion && dealType && dealType !== "project-management");
|
||||
|
||||
const showDealTypeOptions = needsDealTypeQuestion && !dealType && !showOtherInput;
|
||||
const showDealSizeOptions =
|
||||
needsDealSizeQuestion &&
|
||||
(dealType || !needsDealTypeQuestion) &&
|
||||
!showOtherInput;
|
||||
|
||||
const handleDocSelect = (docType: string) => {
|
||||
setSelectedDoc(docType);
|
||||
setDealType(null);
|
||||
setDealTypeOther("");
|
||||
setShowOtherInput(false);
|
||||
setDealSize(null);
|
||||
|
||||
// Auto-set deal type for specific document types
|
||||
if (docType === "pitchdeck") {
|
||||
setDealType("startup-fundraising");
|
||||
} else if (docType === "sales-document") {
|
||||
setDealType("sales");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDealTypeSelect = (value: string) => {
|
||||
if (isSubmitting) return;
|
||||
setDealType(value);
|
||||
setShowOtherInput(false);
|
||||
if (value === "project-management") {
|
||||
setIsSubmitting(true);
|
||||
saveSurveyAndProceed(value, null, null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOtherConfirm = () => {
|
||||
if (!dealTypeOther.trim()) return;
|
||||
setDealType("other");
|
||||
setShowOtherInput(false);
|
||||
// Show deal size question after confirming "Other"
|
||||
};
|
||||
|
||||
const handleDealSizeSelect = (value: string) => {
|
||||
if (isSubmitting) return;
|
||||
setDealSize(value);
|
||||
setIsSubmitting(true);
|
||||
saveSurveyAndProceed(dealType, value, dealTypeOther || null);
|
||||
};
|
||||
|
||||
const saveSurveyAndProceed = async (
|
||||
type: string | null,
|
||||
size: string | null,
|
||||
otherText: string | null,
|
||||
) => {
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
if (teamInfo?.currentTeam?.id && type) {
|
||||
const res = await fetch(
|
||||
`/api/teams/${teamInfo.currentTeam.id}/survey`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
dealType: type,
|
||||
dealTypeOther: otherText,
|
||||
dealSize: size,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Survey save failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
await router.push({
|
||||
pathname: "/welcome",
|
||||
query: { type: selectedDoc },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to save survey:", error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getDealSizeQuestion = () => {
|
||||
if (
|
||||
selectedDoc === "pitchdeck" ||
|
||||
dealType === "startup-fundraising" ||
|
||||
dealType === "fund-management"
|
||||
) {
|
||||
return "How much are you raising?";
|
||||
}
|
||||
if (
|
||||
dealType === "mergers-acquisitions" ||
|
||||
dealType === "real-estate" ||
|
||||
dealType === "financial-operations" ||
|
||||
dealType === "other"
|
||||
) {
|
||||
return "What's the deal size?";
|
||||
}
|
||||
return "What's the typical deal size?";
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="z-10 mx-5 flex flex-col items-center space-y-10 text-center sm:mx-auto"
|
||||
@@ -38,88 +176,158 @@ export default function Next() {
|
||||
Which document do you want to share today?
|
||||
</h1>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
variants={STAGGER_CHILD_VARIANTS}
|
||||
className="grid w-full grid-cols-1 divide-y divide-border rounded-md border border-border text-foreground md:grid-cols-4 md:divide-x"
|
||||
>
|
||||
<button
|
||||
onClick={() =>
|
||||
router.push({
|
||||
pathname: "/welcome",
|
||||
query: {
|
||||
type: "pitchdeck",
|
||||
},
|
||||
})
|
||||
}
|
||||
className="flex min-h-[200px] flex-col items-center justify-center space-y-5 overflow-hidden p-5 transition-colors hover:bg-gray-200 hover:dark:bg-gray-800 md:p-10"
|
||||
onClick={() => handleDocSelect("pitchdeck")}
|
||||
className={`flex min-h-[200px] flex-col items-center justify-center space-y-5 overflow-hidden p-5 transition-colors md:p-10 ${
|
||||
selectedDoc === "pitchdeck"
|
||||
? "bg-primary/5 ring-2 ring-inset ring-primary"
|
||||
: "hover:bg-gray-200 hover:dark:bg-gray-800"
|
||||
}`}
|
||||
>
|
||||
<PresentationIcon className="pointer-events-none h-auto w-12 sm:w-12" />
|
||||
<p>Pitchdeck</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() =>
|
||||
router.push({
|
||||
pathname: "/welcome",
|
||||
query: {
|
||||
type: "sales-document",
|
||||
},
|
||||
})
|
||||
}
|
||||
className="flex min-h-[200px] flex-col items-center justify-center space-y-5 overflow-hidden p-5 transition-colors hover:bg-gray-200 hover:dark:bg-gray-800 md:p-10"
|
||||
onClick={() => handleDocSelect("sales-document")}
|
||||
className={`flex min-h-[200px] flex-col items-center justify-center space-y-5 overflow-hidden p-5 transition-colors md:p-10 ${
|
||||
selectedDoc === "sales-document"
|
||||
? "bg-primary/5 ring-2 ring-inset ring-primary"
|
||||
: "hover:bg-gray-200 hover:dark:bg-gray-800"
|
||||
}`}
|
||||
>
|
||||
<FileChartPieIcon className="pointer-events-none h-auto w-12 sm:w-12" />
|
||||
<p>Sales document</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() =>
|
||||
router.push({
|
||||
pathname: "/welcome",
|
||||
query: {
|
||||
type: "notion",
|
||||
},
|
||||
})
|
||||
}
|
||||
className="flex min-h-[200px] flex-col items-center justify-center space-y-5 overflow-hidden p-5 transition-colors hover:bg-gray-200 hover:dark:bg-gray-800 md:p-10"
|
||||
onClick={() => handleDocSelect("notion")}
|
||||
className={`flex min-h-[200px] flex-col items-center justify-center space-y-5 overflow-hidden p-5 transition-colors md:p-10 ${
|
||||
selectedDoc === "notion"
|
||||
? "bg-primary/5 ring-2 ring-inset ring-primary"
|
||||
: "hover:bg-gray-200 hover:dark:bg-gray-800"
|
||||
}`}
|
||||
>
|
||||
<NotionIcon className="pointer-events-none h-auto w-12 sm:w-12" />
|
||||
<p>Notion Page</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() =>
|
||||
router.push({
|
||||
pathname: "/welcome",
|
||||
query: {
|
||||
type: "document",
|
||||
},
|
||||
})
|
||||
}
|
||||
className="flex min-h-[200px] flex-col items-center justify-center space-y-5 overflow-hidden p-5 transition-colors hover:bg-gray-200 hover:dark:bg-gray-800 md:p-10"
|
||||
onClick={() => handleDocSelect("document")}
|
||||
className={`flex min-h-[200px] flex-col items-center justify-center space-y-5 overflow-hidden p-5 transition-colors md:p-10 ${
|
||||
selectedDoc === "document"
|
||||
? "bg-primary/5 ring-2 ring-inset ring-primary"
|
||||
: "hover:bg-gray-200 hover:dark:bg-gray-800"
|
||||
}`}
|
||||
>
|
||||
<FileIcon className="pointer-events-none h-auto w-12 sm:w-12" />
|
||||
<p>Another document</p>
|
||||
</button>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
variants={STAGGER_CHILD_VARIANTS}
|
||||
className="text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{/* <button
|
||||
className="text-center text-sm text-muted-foreground underline-offset-4 transition-all hover:text-gray-800 hover:underline hover:dark:text-muted-foreground/80"
|
||||
onClick={() =>
|
||||
router.push({
|
||||
pathname: "/welcome",
|
||||
query: {
|
||||
type: "dataroom",
|
||||
},
|
||||
})
|
||||
}
|
||||
> */}
|
||||
{/* You can start by sharing documents and create a data room later. */}
|
||||
{/* </button> */}
|
||||
</motion.div>
|
||||
{/* Inline Survey Questions */}
|
||||
{selectedDoc && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="w-full max-w-xl space-y-4"
|
||||
>
|
||||
{/* Deal Type Question (for notion/document) */}
|
||||
{showDealTypeOptions && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
What do you use Papermark for?
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{DEAL_TYPE_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => handleDealTypeSelect(option.value)}
|
||||
disabled={isSubmitting}
|
||||
className="rounded-full border border-border px-4 py-2 text-sm transition-all hover:border-primary hover:bg-primary/5"
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setShowOtherInput(true)}
|
||||
disabled={isSubmitting}
|
||||
className="rounded-full border border-border px-4 py-2 text-sm transition-all hover:border-primary hover:bg-primary/5"
|
||||
>
|
||||
Other
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Other Input Field - inline */}
|
||||
{showOtherInput && !dealType && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
What do you use Papermark for?
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={dealTypeOther}
|
||||
onChange={(e) => setDealTypeOther(e.target.value)}
|
||||
placeholder="Please specify..."
|
||||
className="max-w-xs rounded-full border border-border bg-background px-4 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && dealTypeOther.trim()) {
|
||||
handleOtherConfirm();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleOtherConfirm}
|
||||
disabled={!dealTypeOther.trim() || isSubmitting}
|
||||
className="rounded-full border border-primary bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-all hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Deal Size Question */}
|
||||
{showDealSizeOptions && !showDealTypeOptions && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{getDealSizeQuestion()}
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{DEAL_SIZE_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => handleDealSizeSelect(option.value)}
|
||||
disabled={isSubmitting}
|
||||
className="rounded-full border border-border px-4 py-2 text-sm transition-all hover:border-primary hover:bg-primary/5"
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skip option */}
|
||||
<button
|
||||
onClick={() => saveSurveyAndProceed(dealType, null, dealTypeOther || null)}
|
||||
disabled={isSubmitting}
|
||||
aria-disabled={isSubmitting}
|
||||
className="text-xs text-muted-foreground underline-offset-4 hover:underline disabled:opacity-50 disabled:pointer-events-none"
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
export type PendingUploadDocument = {
|
||||
id: string;
|
||||
name: string;
|
||||
folderId: string | null;
|
||||
uploadedAt: Date;
|
||||
status: "uploading" | "processing" | "complete" | "error";
|
||||
progress: number;
|
||||
documentId?: string;
|
||||
dataroomDocumentId?: string;
|
||||
/** Version ID used to subscribe to Trigger.dev realtime processing updates */
|
||||
documentVersionId?: string;
|
||||
fileType?: string;
|
||||
errorMessage?: string;
|
||||
/** Whether this upload was loaded from the server (persisted) */
|
||||
persisted?: boolean;
|
||||
};
|
||||
|
||||
export type PendingUploadsContextType = {
|
||||
pendingUploads: PendingUploadDocument[];
|
||||
addPendingUpload: (upload: PendingUploadDocument) => void;
|
||||
updatePendingUpload: (
|
||||
id: string,
|
||||
update: Partial<PendingUploadDocument>,
|
||||
) => void;
|
||||
removePendingUpload: (id: string) => void;
|
||||
clearCompletedUploads: () => void;
|
||||
getPendingUploadsForFolder: (
|
||||
folderId: string | null,
|
||||
) => PendingUploadDocument[];
|
||||
/** Returns all uploads (in-flight + persisted) */
|
||||
getAllUploads: () => PendingUploadDocument[];
|
||||
/** Whether the visitor has any uploads */
|
||||
hasUploads: boolean;
|
||||
/** Whether persisted uploads are still loading */
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
export const initialState: PendingUploadsContextType = {
|
||||
pendingUploads: [],
|
||||
addPendingUpload: () => {},
|
||||
updatePendingUpload: () => {},
|
||||
removePendingUpload: () => {},
|
||||
clearCompletedUploads: () => {},
|
||||
getPendingUploadsForFolder: () => [],
|
||||
getAllUploads: () => [],
|
||||
hasUploads: false,
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
const PendingUploadsContext =
|
||||
createContext<PendingUploadsContextType>(initialState);
|
||||
|
||||
export const PendingUploadsProvider = ({
|
||||
children,
|
||||
linkId,
|
||||
dataroomId,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
linkId?: string;
|
||||
dataroomId?: string;
|
||||
}): JSX.Element => {
|
||||
// In-flight uploads from the current session (uploading, processing, etc.)
|
||||
const [pendingUploads, setPendingUploads] = useState<
|
||||
PendingUploadDocument[]
|
||||
>([]);
|
||||
// Persisted uploads loaded from the server
|
||||
const [persistedUploads, setPersistedUploads] = useState<
|
||||
PendingUploadDocument[]
|
||||
>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const fetchedRef = useRef(false);
|
||||
|
||||
// Fetch the viewer's persisted uploads on mount
|
||||
useEffect(() => {
|
||||
if (!linkId || !dataroomId || fetchedRef.current) return;
|
||||
fetchedRef.current = true;
|
||||
|
||||
const fetchUploads = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/links/${linkId}/upload?dataroomId=${dataroomId}`,
|
||||
);
|
||||
if (!res.ok) return;
|
||||
|
||||
const data = await res.json();
|
||||
if (data.uploads && Array.isArray(data.uploads)) {
|
||||
const loaded: PendingUploadDocument[] = data.uploads.map(
|
||||
(u: any) => ({
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
folderId: u.folderId,
|
||||
uploadedAt: new Date(u.uploadedAt),
|
||||
status: u.status as "complete" | "processing",
|
||||
progress: 100,
|
||||
documentId: u.documentId,
|
||||
dataroomDocumentId: u.dataroomDocumentId,
|
||||
documentVersionId: u.documentVersionId ?? undefined,
|
||||
fileType: u.fileType,
|
||||
persisted: true,
|
||||
}),
|
||||
);
|
||||
setPersistedUploads(loaded);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch viewer uploads:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUploads();
|
||||
}, [linkId, dataroomId]);
|
||||
|
||||
const addPendingUpload = useCallback((upload: PendingUploadDocument) => {
|
||||
setPendingUploads((prev) => [...prev, upload]);
|
||||
}, []);
|
||||
|
||||
const updatePendingUpload = useCallback(
|
||||
(id: string, update: Partial<PendingUploadDocument>) => {
|
||||
setPendingUploads((prev) =>
|
||||
prev.map((upload) =>
|
||||
upload.id === id ? { ...upload, ...update } : upload,
|
||||
),
|
||||
);
|
||||
setPersistedUploads((prev) =>
|
||||
prev.map((upload) =>
|
||||
upload.id === id ? { ...upload, ...update } : upload,
|
||||
),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const removePendingUpload = useCallback((id: string) => {
|
||||
setPendingUploads((prev) => prev.filter((upload) => upload.id !== id));
|
||||
}, []);
|
||||
|
||||
const clearCompletedUploads = useCallback(() => {
|
||||
setPendingUploads((prev) =>
|
||||
prev.filter((upload) => upload.status !== "complete"),
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Merge in-flight and persisted uploads, deduplicating by documentId
|
||||
const allUploads = useMemo(() => {
|
||||
// In-flight uploads take priority (they have real-time status)
|
||||
const inFlightDocIds = new Set(
|
||||
pendingUploads
|
||||
.filter((u) => u.documentId)
|
||||
.map((u) => u.documentId),
|
||||
);
|
||||
|
||||
// Filter out persisted uploads that already have an in-flight version
|
||||
const filteredPersisted = persistedUploads.filter(
|
||||
(u) => !inFlightDocIds.has(u.documentId),
|
||||
);
|
||||
|
||||
return [...pendingUploads, ...filteredPersisted];
|
||||
}, [pendingUploads, persistedUploads]);
|
||||
|
||||
const getPendingUploadsForFolder = useCallback(
|
||||
(folderId: string | null) => {
|
||||
return allUploads.filter((upload) => upload.folderId === folderId);
|
||||
},
|
||||
[allUploads],
|
||||
);
|
||||
|
||||
const getAllUploads = useCallback(() => {
|
||||
return allUploads;
|
||||
}, [allUploads]);
|
||||
|
||||
const hasUploads = allUploads.length > 0;
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
pendingUploads: allUploads,
|
||||
addPendingUpload,
|
||||
updatePendingUpload,
|
||||
removePendingUpload,
|
||||
clearCompletedUploads,
|
||||
getPendingUploadsForFolder,
|
||||
getAllUploads,
|
||||
hasUploads,
|
||||
isLoading,
|
||||
}),
|
||||
[
|
||||
allUploads,
|
||||
addPendingUpload,
|
||||
updatePendingUpload,
|
||||
removePendingUpload,
|
||||
clearCompletedUploads,
|
||||
getPendingUploadsForFolder,
|
||||
getAllUploads,
|
||||
hasUploads,
|
||||
isLoading,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<PendingUploadsContext.Provider value={value}>
|
||||
{children}
|
||||
</PendingUploadsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const usePendingUploads = () => useContext(PendingUploadsContext);
|
||||
@@ -1,14 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useState } from "react";
|
||||
import { type ComponentPropsWithoutRef, memo, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
CopyIcon,
|
||||
ThumbsDownIcon,
|
||||
ThumbsUpIcon,
|
||||
FileIcon,
|
||||
FileSpreadsheetIcon,
|
||||
FileTextIcon,
|
||||
FolderIcon,
|
||||
ImageIcon,
|
||||
PresentationIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
Message,
|
||||
MessageAction,
|
||||
@@ -18,6 +25,8 @@ import {
|
||||
} from "@/components/ai-elements/message";
|
||||
import { Shimmer } from "@/components/ai-elements/shimmer";
|
||||
|
||||
import type { ChatStreamSource } from "../lib/chat/send-message";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
@@ -29,10 +38,214 @@ export interface ChatMessageProps {
|
||||
className?: string;
|
||||
messageId?: string;
|
||||
onFeedback?: (messageId: string, feedback: "up" | "down") => void;
|
||||
sources?: ChatStreamSource[];
|
||||
suggestedQuestions?: string[];
|
||||
onSuggestedQuestionClick?: (question: string) => void;
|
||||
isLastAssistantMessage?: boolean;
|
||||
}
|
||||
|
||||
type FeedbackState = "up" | "down" | null;
|
||||
|
||||
function getViewerBasePath(): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
const path = window.location.pathname;
|
||||
const dIndex = path.indexOf("/d/");
|
||||
return dIndex !== -1 ? path.slice(0, dIndex) : path;
|
||||
}
|
||||
|
||||
function buildDocumentUrl(
|
||||
dataroomDocumentId: string,
|
||||
page?: number,
|
||||
): string {
|
||||
const base = getViewerBasePath();
|
||||
const query = page ? `?p=${page}` : "";
|
||||
return `${base}/d/${dataroomDocumentId}${query}`;
|
||||
}
|
||||
|
||||
function normalizeAssistantLinkHref(href?: string): string | undefined {
|
||||
if (!href) {
|
||||
return href;
|
||||
}
|
||||
|
||||
if (href.startsWith("/")) {
|
||||
const dIndex = href.indexOf("/d/");
|
||||
if (dIndex !== -1) {
|
||||
const docSegment = href.slice(dIndex);
|
||||
return `${getViewerBasePath()}${docSegment}`;
|
||||
}
|
||||
return href;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(href);
|
||||
const dIndex = parsed.pathname.indexOf("/d/");
|
||||
if (dIndex !== -1) {
|
||||
const docSegment = parsed.pathname.slice(dIndex);
|
||||
return `${getViewerBasePath()}${docSegment}${parsed.search}${parsed.hash}`;
|
||||
}
|
||||
} catch {
|
||||
// Keep original href if URL parsing fails.
|
||||
}
|
||||
|
||||
return href;
|
||||
}
|
||||
|
||||
function AssistantLink({
|
||||
children,
|
||||
className,
|
||||
href,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<"a">) {
|
||||
const normalizedHref = normalizeAssistantLinkHref(href);
|
||||
|
||||
return (
|
||||
<a
|
||||
{...props}
|
||||
className={cn("inline-flex items-center", className)}
|
||||
href={normalizedHref}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// File type icon helper
|
||||
// ============================================================================
|
||||
|
||||
function getFileIcon(filename: string) {
|
||||
const ext = filename.split(".").pop()?.toLowerCase();
|
||||
switch (ext) {
|
||||
case "pdf":
|
||||
return <FileTextIcon className="size-3.5 text-red-500" />;
|
||||
case "ppt":
|
||||
case "pptx":
|
||||
return <PresentationIcon className="size-3.5 text-orange-500" />;
|
||||
case "xls":
|
||||
case "xlsx":
|
||||
case "csv":
|
||||
return <FileSpreadsheetIcon className="size-3.5 text-green-600" />;
|
||||
case "jpg":
|
||||
case "jpeg":
|
||||
case "png":
|
||||
case "gif":
|
||||
case "webp":
|
||||
return <ImageIcon className="size-3.5 text-purple-500" />;
|
||||
default:
|
||||
return <FileIcon className="size-3.5 text-blue-500" />;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Sources Section
|
||||
// ============================================================================
|
||||
|
||||
function SourcesSection({ sources }: { sources: ChatStreamSource[] }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
if (sources.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="!mt-3 rounded-lg border border-gray-200 bg-gray-50/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex w-full items-center justify-between px-3 py-2 text-left text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100/50"
|
||||
>
|
||||
<span>
|
||||
Source: {sources.length}{" "}
|
||||
{sources.length === 1 ? "document" : "documents"}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-3.5 text-gray-400 transition-transform duration-200",
|
||||
expanded && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t border-gray-200 px-3 py-2">
|
||||
<div className="space-y-1.5">
|
||||
{sources.map((source) => (
|
||||
<a
|
||||
key={`${source.id}-${source.page}`}
|
||||
href={
|
||||
source.dataroomDocumentId
|
||||
? buildDocumentUrl(source.dataroomDocumentId, source.page)
|
||||
: normalizeAssistantLinkHref(source.url)
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-start gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-gray-100"
|
||||
>
|
||||
<span className="mt-0.5 shrink-0 text-[10px] font-semibold text-gray-400">
|
||||
{source.id}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{getFileIcon(source.name)}
|
||||
<span className="truncate text-xs font-medium text-gray-900 group-hover:text-primary">
|
||||
{source.name}
|
||||
</span>
|
||||
{source.page && (
|
||||
<span className="shrink-0 rounded bg-gray-200 px-1.5 py-0.5 text-[10px] font-medium text-gray-600">
|
||||
Page {source.page}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{source.folderPath && (
|
||||
<div className="mt-0.5 flex items-center gap-1 text-[10px] text-gray-400">
|
||||
<FolderIcon className="size-2.5" />
|
||||
<span className="truncate">{source.folderPath}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Suggested Questions
|
||||
// ============================================================================
|
||||
|
||||
function SuggestedQuestions({
|
||||
questions,
|
||||
onQuestionClick,
|
||||
}: {
|
||||
questions: string[];
|
||||
onQuestionClick?: (question: string) => void;
|
||||
}) {
|
||||
if (questions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="!mt-5">
|
||||
<p className="mb-1.5 text-[10px] font-medium uppercase tracking-wider text-gray-400">
|
||||
Suggested questions
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{questions.map((question, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => onQuestionClick?.(question)}
|
||||
className="block w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-left text-xs text-gray-700 transition-colors hover:border-primary/30 hover:bg-primary/5 hover:text-gray-900"
|
||||
>
|
||||
{question}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Component
|
||||
// ============================================================================
|
||||
@@ -44,10 +257,20 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
className,
|
||||
messageId,
|
||||
onFeedback,
|
||||
sources,
|
||||
suggestedQuestions,
|
||||
onSuggestedQuestionClick,
|
||||
isLastAssistantMessage,
|
||||
}: ChatMessageProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [feedback, setFeedback] = useState<FeedbackState>(null);
|
||||
const isUser = role === "user";
|
||||
const responseComponents = useMemo(
|
||||
() => ({
|
||||
a: AssistantLink,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
@@ -60,7 +283,6 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
};
|
||||
|
||||
const handleFeedback = (type: "up" | "down") => {
|
||||
// Toggle feedback if clicking the same button, otherwise set new feedback
|
||||
const newFeedback = feedback === type ? null : type;
|
||||
setFeedback(newFeedback);
|
||||
|
||||
@@ -69,7 +291,6 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
}
|
||||
};
|
||||
|
||||
// For user messages, simple rendering
|
||||
if (isUser) {
|
||||
return (
|
||||
<Message from="user" className={className}>
|
||||
@@ -80,7 +301,6 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
);
|
||||
}
|
||||
|
||||
// For assistant messages
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Message from="assistant" className={className}>
|
||||
@@ -89,62 +309,49 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
<Shimmer duration={1.5}>Generating response...</Shimmer>
|
||||
) : (
|
||||
<div className="prose prose-sm max-w-none dark:prose-invert">
|
||||
<MessageResponse>{content}</MessageResponse>
|
||||
<MessageResponse
|
||||
components={responseComponents}
|
||||
linkSafety={{ enabled: false }}
|
||||
>
|
||||
{content}
|
||||
</MessageResponse>
|
||||
</div>
|
||||
)}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
|
||||
{/* Message actions - always visible when there's content and not streaming */}
|
||||
{content && !isStreaming && (
|
||||
<MessageActions className="ml-0">
|
||||
<MessageAction
|
||||
onClick={handleCopy}
|
||||
tooltip={copied ? "Copied!" : "Copy"}
|
||||
label={copied ? "Copied" : "Copy message"}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className="size-3.5 text-green-500" />
|
||||
) : (
|
||||
<CopyIcon className="size-3.5 text-muted-foreground" />
|
||||
<>
|
||||
<MessageActions className="ml-0">
|
||||
<MessageAction
|
||||
onClick={handleCopy}
|
||||
tooltip={copied ? "Copied!" : "Copy"}
|
||||
label={copied ? "Copied" : "Copy message"}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className="size-3.5 text-green-500" />
|
||||
) : (
|
||||
<CopyIcon className="size-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</MessageAction>
|
||||
</MessageActions>
|
||||
|
||||
{sources && sources.length > 0 && (
|
||||
<SourcesSection sources={sources} />
|
||||
)}
|
||||
|
||||
{isLastAssistantMessage &&
|
||||
suggestedQuestions &&
|
||||
suggestedQuestions.length > 0 && (
|
||||
<SuggestedQuestions
|
||||
questions={suggestedQuestions}
|
||||
onQuestionClick={onSuggestedQuestionClick}
|
||||
/>
|
||||
)}
|
||||
</MessageAction>
|
||||
{/* <MessageAction
|
||||
onClick={() => handleFeedback("up")}
|
||||
tooltip="Good response"
|
||||
label="Good response"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
>
|
||||
<ThumbsUpIcon
|
||||
className={`size-3.5 ${
|
||||
feedback === "up"
|
||||
? "fill-green-500 text-green-500"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
/>
|
||||
</MessageAction>
|
||||
<MessageAction
|
||||
onClick={() => handleFeedback("down")}
|
||||
tooltip="Bad response"
|
||||
label="Bad response"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
>
|
||||
<ThumbsDownIcon
|
||||
className={`size-3.5 ${
|
||||
feedback === "down"
|
||||
? "fill-red-500 text-red-500"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
/>
|
||||
</MessageAction> */}
|
||||
</MessageActions>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,8 +6,11 @@ import {
|
||||
AtSignIcon,
|
||||
FileTextIcon,
|
||||
Loader2,
|
||||
MessageSquareTextIcon,
|
||||
Plus,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
ShieldCheckIcon,
|
||||
SparklesIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
@@ -36,6 +39,14 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
import type {
|
||||
ChatStreamMetadata,
|
||||
ChatStreamSource,
|
||||
} from "../lib/chat/send-message";
|
||||
import {
|
||||
CHAT_METADATA_PREFIX,
|
||||
CHAT_METADATA_SUFFIX,
|
||||
} from "../lib/chat/send-message";
|
||||
import { parseTextStream } from "../lib/stream/parse-text-stream";
|
||||
import ChatMessage from "./chat-message";
|
||||
import {
|
||||
@@ -52,6 +63,67 @@ interface Message {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
isStreaming?: boolean;
|
||||
sources?: ChatStreamSource[];
|
||||
suggestedQuestions?: string[];
|
||||
}
|
||||
|
||||
function extractStreamMetadata(content: string): {
|
||||
text: string;
|
||||
metadata?: ChatStreamMetadata;
|
||||
} {
|
||||
const prefixIdx = content.indexOf(CHAT_METADATA_PREFIX);
|
||||
if (prefixIdx === -1) return { text: content };
|
||||
|
||||
const jsonStart = prefixIdx + CHAT_METADATA_PREFIX.length;
|
||||
const suffixIdx = content.indexOf(CHAT_METADATA_SUFFIX, jsonStart);
|
||||
if (suffixIdx === -1) return { text: content };
|
||||
|
||||
try {
|
||||
const jsonStr = content.slice(jsonStart, suffixIdx);
|
||||
const metadata = JSON.parse(jsonStr) as ChatStreamMetadata;
|
||||
const text = content.slice(0, prefixIdx).trim();
|
||||
return { text, metadata };
|
||||
} catch {
|
||||
return { text: content };
|
||||
}
|
||||
}
|
||||
|
||||
const REFERENCES_REGEX =
|
||||
/\n\n(?:#{1,6}\s*)?References\s*\n(?:\s*\n)?((?:[-*]\s.*(?:\n|$))+)$/i;
|
||||
const REFERENCE_LINE_REGEX = /[-*]\s*\[(.+?)]\((.+?)\)(?:\s*-\s*p\.\s*(\d+))?/;
|
||||
|
||||
interface ResolvedRef {
|
||||
dataroomDocumentId: string;
|
||||
documentName: string;
|
||||
url: string;
|
||||
page?: number;
|
||||
folderPath?: string;
|
||||
}
|
||||
|
||||
function parseMarkdownReferences(content: string): {
|
||||
text: string;
|
||||
sources?: ChatStreamSource[];
|
||||
} {
|
||||
const match = content.match(REFERENCES_REGEX);
|
||||
if (!match) return { text: content };
|
||||
|
||||
const text = content.replace(REFERENCES_REGEX, "").trim();
|
||||
const lines = match[1].trim().split("\n").filter(Boolean);
|
||||
const sources: ChatStreamSource[] = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const lineMatch = lines[i].match(REFERENCE_LINE_REGEX);
|
||||
if (lineMatch) {
|
||||
sources.push({
|
||||
id: `D-${i + 1}`,
|
||||
name: lineMatch[1].replace(/\\([\[\]])/g, "$1"),
|
||||
url: lineMatch[2],
|
||||
page: lineMatch[3] ? parseInt(lineMatch[3]) : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { text, sources: sources.length > 0 ? sources : undefined };
|
||||
}
|
||||
|
||||
interface ViewerChatPanelProps {
|
||||
@@ -246,12 +318,43 @@ function ViewerChatPanelContent({
|
||||
setChatId(selectedChatId);
|
||||
setChatTitle(data.title);
|
||||
|
||||
// Convert messages to the format expected by the UI
|
||||
const loadedMessages: Message[] = data.messages.map((msg: any) => ({
|
||||
id: msg.id,
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
}));
|
||||
const loadedMessages: Message[] = data.messages.map((msg: any) => {
|
||||
if (msg.role === "assistant") {
|
||||
const meta = msg.metadata as Record<string, any> | null;
|
||||
const storedSuggestions = meta?.suggestedQuestions as
|
||||
| string[]
|
||||
| undefined;
|
||||
const storedRefs = meta?.references as ResolvedRef[] | undefined;
|
||||
|
||||
if (storedRefs && storedRefs.length > 0) {
|
||||
const { text } = parseMarkdownReferences(msg.content);
|
||||
return {
|
||||
id: msg.id,
|
||||
role: msg.role,
|
||||
content: text,
|
||||
sources: storedRefs.map((ref: ResolvedRef, idx: number) => ({
|
||||
id: `D-${idx + 1}`,
|
||||
name: ref.documentName,
|
||||
url: ref.url,
|
||||
dataroomDocumentId: ref.dataroomDocumentId,
|
||||
page: ref.page,
|
||||
folderPath: ref.folderPath,
|
||||
})),
|
||||
suggestedQuestions: storedSuggestions,
|
||||
};
|
||||
}
|
||||
|
||||
const { text, sources } = parseMarkdownReferences(msg.content);
|
||||
return {
|
||||
id: msg.id,
|
||||
role: msg.role,
|
||||
content: text,
|
||||
sources,
|
||||
suggestedQuestions: storedSuggestions,
|
||||
};
|
||||
}
|
||||
return { id: msg.id, role: msg.role, content: msg.content };
|
||||
});
|
||||
|
||||
setMessages(loadedMessages);
|
||||
} catch (error) {
|
||||
@@ -355,20 +458,27 @@ function ViewerChatPanelContent({
|
||||
};
|
||||
setMessages((prev) => [...prev, assistantMessage]);
|
||||
|
||||
// Parse the text stream
|
||||
await parseTextStream(reader, {
|
||||
onTextDelta: (_delta, accumulated) => {
|
||||
const { text: visibleText } = extractStreamMetadata(accumulated);
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantMessageId ? { ...m, content: accumulated } : m,
|
||||
m.id === assistantMessageId ? { ...m, content: visibleText } : m,
|
||||
),
|
||||
);
|
||||
},
|
||||
onTextEnd: (content) => {
|
||||
onTextEnd: (rawContent) => {
|
||||
const { text, metadata } = extractStreamMetadata(rawContent);
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantMessageId
|
||||
? { ...m, content, isStreaming: false }
|
||||
? {
|
||||
...m,
|
||||
content: text,
|
||||
isStreaming: false,
|
||||
sources: metadata?.sources,
|
||||
suggestedQuestions: metadata?.suggestedQuestions,
|
||||
}
|
||||
: m,
|
||||
),
|
||||
);
|
||||
@@ -409,6 +519,11 @@ function ViewerChatPanelContent({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSuggestedQuestion = (question: string) => {
|
||||
if (isLoading) return;
|
||||
handleSubmit({ text: question, files: [] });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col border-l border-gray-200 bg-white">
|
||||
{/* Header with Thread Selector and New Chat Button */}
|
||||
@@ -463,28 +578,73 @@ function ViewerChatPanelContent({
|
||||
<Conversation className="flex-1">
|
||||
<ConversationContent className="gap-4 p-4">
|
||||
{messages.length === 0 ? (
|
||||
<ConversationEmptyState
|
||||
icon={
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-primary/10">
|
||||
<SparklesIcon className="size-6 text-primary" />
|
||||
<ConversationEmptyState className="items-start text-left">
|
||||
<div className="flex w-full flex-col items-start gap-5 px-2">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex size-8 items-center justify-center rounded-full bg-primary/10">
|
||||
<SparklesIcon className="size-4 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-gray-900">
|
||||
Welcome
|
||||
</h3>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Your AI Data Room Agent is ready to help.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
title="Ask me anything"
|
||||
description={`I can help you understand and analyze the content of this ${dataroomId ? "data room" : "document"}.`}
|
||||
/>
|
||||
|
||||
<div className="w-full space-y-3">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<SearchIcon className="mt-0.5 size-3.5 shrink-0 text-gray-400" />
|
||||
<p className="text-xs leading-relaxed text-gray-500">
|
||||
Get answers to questions about your{" "}
|
||||
{dataroomId ? "data room" : "document"} files. Open a
|
||||
specific document to ask targeted questions.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<MessageSquareTextIcon className="mt-0.5 size-3.5 shrink-0 text-gray-400" />
|
||||
<p className="text-xs leading-relaxed text-gray-500">
|
||||
Ask clear, specific questions for more accurate results.
|
||||
Multiple languages and document types are supported.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<ShieldCheckIcon className="mt-0.5 size-3.5 shrink-0 text-gray-400" />
|
||||
<p className="text-xs leading-relaxed text-gray-500">
|
||||
All conversations are private and confidential.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ConversationEmptyState>
|
||||
) : (
|
||||
<>
|
||||
{messages.map((message) => (
|
||||
<div key={message.id} className="space-y-2">
|
||||
<ChatMessage
|
||||
role={message.role}
|
||||
content={message.content}
|
||||
isStreaming={message.isStreaming}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{(() => {
|
||||
const lastAssistantIdx = messages.reduce(
|
||||
(acc, m, i) => (m.role === "assistant" ? i : acc),
|
||||
-1,
|
||||
);
|
||||
return messages.map((message, idx) => (
|
||||
<div key={message.id} className="space-y-2">
|
||||
<ChatMessage
|
||||
role={message.role}
|
||||
content={message.content}
|
||||
isStreaming={message.isStreaming}
|
||||
sources={message.sources}
|
||||
suggestedQuestions={message.suggestedQuestions}
|
||||
onSuggestedQuestionClick={handleSuggestedQuestion}
|
||||
isLastAssistantMessage={
|
||||
message.role === "assistant" &&
|
||||
idx === lastAssistantIdx &&
|
||||
!isLoading
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
|
||||
{/* Show loading indicator while waiting for response */}
|
||||
{isLoading && messages[messages.length - 1]?.role === "user" && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
|
||||
@@ -1,19 +1,58 @@
|
||||
import { ItemType } from "@prisma/client";
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
/**
|
||||
* Get filtered dataroom document IDs based on link permissions
|
||||
* @param dataroomId - The dataroom ID
|
||||
* @param linkId - The link ID (optional, for external visitors)
|
||||
* @returns Array of accessible dataroom document IDs
|
||||
* @returns Array of accessible dataroom document IDs, or undefined if unrestricted
|
||||
*/
|
||||
export async function getFilteredDataroomDocumentIds(
|
||||
dataroomId: string,
|
||||
linkId?: string,
|
||||
): Promise<string[]> {
|
||||
): Promise<string[] | undefined> {
|
||||
const getDescendantFolderIds = (
|
||||
rootFolderIds: string[],
|
||||
folders: { id: string; parentId: string | null }[],
|
||||
deniedFolderIds: Set<string>,
|
||||
): Set<string> => {
|
||||
const folderIds = new Set(rootFolderIds);
|
||||
const queue = [...rootFolderIds];
|
||||
|
||||
// Build parent -> children map for O(1) descendant traversal
|
||||
const childFoldersByParent = new Map<string, string[]>();
|
||||
for (const folder of folders) {
|
||||
if (!folder.parentId) continue;
|
||||
const children = childFoldersByParent.get(folder.parentId) ?? [];
|
||||
children.push(folder.id);
|
||||
childFoldersByParent.set(folder.parentId, children);
|
||||
}
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currentFolderId = queue.shift();
|
||||
if (!currentFolderId) continue;
|
||||
|
||||
const children = childFoldersByParent.get(currentFolderId) ?? [];
|
||||
for (const childFolderId of children) {
|
||||
// Explicit deny on a folder should block inherited access via parent folder.
|
||||
if (deniedFolderIds.has(childFolderId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (folderIds.has(childFolderId)) continue;
|
||||
folderIds.add(childFolderId);
|
||||
queue.push(childFolderId);
|
||||
}
|
||||
}
|
||||
|
||||
return folderIds;
|
||||
};
|
||||
|
||||
try {
|
||||
// If external visitor with link, check permission groups
|
||||
// No link means this is an internal/team context without link-level restrictions
|
||||
if (!linkId) {
|
||||
return [];
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const link = await prisma.link.findUnique({
|
||||
@@ -28,41 +67,132 @@ export async function getFilteredDataroomDocumentIds(
|
||||
return [];
|
||||
}
|
||||
|
||||
let accessibleDocuments: { itemId: string }[] = [];
|
||||
|
||||
if (link.permissionGroupId) {
|
||||
accessibleDocuments = await prisma.permissionGroupAccessControls.findMany(
|
||||
{
|
||||
where: {
|
||||
groupId: link.permissionGroupId,
|
||||
itemType: "DATAROOM_DOCUMENT",
|
||||
canView: true,
|
||||
},
|
||||
select: {
|
||||
itemId: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
// If link has no group restrictions, treat as unrestricted dataroom access
|
||||
if (!link.groupId && !link.permissionGroupId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (link.groupId) {
|
||||
accessibleDocuments = await prisma.viewerGroupAccessControls.findMany({
|
||||
let accessControls: {
|
||||
itemId: string;
|
||||
itemType: ItemType;
|
||||
canView: boolean;
|
||||
canDownload: boolean;
|
||||
}[] = [];
|
||||
|
||||
if (link.permissionGroupId) {
|
||||
accessControls = await prisma.permissionGroupAccessControls.findMany({
|
||||
where: {
|
||||
groupId: link.groupId,
|
||||
itemType: "DATAROOM_DOCUMENT",
|
||||
canView: true,
|
||||
groupId: link.permissionGroupId,
|
||||
itemType: {
|
||||
in: [ItemType.DATAROOM_DOCUMENT, ItemType.DATAROOM_FOLDER],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
itemId: true,
|
||||
itemType: true,
|
||||
canView: true,
|
||||
canDownload: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const dataroomDocumentIds = accessibleDocuments.map(
|
||||
(document) => document.itemId,
|
||||
if (link.groupId) {
|
||||
// Legacy group permissions take precedence when present
|
||||
accessControls = await prisma.viewerGroupAccessControls.findMany({
|
||||
where: {
|
||||
groupId: link.groupId,
|
||||
itemType: {
|
||||
in: [ItemType.DATAROOM_DOCUMENT, ItemType.DATAROOM_FOLDER],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
itemId: true,
|
||||
itemType: true,
|
||||
canView: true,
|
||||
canDownload: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const isAllowed = (control: { canView: boolean; canDownload: boolean }) =>
|
||||
control.canView || control.canDownload;
|
||||
|
||||
const explicitlyAllowedDocumentIds = new Set(
|
||||
accessControls
|
||||
.filter(
|
||||
(permission) =>
|
||||
permission.itemType === ItemType.DATAROOM_DOCUMENT &&
|
||||
isAllowed(permission),
|
||||
)
|
||||
.map((permission) => permission.itemId),
|
||||
);
|
||||
|
||||
return dataroomDocumentIds;
|
||||
const explicitlyDeniedDocumentIds = new Set(
|
||||
accessControls
|
||||
.filter(
|
||||
(permission) =>
|
||||
permission.itemType === ItemType.DATAROOM_DOCUMENT &&
|
||||
!isAllowed(permission),
|
||||
)
|
||||
.map((permission) => permission.itemId),
|
||||
);
|
||||
|
||||
const allowedFolderIds = accessControls
|
||||
.filter(
|
||||
(permission) =>
|
||||
permission.itemType === ItemType.DATAROOM_FOLDER &&
|
||||
isAllowed(permission),
|
||||
)
|
||||
.map((permission) => permission.itemId);
|
||||
|
||||
const deniedFolderIds = new Set(
|
||||
accessControls
|
||||
.filter(
|
||||
(permission) =>
|
||||
permission.itemType === ItemType.DATAROOM_FOLDER &&
|
||||
!isAllowed(permission),
|
||||
)
|
||||
.map((permission) => permission.itemId),
|
||||
);
|
||||
|
||||
if (allowedFolderIds.length > 0) {
|
||||
const folders = await prisma.dataroomFolder.findMany({
|
||||
where: { dataroomId },
|
||||
select: {
|
||||
id: true,
|
||||
parentId: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Start from explicitly allowed folder IDs and include descendants,
|
||||
// while respecting explicit folder denies.
|
||||
const accessibleFolderIds = Array.from(
|
||||
getDescendantFolderIds(allowedFolderIds, folders, deniedFolderIds),
|
||||
);
|
||||
|
||||
if (accessibleFolderIds.length > 0) {
|
||||
const folderDocuments = await prisma.dataroomDocument.findMany({
|
||||
where: {
|
||||
dataroomId,
|
||||
folderId: { in: accessibleFolderIds },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const document of folderDocuments) {
|
||||
explicitlyAllowedDocumentIds.add(document.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit document denies should always override inherited folder access.
|
||||
for (const deniedDocumentId of explicitlyDeniedDocumentIds) {
|
||||
explicitlyAllowedDocumentIds.delete(deniedDocumentId);
|
||||
}
|
||||
|
||||
return Array.from(explicitlyAllowedDocumentIds);
|
||||
} catch (error) {
|
||||
console.error("Error getting filtered file IDs:", error);
|
||||
throw new Error("Failed to get filtered file IDs");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { OpenAIResponsesProviderOptions, openai } from "@ai-sdk/openai";
|
||||
import { streamText } from "ai";
|
||||
import { generateText, streamText } from "ai";
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
@@ -7,11 +7,425 @@ interface SendMessageOptions {
|
||||
chatId: string;
|
||||
content: string;
|
||||
vectorStoreId: string;
|
||||
/** Permission-scoped dataroom document IDs; undefined means unrestricted */
|
||||
filteredDataroomDocumentIds?: string[];
|
||||
/** Filter file_search results to a specific document by its ID */
|
||||
filterDocumentId?: string;
|
||||
/** User-selected dataroom document IDs to filter file_search results */
|
||||
userSelectedDataroomDocumentIds?: string[];
|
||||
/** Dataroom ID for citation-to-document mapping */
|
||||
dataroomId?: string;
|
||||
/** Link ID used to generate canonical viewer URLs */
|
||||
linkId?: string;
|
||||
}
|
||||
|
||||
interface CitationCandidate {
|
||||
fileId?: string;
|
||||
filename?: string;
|
||||
page?: number;
|
||||
}
|
||||
|
||||
interface ResolvedReference {
|
||||
dataroomDocumentId: string;
|
||||
documentName: string;
|
||||
url: string;
|
||||
page?: number;
|
||||
folderPath?: string;
|
||||
}
|
||||
|
||||
export const CHAT_METADATA_PREFIX = "\n\n<!-- CHAT_METADATA:";
|
||||
export const CHAT_METADATA_SUFFIX = " -->";
|
||||
|
||||
export interface ChatStreamSource {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
dataroomDocumentId?: string;
|
||||
page?: number;
|
||||
folderPath?: string;
|
||||
}
|
||||
|
||||
export interface ChatStreamMetadata {
|
||||
sources: ChatStreamSource[];
|
||||
suggestedQuestions: string[];
|
||||
}
|
||||
|
||||
function validatePageValue(value: unknown): number | undefined {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const rounded = Math.round(value);
|
||||
return rounded < 0 ? undefined : rounded;
|
||||
}
|
||||
|
||||
function normalizePageNumber(value: unknown): number | undefined {
|
||||
const validated = validatePageValue(value);
|
||||
if (validated === undefined) return undefined;
|
||||
// Treat 0 as first page for 1-based page_number / page fields.
|
||||
return validated === 0 ? 1 : validated;
|
||||
}
|
||||
|
||||
function extractPageNumberFromRecord(
|
||||
record: Record<string, unknown>,
|
||||
): number | undefined {
|
||||
const directPage = normalizePageNumber(
|
||||
record.page_number ?? record.page ?? record.pageNumber,
|
||||
);
|
||||
if (directPage !== undefined) {
|
||||
return directPage;
|
||||
}
|
||||
|
||||
// Some payloads expose 0-based page index.
|
||||
const pageIndex = validatePageValue(record.page_index ?? record.pageIndex);
|
||||
if (pageIndex !== undefined) {
|
||||
return pageIndex + 1;
|
||||
}
|
||||
|
||||
const location =
|
||||
typeof record.location === "object" && record.location
|
||||
? (record.location as Record<string, unknown>)
|
||||
: null;
|
||||
if (location) {
|
||||
const locationPage = normalizePageNumber(
|
||||
location.page_number ?? location.page ?? location.pageNumber,
|
||||
);
|
||||
if (locationPage !== undefined) {
|
||||
return locationPage;
|
||||
}
|
||||
|
||||
const locationPageIndex = validatePageValue(
|
||||
location.page_index ?? location.pageIndex,
|
||||
);
|
||||
if (locationPageIndex !== undefined) {
|
||||
return locationPageIndex + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractFileCitations(response: unknown): CitationCandidate[] {
|
||||
const citations: CitationCandidate[] = [];
|
||||
const visited = new Set<object>();
|
||||
|
||||
const addCitation = (candidate: CitationCandidate) => {
|
||||
if (!candidate.fileId && !candidate.filename) return;
|
||||
citations.push(candidate);
|
||||
};
|
||||
|
||||
const visit = (node: unknown) => {
|
||||
if (!node || typeof node !== "object") return;
|
||||
|
||||
if (visited.has(node as object)) {
|
||||
return;
|
||||
}
|
||||
visited.add(node as object);
|
||||
|
||||
const record = node as Record<string, unknown>;
|
||||
|
||||
// OpenAI often provides annotations with type=file_citation.
|
||||
if (record.type === "file_citation") {
|
||||
addCitation({
|
||||
fileId:
|
||||
(record.file_id as string | undefined) ||
|
||||
(record.fileId as string | undefined),
|
||||
filename:
|
||||
(record.filename as string | undefined) ||
|
||||
(record.file_name as string | undefined),
|
||||
page: extractPageNumberFromRecord(record),
|
||||
});
|
||||
}
|
||||
|
||||
// Sometimes payloads are nested under file_citation key.
|
||||
if (record.file_citation && typeof record.file_citation === "object") {
|
||||
const nested = record.file_citation as Record<string, unknown>;
|
||||
addCitation({
|
||||
fileId:
|
||||
(nested.file_id as string | undefined) ||
|
||||
(nested.fileId as string | undefined),
|
||||
filename:
|
||||
(nested.filename as string | undefined) ||
|
||||
(nested.file_name as string | undefined),
|
||||
page: extractPageNumberFromRecord(nested),
|
||||
});
|
||||
visit(record.file_citation);
|
||||
}
|
||||
|
||||
for (const value of Object.values(record)) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
visit(item);
|
||||
}
|
||||
} else {
|
||||
visit(value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
visit(response);
|
||||
|
||||
return citations;
|
||||
}
|
||||
|
||||
function stripTrailingReferencesSection(text: string): string {
|
||||
return text
|
||||
.replace(
|
||||
/\n{2,}(?:#{1,6}\s*)?References\s*\n(?:\s*\n)?(?:[-*]\s.*(?:\n|$))+$/i,
|
||||
"",
|
||||
)
|
||||
.trimEnd();
|
||||
}
|
||||
|
||||
function escapeMarkdownLinkText(value: string): string {
|
||||
return value
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/\[/g, "\\[")
|
||||
.replace(/\]/g, "\\]");
|
||||
}
|
||||
|
||||
async function resolveReferencesFromCitations({
|
||||
dataroomId,
|
||||
linkId,
|
||||
citations,
|
||||
allowedDocumentIds,
|
||||
}: {
|
||||
dataroomId?: string;
|
||||
linkId?: string;
|
||||
citations: CitationCandidate[];
|
||||
/** Permission-scoped dataroom document IDs; when set, only these documents are considered */
|
||||
allowedDocumentIds?: string[];
|
||||
}): Promise<ResolvedReference[]> {
|
||||
if (!dataroomId || !linkId || citations.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const link = await prisma.link.findUnique({
|
||||
where: { id: linkId },
|
||||
select: { domainId: true, domainSlug: true, slug: true },
|
||||
});
|
||||
|
||||
const isCustomDomain = Boolean(link?.domainId && link.domainSlug && link.slug);
|
||||
const viewerBasePath = isCustomDomain
|
||||
? `/${link!.slug}`
|
||||
: `/view/${linkId}`;
|
||||
|
||||
const citedFileIds = Array.from(
|
||||
new Set(
|
||||
citations
|
||||
.map((citation) => citation.fileId)
|
||||
.filter((fileId): fileId is string => Boolean(fileId)),
|
||||
),
|
||||
);
|
||||
|
||||
const fileIdToDocument = new Map<
|
||||
string,
|
||||
{ dataroomDocumentId: string; documentName: string; folderPath?: string }
|
||||
>();
|
||||
|
||||
if (citedFileIds.length > 0) {
|
||||
const dataroomDocuments = await prisma.dataroomDocument.findMany({
|
||||
where: {
|
||||
dataroomId,
|
||||
...(allowedDocumentIds && { id: { in: allowedDocumentIds } }),
|
||||
document: {
|
||||
versions: {
|
||||
some: {
|
||||
isPrimary: true,
|
||||
fileId: { in: citedFileIds },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
folder: {
|
||||
select: {
|
||||
name: true,
|
||||
path: true,
|
||||
},
|
||||
},
|
||||
document: {
|
||||
select: {
|
||||
name: true,
|
||||
versions: {
|
||||
where: { isPrimary: true },
|
||||
take: 1,
|
||||
select: {
|
||||
fileId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const dataroomDocument of dataroomDocuments) {
|
||||
const primaryFileId = dataroomDocument.document.versions[0]?.fileId;
|
||||
if (!primaryFileId) continue;
|
||||
fileIdToDocument.set(primaryFileId, {
|
||||
dataroomDocumentId: dataroomDocument.id,
|
||||
documentName: dataroomDocument.document.name,
|
||||
folderPath: dataroomDocument.folder?.path,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const citedFilenames = Array.from(
|
||||
new Set(
|
||||
citations
|
||||
.map((citation) => citation.filename)
|
||||
.filter((filename): filename is string => Boolean(filename)),
|
||||
),
|
||||
);
|
||||
|
||||
const uniqueFilenameToDocument = new Map<
|
||||
string,
|
||||
{ dataroomDocumentId: string; documentName: string; folderPath?: string }
|
||||
>();
|
||||
|
||||
if (citedFilenames.length > 0) {
|
||||
const namedDataroomDocuments = await prisma.dataroomDocument.findMany({
|
||||
where: {
|
||||
dataroomId,
|
||||
...(allowedDocumentIds && { id: { in: allowedDocumentIds } }),
|
||||
document: {
|
||||
name: { in: citedFilenames },
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
folder: {
|
||||
select: {
|
||||
name: true,
|
||||
path: true,
|
||||
},
|
||||
},
|
||||
document: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const groupedByName = new Map<
|
||||
string,
|
||||
{ dataroomDocumentId: string; documentName: string; folderPath?: string }[]
|
||||
>();
|
||||
for (const item of namedDataroomDocuments) {
|
||||
const current = groupedByName.get(item.document.name) ?? [];
|
||||
current.push({
|
||||
dataroomDocumentId: item.id,
|
||||
documentName: item.document.name,
|
||||
folderPath: item.folder?.path,
|
||||
});
|
||||
groupedByName.set(item.document.name, current);
|
||||
}
|
||||
|
||||
for (const [filename, docs] of groupedByName.entries()) {
|
||||
if (docs.length === 1) {
|
||||
uniqueFilenameToDocument.set(filename, docs[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const references: ResolvedReference[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const citation of citations) {
|
||||
const mappedByFileId = citation.fileId
|
||||
? fileIdToDocument.get(citation.fileId)
|
||||
: undefined;
|
||||
const mappedByFilename = citation.filename
|
||||
? uniqueFilenameToDocument.get(citation.filename)
|
||||
: undefined;
|
||||
|
||||
const mapped = mappedByFileId ?? mappedByFilename;
|
||||
if (!mapped) continue;
|
||||
|
||||
const dedupeKey = `${mapped.dataroomDocumentId}:${citation.page ?? ""}`;
|
||||
if (seen.has(dedupeKey)) continue;
|
||||
seen.add(dedupeKey);
|
||||
|
||||
references.push({
|
||||
dataroomDocumentId: mapped.dataroomDocumentId,
|
||||
documentName: mapped.documentName,
|
||||
url: `${viewerBasePath}/d/${mapped.dataroomDocumentId}${
|
||||
citation.page ? `?p=${citation.page}` : ""
|
||||
}`,
|
||||
page: citation.page,
|
||||
folderPath: mapped.folderPath,
|
||||
});
|
||||
}
|
||||
|
||||
return references;
|
||||
}
|
||||
|
||||
function buildReferencesSection(references: ResolvedReference[]): string {
|
||||
const lines = ["", "", "References", ""];
|
||||
|
||||
if (references.length === 0) {
|
||||
lines.push("- None");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
for (const reference of references) {
|
||||
const documentLabel = escapeMarkdownLinkText(reference.documentName);
|
||||
const pageSuffix = reference.page ? ` - p. ${reference.page}` : "";
|
||||
lines.push(`- [${documentLabel}](${reference.url})${pageSuffix}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function generateSuggestedQuestions(
|
||||
userQuestion: string,
|
||||
assistantResponse: string,
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const { text } = await generateText({
|
||||
model: openai("gpt-4o-mini"),
|
||||
maxOutputTokens: 200,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content:
|
||||
"Generate exactly 3 concise follow-up questions a user might ask based on this conversation. Output only the questions, one per line. No numbering, no bullets, no prefixes.",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: `User asked: "${userQuestion}"\n\nAssistant answered: "${assistantResponse.slice(0, 500)}"`,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return text
|
||||
.split("\n")
|
||||
.map((q) => q.trim())
|
||||
.filter((q) => q.length > 0 && q.endsWith("?"))
|
||||
.slice(0, 3);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function buildStreamMetadataTail(
|
||||
references: ResolvedReference[],
|
||||
suggestedQuestions: string[],
|
||||
): string {
|
||||
const metadata: ChatStreamMetadata = {
|
||||
sources: references.map((ref, idx) => ({
|
||||
id: `D-${idx + 1}`,
|
||||
name: ref.documentName,
|
||||
url: ref.url,
|
||||
dataroomDocumentId: ref.dataroomDocumentId,
|
||||
page: ref.page,
|
||||
folderPath: ref.folderPath,
|
||||
})),
|
||||
suggestedQuestions,
|
||||
};
|
||||
return `${CHAT_METADATA_PREFIX}${JSON.stringify(metadata)}${CHAT_METADATA_SUFFIX}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,6 +439,8 @@ export async function sendMessage({
|
||||
filteredDataroomDocumentIds,
|
||||
filterDocumentId,
|
||||
userSelectedDataroomDocumentIds,
|
||||
dataroomId,
|
||||
linkId,
|
||||
}: SendMessageOptions) {
|
||||
// Get conversation history from database
|
||||
const history = await prisma.chatMessage.findMany({
|
||||
@@ -33,11 +449,20 @@ export async function sendMessage({
|
||||
take: 10,
|
||||
});
|
||||
|
||||
const hasPriorAssistantReply = history.some((m) => m.role === "assistant");
|
||||
const reasoningEffort = "minimal";
|
||||
const maxOutputTokens = hasPriorAssistantReply ? 420 : 220;
|
||||
|
||||
// Save user message
|
||||
await prisma.chatMessage.create({
|
||||
data: { chatId, role: "user", content },
|
||||
});
|
||||
|
||||
let resolveReferencesForStream: (value: string) => void = () => {};
|
||||
const referencesForStream = new Promise<string>((resolve) => {
|
||||
resolveReferencesForStream = resolve;
|
||||
});
|
||||
|
||||
// Build messages for AI SDK
|
||||
const messages = [
|
||||
{
|
||||
@@ -45,11 +470,30 @@ export async function sendMessage({
|
||||
content: `You are a helpful AI assistant answering questions about documents.
|
||||
Use the file_search tool to find relevant information from the documents.
|
||||
Always cite sources with document names and page numbers when providing information.
|
||||
Do not include a "References" section in your answer. References are appended automatically by the system.
|
||||
Keep answers concise, direct, and non-repetitive.
|
||||
Start with the direct answer in the first sentence.
|
||||
Default format: 1 short paragraph or up to 2 bullets.
|
||||
Avoid long document lists unless the user explicitly asks for a full list.
|
||||
Only mention documents that are directly needed for the user's question.
|
||||
${
|
||||
!hasPriorAssistantReply
|
||||
? `This is the first assistant reply in the conversation.
|
||||
Prioritize speed:
|
||||
- Start with a direct answer in 1-2 sentences.
|
||||
- Add at most 2 short bullets only if needed.
|
||||
- Keep it under 80 words before references.
|
||||
If deeper analysis might help, add one short sentence offering a deeper follow-up.`
|
||||
: ""
|
||||
}
|
||||
If you cannot find the answer in the documents, say so clearly.`,
|
||||
},
|
||||
...history.map((m) => ({
|
||||
role: m.role as "user" | "assistant",
|
||||
content: m.content,
|
||||
content:
|
||||
m.role === "assistant"
|
||||
? stripTrailingReferencesSection(m.content)
|
||||
: m.content,
|
||||
})),
|
||||
{ role: "user" as const, content },
|
||||
];
|
||||
@@ -59,6 +503,9 @@ If you cannot find the answer in the documents, say so clearly.`,
|
||||
vectorStoreIds: [vectorStoreId],
|
||||
};
|
||||
|
||||
const NO_ACCESS_SENTINEL = "__no_access__";
|
||||
let effectiveDataroomDocumentIds: string[] | undefined;
|
||||
|
||||
// Add document filter when viewing a specific document in a dataroom
|
||||
if (filterDocumentId) {
|
||||
fileSearchOptions.filters = {
|
||||
@@ -73,35 +520,39 @@ If you cannot find the answer in the documents, say so clearly.`,
|
||||
// User explicitly selected documents to filter to
|
||||
// Intersect with permission-based filter if it exists
|
||||
let effectiveIds = userSelectedDataroomDocumentIds;
|
||||
if (filteredDataroomDocumentIds && filteredDataroomDocumentIds.length > 0) {
|
||||
if (filteredDataroomDocumentIds !== undefined) {
|
||||
// Only include user-selected IDs that are also in the permission-filtered list
|
||||
effectiveIds = userSelectedDataroomDocumentIds.filter((id) =>
|
||||
filteredDataroomDocumentIds.includes(id),
|
||||
);
|
||||
// Security: If intersection is empty (user sent unauthorized IDs),
|
||||
// fall back to the permission-based filter to prevent searching all documents
|
||||
// fall back to the permission-based filter
|
||||
if (effectiveIds.length === 0) {
|
||||
effectiveIds = filteredDataroomDocumentIds;
|
||||
}
|
||||
}
|
||||
if (effectiveIds.length > 0) {
|
||||
effectiveDataroomDocumentIds = effectiveIds;
|
||||
} else if (filteredDataroomDocumentIds !== undefined) {
|
||||
// Permission-restricted dataroom chat scope
|
||||
effectiveDataroomDocumentIds = filteredDataroomDocumentIds;
|
||||
}
|
||||
|
||||
if (!filterDocumentId && effectiveDataroomDocumentIds !== undefined) {
|
||||
if (effectiveDataroomDocumentIds.length > 0) {
|
||||
fileSearchOptions.filters = {
|
||||
type: "in",
|
||||
key: "dataroomDocumentId",
|
||||
value: effectiveIds,
|
||||
value: effectiveDataroomDocumentIds,
|
||||
};
|
||||
} else {
|
||||
// Keep retrieval restricted when the viewer has no accessible documents
|
||||
// by applying a guaranteed-empty metadata filter.
|
||||
fileSearchOptions.filters = {
|
||||
type: "eq",
|
||||
key: "dataroomDocumentId",
|
||||
value: NO_ACCESS_SENTINEL,
|
||||
};
|
||||
}
|
||||
} else if (
|
||||
filteredDataroomDocumentIds &&
|
||||
filteredDataroomDocumentIds.length > 0
|
||||
) {
|
||||
// Only apply filter if there are specific documents to filter to
|
||||
// An empty array would filter out ALL documents
|
||||
fileSearchOptions.filters = {
|
||||
type: "in",
|
||||
key: "dataroomDocumentId",
|
||||
value: filteredDataroomDocumentIds,
|
||||
};
|
||||
}
|
||||
|
||||
const latestMessage = history.at(0);
|
||||
@@ -112,6 +563,7 @@ If you cannot find the answer in the documents, say so clearly.`,
|
||||
// Use AI SDK streamText with file_search tool
|
||||
const result = streamText({
|
||||
model: openai.responses("gpt-4o-mini"),
|
||||
maxOutputTokens,
|
||||
messages,
|
||||
tools: {
|
||||
file_search: openai.tools.fileSearch(fileSearchOptions),
|
||||
@@ -119,31 +571,111 @@ If you cannot find the answer in the documents, say so clearly.`,
|
||||
providerOptions: {
|
||||
openai: {
|
||||
previousResponseId,
|
||||
} satisfies OpenAIResponsesProviderOptions,
|
||||
} as OpenAIResponsesProviderOptions,
|
||||
},
|
||||
onFinish: async ({ text, usage, response }) => {
|
||||
await Promise.all([
|
||||
prisma.chatMessage.create({
|
||||
data: {
|
||||
chatId,
|
||||
role: "assistant",
|
||||
content: text,
|
||||
metadata: {
|
||||
usage,
|
||||
vectorStoreId,
|
||||
responseId: response.id,
|
||||
modelId: response.modelId,
|
||||
filters: fileSearchOptions.filters,
|
||||
try {
|
||||
const shouldAppendReferences = Boolean(dataroomId && linkId);
|
||||
const citations = extractFileCitations(response);
|
||||
|
||||
const [resolvedReferences, suggestedQuestions] = await Promise.all([
|
||||
resolveReferencesFromCitations({
|
||||
dataroomId,
|
||||
linkId,
|
||||
citations,
|
||||
allowedDocumentIds: filteredDataroomDocumentIds,
|
||||
}),
|
||||
generateSuggestedQuestions(content, text),
|
||||
]);
|
||||
|
||||
const maxReferences = hasPriorAssistantReply ? 6 : 3;
|
||||
const limitedReferences = resolvedReferences.slice(0, maxReferences);
|
||||
|
||||
const referencesSection = shouldAppendReferences
|
||||
? buildReferencesSection(limitedReferences)
|
||||
: "";
|
||||
|
||||
const strippedText = stripTrailingReferencesSection(text).trim();
|
||||
const rawTrimmedText = text.trim();
|
||||
const hasRawText = rawTrimmedText.length > 0;
|
||||
const resolvedText =
|
||||
strippedText ||
|
||||
rawTrimmedText ||
|
||||
"I couldn't find that in the indexed documents.";
|
||||
|
||||
const metadataTail = buildStreamMetadataTail(
|
||||
shouldAppendReferences ? limitedReferences : [],
|
||||
suggestedQuestions,
|
||||
);
|
||||
|
||||
const streamTail = hasRawText
|
||||
? metadataTail
|
||||
: `${resolvedText}${metadataTail}`;
|
||||
|
||||
resolveReferencesForStream(streamTail);
|
||||
|
||||
const finalContent = shouldAppendReferences
|
||||
? `${resolvedText}${referencesSection}`
|
||||
: resolvedText;
|
||||
|
||||
await Promise.all([
|
||||
prisma.chatMessage.create({
|
||||
data: {
|
||||
chatId,
|
||||
role: "assistant",
|
||||
content: finalContent,
|
||||
metadata: {
|
||||
usage: usage as any,
|
||||
vectorStoreId,
|
||||
responseId: response.id,
|
||||
modelId: response.modelId,
|
||||
filters: fileSearchOptions.filters as any,
|
||||
...(shouldAppendReferences && {
|
||||
citations: citations as any,
|
||||
references: limitedReferences as any,
|
||||
}),
|
||||
suggestedQuestions,
|
||||
reasoningEffort,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.chat.update({
|
||||
where: { id: chatId },
|
||||
data: { lastMessageAt: new Date() },
|
||||
}),
|
||||
]);
|
||||
}),
|
||||
prisma.chat.update({
|
||||
where: { id: chatId },
|
||||
data: { lastMessageAt: new Date() },
|
||||
}),
|
||||
]);
|
||||
} catch (error) {
|
||||
resolveReferencesForStream("");
|
||||
console.error("Error finalizing AI response references:", error);
|
||||
|
||||
await Promise.all([
|
||||
prisma.chatMessage.create({
|
||||
data: {
|
||||
chatId,
|
||||
role: "assistant",
|
||||
content: text,
|
||||
metadata: {
|
||||
usage,
|
||||
vectorStoreId,
|
||||
responseId: response.id,
|
||||
modelId: response.modelId,
|
||||
filters: fileSearchOptions.filters,
|
||||
referenceError:
|
||||
error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.chat.update({
|
||||
where: { id: chatId },
|
||||
data: { lastMessageAt: new Date() },
|
||||
}),
|
||||
]);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
return {
|
||||
result,
|
||||
referencesForStream,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -564,6 +564,12 @@ const routeHandlers = {
|
||||
return res.status(201).json(message);
|
||||
} catch (error) {
|
||||
console.error("Error adding message:", error);
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message.startsWith("Content cannot be")
|
||||
) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
return res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -29,7 +29,7 @@ export function ConversationMessage({
|
||||
return (
|
||||
<div className="group relative">
|
||||
<div
|
||||
className={`flex w-max max-w-[80%] cursor-pointer flex-col rounded-lg px-4 py-2 transition-all ${
|
||||
className={`flex w-fit max-w-[80%] cursor-pointer flex-col rounded-lg px-4 py-2 transition-all ${
|
||||
isAuthor ? "ml-auto bg-primary text-primary-foreground" : "bg-muted"
|
||||
} ${
|
||||
isSelected
|
||||
@@ -41,7 +41,9 @@ export function ConversationMessage({
|
||||
onClick={() => canBeSelected && onSelect?.(message.id, isVisitor)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="flex-1 text-sm">{message.content}</p>
|
||||
<p className="min-w-0 flex-1 break-words text-sm">
|
||||
{message.content}
|
||||
</p>
|
||||
<div className="mt-0.5 flex items-center gap-1">
|
||||
{isPublished && (
|
||||
<div className="flex items-center text-xs opacity-60">
|
||||
|
||||
@@ -11,6 +11,7 @@ import { toast } from "sonner";
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
import { fetcher } from "@/lib/utils";
|
||||
import { MAX_MESSAGE_LENGTH } from "@/lib/utils/sanitize-html";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
@@ -342,6 +343,7 @@ export function ConversationViewSidebar({
|
||||
type="text"
|
||||
value={newMessage}
|
||||
onChange={(e) => setNewMessage(e.target.value)}
|
||||
maxLength={MAX_MESSAGE_LENGTH}
|
||||
className="flex-1 rounded-md border border-input px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
placeholder="Type your message..."
|
||||
/>
|
||||
@@ -382,6 +384,7 @@ export function ConversationViewSidebar({
|
||||
id="message"
|
||||
value={newMessage}
|
||||
onChange={(e) => setNewMessage(e.target.value)}
|
||||
maxLength={MAX_MESSAGE_LENGTH}
|
||||
placeholder="Type your question..."
|
||||
className="min-h-[100px] w-full rounded-md border border-input px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
required
|
||||
|
||||
@@ -203,12 +203,12 @@ export function FAQSection({
|
||||
value={faq.id}
|
||||
className="rounded-md border border-gray-200 bg-white"
|
||||
>
|
||||
<AccordionTrigger className="px-3 py-2.5 hover:no-underline">
|
||||
<AccordionTrigger className="min-w-0 px-3 py-2.5 hover:no-underline">
|
||||
<div className="flex w-full items-start justify-between gap-2">
|
||||
<span className="flex-shrink-0 rounded bg-secondary px-1.5 py-0.5 text-xs font-medium text-secondary-foreground">
|
||||
Q
|
||||
</span>
|
||||
<div className="line-clamp-2 min-w-0 flex-1 text-left text-sm font-medium text-gray-900">
|
||||
<div className="line-clamp-2 min-w-0 flex-1 break-words text-left text-sm font-medium text-gray-900">
|
||||
{faq.editedQuestion}
|
||||
</div>
|
||||
</div>
|
||||
@@ -220,7 +220,7 @@ export function FAQSection({
|
||||
<span className="flex-shrink-0 rounded bg-primary/80 px-1.5 py-0.5 text-xs font-medium text-primary-foreground">
|
||||
A
|
||||
</span>
|
||||
<p className="whitespace-pre-wrap text-sm font-medium text-gray-700">
|
||||
<p className="min-w-0 break-words whitespace-pre-wrap text-sm font-medium text-gray-700">
|
||||
{faq.answer}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,7 @@ import z from "zod";
|
||||
import { useDataroom } from "@/lib/swr/use-dataroom";
|
||||
import { CustomUser } from "@/lib/types";
|
||||
import { fetcher } from "@/lib/utils";
|
||||
import { MAX_MESSAGE_LENGTH } from "@/lib/utils/sanitize-html";
|
||||
|
||||
import { DataroomHeader } from "@/components/datarooms/dataroom-header";
|
||||
import { DataroomNavigation } from "@/components/datarooms/dataroom-navigation";
|
||||
@@ -334,7 +335,10 @@ export default function ConversationDetailPage() {
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) throw new Error("Failed to send message");
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || "Failed to send message");
|
||||
}
|
||||
|
||||
// Revalidate both the conversation and the summaries
|
||||
mutate(
|
||||
@@ -347,7 +351,9 @@ export default function ConversationDetailPage() {
|
||||
toast.success("Message sent");
|
||||
} catch (error) {
|
||||
console.error("Error sending message:", error);
|
||||
toast.error("Failed to send message");
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to send message",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -705,10 +711,11 @@ function ConversationReplyForm({
|
||||
}) {
|
||||
const [newMessage, setNewMessage] = useState("");
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const isOverLimit = newMessage.length > MAX_MESSAGE_LENGTH;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newMessage.trim()) return;
|
||||
if (!newMessage.trim() || isOverLimit) return;
|
||||
|
||||
setIsSending(true);
|
||||
try {
|
||||
@@ -724,15 +731,20 @@ function ConversationReplyForm({
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<Textarea
|
||||
placeholder="Type your reply..."
|
||||
className="min-h-[100px]"
|
||||
className={`min-h-[100px] ${isOverLimit ? "border-destructive focus-visible:ring-destructive" : ""}`}
|
||||
value={newMessage}
|
||||
onChange={(e) => setNewMessage(e.target.value)}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={`text-xs ${isOverLimit ? "font-medium text-destructive" : "text-muted-foreground"}`}
|
||||
>
|
||||
{newMessage.length > 0 &&
|
||||
`${newMessage.length} / ${MAX_MESSAGE_LENGTH}`}
|
||||
</span>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!newMessage.trim() || isSending}
|
||||
className="ml-auto"
|
||||
disabled={!newMessage.trim() || isOverLimit || isSending}
|
||||
>
|
||||
{isSending ? (
|
||||
<>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user