chore(ci): correct Gitea runner to runs-on ubuntu-24.04 and feat(billing): implement US-3.7 billing/subscription UX
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"version": 1,
|
||||
"lastRunAtMs": 1779998560332,
|
||||
"turnsSinceLastRun": 2,
|
||||
"turnsSinceLastRun": 3,
|
||||
"lastTranscriptMtimeMs": 1779998515529,
|
||||
"lastProcessedGenerationId": "5e947d58-6d03-4f90-ad7c-4a97606f4d11",
|
||||
"lastProcessedGenerationId": "250d4e92-b72e-4ca2-b0c1-625292c59d32",
|
||||
"trialStartedAtMs": null
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ on:
|
||||
jobs:
|
||||
ci:
|
||||
name: Lint, Unit Tests & Build
|
||||
runs-on: docker-host
|
||||
runs-on: ubuntu-24.04
|
||||
defaults:
|
||||
run:
|
||||
working-directory: memento-note
|
||||
@@ -21,6 +21,19 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node 22
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Cache node_modules
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: memento-note/node_modules
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('memento-note/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { X, Plus } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { InlinePaywall } from '@/components/settings/inline-paywall'
|
||||
|
||||
export function NoteContentArea() {
|
||||
const { state, actions, readOnly, fullPage, textareaRef, note, richTextEditorRef } = useNoteEditorContext()
|
||||
@@ -85,6 +86,12 @@ export function NoteContentArea() {
|
||||
readOnly && "cursor-default"
|
||||
)}
|
||||
/>
|
||||
{state.quotaExceededFeature === 'auto_tag' ? (
|
||||
<InlinePaywall
|
||||
feature="auto_tag"
|
||||
onDismiss={() => actions.setQuotaExceededFeature(null)}
|
||||
/>
|
||||
) : (
|
||||
<GhostTags
|
||||
suggestions={state.filteredSuggestions}
|
||||
addedTags={state.labels}
|
||||
@@ -92,6 +99,7 @@ export function NoteContentArea() {
|
||||
onSelectTag={actions.handleSelectGhostTag}
|
||||
onDismissTag={actions.handleDismissGhostTag}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -128,6 +136,12 @@ export function NoteContentArea() {
|
||||
noteTitle={state.title || note.title || undefined}
|
||||
sourceUrl={note.sourceUrl}
|
||||
/>
|
||||
{state.quotaExceededFeature === 'auto_tag' ? (
|
||||
<InlinePaywall
|
||||
feature="auto_tag"
|
||||
onDismiss={() => actions.setQuotaExceededFeature(null)}
|
||||
/>
|
||||
) : (
|
||||
<GhostTags
|
||||
suggestions={state.filteredSuggestions}
|
||||
addedTags={state.labels}
|
||||
@@ -135,6 +149,7 @@ export function NoteContentArea() {
|
||||
onSelectTag={actions.handleSelectGhostTag}
|
||||
onDismissTag={actions.handleDismissGhostTag}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
}
|
||||
}, [session?.user?.id])
|
||||
|
||||
const [quotaExceededFeature, setQuotaExceededFeature] = useState<string | null>(null)
|
||||
const [title, setTitle] = useState(note.title || '')
|
||||
const contentRef = useRef(note.content)
|
||||
const [content, setContentState] = useState(note.content)
|
||||
@@ -93,6 +94,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
const prev = prevNoteRef.current
|
||||
|
||||
if (note.id !== prev.id) {
|
||||
setQuotaExceededFeature(null)
|
||||
setTitle(note.title || '')
|
||||
setContentImmediate(note.content)
|
||||
setCheckItems(note.checkItems || [])
|
||||
@@ -165,7 +167,8 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
const { suggestions, isAnalyzing: isAnalyzingSuggestions } = useAutoTagging({
|
||||
content: content,
|
||||
notebookId: note.notebookId,
|
||||
enabled: autoTaggingEnabled
|
||||
enabled: autoTaggingEnabled,
|
||||
onQuotaExceeded: () => setQuotaExceededFeature('auto_tag')
|
||||
})
|
||||
|
||||
const [showReminderDialog, setShowReminderDialog] = useState(false)
|
||||
@@ -382,6 +385,10 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 402) {
|
||||
setQuotaExceededFeature('auto_title')
|
||||
return
|
||||
}
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || t('ai.titleGenerationError'))
|
||||
}
|
||||
@@ -454,6 +461,10 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 402) {
|
||||
setQuotaExceededFeature('reformulate')
|
||||
return
|
||||
}
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || t('ai.reformulationError'))
|
||||
}
|
||||
@@ -490,6 +501,10 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: content, option: 'clarify' })
|
||||
})
|
||||
if (response.status === 402) {
|
||||
setQuotaExceededFeature('reformulate')
|
||||
return
|
||||
}
|
||||
const data = await response.json()
|
||||
if (!response.ok) throw new Error(data.error || t('notes.clarifyFailed'))
|
||||
setContentImmediate(data.reformulatedText || data.text)
|
||||
@@ -519,6 +534,10 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: content, option: 'shorten' })
|
||||
})
|
||||
if (response.status === 402) {
|
||||
setQuotaExceededFeature('reformulate')
|
||||
return
|
||||
}
|
||||
const data = await response.json()
|
||||
if (!response.ok) throw new Error(data.error || t('notes.shortenFailed'))
|
||||
setContentImmediate(data.reformulatedText || data.text)
|
||||
@@ -548,6 +567,10 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: content, option: 'improve' })
|
||||
})
|
||||
if (response.status === 402) {
|
||||
setQuotaExceededFeature('reformulate')
|
||||
return
|
||||
}
|
||||
const data = await response.json()
|
||||
if (!response.ok) throw new Error(data.error || t('notes.improveFailed'))
|
||||
setContentImmediate(data.reformulatedText || data.text)
|
||||
@@ -864,13 +887,14 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
isMarkdown,
|
||||
allImages,
|
||||
colorClasses,
|
||||
quotaExceededFeature,
|
||||
}), [
|
||||
title, content, checkItems, labels, images, links, newLabel, color, size,
|
||||
showMarkdownPreview, removedImageUrls, isSaving, isDirty, isProcessingAI, aiOpen, infoOpen,
|
||||
isGeneratingTitles, titleSuggestions, dismissedTitleSuggestions, isReformulating,
|
||||
reformulationModal, previousContentForCopilot, showReminderDialog, currentReminder,
|
||||
showLinkDialog, linkUrl, comparisonNotes, fusionNotes, dismissedTags, filteredSuggestions,
|
||||
isAnalyzingSuggestions, isMarkdown, allImages, colorClasses
|
||||
isAnalyzingSuggestions, isMarkdown, allImages, colorClasses, quotaExceededFeature
|
||||
])
|
||||
|
||||
const actions: NoteEditorActions = useMemo(() => ({
|
||||
@@ -924,13 +948,14 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
setIsGeneratingTitles,
|
||||
setIsAnalyzingSuggestions: (_a) => { /* handled by useAutoTagging */ },
|
||||
setPreviousContentForCopilot,
|
||||
setQuotaExceededFeature,
|
||||
}), [
|
||||
handleCheckItem, handleUpdateCheckItem, handleAddCheckItem, handleRemoveCheckItem,
|
||||
handleSelectGhostTag, handleDismissGhostTag, handleRemoveLabel, handleImageUpload,
|
||||
handleRemoveImage, handleAddLink, handleRemoveLink, handleReminderSave,
|
||||
handleRemoveReminder, handleGenerateTitles, handleSelectTitle, handleReformulate,
|
||||
handleApplyRefactor, handleClarifyDirect, handleShortenDirect, handleImproveDirect,
|
||||
handleTransformMarkdown, handleSave, handleSaveInPlace, handleMakeCopy
|
||||
handleTransformMarkdown, handleSave, handleSaveInPlace, handleMakeCopy, setQuotaExceededFeature
|
||||
])
|
||||
|
||||
const value: NoteEditorContextValue = useMemo(() => ({
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useNoteEditorContext } from './note-editor-context'
|
||||
import { NoteTitleBlock } from './note-title-block'
|
||||
import { NoteContentArea } from './note-content-area'
|
||||
import { NoteMetadataSection } from './note-metadata-section'
|
||||
import { InlinePaywall } from '@/components/settings/inline-paywall'
|
||||
import { EditorImages } from '@/components/editor-images'
|
||||
import { ComparisonModal } from '@/components/comparison-modal'
|
||||
import { FusionModal } from '@/components/fusion-modal'
|
||||
@@ -107,6 +108,12 @@ export function NoteEditorDialog({ onClose }: NoteEditorDialogProps) {
|
||||
)}
|
||||
|
||||
{/* Content Area */}
|
||||
{state.quotaExceededFeature === 'reformulate' && (
|
||||
<InlinePaywall
|
||||
feature="reformulate"
|
||||
onDismiss={() => actions.setQuotaExceededFeature(null)}
|
||||
/>
|
||||
)}
|
||||
<NoteContentArea />
|
||||
|
||||
{/* Metadata Section */}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { ChevronRight } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Note } from '@/lib/types'
|
||||
import { GhostTags } from '@/components/ghost-tags'
|
||||
import { InlinePaywall } from '@/components/settings/inline-paywall'
|
||||
import { LabelBadge } from '@/components/label-badge'
|
||||
import { NoteAttachments } from '@/components/note-attachments'
|
||||
import { DocumentQAOverlay } from '@/components/document-qa-overlay'
|
||||
@@ -88,7 +89,7 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
|
||||
{/* Title */}
|
||||
<NoteTitleBlock />
|
||||
|
||||
{(state.labels.length > 0 || (state.filteredSuggestions.length > 0)) && (
|
||||
{(state.labels.length > 0 || state.filteredSuggestions.length > 0 || state.quotaExceededFeature === 'auto_tag') && (
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{state.labels.map((label) => (
|
||||
<LabelBadge
|
||||
@@ -99,6 +100,12 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
|
||||
/>
|
||||
))}
|
||||
{!readOnly && (
|
||||
state.quotaExceededFeature === 'auto_tag' ? (
|
||||
<InlinePaywall
|
||||
feature="auto_tag"
|
||||
onDismiss={() => actions.setQuotaExceededFeature(null)}
|
||||
/>
|
||||
) : (
|
||||
<GhostTags
|
||||
suggestions={state.filteredSuggestions}
|
||||
addedTags={state.labels}
|
||||
@@ -106,6 +113,7 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
|
||||
onSelectTag={actions.handleSelectGhostTag}
|
||||
onDismissTag={actions.handleDismissGhostTag}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -124,6 +132,12 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
|
||||
|
||||
{/* Content area — max-w-3xl for wider reading column */}
|
||||
<div className="max-w-3xl mx-auto w-full space-y-8 pb-32">
|
||||
{state.quotaExceededFeature === 'reformulate' && (
|
||||
<InlinePaywall
|
||||
feature="reformulate"
|
||||
onDismiss={() => actions.setQuotaExceededFeature(null)}
|
||||
/>
|
||||
)}
|
||||
<NoteContentArea />
|
||||
|
||||
{!readOnly && (
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useNoteEditorContext } from './note-editor-context'
|
||||
import { LabelBadge } from '../label-badge'
|
||||
import { GhostTags } from '../ghost-tags'
|
||||
import { InlinePaywall } from '@/components/settings/inline-paywall'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function NoteMetadataSection() {
|
||||
@@ -31,6 +32,12 @@ export function NoteMetadataSection() {
|
||||
|
||||
{/* Ghost Tags - only show in dialog mode */}
|
||||
{!readOnly && !state.isMarkdown && (
|
||||
state.quotaExceededFeature === 'auto_tag' ? (
|
||||
<InlinePaywall
|
||||
feature="auto_tag"
|
||||
onDismiss={() => actions.setQuotaExceededFeature(null)}
|
||||
/>
|
||||
) : (
|
||||
<GhostTags
|
||||
suggestions={state.filteredSuggestions}
|
||||
addedTags={state.labels}
|
||||
@@ -38,6 +45,7 @@ export function NoteMetadataSection() {
|
||||
onSelectTag={actions.handleSelectGhostTag}
|
||||
onDismissTag={actions.handleDismissGhostTag}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Color indicator */}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
import { resolveTitleDirection, resolveTitleLang } from '@/lib/clip/rtl-content'
|
||||
import { InlinePaywall } from '@/components/settings/inline-paywall'
|
||||
|
||||
export function NoteTitleBlock() {
|
||||
const { note, state, actions, readOnly, fullPage } = useNoteEditorContext()
|
||||
@@ -30,6 +31,12 @@ export function NoteTitleBlock() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{state.quotaExceededFeature === 'auto_title' && (
|
||||
<InlinePaywall
|
||||
feature="auto_title"
|
||||
onDismiss={() => actions.setQuotaExceededFeature(null)}
|
||||
/>
|
||||
)}
|
||||
{/* Title — auto-resizing textarea, adaptive size */}
|
||||
<div className="group relative">
|
||||
<textarea
|
||||
@@ -84,6 +91,10 @@ export function NoteTitleBlock() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: plain }),
|
||||
})
|
||||
if (res.status === 402) {
|
||||
actions.setQuotaExceededFeature('auto_title')
|
||||
return
|
||||
}
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const s = data.suggestions?.[0]?.title ?? ''
|
||||
@@ -127,6 +138,7 @@ export function NoteTitleBlock() {
|
||||
|
||||
// Dialog mode title block
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="relative">
|
||||
<input
|
||||
dir={titleDir}
|
||||
@@ -154,5 +166,12 @@ export function NoteTitleBlock() {
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{state.quotaExceededFeature === 'auto_title' && (
|
||||
<InlinePaywall
|
||||
feature="auto_title"
|
||||
onDismiss={() => actions.setQuotaExceededFeature(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -51,6 +51,7 @@ export interface NoteEditorState {
|
||||
isMarkdown: boolean
|
||||
allImages: string[]
|
||||
colorClasses: typeof NOTE_COLORS[keyof typeof NOTE_COLORS]
|
||||
quotaExceededFeature: string | null
|
||||
}
|
||||
|
||||
export interface NoteEditorActions {
|
||||
@@ -119,6 +120,7 @@ export interface NoteEditorActions {
|
||||
setIsGeneratingTitles: (generating: boolean) => void
|
||||
setIsAnalyzingSuggestions: (analyzing: boolean) => void
|
||||
setPreviousContentForCopilot: (content: string | null) => void
|
||||
setQuotaExceededFeature: (feature: string | null) => void
|
||||
}
|
||||
|
||||
export interface NoteEditorContextValue {
|
||||
|
||||
@@ -297,7 +297,7 @@ export function BillingPlans() {
|
||||
let totalUsed = 0;
|
||||
let totalLimit = 0;
|
||||
if (quotas) {
|
||||
Object.entries(quotas).forEach(([_, q]) => {
|
||||
Object.entries(quotas as Record<string, any>).forEach(([_, q]) => {
|
||||
if (q.limit > 0 && q.limit !== Infinity) {
|
||||
totalUsed += q.used;
|
||||
totalLimit += q.limit;
|
||||
@@ -471,7 +471,7 @@ export function BillingPlans() {
|
||||
<Loader2 className="h-6 w-6 animate-spin text-brand-accent" />
|
||||
</div>
|
||||
)}
|
||||
{quotas && Object.entries(quotas).map(([key, q]) => {
|
||||
{quotas && Object.entries(quotas as Record<string, any>).map(([key, q]) => {
|
||||
const pct = q.limit > 0 && q.limit !== Infinity ? (q.used / q.limit) * 100 : 0;
|
||||
const isUnlimited = q.limit === Infinity || q.limit <= 0;
|
||||
|
||||
@@ -516,6 +516,7 @@ export function BillingPlans() {
|
||||
{isPaid && <BillingHistory />}
|
||||
|
||||
{/* Interval Toggle & Plan Cards */}
|
||||
{!isPaid && (
|
||||
<div className="space-y-8 pt-6 border-t border-border/40">
|
||||
<div className="text-center space-y-2">
|
||||
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-concrete">
|
||||
@@ -603,6 +604,7 @@ export function BillingPlans() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer Info */}
|
||||
<div className="bg-slate-50 dark:bg-black/20 rounded-[32px] p-8 border border-border/40 flex flex-col md:flex-row items-center justify-between gap-8">
|
||||
|
||||
@@ -8,9 +8,10 @@ interface UseAutoTaggingProps {
|
||||
content: string;
|
||||
notebookId?: string | null;
|
||||
enabled?: boolean;
|
||||
onQuotaExceeded?: () => void;
|
||||
}
|
||||
|
||||
export function useAutoTagging({ content, notebookId, enabled = true }: UseAutoTaggingProps) {
|
||||
export function useAutoTagging({ content, notebookId, enabled = true, onQuotaExceeded }: UseAutoTaggingProps) {
|
||||
const { language } = useLanguage();
|
||||
const { hasAiConsent, requestAiConsent } = useAiConsent();
|
||||
const [suggestions, setSuggestions] = useState<TagSuggestion[]>([]);
|
||||
@@ -62,6 +63,9 @@ export function useAutoTagging({ content, notebookId, enabled = true }: UseAutoT
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 402 && onQuotaExceeded) {
|
||||
onQuotaExceeded();
|
||||
}
|
||||
throw new Error('Error during analysis');
|
||||
}
|
||||
|
||||
@@ -75,7 +79,7 @@ export function useAutoTagging({ content, notebookId, enabled = true }: UseAutoT
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
}
|
||||
}, [hasAiConsent, requestAiConsent]);
|
||||
}, [hasAiConsent, requestAiConsent, onQuotaExceeded]);
|
||||
|
||||
// Trigger on content change
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user