Skip to content

Commit 16a36cb

Browse files
Merge pull request #433 from ever-works/feat/integrate-url-extraction
Feat(add-item): integrate automatic item extraction from URL
1 parent 38cd896 commit 16a36cb

38 files changed

+57909
-57518
lines changed

.env.example

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# Data Repository (required)
22
DATA_REPOSITORY=https://github.com/ever-works/awesome-time-tracking-data
33

4-
# Database connection (optional, but recommended.
5-
## NOTE: many features will NOT work without DB)
4+
# Database connection
5+
## (optional, but recommended. NOTE: many features will NOT work without DB)
66
DATABASE_URL=
77

88
# GitHub Integration
@@ -11,6 +11,16 @@ GH_TOKEN=
1111
GITHUB_TOKEN=
1212
GITHUB_BRANCH=master
1313

14+
# Ever Works Platform API (optional)
15+
## Platform APIs are used for item extraction feature, sync of directory data, and other advanced functionality.
16+
## Register at https://app.ever.works
17+
## Default value is: https://api.ever.works/api
18+
PLATFORM_API_URL=
19+
20+
# Secret Token for authenticating requests to the Ever Works Platform API (optional)
21+
## Register at https://app.ever.works to get Secret Token
22+
PLATFORM_API_SECRET_TOKEN=
23+
1424
# Next Auth
1525
AUTH_SECRET=
1626

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,11 +90,21 @@ Create a `.env.local` file in the root directory with the following configuratio
9090
NODE_ENV=development
9191

9292
# API Configuration
93+
# Internal website API (Next.js API routes)
9394
NEXT_PUBLIC_API_BASE_URL="http://localhost:3000/api"
9495
API_TIMEOUT=10000
9596
API_RETRY_ATTEMPTS=3
9697
API_RETRY_DELAY=1000
9798

99+
# Ever Works Platform API (optional)
100+
# Used for automatic metadata extraction from URLs when submitting items, data sync, and other advanced features
101+
# Register at https://api.ever.works to get access.
102+
# If not configured, the extraction feature and other advanced functionality will be disabled.
103+
# Base URL of the Ever Works Platform API:
104+
PLATFORM_API_URL="https://api.ever.works/api" # For local dev, you should use "http://localhost:3100/api"
105+
# Secret Token for Platform API (register at https://api.ever.works to get secret token)
106+
PLATFORM_API_SECRET_TOKEN="your-platform-api-secret-token"
107+
98108
# Cookie Security
99109
COOKIE_SECRET="your-secure-cookie-secret" # Generate with: openssl rand -base64 32
100110
COOKIE_DOMAIN="localhost" # In production: your-domain.com

