Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
#!/usr/bin/env sh
. "$(dirname "$0")/_/husky.sh"

# Load nvm
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
Expand Down
3 changes: 0 additions & 3 deletions .husky/pre-push
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
#!/usr/bin/env sh
. "$(dirname "$0")/_/husky.sh"

# Load nvm
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
Expand Down
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@
},
"pnpm": {
"overrides": {
"tmp": ">=0.2.4"
"tmp": ">=0.2.4",
"glob": ">=11.1.0",
"js-yaml": ">=4.1.1"
}
},
"dependencies": {
Expand Down Expand Up @@ -131,7 +133,7 @@
"lint-staged": "^15.5.2",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.6.14",
"rimraf": "^6.0.1",
"rimraf": "^6.1.2",
"semantic-release": "^24.2.9",
"tw-animate-css": "^1.3.8",
"typescript": "~5.9.2",
Expand Down
6,190 changes: 3,079 additions & 3,111 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

14 changes: 5 additions & 9 deletions src/components/common/MobileAppBanner/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState } from 'react';
import { X, Smartphone } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useMobilePlatform } from '@/hooks/useIsMobile';
Expand All @@ -10,15 +10,11 @@ const BANNER_DISMISSED_KEY = 'typelets_mobile_banner_dismissed';

export function MobileAppBanner() {
const platform = useMobilePlatform();
const [isDismissed, setIsDismissed] = useState(true);

useEffect(() => {
// Check if banner was previously dismissed
// Initialize state based on localStorage to avoid setState in effect
const [isDismissed, setIsDismissed] = useState(() => {
const dismissed = localStorage.getItem(BANNER_DISMISSED_KEY);
if (!dismissed && platform) {
setIsDismissed(false);
}
}, [platform]);
return !!dismissed || !platform;
});

const handleDismiss = () => {
setIsDismissed(true);
Expand Down
4 changes: 3 additions & 1 deletion src/components/editor/extensions/NoteLinkSuggestionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,12 @@ export const NoteLinkSuggestionList = forwardRef<
);
}, [filteredItems.length]);

// Reset selection when items change
// Reset selection when filtered items change
/* eslint-disable react-hooks/set-state-in-effect -- Legitimate reset on data change */
useEffect(() => {
setSelectedIndex(0);
}, [filteredItems]);
/* eslint-enable react-hooks/set-state-in-effect */

