diff --git a/.dockerignore b/.dockerignore index 49df68b..913f304 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,6 @@ .cursor +.claude +.kiro .github .next .open-next @@ -10,4 +12,4 @@ node_modules **/node_modules Dockerfile -LICENSE \ No newline at end of file +LICENSE diff --git a/README.md b/README.md index a56b1c4..7fb8f22 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ If you found anything that could be improved, please let me know. - 📚 documentation: [mksaas.com/docs](https://mksaas.com/docs) - 🗓️ roadmap: [mksaas roadmap](https://mksaas.link/roadmap) - 👨‍💻 discord: [mksaas.link/discord](https://mksaas.link/discord) -- 📹 video (WIP): [mksaas.link/youtube](https://mksaas.link/youtube) +- 📹 video: [mksaas.link/youtube](https://mksaas.link/youtube) ## Repositories diff --git a/biome.json b/biome.json index fa3eef5..bcb08bb 100644 --- a/biome.json +++ b/biome.json @@ -12,6 +12,8 @@ ".open-next/**", ".wrangler/**", ".cursor/**", + ".claude/**", + ".kiro/**", ".vscode/**", ".source/**", "node_modules/**", @@ -27,8 +29,7 @@ "src/app/[[]locale]/preview/**", "src/payment/types.ts", "src/credits/types.ts", - "src/types/index.d.ts", - "public/sw.js" + "src/types/index.d.ts" ] }, "formatter": { @@ -75,6 +76,8 @@ ".open-next/**", ".wrangler/**", ".cursor/**", + ".claude/**", + ".kiro/**", ".vscode/**", ".source/**", "node_modules/**", @@ -90,8 +93,7 @@ "src/app/[[]locale]/preview/**", "src/payment/types.ts", "src/credits/types.ts", - "src/types/index.d.ts", - "public/sw.js" + "src/types/index.d.ts" ] }, "javascript": { diff --git a/package.json b/package.json index a95b14e..94ba18c 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,6 @@ "@types/canvas-confetti": "^1.9.0", "@vercel/analytics": "^1.5.0", "@vercel/speed-insights": "^1.2.0", - "@widgetbot/react-embed": "^1.9.0", "ai": "^5.0.0", "better-auth": "^1.1.19", "canvas-confetti": "^1.9.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7da14c8..d8e4447 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -191,9 +191,6 @@ importers: '@vercel/speed-insights': specifier: ^1.2.0 version: 1.2.0(next@15.2.1(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0) - '@widgetbot/react-embed': - specifier: ^1.9.0 - version: 1.9.0(react@19.0.0) ai: specifier: ^5.0.0 version: 5.0.0(zod@4.0.17) @@ -4824,14 +4821,6 @@ packages: vue-router: optional: true - '@widgetbot/embed-api@1.2.17': - resolution: {integrity: sha512-qoiFLMak+mBG64pgKN5xFv3amPHcG2qcurPefAbof4DI/eip5OU59pbM+ak4d9d9OIkwA1QhoDzo9KWD/cOn0w==} - - '@widgetbot/react-embed@1.9.0': - resolution: {integrity: sha512-+Qgqy7lwLy++lIiHmSsgxUjwcX80iFIHR0QJpKq4W82ePUmq4bTuxvUbxcE+VQH5IjNrWaydGNR8zROV5vUQsA==} - peerDependencies: - react: '>= 15' - abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -5157,12 +5146,6 @@ packages: crisp-sdk-web@1.0.25: resolution: {integrity: sha512-CWTHFFeHRV0oqiXoPh/aIAKhFs6xcIM4NenGPnClAMCZUDQgQsF1OWmZWmnVNjJriXUmWRgDfeUxcxygS0dCRA==} - cross-domain-safe-weakmap@1.0.29: - resolution: {integrity: sha512-VLoUgf2SXnf3+na8NfeUFV59TRZkIJqCIATaMdbhccgtnTlSnHXkyTRwokngEGYdQXx8JbHT9GDYitgR2sdjuA==} - - cross-domain-utils@2.0.38: - resolution: {integrity: sha512-zZfi3+2EIR9l4chrEiXI2xFleyacsJf8YMLR1eJ0Veb5FTMXeJ3DpxDjZkto2FhL/g717WSELqbptNSo85UJDw==} - cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -6947,9 +6930,6 @@ packages: resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} engines: {node: '>=12'} - post-robot@8.0.32: - resolution: {integrity: sha512-PMOdDAt3pyuKUxZcTzdcXXFxLqkdeLpRlcCQl7QAJpI+e7J1YHH+PfC7KAbcL8hRVQ1LknQYGoirbA1/eO/a1g==} - postcss-selector-parser@7.1.0: resolution: {integrity: sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==} engines: {node: '>=4'} @@ -7918,9 +7898,6 @@ packages: youch@4.1.0-beta.10: resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} - zalgo-promise@1.0.48: - resolution: {integrity: sha512-LLHANmdm53+MucY9aOFIggzYtUdkSBFxUsy4glTTQYNyK6B3uCPWTbfiGvSrEvLojw0mSzyFJ1/RRLv+QMNdzQ==} - zod-to-json-schema@3.24.2: resolution: {integrity: sha512-pNUqrcSxuuB3/+jBbU8qKUbTbDqYUaG1vf5cXFjbhGgoUuA1amO/y4Q8lzfOhHU8HNPK6VFJ18lBDKj3OHyDsg==} peerDependencies: @@ -13117,15 +13094,6 @@ snapshots: next: 15.2.1(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) react: 19.0.0 - '@widgetbot/embed-api@1.2.17': - dependencies: - post-robot: 8.0.32 - - '@widgetbot/react-embed@1.9.0(react@19.0.0)': - dependencies: - '@widgetbot/embed-api': 1.2.17 - react: 19.0.0 - abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -13459,14 +13427,6 @@ snapshots: crisp-sdk-web@1.0.25: {} - cross-domain-safe-weakmap@1.0.29: - dependencies: - cross-domain-utils: 2.0.38 - - cross-domain-utils@2.0.38: - dependencies: - zalgo-promise: 1.0.48 - cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -15667,12 +15627,6 @@ snapshots: picomatch@4.0.2: {} - post-robot@8.0.32: - dependencies: - cross-domain-safe-weakmap: 1.0.29 - cross-domain-utils: 2.0.38 - zalgo-promise: 1.0.48 - postcss-selector-parser@7.1.0: dependencies: cssesc: 3.0.0 @@ -16851,8 +16805,6 @@ snapshots: cookie: 1.0.2 youch-core: 0.3.3 - zalgo-promise@1.0.48: {} - zod-to-json-schema@3.24.2(zod@3.25.64): dependencies: zod: 3.25.64 diff --git a/public/sw.js b/public/sw.js deleted file mode 100644 index 3ac3175..0000000 --- a/public/sw.js +++ /dev/null @@ -1,129 +0,0 @@ -// Service Worker for caching iframe content -const CACHE_NAME = 'cnblocks-iframe-cache-v1' - -// Add iframe URLs to this list to prioritize caching -const URLS_TO_CACHE = [ - // Default assets that should be cached - '/favicon.ico', - // Images used in iframes - '/payments.png', - '/payments-light.png', - '/origin-cal.png', - '/origin-cal-dark.png', - '/exercice.png', - '/exercice-dark.png', - '/charts-light.png', - '/charts.png', - '/music-light.png', - '/music.png', - '/mail-back-light.png', - '/mail-upper.png', - '/mail-back.png', - '/card.png', - '/dark-card.webp', -] - -// Install event - cache resources -self.addEventListener('install', (event) => { - event.waitUntil( - caches - .open(CACHE_NAME) - .then((cache) => { - console.log('Opened cache') - return cache.addAll(URLS_TO_CACHE) - }) - .then(() => self.skipWaiting()) // Activate SW immediately - ) -}) - -// Activate event - clean up old caches -self.addEventListener('activate', (event) => { - const currentCaches = [CACHE_NAME] - event.waitUntil( - caches - .keys() - .then((cacheNames) => { - return cacheNames.filter((cacheName) => !currentCaches.includes(cacheName)) - }) - .then((cachesToDelete) => { - return Promise.all( - cachesToDelete.map((cacheToDelete) => { - return caches.delete(cacheToDelete) - }) - ) - }) - .then(() => self.clients.claim()) // Take control of clients immediately - ) -}) - -// Fetch event - serve from cache or fetch from network and cache -self.addEventListener('fetch', (event) => { - // Check if this is an iframe request - typically they'll be HTML or have 'preview' in the URL - const isIframeRequest = event.request.url.includes('/preview/') || event.request.url.includes('/examples/') - - if (isIframeRequest) { - event.respondWith( - caches.match(event.request, { ignoreSearch: true }).then((response) => { - // Return cached response if found - if (response) { - return response - } - - // Clone the request (requests are one-time use) - const fetchRequest = event.request.clone() - - return fetch(fetchRequest).then((response) => { - // Check if we received a valid response - if (!response || response.status !== 200 || response.type !== 'basic') { - return response - } - - // Clone the response (responses are one-time use) - const responseToCache = response.clone() - - caches.open(CACHE_NAME).then((cache) => { - cache.put(event.request, responseToCache) - }) - - return response - }) - }) - ) - } else { - // For non-iframe requests, use a standard cache-first strategy - event.respondWith( - caches.match(event.request).then((response) => { - if (response) { - return response - } - return fetch(event.request) - }) - ) - } -}) - -// Listen for messages from clients (to force cache update, etc) -self.addEventListener('message', (event) => { - if (event.data && event.data.type === 'SKIP_WAITING') { - self.skipWaiting() - } - - // Handle cache clearing - if (event.data && event.data.type === 'CLEAR_IFRAME_CACHE') { - const url = event.data.url - - if (url) { - // Clear specific URL from cache - caches.open(CACHE_NAME).then((cache) => { - cache.delete(url).then(() => { - console.log(`Cleared cache for: ${url}`) - }) - }) - } else { - // Clear the entire cache - caches.delete(CACHE_NAME).then(() => { - console.log('Cleared entire iframe cache') - }) - } - } -}) diff --git a/src/actions/check-web-content-analysis-credits.ts b/src/actions/check-web-content-analysis-credits.ts deleted file mode 100644 index 3ca2c7f..0000000 --- a/src/actions/check-web-content-analysis-credits.ts +++ /dev/null @@ -1,37 +0,0 @@ -'use server'; - -import { getWebContentAnalysisCost } from '@/ai/text/utils/web-content-analyzer-config'; -import { getUserCredits, hasEnoughCredits } from '@/credits/credits'; -import type { User } from '@/lib/auth-types'; -import { userActionClient } from '@/lib/safe-action'; - -/** - * Check if user has enough credits for web content analysis - */ -export const checkWebContentAnalysisCreditsAction = userActionClient.action( - async ({ ctx }) => { - const currentUser = (ctx as { user: User }).user; - - try { - const requiredCredits = getWebContentAnalysisCost(); - const currentCredits = await getUserCredits(currentUser.id); - const hasCredits = await hasEnoughCredits({ - userId: currentUser.id, - requiredCredits, - }); - - return { - success: true, - hasEnoughCredits: hasCredits, - currentCredits, - requiredCredits, - }; - } catch (error) { - console.error('check web content analysis credits error:', error); - return { - success: false, - error: error instanceof Error ? error.message : 'Something went wrong', - }; - } - } -); diff --git a/src/ai/chat/components/ChatBot.tsx b/src/ai/chat/components/ChatBot.tsx index 10408ee..9ac8aac 100644 --- a/src/ai/chat/components/ChatBot.tsx +++ b/src/ai/chat/components/ChatBot.tsx @@ -70,7 +70,7 @@ export default function ChatBot() { }; return ( -
+
diff --git a/src/ai/image/components/ImagePlayground.tsx b/src/ai/image/components/ImagePlayground.tsx index a88887d..105e3db 100644 --- a/src/ai/image/components/ImagePlayground.tsx +++ b/src/ai/image/components/ImagePlayground.tsx @@ -76,9 +76,9 @@ export function ImagePlayground({ return (
-
+
{/* header */} - + {/* */} {/* input prompt */} balance >= amount; - - const handleConsume = async () => { - if (!hasEnoughCredits(CONSUME_CREDITS)) { - toast.error('Insufficient credits, please buy more credits.'); - return; - } - setLoading(true); - try { - await consumeCreditsMutation.mutateAsync({ - amount: CONSUME_CREDITS, - description: `AI Text Credit Consumption (${CONSUME_CREDITS} credits)`, - }); - toast.success(`${CONSUME_CREDITS} credits have been consumed.`); - } catch (error) { - toast.error('Failed to consume credits, please try again later.'); - } finally { - setLoading(false); - } - }; - - return ( -
-
- -
- -
- ); -} diff --git a/src/ai/text/components/error-display.tsx b/src/ai/text/components/error-display.tsx index 489ddd6..43a9435 100644 --- a/src/ai/text/components/error-display.tsx +++ b/src/ai/text/components/error-display.tsx @@ -34,7 +34,6 @@ interface ErrorDisplayProps { const errorIcons = { [ErrorType.VALIDATION]: AlertCircleIcon, [ErrorType.NETWORK]: WifiOffIcon, - [ErrorType.CREDITS]: CreditCardIcon, [ErrorType.SCRAPING]: ServerIcon, [ErrorType.ANALYSIS]: HelpCircleIcon, [ErrorType.TIMEOUT]: ClockIcon, @@ -84,7 +83,6 @@ const severityColors = { const errorTitles = { [ErrorType.VALIDATION]: 'Invalid Input', [ErrorType.NETWORK]: 'Connection Error', - [ErrorType.CREDITS]: 'Insufficient Credits', [ErrorType.SCRAPING]: 'Unable to Access Website', [ErrorType.ANALYSIS]: 'Analysis Failed', [ErrorType.TIMEOUT]: 'Request Timed Out', diff --git a/src/ai/text/components/index.ts b/src/ai/text/components/index.ts index d0cea3d..de8876e 100644 --- a/src/ai/text/components/index.ts +++ b/src/ai/text/components/index.ts @@ -1,5 +1,4 @@ export { AnalysisResults } from './analysis-results'; -export { ConsumeCreditCard } from './consume-credit-card'; export { LoadingStates } from './loading-states'; export { UrlInputForm } from './url-input-form'; export { WebContentAnalyzer } from './web-content-analyzer'; diff --git a/src/ai/text/components/url-input-form.tsx b/src/ai/text/components/url-input-form.tsx index 504d7de..39c33dd 100644 --- a/src/ai/text/components/url-input-form.tsx +++ b/src/ai/text/components/url-input-form.tsx @@ -1,9 +1,7 @@ 'use client'; -import { checkWebContentAnalysisCreditsAction } from '@/actions/check-web-content-analysis-credits'; import type { UrlInputFormProps } from '@/ai/text/utils/web-content-analyzer'; import { webContentAnalyzerConfig } from '@/ai/text/utils/web-content-analyzer-config'; -import { LoginWrapper } from '@/components/auth/login-wrapper'; import { Button } from '@/components/ui/button'; import { Form, @@ -20,21 +18,10 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { useLocalePathname } from '@/i18n/navigation'; -import { authClient } from '@/lib/auth-client'; import { zodResolver } from '@hookform/resolvers/zod'; -import { - AlertCircleIcon, - CoinsIcon, - LinkIcon, - Loader2Icon, - LogInIcon, - SparklesIcon, -} from 'lucide-react'; -import { useAction } from 'next-safe-action/hooks'; +import { LinkIcon, Loader2Icon, SparklesIcon } from 'lucide-react'; import { useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; -import { toast } from 'sonner'; import { z } from 'zod'; import { useDebounce } from '../utils/performance'; @@ -52,19 +39,9 @@ export function UrlInputForm({ modelProvider, setModelProvider, }: UrlInputFormProps) { - const [creditInfo, setCreditInfo] = useState<{ - hasEnoughCredits: boolean; - currentCredits: number; - requiredCredits: number; - } | null>(null); const [mounted, setMounted] = useState(false); - // Get authentication status and current path for callback - const { data: session, isPending: isAuthLoading } = authClient.useSession(); - const isAuthenticated = !!session?.user; - const currentPath = useLocalePathname(); - - // Prevent hydration mismatch by only rendering auth-dependent content after mount + // Prevent hydration mismatch by only rendering content after mount useEffect(() => { setMounted(true); }, []); @@ -84,42 +61,6 @@ export function UrlInputForm({ webContentAnalyzerConfig.performance.urlInputDebounceMs ); - const { execute: checkCredits, isExecuting: isCheckingCredits } = useAction( - checkWebContentAnalysisCreditsAction, - { - onSuccess: (result) => { - if (result.data?.success) { - setCreditInfo({ - hasEnoughCredits: result.data.hasEnoughCredits ?? false, - currentCredits: result.data.currentCredits ?? 0, - requiredCredits: result.data.requiredCredits ?? 0, - }); - } else { - // Only show error toast if it's not an auth error - if (result.data?.error !== 'Unauthorized') { - setTimeout(() => { - toast.error(result.data?.error || 'Failed to check credits'); - }, 0); - } - } - }, - onError: (error) => { - console.error('Credit check error:', error); - // Only show error toast for non-auth errors - setTimeout(() => { - toast.error('Failed to check credits'); - }, 0); - }, - } - ); - - // Check credits only when user is authenticated - useEffect(() => { - if (isAuthenticated && !isAuthLoading) { - checkCredits(); - } - }, [isAuthenticated, isAuthLoading, checkCredits]); - // Debounced URL validation effect useEffect(() => { if (debouncedUrl && debouncedUrl !== urlValue) { @@ -129,23 +70,12 @@ export function UrlInputForm({ }, [debouncedUrl, urlValue, form]); const handleSubmit = (data: UrlFormData) => { - // For authenticated users, check credits before submitting - if (creditInfo && !creditInfo.hasEnoughCredits) { - // Defer toast to avoid flushSync during render - setTimeout(() => { - toast.error( - `Insufficient credits. You need ${creditInfo.requiredCredits} credits but only have ${creditInfo.currentCredits}.` - ); - }, 0); - return; - } onSubmit(data.url ?? '', modelProvider); }; const handleFormSubmit = form.handleSubmit(handleSubmit); - const isInsufficientCredits = creditInfo && !creditInfo.hasEnoughCredits; - const isFormDisabled = isLoading || disabled || !!isInsufficientCredits; + const isFormDisabled = isLoading || disabled; return ( <> @@ -161,10 +91,10 @@ export function UrlInputForm({ + OpenRouter OpenAI GPT-4o Google Gemini - DeepSeek - OpenRouter + DeepSeek R1
@@ -194,67 +124,20 @@ export function UrlInputForm({ )} /> - {/* Credit Information - Only show for authenticated users */} - {isAuthenticated && creditInfo && ( -
-
- - - Cost: {creditInfo.requiredCredits} credits - -
-
- - Balance: {creditInfo.currentCredits} - - {!creditInfo.hasEnoughCredits && ( - - )} -
-
- )} - - {/* Insufficient Credits Warning */} - {isAuthenticated && isInsufficientCredits && ( -
- - - Insufficient credits. You need {creditInfo.requiredCredits}{' '} - credits but only have {creditInfo.currentCredits}. - -
- )} - {!mounted ? ( // Show loading state during hydration to prevent mismatch - ) : isAuthenticated ? ( + ) : ( - ) : ( - - - )} diff --git a/src/ai/text/components/web-content-analyzer.tsx b/src/ai/text/components/web-content-analyzer.tsx index e2b23d2..4e7f769 100644 --- a/src/ai/text/components/web-content-analyzer.tsx +++ b/src/ai/text/components/web-content-analyzer.tsx @@ -194,7 +194,8 @@ export function WebContentAnalyzer({ className }: WebContentAnalyzerProps) { const [state, dispatch] = useReducer(analysisReducer, initialState); // Model provider state - const [modelProvider, setModelProvider] = useState('openai'); + const [modelProvider, setModelProvider] = + useState('openrouter'); // Enhanced error state const [analyzedError, setAnalyzedError] = @@ -232,16 +233,6 @@ export function WebContentAnalyzer({ className }: WebContentAnalyzerProps) { errorType = ErrorType.VALIDATION; retryable = false; break; - case 401: - errorType = ErrorType.AUTHENTICATION; - severity = ErrorSeverity.HIGH; - retryable = false; - break; - case 402: - errorType = ErrorType.CREDITS; - severity = ErrorSeverity.HIGH; - retryable = false; - break; case 408: errorType = ErrorType.TIMEOUT; break; diff --git a/src/ai/text/utils/error-handling.ts b/src/ai/text/utils/error-handling.ts index 8896d5e..ea648a6 100644 --- a/src/ai/text/utils/error-handling.ts +++ b/src/ai/text/utils/error-handling.ts @@ -9,7 +9,6 @@ import { webContentAnalyzerConfig } from '@/ai/text/utils/web-content-analyzer-c export enum ErrorType { VALIDATION = 'validation', NETWORK = 'network', - CREDITS = 'credits', SCRAPING = 'scraping', ANALYSIS = 'analysis', TIMEOUT = 'timeout', @@ -96,22 +95,6 @@ export function classifyError(error: unknown): WebContentAnalyzerError { ); } - // Credit errors - if ( - message.includes('credit') || - message.includes('insufficient') || - message.includes('balance') - ) { - return new WebContentAnalyzerError( - ErrorType.CREDITS, - error.message, - 'Insufficient credits to perform analysis. Please purchase more credits.', - ErrorSeverity.HIGH, - false, - error - ); - } - // Scraping errors if ( message.includes('scrape') || @@ -278,16 +261,6 @@ export function getRecoveryActions(error: WebContentAnalyzerError): Array<{ { label: 'Try Simpler URL', action: 'simplify_url' }, ]; - case ErrorType.CREDITS: - return [ - { - label: 'Purchase Credits', - action: 'purchase_credits', - primary: true, - }, - { label: 'Check Balance', action: 'check_balance' }, - ]; - case ErrorType.SCRAPING: return [ { label: 'Try Again', action: 'retry', primary: true }, diff --git a/src/ai/text/utils/web-content-analyzer-config.ts b/src/ai/text/utils/web-content-analyzer-config.ts index 83b183c..1fb1c58 100644 --- a/src/ai/text/utils/web-content-analyzer-config.ts +++ b/src/ai/text/utils/web-content-analyzer-config.ts @@ -6,11 +6,6 @@ */ export const webContentAnalyzerConfig = { - /** - * Credit cost for performing a web content analysis - */ - creditsCost: 100, - /** * Maximum content length for AI analysis (in characters) * Optimized to prevent token limit issues while maintaining quality @@ -118,21 +113,15 @@ export const webContentAnalyzerConfig = { maxTokens: 2000, }, openrouter: { - model: 'openrouter/horizon-beta', + // model: 'openrouter/horizon-beta', // model: 'x-ai/grok-3-beta', // model: 'openai/gpt-4o-mini', + model: 'deepseek/deepseek-r1:free', temperature: 0.1, maxTokens: 2000, }, } as const; -/** - * Get the credit cost for web content analysis - */ -export function getWebContentAnalysisCost(): number { - return webContentAnalyzerConfig.creditsCost; -} - /** * Validates if the Firecrawl API key is configured */ @@ -151,8 +140,6 @@ export function validateFirecrawlConfig(): boolean { */ export function validateWebContentAnalyzerConfig(): boolean { return ( - typeof webContentAnalyzerConfig.creditsCost === 'number' && - webContentAnalyzerConfig.creditsCost > 0 && typeof webContentAnalyzerConfig.maxContentLength === 'number' && webContentAnalyzerConfig.maxContentLength > 0 && typeof webContentAnalyzerConfig.timeoutMillis === 'number' && diff --git a/src/ai/text/utils/web-content-analyzer.ts b/src/ai/text/utils/web-content-analyzer.ts index 9e9e07b..a49d8c5 100644 --- a/src/ai/text/utils/web-content-analyzer.ts +++ b/src/ai/text/utils/web-content-analyzer.ts @@ -67,7 +67,7 @@ export interface AnalysisState { } // Component Props Interfaces -export type ModelProvider = 'openai' | 'gemini' | 'deepseek'; +export type ModelProvider = 'openai' | 'gemini' | 'deepseek' | 'openrouter'; export interface WebContentAnalyzerProps { className?: string; diff --git a/src/app/[locale]/(marketing)/(pages)/test/page.tsx b/src/app/[locale]/(marketing)/(pages)/test/page.tsx index 8d5a2a6..abea424 100644 --- a/src/app/[locale]/(marketing)/(pages)/test/page.tsx +++ b/src/app/[locale]/(marketing)/(pages)/test/page.tsx @@ -1,12 +1,12 @@ import Container from '@/components/layout/container'; -import { CreditsTest } from '@/components/test/credits-test'; +import { ConsumeCreditsCard } from '@/components/test/consume-credits-card'; export default async function TestPage() { return (
{/* credits test */} - +
); diff --git a/src/app/[locale]/(marketing)/ai/chat/page.tsx b/src/app/[locale]/(marketing)/ai/chat/page.tsx index ba4041d..bbafd80 100644 --- a/src/app/[locale]/(marketing)/ai/chat/page.tsx +++ b/src/app/[locale]/(marketing)/ai/chat/page.tsx @@ -37,7 +37,9 @@ export default async function AIChatPage() {
{/* Chat Bot */} - +
+ +
); diff --git a/src/app/[locale]/(marketing)/ai/image/page.tsx b/src/app/[locale]/(marketing)/ai/image/page.tsx index 09a440f..fa34aa0 100644 --- a/src/app/[locale]/(marketing)/ai/image/page.tsx +++ b/src/app/[locale]/(marketing)/ai/image/page.tsx @@ -2,6 +2,7 @@ import { ImagePlayground } from '@/ai/image/components/ImagePlayground'; import { getRandomSuggestions } from '@/ai/image/lib/suggestions'; import { constructMetadata } from '@/lib/metadata'; import { getUrlWithLocale } from '@/lib/urls/urls'; +import { ImageIcon } from 'lucide-react'; import type { Metadata } from 'next'; import type { Locale } from 'next-intl'; import { getTranslations } from 'next-intl/server'; @@ -26,8 +27,21 @@ export default async function AIImagePage() { const t = await getTranslations('AIImagePage'); return ( -
- +
+
+ {/* Header Section */} +
+
+ + {t('title')} +
+
+ + {/* Image Playground Component */} +
+ +
+
); } diff --git a/src/app/[locale]/(marketing)/blocks/[category]/layout.tsx b/src/app/[locale]/(marketing)/blocks/[category]/layout.tsx deleted file mode 100644 index c988f6f..0000000 --- a/src/app/[locale]/(marketing)/blocks/[category]/layout.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { categories } from '@/components/tailark/blocks'; -import BlocksNav from '@/components/tailark/blocks-nav'; -import type { PropsWithChildren } from 'react'; - -/** - * The locale inconsistency issue has been fixed in the BlocksNav component - */ -export default function BlockCategoryLayout({ children }: PropsWithChildren) { - return ( - <> - - -
{children}
- - ); -} diff --git a/src/app/[locale]/(marketing)/blocks/[category]/page.tsx b/src/app/[locale]/(marketing)/blocks/[category]/page.tsx deleted file mode 100644 index cd893d1..0000000 --- a/src/app/[locale]/(marketing)/blocks/[category]/page.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import BlockPreview from '@/components/tailark/block-preview'; -import { blocks, categories } from '@/components/tailark/blocks'; -import { constructMetadata } from '@/lib/metadata'; -import { getUrlWithLocale } from '@/lib/urls/urls'; -import type { Metadata } from 'next'; -import type { Locale } from 'next-intl'; -import { getTranslations } from 'next-intl/server'; -import { notFound } from 'next/navigation'; - -export const dynamic = 'force-static'; -export const revalidate = 3600; - -export async function generateStaticParams() { - return categories.map((category) => ({ - category: category, - })); -} - -export async function generateMetadata({ - params, -}: { - params: Promise<{ locale: Locale; category: string }>; -}): Promise { - const { locale, category } = await params; - const t = await getTranslations({ locale, namespace: 'Metadata' }); - return constructMetadata({ - title: category + ' | ' + t('title'), - description: t('description'), - canonicalUrl: getUrlWithLocale(`/blocks/${category}`, locale), - }); -} - -interface BlockCategoryPageProps { - params: Promise<{ category: string }>; -} - -export default async function BlockCategoryPage({ - params, -}: BlockCategoryPageProps) { - const { category } = await params; - const categoryBlocks = blocks.filter((b) => b.category === category); - - if (categoryBlocks.length === 0) { - notFound(); - } - - return ( - <> - {categoryBlocks.map((block, index) => ( - - ))} - - ); -} diff --git a/src/app/[locale]/providers.tsx b/src/app/[locale]/providers.tsx index 76ebae3..c3e89a9 100644 --- a/src/app/[locale]/providers.tsx +++ b/src/app/[locale]/providers.tsx @@ -29,7 +29,7 @@ interface ProvidersProps { */ export function Providers({ children, locale }: ProvidersProps) { const theme = useTheme(); - const defaultMode = websiteConfig.metadata.mode?.defaultMode ?? 'system'; + const defaultMode = websiteConfig.ui.mode?.defaultMode ?? 'system'; // available languages that will be displayed in the docs UI // make sure `locale` is consistent with your i18n config diff --git a/src/app/api/analyze-content/route.ts b/src/app/api/analyze-content/route.ts index 91db24e..ef01183 100644 --- a/src/app/api/analyze-content/route.ts +++ b/src/app/api/analyze-content/route.ts @@ -13,12 +13,9 @@ import { validateUrl, } from '@/ai/text/utils/web-content-analyzer'; import { - getWebContentAnalysisCost, validateFirecrawlConfig, webContentAnalyzerConfig, } from '@/ai/text/utils/web-content-analyzer-config'; -import { consumeCredits, hasEnoughCredits } from '@/credits/credits'; -import { getSession } from '@/lib/server'; import { createDeepSeek } from '@ai-sdk/deepseek'; import { createGoogleGenerativeAI } from '@ai-sdk/google'; import { createOpenAI } from '@ai-sdk/openai'; @@ -30,7 +27,6 @@ import { z } from 'zod'; // Constants from configuration const TIMEOUT_MILLIS = webContentAnalyzerConfig.timeoutMillis; -const CREDITS_COST = getWebContentAnalysisCost(); const MAX_CONTENT_LENGTH = webContentAnalyzerConfig.maxContentLength; // Initialize Firecrawl client @@ -361,28 +357,6 @@ export async function POST(req: NextRequest) { ); } - // Check authentication - const session = await getSession(); - if (!session) { - const authError = new WebContentAnalyzerError( - ErrorType.AUTHENTICATION, - 'Authentication required', - 'Please sign in to analyze web content.', - ErrorSeverity.HIGH, - false - ); - - logError(authError, { requestId }); - - return NextResponse.json( - { - success: false, - error: authError.userMessage, - } satisfies AnalyzeContentResponse, - { status: 401 } - ); - } - // Check if Firecrawl is configured if (!validateFirecrawlConfig()) { const configError = new WebContentAnalyzerError( @@ -404,39 +378,7 @@ export async function POST(req: NextRequest) { ); } - // Check if user has sufficient credits before starting analysis - const hasCredits = await hasEnoughCredits({ - userId: session.user.id, - requiredCredits: CREDITS_COST, - }); - - if (!hasCredits) { - const creditError = new WebContentAnalyzerError( - ErrorType.CREDITS, - 'Insufficient credits to perform analysis', - "You don't have enough credits to analyze this webpage. Please purchase more credits.", - ErrorSeverity.HIGH, - false - ); - - logError(creditError, { - requestId, - userId: session.user.id, - requiredCredits: CREDITS_COST, - }); - - return NextResponse.json( - { - success: false, - error: creditError.userMessage, - } satisfies AnalyzeContentResponse, - { status: 402 } - ); - } - - console.log( - `Starting analysis [requestId=${requestId}, url=${url}, userId=${session.user.id}]` - ); + console.log(`Starting analysis [requestId=${requestId}, url=${url}]`); // Perform analysis with timeout and enhanced error handling const analysisPromise = (async () => { @@ -447,13 +389,6 @@ export async function POST(req: NextRequest) { // Step 2: Analyze content with AI (pass provider) const analysis = await analyzeContent(content, url, modelProvider); - // Step 3: Consume credits (only on successful analysis) - await consumeCredits({ - userId: session.user.id, - amount: CREDITS_COST, - description: `Web content analysis: ${url}`, - }); - return { analysis, screenshot }; } catch (error) { // If it's already a WebContentAnalyzerError, just re-throw @@ -477,7 +412,6 @@ export async function POST(req: NextRequest) { return NextResponse.json({ success: true, data: result, - creditsConsumed: CREDITS_COST, } satisfies AnalyzeContentResponse); } catch (error) { const elapsed = ((performance.now() - startTime) / 1000).toFixed(1); @@ -499,12 +433,6 @@ export async function POST(req: NextRequest) { case ErrorType.VALIDATION: statusCode = 400; break; - case ErrorType.AUTHENTICATION: - statusCode = 401; - break; - case ErrorType.CREDITS: - statusCode = 402; - break; case ErrorType.TIMEOUT: statusCode = 408; break; diff --git a/src/components/dashboard/sidebar-user.tsx b/src/components/dashboard/sidebar-user.tsx index 6a0831a..11317b4 100644 --- a/src/components/dashboard/sidebar-user.tsx +++ b/src/components/dashboard/sidebar-user.tsx @@ -71,7 +71,7 @@ export function SidebarUser({ user, className }: SidebarUserProps) { }); }; - const showModeSwitch = websiteConfig.metadata.mode?.enableSwitch ?? false; + const showModeSwitch = websiteConfig.ui.mode?.enableSwitch ?? false; const showLocaleSwitch = LOCALES.length > 1; const handleSignOut = async () => { diff --git a/src/components/layout/active-theme-provider.tsx b/src/components/layout/active-theme-provider.tsx index 412e553..616677a 100644 --- a/src/components/layout/active-theme-provider.tsx +++ b/src/components/layout/active-theme-provider.tsx @@ -10,7 +10,7 @@ import { } from 'react'; const COOKIE_NAME = 'active_theme'; -const DEFAULT_THEME = websiteConfig.metadata.theme?.defaultTheme ?? 'default'; +const DEFAULT_THEME = websiteConfig.ui.theme?.defaultTheme ?? 'default'; function setThemeCookie(theme: string) { if (typeof window === 'undefined') return; diff --git a/src/components/layout/mode-switcher-horizontal.tsx b/src/components/layout/mode-switcher-horizontal.tsx index 0c707cf..e7f3dc7 100644 --- a/src/components/layout/mode-switcher-horizontal.tsx +++ b/src/components/layout/mode-switcher-horizontal.tsx @@ -12,7 +12,7 @@ import { useEffect, useState } from 'react'; * Mode switcher component, used in the footer */ export function ModeSwitcherHorizontal() { - if (!websiteConfig.metadata.mode?.enableSwitch) { + if (!websiteConfig.ui.mode?.enableSwitch) { return null; } diff --git a/src/components/layout/mode-switcher.tsx b/src/components/layout/mode-switcher.tsx index 323d413..d98d1b9 100644 --- a/src/components/layout/mode-switcher.tsx +++ b/src/components/layout/mode-switcher.tsx @@ -16,7 +16,7 @@ import { useTheme } from 'next-themes'; * Mode switcher component, used in the navbar */ export function ModeSwitcher() { - if (!websiteConfig.metadata.mode?.enableSwitch) { + if (!websiteConfig.ui.mode?.enableSwitch) { return null; } diff --git a/src/components/layout/theme-selector.tsx b/src/components/layout/theme-selector.tsx index ba8af28..2894fcb 100644 --- a/src/components/layout/theme-selector.tsx +++ b/src/components/layout/theme-selector.tsx @@ -21,7 +21,7 @@ import { useThemeConfig } from './active-theme-provider'; * https://github.com/TheOrcDev/orcish-dashboard/blob/main/components/theme-selector.tsx */ export function ThemeSelector() { - if (!websiteConfig.metadata.theme?.enableSwitch) { + if (!websiteConfig.ui.theme?.enableSwitch) { return null; } diff --git a/src/components/shared/discord-widget.tsx b/src/components/shared/discord-widget.tsx deleted file mode 100644 index 6c42c5f..0000000 --- a/src/components/shared/discord-widget.tsx +++ /dev/null @@ -1,88 +0,0 @@ -'use client'; - -import { DiscordIcon } from '@/components/icons/discord'; -import { websiteConfig } from '@/config/website'; -import { useMediaQuery } from '@/hooks/use-media-query'; -import WidgetBot from '@widgetbot/react-embed'; -import { useEffect, useRef, useState } from 'react'; - -/** - * Discord Widget, shows the channels and messages in the discord server - * - * @deprecated - * This feature is deprecated for Discord Widget can not be used anymore. - * - * https://docs.widgetbot.io/embed/react-embed/ - */ -export default function DiscordWidget() { - if (!websiteConfig.features.enableDiscordWidget) { - return null; - } - - const serverId = process.env.NEXT_PUBLIC_DISCORD_WIDGET_SERVER_ID as string; - const channelId = process.env.NEXT_PUBLIC_DISCORD_WIDGET_CHANNEL_ID as string; - if (!serverId || !channelId) { - return null; - } - - const [open, setOpen] = useState(false); - const widgetRef = useRef(null); - const { device, width: windowWidth, height: windowHeight } = useMediaQuery(); - - let widgetWidth = 800; - let widgetHeight = 600; - if (device === 'mobile') { - widgetWidth = windowWidth ? Math.floor(windowWidth * 0.9) : 320; - widgetHeight = windowHeight ? Math.floor(windowHeight * 0.8) : 400; - } else if (device === 'tablet' || device === 'sm') { - widgetWidth = windowWidth ? Math.floor(windowWidth * 0.9) : 600; - widgetHeight = windowHeight ? Math.floor(windowHeight * 0.8) : 480; - } - - useEffect(() => { - if (!open) return; - function handleClick(e: MouseEvent) { - if (widgetRef.current && !widgetRef.current.contains(e.target as Node)) { - setOpen(false); - } - } - document.addEventListener('mousedown', handleClick); - return () => document.removeEventListener('mousedown', handleClick); - }, [open]); - - return ( -
- {/* discord icon button, show in bottom right corner */} - {!open && ( - - )} - - {/* discord widget expand layer */} - {open && ( -
-
- -
-
- )} -
- ); -} diff --git a/src/components/tailark/block-preview.tsx b/src/components/tailark/block-preview.tsx deleted file mode 100644 index 797e49e..0000000 --- a/src/components/tailark/block-preview.tsx +++ /dev/null @@ -1,368 +0,0 @@ -'use client'; - -import { Button } from '@/components/ui/button'; -import { Separator } from '@/components/ui/separator'; -import { useCopyToClipboard } from '@/hooks/use-clipboard'; -import { isUrlCached } from '@/lib/serviceWorker'; -import { cn } from '@/lib/utils'; -import * as RadioGroup from '@radix-ui/react-radio-group'; -import { Check, Code2, Copy, Eye, Maximize, Terminal } from 'lucide-react'; -import Link from 'next/link'; -import type React from 'react'; -import { useEffect, useRef, useState } from 'react'; -import { - Panel, - PanelGroup, - PanelResizeHandle, - type ImperativePanelGroupHandle, -} from 'react-resizable-panels'; -import { useMedia } from 'use-media'; - -export interface BlockPreviewProps { - code?: string; - preview: string; - title: string; - category: string; - previewOnly?: boolean; -} - -const radioItem = - 'rounded-(--radius) duration-200 flex items-center justify-center h-8 px-2.5 gap-2 transition-[color] data-[state=checked]:bg-muted'; - -const DEFAULTSIZE = 100; -const SMSIZE = 30; -const MDSIZE = 62; -const LGSIZE = 82; - -const getCacheKey = (src: string) => `iframe-cache-${src}`; - -const titleToNumber = (title: string): number => { - const titles = ["one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen", "sixteen", "seventeen", "eighteen", "nineteen", "twenty"]; - return titles.indexOf(title.toLowerCase()) + 1; -}; - -export const BlockPreview: React.FC = ({ - code, - preview, - title, - category, - previewOnly, -}) => { - const [width, setWidth] = useState(DEFAULTSIZE); - const [mode, setMode] = useState<'preview' | 'code'>('preview'); - const [iframeHeight, setIframeHeight] = useState(0); - const [shouldLoadIframe, setShouldLoadIframe] = useState(false); - const [cachedHeight, setCachedHeight] = useState(null); - const [isIframeCached, setIsIframeCached] = useState(false); - - const terminalCode = `pnpm dlx shadcn@canary add https://nsui.irung.me/r/${category}-${titleToNumber(title)}.json`; - const { copied, copy } = useCopyToClipboard({ code: code as string, title, category, eventName: 'block_copy' }) - const { copied: cliCopied, copy: cliCopy } = useCopyToClipboard({ code: terminalCode, title, category, eventName: 'block_cli_copy' }) - - const ref = useRef(null); - const isLarge = useMedia('(min-width: 1024px)'); - - const iframeRef = useRef(null); - const observer = useRef(null); - const blockRef = useRef(null); - - useEffect(() => { - observer.current = new IntersectionObserver( - (entries) => { - if (entries[0].isIntersecting) { - setShouldLoadIframe(true); - observer.current?.disconnect(); - } - }, - { threshold: 0.1 } - ); - - if (blockRef.current) { - observer.current.observe(blockRef.current); - } - - return () => { - observer.current?.disconnect(); - }; - }, []); - - useEffect(() => { - const checkCache = async () => { - try { - const isCached = await isUrlCached(preview); - setIsIframeCached(isCached); - if (isCached) { - setShouldLoadIframe(true); - } - } catch (error) { - console.error('Error checking cache status:', error); - } - }; - - checkCache(); - - try { - const cacheKey = getCacheKey(preview); - const cached = localStorage.getItem(cacheKey); - if (cached) { - const { height, timestamp } = JSON.parse(cached); - const now = Date.now(); - if (now - timestamp < 24 * 60 * 60 * 1000) { - setCachedHeight(height); - setIframeHeight(height); - } - } - } catch (error) { - console.error('Error retrieving cache:', error); - } - }, [preview]); - - useEffect(() => { - const iframe = iframeRef.current; - if (!iframe || !shouldLoadIframe) return; - - const handleLoad = () => { - try { - const contentHeight = iframe.contentWindow!.document.body.scrollHeight; - setIframeHeight(contentHeight); - - const cacheKey = getCacheKey(preview); - const cacheValue = JSON.stringify({ - height: contentHeight, - timestamp: Date.now(), - }); - localStorage.setItem(cacheKey, cacheValue); - } catch (e) { - console.error('Error accessing iframe content:', e); - } - }; - - iframe.addEventListener('load', handleLoad); - return () => { - iframe.removeEventListener('load', handleLoad); - }; - }, [shouldLoadIframe, preview]); - - useEffect(() => { - if (!blockRef.current || shouldLoadIframe) return; - - const linkElement = document.createElement('link'); - linkElement.rel = 'preload'; - linkElement.href = preview; - linkElement.as = 'document'; - - if ( - !document.head.querySelector(`link[rel="preload"][href="${preview}"]`) - ) { - document.head.appendChild(linkElement); - } - - return () => { - const existingLink = document.head.querySelector( - `link[rel="preload"][href="${preview}"]` - ); - if (existingLink) { - document.head.removeChild(existingLink); - } - }; - }, [preview, shouldLoadIframe]); - - return ( -
-
-
-
-
-
- -
-
- {code && ( - <> - - setMode('preview')} - aria-label="Block preview" - value="100" - checked={mode == 'preview'} - className={radioItem} - > - - Preview - - - setMode('code')} - aria-label="Code" - value="0" - checked={mode == 'code'} - className={radioItem} - > - - Code - - - - - - )} - {previewOnly && ( - <> - {' '} - {title} - {' '} - - )} - {/* */} - - - {width < MDSIZE - ? 'Mobile' - : width < LGSIZE - ? 'Tablet' - : 'Desktop'} - {' '} -
- -
- {code && ( - <> - - - {/* */} - - - - - )} - {!code && ( - - {/* pnpm dlx shadcn@canary add */}{category}-{titleToNumber(title)} - - )} -
-
-
- -
-
-
-
-
- -
-
- - { - setWidth(Number(size)); - }} - defaultSize={DEFAULTSIZE} - minSize={SMSIZE} - className="h-fit border-x" - > -
- {shouldLoadIframe ? ( -