app/api/extract/route.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { NextResponse } from 'next/server';
2+
import { z } from 'zod';
3+
4+
const extractSchema = z.object({
5+
url: z.string().url('Invalid URL format'),
6+
existingCategories: z.array(z.string()).optional()
7+
});
8+
9+
/**
10+
* @swagger
11+
* /api/extract:
12+
* post:
13+
* tags: ["Items"]
14+
* summary: "Extract item metadata from URL"
15+
* description: "Secure proxy route that extracts item metadata (name, description, etc.) from a given URL using the Ever Works Platform API. This endpoint requires PLATFORM_API_URL to be configured. If not configured, returns a graceful response indicating the feature is disabled (featureDisabled: true)."
16+
* requestBody:
17+
* required: true
18+
* content:
19+
* application/json:
20+
* schema:
21+
* type: object
22+
* properties:
23+
* url:
24+
* type: string
25+
* format: uri
26+
* description: "The URL to extract metadata from"
27+
* example: "https://example.com/product"
28+
* existingCategories:
29+
* type: array
30+
* items:
31+
* type: string
32+
* description: "Optional array of existing category names to help with categorization"
33+
* example: ["Productivity", "Developer Tools"]
34+
* required: ["url"]
35+
* responses:
36+
* 200:
37+
* description: "Response from extraction endpoint - can be success, feature disabled, or error"
38+
* content:
39+
* application/json:
40+
* schema:
41+
* oneOf:
42+
* - type: object
43+
* description: "Successfully extracted metadata"
44+
* properties:
45+
* success:
46+
* type: boolean
47+
* example: true
48+
* data:
49+
* type: object
50+
* description: "Extracted item metadata (name, description, etc.)"
51+
* properties:
52+
* name:
53+
* type: string
54+
* example: "Awesome Product"
55+
* description:
56+
* type: string
57+
* example: "A great product description"
58+
* - type: object
59+
* description: "Feature disabled - Platform API not configured"
60+
* properties:
61+
* success:
62+
* type: boolean
63+
* example: false
64+
* featureDisabled:
65+
* type: boolean
66+
* example: true
67+
* description: "Indicates that the extraction feature is not available"
68+
* message:
69+
* type: string
70+
* example: "URL extraction feature is not available. This feature requires PLATFORM_API_URL to be configured."
71+
* 400:
72+
* description: "Bad request - Invalid URL format or validation error"
73+
* content:
74+
* application/json:
75+
* schema:
76+
* type: object
77+
* properties:
78+
* success:
79+
* type: boolean
80+
* example: false
81+
* error:
82+
* type: string
83+
* example: "Invalid URL format"
84+
* 500:
85+
* description: "Internal server error during extraction"
86+
* content:
87+
* application/json:
88+
* schema:
89+
* type: object
90+
* properties:
91+
* success:
92+
* type: boolean
93+
* example: false
94+
* error:
95+
* type: string
96+
* example: "Internal server error during extraction"
97+
*/
98+
export async function POST(request: Request) {
99+
try {
100+
// Check if platform API is configured
101+
const platformApiUrl = process.env.PLATFORM_API_URL;
102+
const platformApiToken = process.env.PLATFORM_API_SECRET_TOKEN;
103+
104+
// If not configured, return a graceful response indicating the feature is disabled
105+
if (!platformApiUrl) {
106+
return NextResponse.json({
107+
success: false,
108+
featureDisabled: true,
109+
message:
110+
'URL extraction feature is not available. This feature requires PLATFORM_API_URL to be configured.'
111+
});
112+
}
113+
114+
const body = await request.json();
115+
const result = extractSchema.safeParse(body);
116+
117+
if (!result.success) {
118+
return NextResponse.json({ success: false, error: result.error.issues[0].message }, { status: 400 });
119+
}
120+
121+
const { url, existingCategories } = result.data;
122+
123+
// Build the extraction endpoint URL
124+
const extractionEndpoint = `${platformApiUrl.replace(/\/+$/, '')}/extract-item-details`;
125+
126+
const response = await fetch(extractionEndpoint, {
127+
method: 'POST',
128+
headers: {
129+
'Content-Type': 'application/json',
130+
Accept: 'application/json',
131+
...(platformApiToken ? { Authorization: `Bearer ${platformApiToken}` } : {})
132+
},
133+
body: JSON.stringify({
134+
source_url: url,
135+
existing_data: existingCategories && existingCategories.length > 0 ? existingCategories : undefined
136+
})
137+
});
138+
139+
const data = await response.json();
140+
141+
if (!response.ok) {
142+
return NextResponse.json(
143+
{ success: false, error: data.message || 'Failed to extract data from platform API' },
144+
{ status: response.status }
145+
);
146+
}
147+
148+
return NextResponse.json(data);
149+
} catch (error) {
150+
console.error('[ExtractProxy] Error:', error);
151+
return NextResponse.json({ success: false, error: 'Internal server error during extraction' }, { status: 500 });
152+
}
153+
}

components/directory/details-form.tsx

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,13 @@ interface DetailsFormProps {
3434
isSubmitting?: boolean;
3535
}
3636

