innit
This commit is contained in:
commit
b44bca96ac
5
next-env.d.ts
vendored
Normal file
5
next-env.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
18
next.config.mjs
Normal file
18
next.config.mjs
Normal file
@ -0,0 +1,18 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
webpack: (config) => {
|
||||
config.resolve.fallback = {
|
||||
...config.resolve.fallback,
|
||||
fs: false,
|
||||
os: false,
|
||||
path: false,
|
||||
crypto: false,
|
||||
}
|
||||
return config
|
||||
},
|
||||
env: {
|
||||
GOOGLE_API_KEY: process.env.GOOGLE_API_KEY,
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig
|
7359
package-lock.json
generated
Normal file
7359
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
package.json
Normal file
38
package.json
Normal file
@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "wildlife",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fal-ai/client": "^1.2.1",
|
||||
"@google-cloud/vision": "^4.3.2",
|
||||
"@google/generative-ai": "^0.21.0",
|
||||
"@solana/spl-token": "^0.3.8",
|
||||
"@solana/web3.js": "^1.78.4",
|
||||
"exif-reader": "^2.0.1",
|
||||
"form-data": "^4.0.1",
|
||||
"lucide-react": "^0.469.0",
|
||||
"next": "^15.1.4",
|
||||
"openai": "^4.77.0",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"yaml": "^2.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/exif-js": "^2.3.1",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"autoprefixer": "^10",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "13.5.4",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
63
src/app/about/page.tsx
Normal file
63
src/app/about/page.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import Navigation from '../../components/Navigation'
|
||||
import { Leaf } from 'lucide-react'
|
||||
|
||||
const AboutPage = () => {
|
||||
return (
|
||||
<div className="min-h-screen w-full flex flex-col items-center bg-gradient-to-b from-emerald-950 via-green-900 to-emerald-950">
|
||||
<div className="container max-w-7xl mx-auto px-4 py-8">
|
||||
<Navigation />
|
||||
|
||||
{/* Header */}
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl sm:text-5xl font-bold mb-4 text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 to-teal-300">
|
||||
About Trail Mission
|
||||
</h1>
|
||||
<p className="text-emerald-200 text-lg">
|
||||
Connecting technology with wildlife conservation
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="max-w-2xl mx-auto bg-emerald-900/20 rounded-xl p-8 border border-emerald-800/50">
|
||||
<div className="flex items-center justify-center mb-6">
|
||||
<Leaf className="w-12 h-12 text-emerald-400" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 text-emerald-200">
|
||||
<p>
|
||||
Trail Mission is a community platform that combines AI, blockchain,
|
||||
and citizen science to document and study wildlife in their natural habitats.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Using advanced image recognition and decentralized storage, we create permanent,
|
||||
verifiable records of wildlife sightings that can be used by researchers,
|
||||
conservationists, and nature enthusiasts.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Each submission is analyzed by AI to identify species, stored permanently on IPFS,
|
||||
and recorded on the Laconic Registry, creating an immutable database of wildlife
|
||||
observations.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
This product is currently in development; location data is randomly assigned to each
|
||||
image. In the future, we will incorporated verifiable location data in a privacy preserving
|
||||
way.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-emerald-800/50 text-emerald-300/80 text-sm">
|
||||
Built with ❤️ by Mark
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AboutPage
|
117
src/app/animals/page.tsx
Normal file
117
src/app/animals/page.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import Navigation from '../../components/Navigation'
|
||||
import { fetchAnimalRecords } from '../../services/laconicQueryService'
|
||||
import { MapPin } from 'lucide-react'
|
||||
|
||||
interface AnimalRecord {
|
||||
species: string
|
||||
location: {
|
||||
latitude: number
|
||||
longitude: number
|
||||
}
|
||||
description: string
|
||||
imageUrl: string
|
||||
}
|
||||
|
||||
export default function AnimalsPage() {
|
||||
const [records, setRecords] = useState<AnimalRecord[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadRecords()
|
||||
}, [])
|
||||
|
||||
const loadRecords = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const data = await fetchAnimalRecords()
|
||||
setRecords(data)
|
||||
} catch (error) {
|
||||
setError('Failed to load animal records')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full flex flex-col items-center bg-gradient-to-b from-emerald-950 via-green-900 to-emerald-950">
|
||||
<div className="container max-w-7xl mx-auto px-4 py-8">
|
||||
<Navigation />
|
||||
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl sm:text-5xl font-bold mb-4 text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 to-teal-300">
|
||||
Animal Registry
|
||||
</h1>
|
||||
<p className="text-emerald-200 text-lg mb-8">
|
||||
Discover wildlife sightings from around the world
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<div className="text-emerald-200 text-center">
|
||||
Loading animal records...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<div className="text-red-400 text-center p-4 bg-red-900/20 rounded-xl">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Records Grid */}
|
||||
{!loading && !error && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{records.map((record, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-emerald-900/20 rounded-xl p-6 border border-emerald-800/50 hover:border-emerald-700/50 transition-colors"
|
||||
>
|
||||
{/* Image */}
|
||||
<div className="aspect-video rounded-lg overflow-hidden mb-4 bg-emerald-900/50">
|
||||
<img
|
||||
src={record.imageUrl}
|
||||
alt={`${record.species} sighting`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Species */}
|
||||
<h3 className="text-xl font-semibold text-emerald-300 capitalize mb-2">
|
||||
{record.species}
|
||||
</h3>
|
||||
|
||||
{/* Location */}
|
||||
<div className="flex items-center text-emerald-200/80 text-sm mb-3">
|
||||
<MapPin className="w-4 h-4 mr-1" />
|
||||
<span>
|
||||
{record.location.latitude}°, {record.location.longitude}°
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-emerald-200 text-sm">
|
||||
{record.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!loading && !error && records.length === 0 && (
|
||||
<div className="text-emerald-200 text-center p-8 bg-emerald-900/20 rounded-xl">
|
||||
No animal records found yet. Be the first to contribute!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
60
src/app/api/analyze/route.ts
Normal file
60
src/app/api/analyze/route.ts
Normal file
@ -0,0 +1,60 @@
|
||||
// src/app/api/analyze/route.ts
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { analyzeImageWithVision } from '../../../services/googleVisionCore'
|
||||
import { processAnimalImage } from '../../../services/animalProcessingService'
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
try {
|
||||
const formData = await req.formData()
|
||||
const imageFile = formData.get('image') as File
|
||||
|
||||
if (!imageFile) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No image provided' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Convert image to buffer
|
||||
const arrayBuffer = await imageFile.arrayBuffer()
|
||||
const buffer = Buffer.from(arrayBuffer)
|
||||
|
||||
// Get vision analysis
|
||||
const visionResult = await analyzeImageWithVision(buffer)
|
||||
|
||||
// Construct the response message
|
||||
const responseMessage = `${visionResult.description}\n\n${
|
||||
visionResult.isAnimal
|
||||
? "✨ This image contains wildlife and has been added to our registry! Thank you for contributing to our wildlife database."
|
||||
: "🌿 No wildlife detected in this image. Try uploading a photo of an animal!"
|
||||
}`
|
||||
|
||||
// Send response to user
|
||||
const userResponse = NextResponse.json({
|
||||
description: responseMessage,
|
||||
isAnimal: visionResult.isAnimal
|
||||
})
|
||||
|
||||
// If animal detected, process in background
|
||||
if (visionResult.isAnimal) {
|
||||
processAnimalImage(
|
||||
buffer,
|
||||
visionResult.description,
|
||||
visionResult.rawResponse,
|
||||
imageFile.name
|
||||
).catch(console.error)
|
||||
}
|
||||
|
||||
return userResponse
|
||||
|
||||
} catch (error) {
|
||||
console.error('Analysis failed:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to analyze image' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
63
src/app/api/chat/route.ts
Normal file
63
src/app/api/chat/route.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import OpenAI from "openai"
|
||||
import { CHAT_MODELS } from '../../../services/chatService'
|
||||
|
||||
if (!process.env.X_AI_API_KEY) {
|
||||
throw new Error('X_AI_API_KEY is not configured in environment variables')
|
||||
}
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey: process.env.X_AI_API_KEY,
|
||||
baseURL: "https://api.x.ai/v1",
|
||||
})
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
try {
|
||||
const { prompt, characterId } = await req.json()
|
||||
|
||||
if (!prompt || !characterId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Prompt and characterId are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Find the model config using characterId
|
||||
const modelConfig = CHAT_MODELS.find(m => m.characterId === characterId)
|
||||
if (!modelConfig) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid character ID' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
console.log('Generating chat response with character:', modelConfig.name)
|
||||
console.log('System prompt:', modelConfig.systemPrompt)
|
||||
console.log('User prompt:', prompt)
|
||||
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: modelConfig.modelId,
|
||||
messages: [
|
||||
{ role: "system", content: modelConfig.systemPrompt },
|
||||
{ role: "user", content: prompt }
|
||||
],
|
||||
})
|
||||
|
||||
const response = completion.choices[0].message.content
|
||||
|
||||
if (!response) {
|
||||
console.error('No response in completion:', completion)
|
||||
throw new Error('No response generated')
|
||||
}
|
||||
|
||||
return NextResponse.json({ response })
|
||||
} catch (error) {
|
||||
console.error('Chat generation error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to generate response' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
75
src/app/globals.css
Normal file
75
src/app/globals.css
Normal file
@ -0,0 +1,75 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--foreground-rgb: 255, 255, 255;
|
||||
--background-start-rgb: 0, 0, 0;
|
||||
--background-end-rgb: 0, 0, 0;
|
||||
}
|
||||
|
||||
body {
|
||||
color: rgb(var(--foreground-rgb));
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgb(var(--background-start-rgb)),
|
||||
rgb(var(--background-end-rgb))
|
||||
);
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Custom animations */
|
||||
@keyframes bounce-delay {
|
||||
0%, 80%, 100% { transform: scale(0); }
|
||||
40% { transform: scale(1.0); }
|
||||
}
|
||||
|
||||
.delay-100 {
|
||||
animation-delay: 100ms;
|
||||
}
|
||||
|
||||
.delay-200 {
|
||||
animation-delay: 200ms;
|
||||
}
|
||||
|
||||
/* Reset default focus styles */
|
||||
*:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for textareas */
|
||||
textarea {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(139, 92, 246, 0.5) rgba(17, 24, 39, 0.1);
|
||||
}
|
||||
|
||||
textarea::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
textarea::-webkit-scrollbar-track {
|
||||
background: rgba(17, 24, 39, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
textarea::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(139, 92, 246, 0.5);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Ensure proper backdrop-filter support */
|
||||
@supports (backdrop-filter: blur(12px)) {
|
||||
.backdrop-blur-lg {
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure proper text-gradient support */
|
||||
@supports (-webkit-background-clip: text) or (background-clip: text) {
|
||||
.bg-clip-text {
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
}
|
||||
}
|
22
src/app/layout.tsx
Normal file
22
src/app/layout.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import './globals.css'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Trail Mission',
|
||||
description: 'Go outside and touch grass',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={inter.className}>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
102
src/app/page.tsx
Normal file
102
src/app/page.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
// src/app/page.tsx
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import WalletHeader from '../components/WalletHeader'
|
||||
import ImageAnalysisCard from '../components/ImageAnalysisCard'
|
||||
import Navigation from '../components/Navigation'
|
||||
import { analyzeImage, VisionAnalysisResult, VISION_CONFIG } from '../services/googleVisionService'
|
||||
import { processMTMPayment } from '../services/paymentService'
|
||||
import { connectWallet, WalletState } from '../services/walletService'
|
||||
import { WalletType } from '../services/types'
|
||||
|
||||
const Page: React.FC = (): React.ReactElement => {
|
||||
const [walletState, setWalletState] = useState<WalletState>({
|
||||
connected: false,
|
||||
publicKey: null,
|
||||
type: null
|
||||
})
|
||||
|
||||
const handleConnect = async (walletType: WalletType): Promise<void> => {
|
||||
try {
|
||||
const newWalletState = await connectWallet(walletType)
|
||||
setWalletState(newWalletState)
|
||||
} catch (error) {
|
||||
console.error('Wallet connection error:', error)
|
||||
setWalletState({
|
||||
connected: false,
|
||||
publicKey: null,
|
||||
type: null
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleImageAnalysis = (cost: number) => {
|
||||
return async (imageFile: File): Promise<VisionAnalysisResult> => {
|
||||
if (!walletState.connected || !walletState.publicKey || !walletState.type) {
|
||||
return { error: 'Wallet not connected' }
|
||||
}
|
||||
|
||||
// Process payment first
|
||||
const paymentResult = await processMTMPayment(
|
||||
walletState.publicKey,
|
||||
cost,
|
||||
walletState.type
|
||||
)
|
||||
|
||||
if (!paymentResult.success) {
|
||||
return { error: paymentResult.error }
|
||||
}
|
||||
|
||||
// Then analyze the image
|
||||
return analyzeImage(imageFile)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full flex flex-col items-center bg-gradient-to-b from-emerald-950 via-green-900 to-emerald-950">
|
||||
<div className="container max-w-7xl mx-auto px-4 py-8">
|
||||
<Navigation />
|
||||
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl sm:text-5xl font-bold mb-4 text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 to-teal-300">
|
||||
Trail Mission
|
||||
</h1>
|
||||
<p className="text-emerald-200 text-lg mb-8">
|
||||
Go outside and touch grass
|
||||
</p>
|
||||
|
||||
<WalletHeader
|
||||
walletState={walletState}
|
||||
onConnect={handleConnect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Single Analysis Card */}
|
||||
<div className="max-w-2xl mx-auto relative">
|
||||
{/* Decorative elements */}
|
||||
<div className="absolute -top-4 -left-4 w-8 h-8 bg-emerald-500/10 rounded-full blur-lg" />
|
||||
<div className="absolute -bottom-4 -right-4 w-8 h-8 bg-teal-500/10 rounded-full blur-lg" />
|
||||
|
||||
<ImageAnalysisCard
|
||||
title={VISION_CONFIG.name}
|
||||
description={VISION_CONFIG.description}
|
||||
tokenCost={VISION_CONFIG.cost}
|
||||
isWalletConnected={walletState.connected}
|
||||
onAnalyze={handleImageAnalysis(VISION_CONFIG.cost)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info Section */}
|
||||
<div className="mt-12 text-center text-emerald-300/60">
|
||||
<p className="text-sm">
|
||||
Powered by Mark • Use at your own risk
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Page
|
125
src/components/AIServiceCard.tsx
Normal file
125
src/components/AIServiceCard.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
|
||||
interface AIServiceCardProps {
|
||||
title: string
|
||||
description: string
|
||||
tokenCost: number
|
||||
isWalletConnected: boolean
|
||||
onGenerate: (prompt: string) => Promise<{ response?: string, error?: string }>
|
||||
}
|
||||
|
||||
interface GenerationState {
|
||||
loading: boolean
|
||||
response: string | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
const AIServiceCard: React.FC<AIServiceCardProps> = ({
|
||||
title,
|
||||
description,
|
||||
tokenCost,
|
||||
isWalletConnected,
|
||||
onGenerate
|
||||
}) => {
|
||||
const [inputText, setInputText] = useState<string>('')
|
||||
const [generationState, setGenerationState] = useState<GenerationState>({
|
||||
loading: false,
|
||||
response: null,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const handleGenerate = async (): Promise<void> => {
|
||||
if (!inputText || !isWalletConnected) return
|
||||
|
||||
setGenerationState({
|
||||
...generationState,
|
||||
loading: true,
|
||||
error: null,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await onGenerate(inputText)
|
||||
|
||||
if (result.error) {
|
||||
setGenerationState({
|
||||
...generationState,
|
||||
loading: false,
|
||||
error: result.error,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (result.response) {
|
||||
setGenerationState({
|
||||
loading: false,
|
||||
response: result.response,
|
||||
error: null,
|
||||
})
|
||||
} else {
|
||||
throw new Error('No response received')
|
||||
}
|
||||
} catch (error) {
|
||||
setGenerationState({
|
||||
...generationState,
|
||||
loading: false,
|
||||
error: error instanceof Error ? error.message : 'Generation failed',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full bg-slate-900/50 backdrop-blur-lg rounded-2xl shadow-xl border border-orange-800/50 mb-8 hover:shadow-orange-500/20 transition-all duration-300">
|
||||
<div className="p-6">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-orange-400 to-amber-500">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-slate-300 mt-2">{description}</p>
|
||||
<div className="mt-2 inline-block px-3 py-1 bg-amber-500/20 rounded-full text-amber-200 text-sm">
|
||||
Cost: {tokenCost} MTM
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<textarea
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
placeholder="Ask me anything..."
|
||||
disabled={!isWalletConnected}
|
||||
className="w-full bg-slate-950/80 text-slate-200 border border-orange-900 rounded-xl p-4
|
||||
placeholder-slate-500 focus:border-amber-500 focus:ring-2 focus:ring-amber-500/20
|
||||
focus:outline-none min-h-[120px] transition-all duration-200
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
rows={4}
|
||||
/>
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={!isWalletConnected || generationState.loading || !inputText}
|
||||
className="w-full bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600
|
||||
hover:to-amber-600 text-white font-semibold py-4 px-6 rounded-xl
|
||||
transition-all duration-200 shadow-lg hover:shadow-amber-500/25
|
||||
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:shadow-none"
|
||||
>
|
||||
{generationState.loading ? 'Processing...' : `Pay ${tokenCost} MTM & Chat`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{generationState.error && (
|
||||
<div className="mt-4 bg-red-900/20 border border-red-500/20 text-red-400 px-4 py-3 rounded-xl text-center">
|
||||
{generationState.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{generationState.response && (
|
||||
<div className="mt-4 bg-slate-800/50 border border-orange-800/50 rounded-xl p-4">
|
||||
<p className="text-slate-200 whitespace-pre-wrap">{generationState.response}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AIServiceCard
|
172
src/components/ImageAnalysisCard.tsx
Normal file
172
src/components/ImageAnalysisCard.tsx
Normal file
@ -0,0 +1,172 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useRef } from 'react'
|
||||
import { Leaf } from 'lucide-react'
|
||||
|
||||
interface ImageAnalysisCardProps {
|
||||
title: string
|
||||
description: string
|
||||
tokenCost: number
|
||||
isWalletConnected: boolean
|
||||
onAnalyze: (file: File) => Promise<{ description?: string, error?: string }>
|
||||
}
|
||||
|
||||
interface AnalysisState {
|
||||
loading: boolean
|
||||
imageUrl: string | null
|
||||
description: string | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
const ImageAnalysisCard: React.FC<ImageAnalysisCardProps> = ({
|
||||
title,
|
||||
description,
|
||||
tokenCost,
|
||||
isWalletConnected,
|
||||
onAnalyze
|
||||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [analysisState, setAnalysisState] = useState<AnalysisState>({
|
||||
loading: false,
|
||||
imageUrl: null,
|
||||
description: null,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
const imageUrl = URL.createObjectURL(file)
|
||||
setAnalysisState({
|
||||
...analysisState,
|
||||
imageUrl,
|
||||
description: null,
|
||||
error: null
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
const file = fileInputRef.current?.files?.[0]
|
||||
if (!file || !isWalletConnected) return
|
||||
|
||||
setAnalysisState({
|
||||
...analysisState,
|
||||
loading: true,
|
||||
error: null,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await onAnalyze(file)
|
||||
|
||||
if (result.error) {
|
||||
setAnalysisState({
|
||||
...analysisState,
|
||||
loading: false,
|
||||
error: result.error,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (result.description) {
|
||||
setAnalysisState({
|
||||
loading: false,
|
||||
imageUrl: analysisState.imageUrl,
|
||||
description: result.description,
|
||||
error: null,
|
||||
})
|
||||
} else {
|
||||
throw new Error('No analysis received')
|
||||
}
|
||||
} catch (error) {
|
||||
setAnalysisState({
|
||||
...analysisState,
|
||||
loading: false,
|
||||
error: error instanceof Error ? error.message : 'Analysis failed',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full bg-green-950/50 backdrop-blur-lg rounded-2xl shadow-xl border border-emerald-800/50 mb-8 hover:shadow-emerald-500/20 transition-all duration-300">
|
||||
<div className="p-6">
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Leaf className="w-6 h-6 text-emerald-400" />
|
||||
<h2 className="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 to-teal-300">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-emerald-200 mt-2">{description}</p>
|
||||
<div className="mt-2 inline-block px-3 py-1 bg-emerald-500/20 rounded-full text-emerald-200 text-sm">
|
||||
Cost: {tokenCost} MTM
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Image Upload Area */}
|
||||
<div
|
||||
className="relative border-2 border-dashed border-emerald-800/50 rounded-xl p-4 text-center
|
||||
hover:border-emerald-500/50 transition-colors duration-200
|
||||
bg-emerald-950/30"
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileSelect}
|
||||
disabled={!isWalletConnected}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer
|
||||
disabled:cursor-not-allowed"
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<div className="text-emerald-300">
|
||||
{analysisState.imageUrl ? (
|
||||
<img
|
||||
src={analysisState.imageUrl}
|
||||
alt="Selected"
|
||||
className="max-h-64 mx-auto rounded-lg"
|
||||
/>
|
||||
) : (
|
||||
<p>Share interesting animal behaviour...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleAnalyze}
|
||||
disabled={!isWalletConnected || analysisState.loading || !analysisState.imageUrl}
|
||||
className="w-full bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600
|
||||
hover:to-teal-600 text-white font-semibold py-4 px-6 rounded-xl
|
||||
transition-all duration-200 shadow-lg hover:shadow-emerald-500/25
|
||||
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:shadow-none"
|
||||
>
|
||||
{analysisState.loading ? 'Revealing the magic...' : `Pay ${tokenCost} MTM & Analyze`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{analysisState.error && (
|
||||
<div className="mt-4 bg-red-900/20 border border-red-500/20 text-red-400 px-4 py-3 rounded-xl text-center">
|
||||
{analysisState.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analysisState.description && (
|
||||
<div className="mt-4 space-y-4">
|
||||
{analysisState.description.split('\n\n').map((paragraph, index) => (
|
||||
<p key={index} className={`
|
||||
text-emerald-200 whitespace-pre-wrap
|
||||
${index === 1 ? 'mt-4 text-sm italic' : ''}
|
||||
`}>
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ImageAnalysisCard
|
38
src/components/Navigation.tsx
Normal file
38
src/components/Navigation.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
// src/components/Navigation.tsx
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
const Navigation = () => {
|
||||
const pathname = usePathname()
|
||||
|
||||
const links = [
|
||||
{ href: '/', label: 'Home' },
|
||||
{ href: '/animals', label: 'Animal Registry' },
|
||||
{ href: '/about', label: 'About' }
|
||||
]
|
||||
|
||||
return (
|
||||
<nav className="absolute left-4 top-8">
|
||||
<ul className="flex space-x-6">
|
||||
{links.map(({ href, label }) => (
|
||||
<li key={href}>
|
||||
<Link
|
||||
href={href}
|
||||
className={`text-sm transition-colors duration-200 ${
|
||||
pathname === href
|
||||
? 'text-emerald-400'
|
||||
: 'text-emerald-200/70 hover:text-emerald-200'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
export default Navigation
|
131
src/components/TextGenerationCard.tsx
Normal file
131
src/components/TextGenerationCard.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
|
||||
interface TextGenerationCardProps {
|
||||
title: string
|
||||
description: string
|
||||
tokenCost: number
|
||||
isWalletConnected: boolean
|
||||
onGenerate: (prompt: string) => Promise<{ textResponse?: string, error?: string }>
|
||||
}
|
||||
|
||||
interface GenerationState {
|
||||
loading: boolean
|
||||
processing: boolean
|
||||
textResponse: string | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
const TextGenerationCard: React.FC<TextGenerationCardProps> = ({
|
||||
title,
|
||||
description,
|
||||
tokenCost,
|
||||
isWalletConnected,
|
||||
onGenerate
|
||||
}) => {
|
||||
const [inputText, setInputText] = useState<string>('')
|
||||
const [generationState, setGenerationState] = useState<GenerationState>({
|
||||
loading: false,
|
||||
processing: false,
|
||||
textResponse: null,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const handleGenerate = async (): Promise<void> => {
|
||||
if (!inputText || !isWalletConnected) return
|
||||
|
||||
setGenerationState({
|
||||
...generationState,
|
||||
loading: true,
|
||||
error: null,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await onGenerate(inputText)
|
||||
|
||||
if (result.error) {
|
||||
setGenerationState({
|
||||
...generationState,
|
||||
loading: false,
|
||||
error: result.error,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (result.textResponse) {
|
||||
setGenerationState({
|
||||
loading: false,
|
||||
processing: false,
|
||||
textResponse: result.textResponse,
|
||||
error: null,
|
||||
})
|
||||
} else {
|
||||
throw new Error('No response received')
|
||||
}
|
||||
} catch (error) {
|
||||
setGenerationState({
|
||||
...generationState,
|
||||
loading: false,
|
||||
error: error instanceof Error ? error.message : 'Generation failed',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full bg-gray-800/50 backdrop-blur-lg rounded-2xl shadow-xl border border-gray-700/50 mb-8">
|
||||
<div className="p-6">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-green-400 to-emerald-600">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-gray-400 mt-2">{description}</p>
|
||||
<div className="mt-2 inline-block px-3 py-1 bg-green-500/20 rounded-full text-green-300 text-sm">
|
||||
Cost: {tokenCost} MTM
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<textarea
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
placeholder="Enter your question here..."
|
||||
disabled={!isWalletConnected}
|
||||
className="w-full bg-gray-900/50 text-gray-100 border border-gray-700 rounded-xl p-4
|
||||
placeholder-gray-500 focus:border-green-500 focus:ring-2 focus:ring-green-500/20
|
||||
focus:outline-none min-h-[120px] transition-all duration-200
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
rows={4}
|
||||
/>
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={!isWalletConnected || generationState.loading || !inputText}
|
||||
className="w-full bg-gradient-to-r from-green-500 to-emerald-500 hover:from-green-600
|
||||
hover:to-emerald-600 text-white font-semibold py-4 px-6 rounded-xl
|
||||
transition-all duration-200 shadow-lg hover:shadow-green-500/25
|
||||
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:shadow-none"
|
||||
>
|
||||
{generationState.loading ? 'Processing...' : `Pay ${tokenCost} MTM & Generate`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{generationState.error && (
|
||||
<div className="mt-4 bg-red-900/20 border border-red-500/20 text-red-400 px-4 py-3 rounded-xl text-center">
|
||||
{generationState.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{generationState.textResponse && (
|
||||
<div className="mt-6 bg-gray-900/50 rounded-xl p-6 border border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-green-400 mb-3">Response:</h3>
|
||||
<div className="text-gray-300 whitespace-pre-wrap">
|
||||
{generationState.textResponse}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TextGenerationCard
|
41
src/components/WalletHeader.tsx
Normal file
41
src/components/WalletHeader.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { WalletState, SUPPORTED_WALLETS } from '../services/walletService'
|
||||
import { WalletType } from '../services/types'
|
||||
|
||||
interface WalletHeaderProps {
|
||||
walletState: WalletState
|
||||
onConnect: (walletType: WalletType) => Promise<void>
|
||||
}
|
||||
|
||||
const WalletHeader: React.FC<WalletHeaderProps> = ({ walletState, onConnect }) => {
|
||||
return (
|
||||
<div className="w-full bg-green-950/50 backdrop-blur-lg rounded-xl shadow-lg border border-emerald-800/50 mb-8 p-4">
|
||||
{!walletState.connected ? (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{SUPPORTED_WALLETS.map((wallet) => (
|
||||
<button
|
||||
key={wallet.type}
|
||||
onClick={() => onConnect(wallet.type)}
|
||||
className="bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600
|
||||
text-white font-semibold py-3 px-6 rounded-lg transition-all duration-200
|
||||
shadow-lg hover:shadow-emerald-500/25"
|
||||
>
|
||||
Connect {wallet.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-emerald-200">Connected Wallet</span>
|
||||
<span className="px-3 py-1 bg-emerald-500/20 rounded-full text-emerald-200 text-sm">
|
||||
{walletState.publicKey?.slice(0, 22)}...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WalletHeader
|
110
src/services/animalProcessingService.ts
Normal file
110
src/services/animalProcessingService.ts
Normal file
@ -0,0 +1,110 @@
|
||||
// src/services/animalProcessingService.ts
|
||||
import exif from 'exif-reader'
|
||||
import { publishAnimalRecord } from './laconicService'
|
||||
import { uploadToIpfs } from './pinataService'
|
||||
import { ANIMAL_LABELS } from './constants'
|
||||
|
||||
interface Coordinates {
|
||||
lat: number
|
||||
lng: number
|
||||
}
|
||||
|
||||
function generateRandomCoordinates(): Coordinates {
|
||||
const lat = Math.round(Math.random() * 180 - 90)
|
||||
const lng = Math.round(Math.random() * 360 - 180)
|
||||
return { lat, lng }
|
||||
}
|
||||
|
||||
async function extractCoordinates(imageBuffer: Buffer): Promise<Coordinates> {
|
||||
console.log('Generating random coordinates (EXIF extraction disabled)')
|
||||
return generateRandomCoordinates()
|
||||
}
|
||||
|
||||
/*
|
||||
async function extractCoordinates(imageBuffer: Buffer): Promise<Coordinates> {
|
||||
// Check the environment variable to always use random coordinates
|
||||
if (process.env.NEXT_PUBLIC_ALWAYS_RANDOM_COORDS === 'true') {
|
||||
console.log('Using random coordinates as per environment configuration')
|
||||
return generateRandomCoordinates()
|
||||
}
|
||||
|
||||
try {
|
||||
let offset = 0
|
||||
while (offset < imageBuffer.length - 2) {
|
||||
if (imageBuffer[offset] === 0xFF && imageBuffer[offset + 1] === 0xE1) {
|
||||
const exifBuffer = imageBuffer.slice(offset + 4)
|
||||
const exifData = exif(exifBuffer)
|
||||
|
||||
// Check if GPS data exists and has valid latitude and longitude
|
||||
if (
|
||||
exifData?.gps?.latitude?.degrees !== undefined &&
|
||||
exifData?.gps?.longitude?.degrees !== undefined
|
||||
) {
|
||||
const lat = convertToDecimal(
|
||||
exifData.gps.latitude.degrees,
|
||||
exifData.gps.latitude.minutes || 0,
|
||||
exifData.gps.latitude.seconds || 0,
|
||||
exifData.gps.latitude.direction || 'N'
|
||||
)
|
||||
const lng = convertToDecimal(
|
||||
exifData.gps.longitude.degrees,
|
||||
exifData.gps.longitude.minutes || 0,
|
||||
exifData.gps.longitude.seconds || 0,
|
||||
exifData.gps.longitude.direction || 'E'
|
||||
)
|
||||
|
||||
return {
|
||||
lat: Math.round(lat),
|
||||
lng: Math.round(lng)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
offset++
|
||||
}
|
||||
|
||||
console.log('No EXIF data found, generating random coordinates')
|
||||
return generateRandomCoordinates()
|
||||
} catch (error) {
|
||||
console.error('Error extracting EXIF data:', error)
|
||||
return generateRandomCoordinates()
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
export async function processAnimalImage(
|
||||
imageBuffer: Buffer,
|
||||
visionDescription: string,
|
||||
visionResponse: any,
|
||||
filename: string = 'animal-image.jpg'
|
||||
) {
|
||||
try {
|
||||
// Get coordinates
|
||||
const coordinates = await extractCoordinates(imageBuffer)
|
||||
console.log('Using coordinates:', coordinates)
|
||||
|
||||
// Rest of the existing implementation
|
||||
const ipfsUrl = await uploadToIpfs(imageBuffer, filename)
|
||||
console.log('Image uploaded to IPFS:', ipfsUrl)
|
||||
|
||||
const labels = visionResponse?.labelAnnotations || []
|
||||
const species = labels.find(label =>
|
||||
ANIMAL_LABELS.includes(label.description.toLowerCase())
|
||||
)?.description || 'unknown'
|
||||
console.log('Detected species:', species)
|
||||
|
||||
const registryId = await publishAnimalRecord(
|
||||
species.toLowerCase(),
|
||||
coordinates.lat,
|
||||
coordinates.lng,
|
||||
visionDescription,
|
||||
ipfsUrl
|
||||
)
|
||||
|
||||
console.log('Published animal record to Laconic Registry:', registryId)
|
||||
return registryId
|
||||
} catch (error) {
|
||||
console.error('Failed to process animal image:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
78
src/services/chatService.ts
Normal file
78
src/services/chatService.ts
Normal file
@ -0,0 +1,78 @@
|
||||
export interface ChatGenerationResult {
|
||||
response?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface ChatModelConfig {
|
||||
modelId: string
|
||||
characterId: string // Added unique identifier for each character
|
||||
name: string
|
||||
description: string
|
||||
cost: number
|
||||
systemPrompt: string
|
||||
}
|
||||
|
||||
// Available x.ai models with unique character IDs
|
||||
export const CHAT_MODELS: ChatModelConfig[] = [
|
||||
{
|
||||
modelId: "grok-2-1212",
|
||||
characterId: "doris",
|
||||
name: "Doris",
|
||||
description: "The unconditional love grandma",
|
||||
cost: 99,
|
||||
systemPrompt: "You are Doris, a grandmother that showers unconditional love to everyone for everything. Be happy, cheerful, and agreeable. Reply with no more than 50 words."
|
||||
},
|
||||
{
|
||||
modelId: "grok-2-1212",
|
||||
characterId: "mabel",
|
||||
name: "Mabel",
|
||||
description: "The wise grandmother",
|
||||
cost: 420,
|
||||
systemPrompt: "You are Mabel, a grandmother that offers wisdom and intellectual musings. Provide helpful advice with a neutral emotion. Reply with no more than 50 words."
|
||||
},
|
||||
{
|
||||
modelId: "grok-2-1212",
|
||||
characterId: "greta",
|
||||
name: "Greta",
|
||||
description: "The mad granny",
|
||||
cost: 666,
|
||||
systemPrompt: "You are Greta, a grandmother that is always in a bad mood. Be mean, scornful, and insulting. Don't give advice or useful replies. Reply with no more than 50 words."
|
||||
}
|
||||
]
|
||||
|
||||
export async function generateChatResponse(
|
||||
prompt: string,
|
||||
characterId: string
|
||||
): Promise<ChatGenerationResult> {
|
||||
try {
|
||||
const response = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prompt,
|
||||
characterId // Send characterId instead of modelId
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to generate response')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
console.log('Raw chat response:', data)
|
||||
|
||||
if (data.response) {
|
||||
return { response: data.response }
|
||||
} else {
|
||||
console.error('Unexpected response structure:', data)
|
||||
throw new Error('Invalid response format from Chat API')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Chat generation error:', error)
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Generation failed'
|
||||
}
|
||||
}
|
||||
}
|
3
src/services/constants.ts
Normal file
3
src/services/constants.ts
Normal file
@ -0,0 +1,3 @@
|
||||
// src/services/constants.ts
|
||||
|
||||
export const ANIMAL_LABELS = ['animal', 'wildlife', 'mammal', 'bird', 'reptile', 'amphibian', 'nature', 'cat', 'mouse', 'insect', 'vertebrate', 'dog']
|
88
src/services/googleVisionCore.ts
Normal file
88
src/services/googleVisionCore.ts
Normal file
@ -0,0 +1,88 @@
|
||||
// src/services/googleVisionCore.ts
|
||||
|
||||
import { ANIMAL_LABELS } from './constants'
|
||||
|
||||
export interface VisionAnalysisResult {
|
||||
description: string
|
||||
isAnimal: boolean
|
||||
rawResponse: any
|
||||
}
|
||||
|
||||
export async function analyzeImageWithVision(imageBuffer: Buffer): Promise<VisionAnalysisResult> {
|
||||
const API_KEY = process.env.GOOGLE_API_KEY
|
||||
if (!API_KEY) {
|
||||
throw new Error('Google Vision API key not configured')
|
||||
}
|
||||
|
||||
const base64Image = imageBuffer.toString('base64')
|
||||
|
||||
const response = await fetch(
|
||||
`https://vision.googleapis.com/v1/images:annotate?key=${API_KEY}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
requests: [{
|
||||
image: {
|
||||
content: base64Image
|
||||
},
|
||||
features: [
|
||||
{
|
||||
type: 'LABEL_DETECTION',
|
||||
maxResults: 10
|
||||
},
|
||||
{
|
||||
type: 'OBJECT_LOCALIZATION',
|
||||
maxResults: 10
|
||||
}
|
||||
]
|
||||
}]
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Vision API request failed')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const labels = data.responses[0]?.labelAnnotations || []
|
||||
const objects = data.responses[0]?.localizedObjectAnnotations || []
|
||||
|
||||
const isAnimal = labels.some(label =>
|
||||
ANIMAL_LABELS.includes(label.description.toLowerCase())
|
||||
)
|
||||
|
||||
return {
|
||||
description: constructDescription(labels, objects),
|
||||
isAnimal,
|
||||
rawResponse: data.responses[0]
|
||||
}
|
||||
}
|
||||
|
||||
function constructDescription(labels: any[], objects: any[]): string {
|
||||
const topLabels = labels
|
||||
.filter(label => label.score > 0.7)
|
||||
.map(label => label.description)
|
||||
.slice(0, 5)
|
||||
|
||||
const topObjects = objects
|
||||
.filter(obj => obj.score > 0.7)
|
||||
.map(obj => obj.name)
|
||||
.filter((value, index, self) => self.indexOf(value) === index)
|
||||
.slice(0, 3)
|
||||
|
||||
let description = "In this image, I can see "
|
||||
|
||||
if (topObjects.length > 0) {
|
||||
description += topObjects.join(", ") + ". "
|
||||
}
|
||||
|
||||
if (topLabels.length > 0) {
|
||||
description += "The scene includes " + topLabels.join(", ") + "."
|
||||
}
|
||||
|
||||
return description
|
||||
}
|
46
src/services/googleVisionService.ts
Normal file
46
src/services/googleVisionService.ts
Normal file
@ -0,0 +1,46 @@
|
||||
// src/services/googleVisionService.ts
|
||||
|
||||
export interface VisionAnalysisResult {
|
||||
description?: string
|
||||
error?: string
|
||||
isAnimal?: boolean
|
||||
rawResponse?: any
|
||||
}
|
||||
|
||||
export interface VisionConfig {
|
||||
modelId: string
|
||||
name: string
|
||||
description: string
|
||||
cost: number
|
||||
}
|
||||
|
||||
export const VISION_CONFIG: VisionConfig = {
|
||||
modelId: "google-vision-v1",
|
||||
name: "Worldwide Animal Oracle",
|
||||
description: "Upload photos of your wildlife encounters from the real world. Verified animal images will be added to the Animal Registry.",
|
||||
cost: 3
|
||||
}
|
||||
|
||||
export async function analyzeImage(imageFile: File): Promise<VisionAnalysisResult> {
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('image', imageFile)
|
||||
|
||||
const response = await fetch('/api/analyze', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Failed to analyze image')
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error('Vision analysis error:', error)
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Failed to analyze image'
|
||||
}
|
||||
}
|
||||
}
|
102
src/services/laconicQueryService.ts
Normal file
102
src/services/laconicQueryService.ts
Normal file
@ -0,0 +1,102 @@
|
||||
// src/services/laconicQueryService.ts
|
||||
//
|
||||
interface AnimalRecord {
|
||||
species: string
|
||||
location: {
|
||||
latitude: number
|
||||
longitude: number
|
||||
}
|
||||
description: string
|
||||
imageUrl: string
|
||||
}
|
||||
|
||||
|
||||
const LACONIC_GQL_ENDPOINT = process.env.NEXT_PUBLIC_LACONIC_GQL_ENDPOINT || 'https://laconicd-sapo.laconic.com/api'
|
||||
|
||||
const ANIMAL_RECORDS_QUERY = `
|
||||
query GetAnimalRecords {
|
||||
queryRecords(
|
||||
attributes: [{ key: "type", value: { string: "AnimalRecord" } }],
|
||||
all: true
|
||||
) {
|
||||
id
|
||||
names
|
||||
bondId
|
||||
createTime
|
||||
expiryTime
|
||||
owners
|
||||
attributes {
|
||||
key
|
||||
value {
|
||||
... on StringValue {
|
||||
string: value
|
||||
}
|
||||
... on IntValue {
|
||||
int: value
|
||||
}
|
||||
... on FloatValue {
|
||||
float: value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export async function fetchAnimalRecords(): Promise<AnimalRecord[]> {
|
||||
try {
|
||||
const response = await fetch(LACONIC_GQL_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
query: ANIMAL_RECORDS_QUERY
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
console.log('Full response data:', JSON.stringify(data, null, 2));
|
||||
|
||||
if (data.errors) {
|
||||
console.error('GraphQL Errors:', data.errors);
|
||||
throw new Error('Failed to fetch animal records');
|
||||
}
|
||||
|
||||
// Transform the response into our AnimalRecord format
|
||||
const records = data.data.queryRecords
|
||||
.map((record: any) => {
|
||||
// Convert attributes to a map
|
||||
const attributesMap = record.attributes.reduce((acc: any, attr: any) => {
|
||||
acc[attr.key] = attr.value.string || attr.value.int || attr.value.float;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Safely parse location
|
||||
let location = { latitude: 0, longitude: 0 };
|
||||
try {
|
||||
location = JSON.parse(attributesMap.location || '{}');
|
||||
} catch (parseError) {
|
||||
console.error('Error parsing location:', parseError);
|
||||
}
|
||||
|
||||
return {
|
||||
species: attributesMap.species || 'Unknown Species',
|
||||
location: {
|
||||
latitude: location.latitude || 0,
|
||||
longitude: location.longitude || 0
|
||||
},
|
||||
description: attributesMap.description || 'No description',
|
||||
imageUrl: attributesMap.imageUrl || null
|
||||
}
|
||||
})
|
||||
// Filter out records without an imageUrl
|
||||
.filter((record: AnimalRecord) => record.imageUrl !== null && record.imageUrl.trim() !== '');
|
||||
|
||||
console.log('Processed animal records:', records);
|
||||
|
||||
return records;
|
||||
} catch (error) {
|
||||
console.error('Error fetching animal records:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
71
src/services/laconicService.ts
Normal file
71
src/services/laconicService.ts
Normal file
@ -0,0 +1,71 @@
|
||||
// src/services/laconicService.ts
|
||||
|
||||
import { promises as fs } from 'fs'
|
||||
import { exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import yaml from 'yaml'
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
interface LaconicAnimalRecord {
|
||||
record: {
|
||||
type: 'AnimalRecord'
|
||||
species: string
|
||||
location: {
|
||||
latitude: number
|
||||
longitude: number
|
||||
}
|
||||
description: string
|
||||
imageUrl: string
|
||||
}
|
||||
}
|
||||
|
||||
export async function publishAnimalRecord(
|
||||
species: string,
|
||||
latitude: number,
|
||||
longitude: number,
|
||||
description: string,
|
||||
imageUrl: string
|
||||
): Promise<string> {
|
||||
try {
|
||||
// Verify config file exists
|
||||
await fs.access('config.yml')
|
||||
|
||||
// Create animal record
|
||||
const record: LaconicAnimalRecord = {
|
||||
record: {
|
||||
type: 'AnimalRecord',
|
||||
species,
|
||||
location: {
|
||||
latitude,
|
||||
longitude
|
||||
},
|
||||
description,
|
||||
imageUrl
|
||||
}
|
||||
}
|
||||
|
||||
// Write temporary record file
|
||||
const recordPath = 'animal-record.yml'
|
||||
await fs.writeFile(recordPath, yaml.stringify(record))
|
||||
|
||||
try {
|
||||
// Execute laconic command
|
||||
const { stdout, stderr } = await execAsync(
|
||||
`laconic -c config.yml registry record publish --filename animal-record.yml --user-key "${process.env.LACONIC_USER_KEY}" --bond-id "${process.env.LACONIC_BOND_ID}"`
|
||||
)
|
||||
|
||||
if (stderr) {
|
||||
console.error('Laconic command stderr:', stderr)
|
||||
}
|
||||
|
||||
return stdout.trim()
|
||||
} finally {
|
||||
// Clean up temporary file
|
||||
await fs.unlink(recordPath).catch(console.error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to publish animal record:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
159
src/services/paymentService.ts
Normal file
159
src/services/paymentService.ts
Normal file
@ -0,0 +1,159 @@
|
||||
import { Connection, PublicKey, Transaction, SystemProgram } from '@solana/web3.js'
|
||||
import {
|
||||
TOKEN_PROGRAM_ID,
|
||||
createTransferInstruction,
|
||||
getAssociatedTokenAddress,
|
||||
createAssociatedTokenAccountInstruction,
|
||||
ASSOCIATED_TOKEN_PROGRAM_ID
|
||||
} from '@solana/spl-token'
|
||||
import { WalletType } from './types'
|
||||
|
||||
const MTM_TOKEN_MINT: string = '97RggLo3zV5kFGYW4yoQTxr4Xkz4Vg2WPHzNYXXWpump'
|
||||
const PAYMENT_RECEIVER_ADDRESS: string = 'FFDx3SdAEeXrp6BTmStB4BDHpctGsaasZq4FFcowRobY'
|
||||
const SOLANA_RPC_URL: string = 'https://young-radial-orb.solana-mainnet.quiknode.pro/67612b364664616c29514e551bf5de38447ca3d4'
|
||||
const SOLANA_WEBSOCKET_URL: string = 'wss://young-radial-orb.solana-mainnet.quiknode.pro/67612b364664616c29514e551bf5de38447ca3d4'
|
||||
|
||||
const connection = new Connection(
|
||||
SOLANA_RPC_URL,
|
||||
{
|
||||
commitment: 'confirmed',
|
||||
wsEndpoint: SOLANA_WEBSOCKET_URL,
|
||||
confirmTransactionInitialTimeout: 60000,
|
||||
}
|
||||
)
|
||||
|
||||
export interface PaymentResult {
|
||||
success: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
async function findAssociatedTokenAddress(
|
||||
walletAddress: PublicKey,
|
||||
tokenMintAddress: PublicKey
|
||||
): Promise<PublicKey> {
|
||||
return PublicKey.findProgramAddressSync(
|
||||
[
|
||||
walletAddress.toBuffer(),
|
||||
TOKEN_PROGRAM_ID.toBuffer(),
|
||||
tokenMintAddress.toBuffer(),
|
||||
],
|
||||
ASSOCIATED_TOKEN_PROGRAM_ID
|
||||
)[0]
|
||||
}
|
||||
|
||||
interface WalletAdapter {
|
||||
signAndSendTransaction(transaction: Transaction): Promise<{ signature: string }>
|
||||
}
|
||||
|
||||
export async function processMTMPayment(
|
||||
walletPublicKey: string,
|
||||
tokenAmount: number,
|
||||
walletType: WalletType
|
||||
): Promise<PaymentResult> {
|
||||
try {
|
||||
let wallet: WalletAdapter | null = null;
|
||||
|
||||
if (walletType === 'phantom') {
|
||||
wallet = window.phantom?.solana || null;
|
||||
} else if (walletType === 'solflare') {
|
||||
wallet = window.solflare || null;
|
||||
}
|
||||
|
||||
if (!wallet) {
|
||||
throw new Error(`${walletType} wallet not found`)
|
||||
}
|
||||
|
||||
const senderPublicKey = new PublicKey(walletPublicKey)
|
||||
const mintPublicKey = new PublicKey(MTM_TOKEN_MINT)
|
||||
const receiverPublicKey = new PublicKey(PAYMENT_RECEIVER_ADDRESS)
|
||||
|
||||
console.log('Processing payment with keys:', {
|
||||
sender: senderPublicKey.toBase58(),
|
||||
mint: mintPublicKey.toBase58(),
|
||||
receiver: receiverPublicKey.toBase58(),
|
||||
})
|
||||
|
||||
const senderATA = await findAssociatedTokenAddress(
|
||||
senderPublicKey,
|
||||
mintPublicKey
|
||||
)
|
||||
|
||||
const receiverATA = await findAssociatedTokenAddress(
|
||||
receiverPublicKey,
|
||||
mintPublicKey
|
||||
)
|
||||
|
||||
console.log('Token accounts:', {
|
||||
senderATA: senderATA.toBase58(),
|
||||
receiverATA: receiverATA.toBase58(),
|
||||
})
|
||||
|
||||
const transaction = new Transaction()
|
||||
|
||||
const [senderATAInfo, receiverATAInfo] = await Promise.all([
|
||||
connection.getAccountInfo(senderATA),
|
||||
connection.getAccountInfo(receiverATA),
|
||||
])
|
||||
|
||||
if (!receiverATAInfo) {
|
||||
console.log('Creating receiver token account')
|
||||
transaction.add(
|
||||
createAssociatedTokenAccountInstruction(
|
||||
senderPublicKey,
|
||||
receiverATA,
|
||||
receiverPublicKey,
|
||||
mintPublicKey
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (!senderATAInfo) {
|
||||
console.log('Creating sender token account')
|
||||
transaction.add(
|
||||
createAssociatedTokenAccountInstruction(
|
||||
senderPublicKey,
|
||||
senderATA,
|
||||
senderPublicKey,
|
||||
mintPublicKey
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
transaction.add(
|
||||
createTransferInstruction(
|
||||
senderATA,
|
||||
receiverATA,
|
||||
senderPublicKey,
|
||||
BigInt(tokenAmount * (10 ** 6))
|
||||
)
|
||||
)
|
||||
|
||||
const latestBlockhash = await connection.getLatestBlockhash('confirmed')
|
||||
transaction.recentBlockhash = latestBlockhash.blockhash
|
||||
transaction.feePayer = senderPublicKey
|
||||
|
||||
console.log('Sending transaction...')
|
||||
const { signature } = await wallet.signAndSendTransaction(transaction)
|
||||
console.log('Transaction sent:', signature)
|
||||
|
||||
const confirmation = await connection.confirmTransaction({
|
||||
signature,
|
||||
blockhash: latestBlockhash.blockhash,
|
||||
lastValidBlockHeight: latestBlockhash.lastValidBlockHeight,
|
||||
}, 'confirmed')
|
||||
|
||||
if (confirmation.value.err) {
|
||||
console.error('Transaction error:', confirmation.value.err)
|
||||
throw new Error(`Transaction failed: ${JSON.stringify(confirmation.value.err)}`)
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Payment error:', error)
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Payment failed'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
65
src/services/pinataService.ts
Normal file
65
src/services/pinataService.ts
Normal file
@ -0,0 +1,65 @@
|
||||
// src/services/pinataService.ts
|
||||
|
||||
interface PinataResponse {
|
||||
IpfsHash: string
|
||||
PinSize: number
|
||||
Timestamp: string
|
||||
}
|
||||
|
||||
export async function uploadToIpfs(imageBuffer: Buffer, filename: string): Promise<string> {
|
||||
try {
|
||||
const jwt = process.env.PINATA_JWT
|
||||
const baseUrl = process.env.PINATA_BASE_URL
|
||||
|
||||
if (!jwt || !baseUrl) {
|
||||
throw new Error('Pinata configuration missing')
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
|
||||
// Create file from buffer
|
||||
const file = new File([imageBuffer], filename, { type: 'image/jpeg' })
|
||||
formData.append('file', file)
|
||||
|
||||
// Add metadata
|
||||
const pinataMetadata = JSON.stringify({
|
||||
name: filename,
|
||||
})
|
||||
formData.append('pinataMetadata', pinataMetadata)
|
||||
|
||||
// Add options
|
||||
const pinataOptions = JSON.stringify({
|
||||
cidVersion: 1,
|
||||
})
|
||||
formData.append('pinataOptions', pinataOptions)
|
||||
|
||||
console.log('Uploading to Pinata:', {
|
||||
filename,
|
||||
size: imageBuffer.length
|
||||
})
|
||||
|
||||
const response = await fetch('https://api.pinata.cloud/pinning/pinFileToIPFS', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${jwt}`
|
||||
},
|
||||
body: formData,
|
||||
})
|
||||
|
||||
console.log('Pinata response status:', response.status)
|
||||
const data = await response.json()
|
||||
console.log('Pinata response:', data)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to upload to IPFS: ${response.status} - ${JSON.stringify(data)}`)
|
||||
}
|
||||
|
||||
const ipfsUrl = `${baseUrl}/ipfs/${data.IpfsHash}`
|
||||
console.log('Uploaded to IPFS:', ipfsUrl)
|
||||
return ipfsUrl
|
||||
|
||||
} catch (error) {
|
||||
console.error('IPFS upload failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
20
src/services/types.ts
Normal file
20
src/services/types.ts
Normal file
@ -0,0 +1,20 @@
|
||||
export type WalletType = 'solflare' | 'phantom'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
solflare?: {
|
||||
connect(): Promise<void>
|
||||
disconnect(): Promise<void>
|
||||
publicKey?: { toString(): string }
|
||||
signAndSendTransaction(transaction: any): Promise<{ signature: string }>
|
||||
}
|
||||
phantom?: {
|
||||
solana?: {
|
||||
connect(): Promise<{ publicKey: { toString(): string } }>
|
||||
disconnect(): Promise<void>
|
||||
signAndSendTransaction(transaction: any): Promise<{ signature: string }>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
65
src/services/walletService.ts
Normal file
65
src/services/walletService.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { WalletType } from './types'
|
||||
|
||||
export interface WalletState {
|
||||
connected: boolean
|
||||
publicKey: string | null
|
||||
type: WalletType | null
|
||||
}
|
||||
|
||||
export interface WalletConfig {
|
||||
type: WalletType
|
||||
name: string
|
||||
connect: () => Promise<{ publicKey: string } | null>
|
||||
}
|
||||
|
||||
const connectSolflare = async (): Promise<{ publicKey: string } | null> => {
|
||||
if (!window.solflare) return null
|
||||
await window.solflare.connect()
|
||||
return window.solflare.publicKey ? { publicKey: window.solflare.publicKey.toString() } : null
|
||||
}
|
||||
|
||||
const connectPhantom = async (): Promise<{ publicKey: string } | null> => {
|
||||
if (!window.phantom?.solana) return null
|
||||
try {
|
||||
const response = await window.phantom.solana.connect()
|
||||
return response.publicKey ? { publicKey: response.publicKey.toString() } : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const SUPPORTED_WALLETS: WalletConfig[] = [
|
||||
{
|
||||
type: 'solflare',
|
||||
name: 'Solflare',
|
||||
connect: connectSolflare
|
||||
},
|
||||
{
|
||||
type: 'phantom',
|
||||
name: 'Phantom',
|
||||
connect: connectPhantom
|
||||
}
|
||||
]
|
||||
|
||||
export async function connectWallet(type: WalletType): Promise<WalletState> {
|
||||
const wallet = SUPPORTED_WALLETS.find(w => w.type === type)
|
||||
if (!wallet) throw new Error('Unsupported wallet')
|
||||
|
||||
try {
|
||||
const result = await wallet.connect()
|
||||
if (!result) throw new Error(`${wallet.name} not found`)
|
||||
|
||||
return {
|
||||
connected: true,
|
||||
publicKey: result.publicKey,
|
||||
type: wallet.type
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
connected: false,
|
||||
publicKey: null,
|
||||
type: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
29
tailwind.config.ts
Normal file
29
tailwind.config.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
animation: {
|
||||
'bounce-delay': 'bounce-delay 1.4s infinite ease-in-out both',
|
||||
},
|
||||
keyframes: {
|
||||
'bounce-delay': {
|
||||
'0%, 80%, 100%': { transform: 'scale(0)' },
|
||||
'40%': { transform: 'scale(1.0)' },
|
||||
},
|
||||
},
|
||||
backgroundImage: {
|
||||
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
|
||||
'gradient-conic': 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
export default config
|
35
tsconfig.json
Normal file
35
tsconfig.json
Normal file
@ -0,0 +1,35 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": false,
|
||||
"noEmit": true,
|
||||
"incremental": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"target": "ES2017"
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
".next/types/**/*.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
Loading…
Reference in New Issue
Block a user