useImperativeHandle(
ref,
Expand Down
9 changes: 5 additions & 4 deletions src/components/editor/extensions/ResizableImage.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Node, mergeAttributes } from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react';
import { NodeViewWrapper } from '@tiptap/react';
import { useState, useRef, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { X, Maximize2 } from 'lucide-react';

const ImageComponent = (props: {
Expand All @@ -19,15 +19,16 @@ const ImageComponent = (props: {
const { node, deleteNode, updateAttributes } = props;
const [showControls, setShowControls] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [width, setWidth] = useState(node.attrs.width || 'auto');
const imageRef = useRef<HTMLImageElement>(null);
const containerRef = useRef<HTMLDivElement>(null);

const initialWidth = node.attrs.width || 'auto';
const [width, setWidth] = useState(initialWidth);

// Sync width with node attrs
/* eslint-disable react-hooks/set-state-in-effect -- Sync external prop to local state */
useEffect(() => {
setWidth(node.attrs.width || 'auto');
}, [node.attrs.width]);
/* eslint-enable react-hooks/set-state-in-effect */

const handleResize = (e: React.MouseEvent) => {
e.preventDefault();
Expand Down
5 changes: 0 additions & 5 deletions src/components/editor/extensions/SlashCommands.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import {
useState,
useEffect,
useImperativeHandle,
forwardRef,
useCallback,
Expand Down Expand Up @@ -277,10 +276,6 @@ export const SlashCommandsList = forwardRef<
setSelectedIndex((selectedIndex + 1) % commands.length);
}, [selectedIndex]);

useEffect(() => {
setSelectedIndex(0);
}, []);

useImperativeHandle(
ref,
() => ({
Expand Down
2 changes: 2 additions & 0 deletions src/components/editor/hooks/useEditorEffects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ export function useEditorEffects({

const editorElement = editor.view.dom as HTMLElement;

/* eslint-disable react-hooks/immutability -- Modifying DOM element style, not React state */
if (zoomLevel === 100) {
// At 100%, use the original font size
editorElement.style.fontSize = baseFontSize;
Expand All @@ -161,6 +162,7 @@ export function useEditorEffects({
const newSize = baseSize * scaleFactor;
editorElement.style.fontSize = `${newSize}px`;
}
/* eslint-enable react-hooks/immutability */
} catch {
// Silently ignore zoom level application errors
}
Expand Down
34 changes: 21 additions & 13 deletions src/components/layout/MainLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,18 @@ export default function MainLayout() {
const [activeTabId, setActiveTabId] = useState<string | null>(null);

// Sync responsive panel states with local states for desktop
/* eslint-disable react-hooks/set-state-in-effect -- Sync external state to local state */
useEffect(() => {
if (!isMobile) {
setFolderSidebarOpen(responsiveFolderPanel.isOpen);
setFilesPanelOpen(responsiveNotesPanel.isOpen);
if (folderSidebarOpen !== responsiveFolderPanel.isOpen) {
setFolderSidebarOpen(responsiveFolderPanel.isOpen);
}
if (filesPanelOpen !== responsiveNotesPanel.isOpen) {
setFilesPanelOpen(responsiveNotesPanel.isOpen);
}
}
}, [isMobile, responsiveFolderPanel.isOpen, responsiveNotesPanel.isOpen]);
}, [isMobile, responsiveFolderPanel.isOpen, responsiveNotesPanel.isOpen, folderSidebarOpen, filesPanelOpen]);
/* eslint-enable react-hooks/set-state-in-effect */

const {
notes,
Expand Down Expand Up @@ -280,17 +286,19 @@ export default function MainLayout() {
}, [selectedNote]);

// Update tab properties when note changes
/* eslint-disable react-hooks/set-state-in-effect -- Sync note properties to tab state */
useEffect(() => {
if (!selectedNote?.id) return;

setOpenTabs(tabs =>
tabs.map(tab =>
tab.noteId === selectedNote.id
? { ...tab, title: selectedNote.title || 'Untitled', type: selectedNote.type || 'note', isPublished: selectedNote.isPublished }
: tab
)
);
}, [selectedNote]);
if (selectedNote?.id) {
setOpenTabs(tabs =>
tabs.map(tab =>
tab.noteId === selectedNote.id
? { ...tab, title: selectedNote.title || 'Untitled', type: selectedNote.type || 'note', isPublished: selectedNote.isPublished }
: tab
)
);
}
}, [selectedNote?.id, selectedNote?.title, selectedNote?.type, selectedNote?.isPublished]);
/* eslint-enable react-hooks/set-state-in-effect */

const handlePasswordChange = useCallback(() => {
setSelectedNote(null);
Expand Down
6 changes: 3 additions & 3 deletions src/components/notes/NotesPanel/NoteCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ function NoteCard({
}

return text || 'No additional text';
}, [note?.content, note?.hidden]);
}, [note]);

const folder = useMemo(() => {
// First check if note has embedded folder data
Expand All @@ -88,7 +88,7 @@ function NoteCard({
// Fallback to looking up in folders array
if (!note?.folderId || !folders || folders.length === 0) return null;
return folders.find((f) => f.id === note.folderId) || null;
}, [note?.folder, note?.folderId, folders]);
}, [note, folders]);

const hasExecutableCode = useMemo(() => {
if (!note?.content) return false;
Expand All @@ -99,7 +99,7 @@ function NoteCard({
note.content.includes('class="executable-code-block"') ||
note.content.includes('executableCodeBlock')
);
}, [note?.content]);
}, [note]);

if (!note) {
return null;
Expand Down
3 changes: 3 additions & 0 deletions src/components/password/ChangeMasterPasswordDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export function ChangeMasterPasswordDialog({
const [isChanging, setIsChanging] = useState(false);
const newPasswordRef = useRef<HTMLInputElement>(null);

// Reset form state when dialog opens
/* eslint-disable react-hooks/set-state-in-effect -- Legitimate form reset on dialog open */
useEffect(() => {
if (open) {
setNewPassword('');
Expand All @@ -38,6 +40,7 @@ export function ChangeMasterPasswordDialog({
}, 100);
}
}, [open]);
/* eslint-enable react-hooks/set-state-in-effect */

const handleChange = async () => {
if (!user?.id || isChanging) return;
Expand Down
27 changes: 12 additions & 15 deletions src/hooks/useIsMobile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,16 +62,13 @@ export function useBreakpoint() {
* Checks user agent to distinguish mobile devices from desktop browsers
*/
export function useIsMobileDevice(): boolean {
const [isMobile, setIsMobile] = useState(false);

useEffect(() => {
const [isMobile] = useState(() => {
if (typeof navigator === 'undefined') return false;
const userAgent = navigator.userAgent || navigator.vendor;
const isMobileUA =
/android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(
userAgent.toLowerCase()
);
setIsMobile(isMobileUA);
}, []);
return /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(
userAgent.toLowerCase()
);
});

return isMobile;
}
Expand All @@ -80,17 +77,17 @@ export function useIsMobileDevice(): boolean {
* Detect specific mobile platform (iOS or Android)
*/
export function useMobilePlatform(): 'ios' | 'android' | null {
const [platform, setPlatform] = useState<'ios' | 'android' | null>(null);

useEffect(() => {
const [platform] = useState<'ios' | 'android' | null>(() => {
if (typeof navigator === 'undefined') return null;
const userAgent = navigator.userAgent || navigator.vendor;

if (/iphone|ipad|ipod/i.test(userAgent.toLowerCase())) {
setPlatform('ios');
return 'ios';
} else if (/android/i.test(userAgent.toLowerCase())) {
setPlatform('android');
return 'android';
}
}, []);
return null;
});

return platform;
}
2 changes: 2 additions & 0 deletions src/hooks/useMasterPassword.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export function useMasterPassword() {
const [needsUnlock, setNeedsUnlock] = useState(false);
const [isChecking, setIsChecking] = useState(true);

/* eslint-disable react-hooks/set-state-in-effect -- Check encryption status on user change */
useEffect(() => {
if (!user) {
setIsChecking(false);
Expand All @@ -26,6 +27,7 @@ export function useMasterPassword() {

checkMasterPassword();
}, [user]);
/* eslint-enable react-hooks/set-state-in-effect */

const handleUnlockSuccess = () => {
setNeedsUnlock(false);
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/useSignout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export function useSignout() {

// Perform the actual signout
await signOut();
}, [signOut, user?.id]);
}, [signOut, user]);

return { signOut: handleSignout };
}
29 changes: 28 additions & 1 deletion worker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,22 @@ function stripHtmlTags(html: string): string {
return html.replace(/</g, '').replace(/>/g, '').trim();
}

function extractFirstImage(html: string): string | null {
// Extract the first image src from HTML content
// Matches both <img src="..."> and <img ... src="...">
const imgRegex = /<img[^>]+src=["']([^"']+)["'][^>]*>/i;
const match = html.match(imgRegex);
if (match && match[1]) {
const src = match[1];
// Skip base64 data URIs as they're too large for og:image
if (src.startsWith('data:')) {
return null;
}
return src;
}
return null;
}

function formatDate(dateString: string): string {
try {
return new Intl.DateTimeFormat('en-US', {
Expand All @@ -116,6 +132,12 @@ function generateHTML(note: PublicNote, origin: string): string {
: 'A note shared via Typelets';
const noteUrl = `${origin}/p/${note.slug}`;

// Extract first image from content, or use default OG image
const contentImage = note.content ? extractFirstImage(note.content) : null;
const ogImage = contentImage || `${origin}/og-image.png`;
// Use summary_large_image if note has an image, otherwise summary (square) for logo fallback
const twitterCardType = contentImage ? 'summary_large_image' : 'summary';

// Process content: strip sensitive data attributes
let processedContent = note.content || '';
processedContent = processedContent.replace(/data-note-id="[^"]*"/gi, '');
Expand All @@ -140,15 +162,19 @@ function generateHTML(note: PublicNote, origin: string): string {
<meta property="og:description" content="${description}" />
<meta property="og:url" content="${noteUrl}" />
<meta property="og:site_name" content="Typelets" />
<meta property="og:image" content="${ogImage}" />
<meta property="og:image:alt" content="${escapeHtml(note.title || 'Typelets Note')}" />
${note.authorName ? `<meta property="article:author" content="${escapeHtml(note.authorName)}" />` : ''}
<meta property="article:published_time" content="${note.publishedAt}" />
<meta property="article:modified_time" content="${note.updatedAt}" />

<!-- Twitter Card -->
<meta name="twitter:card" content="summary" />
<meta name="twitter:card" content="${twitterCardType}" />
<meta name="twitter:title" content="${title}" />
<meta name="twitter:description" content="${description}" />
<meta name="twitter:site" content="@typelets" />
<meta name="twitter:image" content="${ogImage}" />
<meta name="twitter:image:alt" content="${escapeHtml(note.title || 'Typelets Note')}" />

<!-- Favicons -->
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
Expand Down Expand Up @@ -193,6 +219,7 @@ function generateHTML(note: PublicNote, origin: string): string {
"headline": "${escapeHtml(note.title || 'Untitled Note')}",
"description": "${description}",
"url": "${noteUrl}",
"image": "${ogImage}",
"datePublished": "${note.publishedAt}",
"dateModified": "${note.updatedAt}",
${note.authorName ? `"author": { "@type": "Person", "name": "${escapeHtml(note.authorName)}" },` : ''}
Expand Down
2 changes: 1 addition & 1 deletion worker/wrangler.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ compatibility_date = "2024-01-01"

[vars]
# Your backend API URL
API_URL = "https://your-api-domain.com/api"
API_URL = "https://api.typelets.com/api"
ENVIRONMENT = "production"

# Routes - intercept /p/* paths
Expand Down
Loading