37-
export function DetailsForm({ initialData = {}, onSubmit, onBack, listingProps, isSubmitting = false }: DetailsFormProps) {
37+
export function DetailsForm({
38+
initialData = {},
39+
onSubmit,
40+
onBack,
41+
listingProps,
42+
isSubmitting = false
43+
}: DetailsFormProps) {
3844
const t = useTranslations();
3945
const editor = useEditor();
4046

@@ -109,10 +115,7 @@ export function DetailsForm({ initialData = {}, onSubmit, onBack, listingProps,
109115

110116
{/* Progress Bar */}
111117
<div className={PROGRESS_BAR_CLASSES.container}>
112-
<div
113-
className={PROGRESS_BAR_CLASSES.bar}
114-
style={{ width: `${progressPercentage}%` }}
115-
>
118+
<div className={PROGRESS_BAR_CLASSES.bar} style={{ width: `${progressPercentage}%` }}>
116119
<div className={PROGRESS_BAR_CLASSES.shimmer}></div>
117120
</div>
118121
</div>
@@ -139,15 +142,13 @@ export function DetailsForm({ initialData = {}, onSubmit, onBack, listingProps,
139142
t={t as (key: string, values?: Record<string, unknown>) => string}
140143
addLink={addLink}
141144
removeLink={removeLink}
145+
setFormData={setFormData}
142146
/>
143147
)}
144148

145149
{/* Step 2: Payment */}
146150
{currentStep === 2 && (
147-
<PaymentStep
148-
onSelectPlan={handlePlanSelect}
149-
selectedPlan={formData.selectedPlan}
150-
/>
151+
<PaymentStep onSelectPlan={handlePlanSelect} selectedPlan={formData.selectedPlan} />
151152
)}
152153

153154
{/* Step 3: Review */}

components/directory/details-form/components/link-input.tsx

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
'use client';
22

33
import { cn } from '@/lib/utils';
4-
import { Check, X, Plus } from 'lucide-react';
4+
import { Check, X, Plus, Sparkles } from 'lucide-react';
5+
import { LoadingSpinner } from '@/components/ui/loading-spinner';
56
import type { FormData } from '../validation/form-validators';
67

78
interface InputLinkProps {
@@ -15,6 +16,8 @@ interface InputLinkProps {
1516
t: (key: string, values?: Record<string, unknown>) => string;
1617
addLink: () => void;
1718
removeLink: (id: string) => void;
19+
onExtract?: (url: string) => void;
20+
isExtracting?: boolean;
1821
}
1922

2023
export function LinkInput({
@@ -27,7 +30,9 @@ export function LinkInput({
2730
getIconComponent,
2831
t,
2932
addLink,
30-
removeLink
33+
removeLink,
34+
onExtract,
35+
isExtracting
3136
}: InputLinkProps) {
3237
return (
3338
<div>
@@ -120,6 +125,7 @@ export function LinkInput({
120125
required={isMain}
121126
className={cn(
122127
'w-full h-12 px-4 pr-12 text-base bg-white dark:bg-gray-800 border-2 border-gray-200 dark:border-gray-600 rounded-xl transition-all duration-300 outline-hidden text-gray-900 dark:text-white placeholder:text-gray-500 dark:placeholder:text-gray-400',
128+
isMain && onExtract && 'pr-28', // Make room for extract button
123129
focusedField === `link-${link.id}` &&
124130
'border-theme-primary-500 dark:border-theme-primary-400 ring-4 ring-theme-primary-500/20 scale-[1.01]',
125131
completedFields.has('mainLink') &&
@@ -129,8 +135,39 @@ export function LinkInput({
129135
/>
130136

131137
{/* Validation Icon */}
132-
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
133-
{isMain && completedFields.has('mainLink') && (
138+
<div className="absolute right-3 top-1/2 transform -translate-y-1/2 flex items-center gap-2 z-10">
139+
{/* Extract Button - Only for main link */}
140+
{isMain && onExtract && link.url && link.url.match(/^https?:\/\//) && (
141+
<div className="flex items-center">
142+
<button
143+
type="button"
144+
onClick={() => onExtract(link.url)}
145+
disabled={isExtracting}
146+
className={cn(
147+
'px-3 py-1.5 text-xs font-medium rounded-lg transition-all duration-200 flex items-center gap-2 cursor-pointer',
148+
isExtracting
149+
? 'bg-gray-100 text-gray-400 cursor-wait dark:bg-gray-700'
150+
: 'bg-theme-primary-50 text-theme-primary-600 hover:bg-theme-primary-100 dark:bg-theme-primary-900/30 dark:text-theme-primary-400 dark:hover:bg-theme-primary-900/50'
151+
)}
152+
>
153+
{isExtracting ? (
154+
<>
155+
<LoadingSpinner
156+
size="sm"
157+
className="border-gray-400 dark:border-gray-500"
158+
/>
159+
<span>{t('directory.DETAILS_FORM.EXTRACTING')}</span>
160+
</>
161+
) : (
162+
<>
163+
<Sparkles className="w-3 h-3" />
164+
<span>{t('directory.DETAILS_FORM.EXTRACT')}</span>
165+
</>
166+
)}
167+
</button>
168+
</div>
169+
)}
170+
{isMain && completedFields.has('mainLink') && !isExtracting && (
134171
<div className="w-6 h-6 rounded-full bg-green-500 flex items-center justify-center animate-scale-in">
135172
<Check className="h-3 w-3 text-white" />
136173
</div>

0 commit comments

Comments
 (0)