Initial commit - CiteLynq Web Sample
This commit is contained in:
commit
604ec5b9ad
9
CiteLynq.WebSample.csproj
Normal file
9
CiteLynq.WebSample.csproj
Normal file
@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
261
Program.cs
Normal file
261
Program.cs
Normal file
@ -0,0 +1,261 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add services
|
||||
builder.Services.AddHttpClient();
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddDefaultPolicy(policy =>
|
||||
{
|
||||
policy.AllowAnyOrigin()
|
||||
.AllowAnyMethod()
|
||||
.AllowAnyHeader();
|
||||
});
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseCors();
|
||||
app.UseDefaultFiles();
|
||||
app.UseStaticFiles();
|
||||
|
||||
// Update this to point to your local API or production API
|
||||
const string API_BASE_URL = "https://citelynq.com/api";
|
||||
|
||||
// Proxy endpoint for full-text search
|
||||
app.MapGet("/api/search", async (HttpContext context, IHttpClientFactory httpClientFactory, ILogger<Program> logger) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var apiKey = context.Request.Headers["X-API-Key"].FirstOrDefault();
|
||||
|
||||
if (string.IsNullOrEmpty(apiKey))
|
||||
{
|
||||
return Results.BadRequest(new { error = "API key is required" });
|
||||
}
|
||||
|
||||
// Get query parameters
|
||||
var query = context.Request.Query["q"].FirstOrDefault();
|
||||
var source = context.Request.Query["source"].FirstOrDefault();
|
||||
var limit = context.Request.Query["limit"].FirstOrDefault() ?? "20";
|
||||
|
||||
if (string.IsNullOrEmpty(query))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Search query (q) is required" });
|
||||
}
|
||||
|
||||
// Build URL for CiteLynq API
|
||||
var httpClient = httpClientFactory.CreateClient();
|
||||
var url = $"{API_BASE_URL}/search?q={Uri.EscapeDataString(query)}&limit={limit}";
|
||||
|
||||
if (!string.IsNullOrEmpty(source))
|
||||
{
|
||||
url += $"&source={Uri.EscapeDataString(source)}";
|
||||
}
|
||||
|
||||
logger.LogInformation("Calling CiteLynq API: {Url}", url);
|
||||
|
||||
var httpRequest = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
httpRequest.Headers.Add("X-API-Key", apiKey);
|
||||
|
||||
var response = await httpClient.SendAsync(httpRequest);
|
||||
var responseContent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
logger.LogInformation("CiteLynq API Response Status: {StatusCode}", response.StatusCode);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
logger.LogError("CiteLynq API Error: {Response}", responseContent);
|
||||
try
|
||||
{
|
||||
var errorObj = JsonSerializer.Deserialize<object>(responseContent);
|
||||
return Results.Json(errorObj, statusCode: (int)response.StatusCode);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Results.Json(new { error = responseContent }, statusCode: (int)response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
return Results.Content(responseContent, "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error in search endpoint");
|
||||
return Results.Json(new { error = ex.Message, details = ex.ToString() }, statusCode: 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Proxy endpoint for semantic search
|
||||
app.MapGet("/api/search/semantic", async (HttpContext context, IHttpClientFactory httpClientFactory, ILogger<Program> logger) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var apiKey = context.Request.Headers["X-API-Key"].FirstOrDefault();
|
||||
|
||||
if (string.IsNullOrEmpty(apiKey))
|
||||
{
|
||||
return Results.BadRequest(new { error = "API key is required" });
|
||||
}
|
||||
|
||||
// Get query parameters
|
||||
var query = context.Request.Query["q"].FirstOrDefault();
|
||||
var source = context.Request.Query["source"].FirstOrDefault();
|
||||
var limit = context.Request.Query["limit"].FirstOrDefault() ?? "20";
|
||||
|
||||
if (string.IsNullOrEmpty(query))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Search query (q) is required" });
|
||||
}
|
||||
|
||||
// Build URL for CiteLynq API
|
||||
var httpClient = httpClientFactory.CreateClient();
|
||||
var url = $"{API_BASE_URL}/search/semantic?q={Uri.EscapeDataString(query)}&limit={limit}";
|
||||
|
||||
if (!string.IsNullOrEmpty(source))
|
||||
{
|
||||
url += $"&source={Uri.EscapeDataString(source)}";
|
||||
}
|
||||
|
||||
logger.LogInformation("Calling CiteLynq API: {Url}", url);
|
||||
|
||||
var httpRequest = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
httpRequest.Headers.Add("X-API-Key", apiKey);
|
||||
|
||||
var response = await httpClient.SendAsync(httpRequest);
|
||||
var responseContent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
logger.LogInformation("CiteLynq API Response Status: {StatusCode}", response.StatusCode);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
logger.LogError("CiteLynq API Error: {Response}", responseContent);
|
||||
try
|
||||
{
|
||||
var errorObj = JsonSerializer.Deserialize<object>(responseContent);
|
||||
return Results.Json(errorObj, statusCode: (int)response.StatusCode);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Results.Json(new { error = responseContent }, statusCode: (int)response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
return Results.Content(responseContent, "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error in semantic search endpoint");
|
||||
return Results.Json(new { error = ex.Message, details = ex.ToString() }, statusCode: 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Proxy endpoint for date-based search
|
||||
app.MapGet("/api/search/bydate", async (HttpContext context, IHttpClientFactory httpClientFactory, ILogger<Program> logger) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var apiKey = context.Request.Headers["X-API-Key"].FirstOrDefault();
|
||||
|
||||
if (string.IsNullOrEmpty(apiKey))
|
||||
{
|
||||
return Results.BadRequest(new { error = "API key is required" });
|
||||
}
|
||||
|
||||
// Get query parameters
|
||||
var query = context.Request.Query["q"].FirstOrDefault();
|
||||
var month = context.Request.Query["month"].FirstOrDefault();
|
||||
var day = context.Request.Query["day"].FirstOrDefault();
|
||||
var source = context.Request.Query["source"].FirstOrDefault();
|
||||
var limit = context.Request.Query["limit"].FirstOrDefault() ?? "20";
|
||||
|
||||
if (string.IsNullOrEmpty(query))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Search query (q) is required" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(month) || string.IsNullOrEmpty(day))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Month and day parameters are required for date search" });
|
||||
}
|
||||
|
||||
// Build URL for CiteLynq API
|
||||
var httpClient = httpClientFactory.CreateClient();
|
||||
var url = $"{API_BASE_URL}/search/bydate?q={Uri.EscapeDataString(query)}&month={month}&day={day}&limit={limit}";
|
||||
|
||||
if (!string.IsNullOrEmpty(source))
|
||||
{
|
||||
url += $"&source={Uri.EscapeDataString(source)}";
|
||||
}
|
||||
|
||||
logger.LogInformation("Calling CiteLynq API: {Url}", url);
|
||||
|
||||
var httpRequest = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
httpRequest.Headers.Add("X-API-Key", apiKey);
|
||||
|
||||
var response = await httpClient.SendAsync(httpRequest);
|
||||
var responseContent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
logger.LogInformation("CiteLynq API Response Status: {StatusCode}", response.StatusCode);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
logger.LogError("CiteLynq API Error: {Response}", responseContent);
|
||||
try
|
||||
{
|
||||
var errorObj = JsonSerializer.Deserialize<object>(responseContent);
|
||||
return Results.Json(errorObj, statusCode: (int)response.StatusCode);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Results.Json(new { error = responseContent }, statusCode: (int)response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
return Results.Content(responseContent, "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error in date search endpoint");
|
||||
return Results.Json(new { error = ex.Message, details = ex.ToString() }, statusCode: 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Proxy endpoint to get article details
|
||||
app.MapGet("/api/articles/{id}", async (string id, HttpContext context, IHttpClientFactory httpClientFactory) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var apiKey = context.Request.Headers["X-API-Key"].FirstOrDefault();
|
||||
|
||||
if (string.IsNullOrEmpty(apiKey))
|
||||
{
|
||||
return Results.BadRequest(new { error = "API key is required" });
|
||||
}
|
||||
|
||||
var httpClient = httpClientFactory.CreateClient();
|
||||
var httpRequest = new HttpRequestMessage(HttpMethod.Get, $"{API_BASE_URL}/articles/{id}");
|
||||
httpRequest.Headers.Add("X-API-Key", apiKey);
|
||||
|
||||
var response = await httpClient.SendAsync(httpRequest);
|
||||
var responseContent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return Results.Json(
|
||||
JsonSerializer.Deserialize<object>(responseContent),
|
||||
statusCode: (int)response.StatusCode
|
||||
);
|
||||
}
|
||||
|
||||
return Results.Content(responseContent, "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Results.Problem(ex.Message);
|
||||
}
|
||||
});
|
||||
|
||||
app.Run();
|
||||
13
Properties/launchSettings.json
Normal file
13
Properties/launchSettings.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:5100",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
268
README.md
Normal file
268
README.md
Normal file
@ -0,0 +1,268 @@
|
||||
# CiteLynq Web Sample
|
||||
|
||||
A sample ASP.NET Core web application demonstrating how to use the CiteLynq API to search for verified citations across multiple authoritative sources.
|
||||
|
||||
## Features
|
||||
|
||||
✅ ASP.NET Core 9.0 minimal API
|
||||
✅ Static file hosting with wwwroot
|
||||
✅ Proxy endpoints to hide API keys from client
|
||||
✅ Clean, modern UI with gradient design
|
||||
✅ Search across multiple data sources
|
||||
✅ Display formatted citations (APA, MLA, Bluebook)
|
||||
✅ Filter by source type (ArXiv, PubMed, CourtListener, etc.)
|
||||
✅ Responsive design
|
||||
✅ Local storage for API key persistence
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Get an API Key
|
||||
|
||||
1. Visit [https://citelynq.com/app/api-keys](https://citelynq.com/app/api-keys)
|
||||
2. Create a new API key
|
||||
3. Copy the key (starts with `cl_`)
|
||||
|
||||
### 2. Run the Application
|
||||
|
||||
```bash
|
||||
cd /repos/CiteFlow/samples/CiteLynq.WebSample
|
||||
dotnet run
|
||||
```
|
||||
|
||||
The application will start on `http://localhost:5100`
|
||||
|
||||
### 3. Use the Application
|
||||
|
||||
1. Open your browser to `http://localhost:5100`
|
||||
2. Enter your API key
|
||||
3. Enter a search query (e.g., "climate change")
|
||||
4. Click "Search"
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
CiteLynq.WebSample/
|
||||
├── Program.cs # ASP.NET Core minimal API
|
||||
├── CiteLynq.WebSample.csproj # Project file
|
||||
├── Properties/
|
||||
│ └── launchSettings.json # Launch configuration
|
||||
└── wwwroot/
|
||||
└── index.html # Single-page application
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### Backend (Program.cs)
|
||||
|
||||
The backend provides proxy endpoints to securely call the CiteLynq API:
|
||||
|
||||
1. **GET /api/search** - Proxies search requests
|
||||
- Accepts: query parameters (q, sourceType, page, pageSize)
|
||||
- Headers: X-API-Key
|
||||
- Returns: Search results from CiteLynq API
|
||||
|
||||
2. **GET /api/articles/{id}** - Proxies article detail requests
|
||||
- Accepts: article ID as path parameter
|
||||
- Headers: X-API-Key
|
||||
- Returns: Article details from CiteLynq API
|
||||
|
||||
**Why use a proxy?**
|
||||
- Hides your API key from client-side code
|
||||
- Prevents CORS issues
|
||||
- Adds an abstraction layer for future enhancements
|
||||
- Enables server-side caching if needed
|
||||
|
||||
### Frontend (wwwroot/index.html)
|
||||
|
||||
The frontend is a self-contained single-page application:
|
||||
|
||||
- **API Key Storage**: Saves API key in localStorage
|
||||
- **Search Interface**: Query input and source filter
|
||||
- **Results Display**: Shows citations with metadata
|
||||
- **Responsive Design**: Works on desktop and mobile
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Search Citations
|
||||
|
||||
```http
|
||||
GET /api/search?q=climate%20change&sourceType=ArXiv&page=1&pageSize=20
|
||||
X-API-Key: cl_your-api-key-here
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"results": [
|
||||
{
|
||||
"id": "arxiv-2301.12345",
|
||||
"sourceType": "ArXiv",
|
||||
"title": "Climate Change Research",
|
||||
"authors": ["Dr. Jane Smith"],
|
||||
"abstract": "...",
|
||||
"publishedDate": "2023-01-15",
|
||||
"url": "https://arxiv.org/abs/2301.12345",
|
||||
"citations": {
|
||||
"apa": "Smith, J. (2023)...",
|
||||
"mla": "Smith, Jane...",
|
||||
"chicago": "..."
|
||||
}
|
||||
}
|
||||
],
|
||||
"totalCount": 145
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Get Article Details
|
||||
|
||||
```http
|
||||
GET /api/articles/arxiv-2301.12345
|
||||
X-API-Key: cl_your-api-key-here
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Change API Base URL
|
||||
|
||||
Edit `Program.cs` line 24 to point to a different API:
|
||||
|
||||
```csharp
|
||||
const string API_BASE_URL = "https://citelynq.com/api";
|
||||
// Or for local development:
|
||||
// const string API_BASE_URL = "http://localhost:5000/api";
|
||||
```
|
||||
|
||||
### Change Port
|
||||
|
||||
Edit `Properties/launchSettings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"profiles": {
|
||||
"http": {
|
||||
"applicationUrl": "http://localhost:5100"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- .NET 9.0 SDK
|
||||
- Visual Studio 2022 / VS Code / Rider
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
dotnet build
|
||||
```
|
||||
|
||||
### Run
|
||||
|
||||
```bash
|
||||
dotnet run
|
||||
```
|
||||
|
||||
### Publish
|
||||
|
||||
```bash
|
||||
dotnet publish -c Release -o ./publish
|
||||
```
|
||||
|
||||
## Customization
|
||||
|
||||
### Add More Endpoints
|
||||
|
||||
Add new proxy endpoints in `Program.cs`:
|
||||
|
||||
```csharp
|
||||
app.MapGet("/api/sources", async (HttpContext context, IHttpClientFactory httpClientFactory) =>
|
||||
{
|
||||
var apiKey = context.Request.Headers["X-API-Key"].FirstOrDefault();
|
||||
var httpClient = httpClientFactory.CreateClient();
|
||||
var httpRequest = new HttpRequestMessage(HttpMethod.Get, $"{API_BASE_URL}/sources");
|
||||
httpRequest.Headers.Add("X-API-Key", apiKey);
|
||||
var response = await httpClient.SendAsync(httpRequest);
|
||||
return Results.Content(await response.Content.ReadAsStringAsync(), "application/json");
|
||||
});
|
||||
```
|
||||
|
||||
### Customize UI
|
||||
|
||||
Edit `wwwroot/index.html`:
|
||||
|
||||
```css
|
||||
/* Change color scheme */
|
||||
body {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
```
|
||||
|
||||
### Add Pagination
|
||||
|
||||
Add pagination controls in the HTML:
|
||||
|
||||
```javascript
|
||||
function displayResults(data) {
|
||||
// ... existing code ...
|
||||
|
||||
if (data.totalPages > 1) {
|
||||
html += `<div class="pagination">
|
||||
<button onclick="loadPage(${data.currentPage - 1})">Previous</button>
|
||||
<span>Page ${data.currentPage} of ${data.totalPages}</span>
|
||||
<button onclick="loadPage(${data.currentPage + 1})">Next</button>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
⚠️ **Important:**
|
||||
|
||||
1. **API Key Protection**: The proxy pattern keeps API keys server-side
|
||||
2. **CORS**: Configured for development (allow all origins). Restrict in production.
|
||||
3. **Input Validation**: Always validate user input
|
||||
4. **HTTPS**: Use HTTPS in production environments
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Port Already in Use
|
||||
|
||||
Change the port in `Properties/launchSettings.json`
|
||||
|
||||
### CORS Errors
|
||||
|
||||
The application includes CORS middleware for development. For production, configure specific origins:
|
||||
|
||||
```csharp
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddDefaultPolicy(policy =>
|
||||
{
|
||||
policy.WithOrigins("https://yourdomain.com")
|
||||
.AllowAnyMethod()
|
||||
.AllowAnyHeader();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### API Key Not Working
|
||||
|
||||
1. Verify the key starts with `cl_`
|
||||
2. Check the key is active in your account
|
||||
3. Ensure the key has available credits
|
||||
|
||||
## Learn More
|
||||
|
||||
- **CiteLynq API Docs**: [https://citelynq.com/docs](https://citelynq.com/docs)
|
||||
- **Get API Key**: [https://citelynq.com/app/api-keys](https://citelynq.com/app/api-keys)
|
||||
- **ASP.NET Core**: [https://docs.microsoft.com/aspnet/core](https://docs.microsoft.com/aspnet/core)
|
||||
|
||||
## License
|
||||
|
||||
This sample is provided as-is for demonstration purposes.
|
||||
794
wwwroot/index.html
Normal file
794
wwwroot/index.html
Normal file
@ -0,0 +1,794 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CiteLynq Web Sample</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
color: white;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2.5em;
|
||||
margin-bottom: 10px;
|
||||
text-shadow: 2px 2px 4px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.header p {
|
||||
font-size: 1.1em;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 30px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
input[type="text"]:focus,
|
||||
input[type="password"]:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.search-input-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.search-input-row input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.filters-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filters-row {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.filters-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.status-box {
|
||||
background: #f5f5f5;
|
||||
border-left: 4px solid #667eea;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.status-box.success {
|
||||
background: #e8f5e9;
|
||||
border-left-color: #4caf50;
|
||||
}
|
||||
|
||||
.status-box.error {
|
||||
background: #ffebee;
|
||||
border-left-color: #f44336;
|
||||
}
|
||||
|
||||
.results {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.results.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
background: #f9f9f9;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 15px;
|
||||
border-left: 4px solid #667eea;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.result-item:hover {
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
border-left-color: #764ba2;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
font-size: 1.3em;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.result-meta {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.meta-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 12px;
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.meta-tag.source {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.result-abstract {
|
||||
color: #555;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.citation-box {
|
||||
background: white;
|
||||
border-left: 4px solid #667eea;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
margin-top: 12px;
|
||||
color: #333;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.citation-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-copy {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-copy:hover {
|
||||
background: #5568d3;
|
||||
}
|
||||
|
||||
.score-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.score-badge.high {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
border-color: #81c784;
|
||||
}
|
||||
|
||||
.score-badge.medium {
|
||||
background: #fff3e0;
|
||||
color: #e65100;
|
||||
border-color: #ffb74d;
|
||||
}
|
||||
|
||||
.score-badge.low {
|
||||
background: #f5f5f5;
|
||||
color: #666;
|
||||
border-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.result-link {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.result-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
border: 3px solid #f3f3f3;
|
||||
border-top: 3px solid #667eea;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 20px auto;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.no-results {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.no-results-icon {
|
||||
font-size: 4em;
|
||||
margin-bottom: 20px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.stats-bar {
|
||||
background: #f5f5f5;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stats-bar span {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.stats-bar strong {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
small {
|
||||
color: #666;
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🔍 CiteLynq Web Sample</h1>
|
||||
<p>Search verified citations from authoritative sources</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="form-group">
|
||||
<label for="apiKey">🔑 API Key</label>
|
||||
<input type="password" id="apiKey" placeholder="cl_your-api-key-here">
|
||||
<small>Get your API key from <a href="https://citelynq.com/app/api-keys" target="_blank" style="color: #667eea;">citelynq.com/app/api-keys</a></small>
|
||||
</div>
|
||||
|
||||
<h3 style="margin-bottom: 15px; color: #333;">🔎 Search Citations</h3>
|
||||
|
||||
<div class="search-container">
|
||||
<!-- Search Input Row -->
|
||||
<div class="search-input-row">
|
||||
<input type="text" id="query" placeholder="Enter your search query..." value="climate change">
|
||||
<button class="btn btn-primary" id="searchBtn" onclick="performSearch()">
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters Row -->
|
||||
<div class="filters-row">
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">Search Type</label>
|
||||
<select id="searchType" onchange="toggleDateFields()">
|
||||
<option value="semantic">Semantic (AI)</option>
|
||||
<option value="fulltext">Full-Text</option>
|
||||
<option value="bydate">By Date</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">Data Source</label>
|
||||
<select id="sourceType">
|
||||
<option value="">All Sources</option>
|
||||
<option value="arxiv">ArXiv</option>
|
||||
<option value="pubmed">PubMed</option>
|
||||
<option value="wikipedia">Wikipedia</option>
|
||||
<option value="federalregister">Federal Register</option>
|
||||
<option value="govinfo">GovInfo</option>
|
||||
<option value="fred">FRED</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">Citation Format</label>
|
||||
<select id="citationStyle">
|
||||
<option value="mla">MLA</option>
|
||||
<option value="apa">APA</option>
|
||||
<option value="chicago">Chicago</option>
|
||||
<option value="bibtex">BibTeX</option>
|
||||
<option value="bluebook">Bluebook</option>
|
||||
<option value="endnote">EndNote</option>
|
||||
<option value="ris">RIS</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">Min. Relevance</label>
|
||||
<select id="minRelevance">
|
||||
<option value="all">All Results</option>
|
||||
<option value="medium">Medium+</option>
|
||||
<option value="high">High Only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Date fields (hidden by default) -->
|
||||
<div class="filter-group" id="monthField" style="display: none;">
|
||||
<label class="filter-label">Month</label>
|
||||
<select id="searchMonth">
|
||||
<option value="1">January</option>
|
||||
<option value="2">February</option>
|
||||
<option value="3">March</option>
|
||||
<option value="4">April</option>
|
||||
<option value="5">May</option>
|
||||
<option value="6">June</option>
|
||||
<option value="7">July</option>
|
||||
<option value="8">August</option>
|
||||
<option value="9">September</option>
|
||||
<option value="10">October</option>
|
||||
<option value="11">November</option>
|
||||
<option value="12">December</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-group" id="dayField" style="display: none;">
|
||||
<label class="filter-label">Day</label>
|
||||
<select id="searchDay">
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
<option value="3">3</option>
|
||||
<option value="4">4</option>
|
||||
<option value="5">5</option>
|
||||
<option value="6">6</option>
|
||||
<option value="7">7</option>
|
||||
<option value="8">8</option>
|
||||
<option value="9">9</option>
|
||||
<option value="10">10</option>
|
||||
<option value="11">11</option>
|
||||
<option value="12">12</option>
|
||||
<option value="13">13</option>
|
||||
<option value="14">14</option>
|
||||
<option value="15">15</option>
|
||||
<option value="16">16</option>
|
||||
<option value="17">17</option>
|
||||
<option value="18">18</option>
|
||||
<option value="19">19</option>
|
||||
<option value="20">20</option>
|
||||
<option value="21">21</option>
|
||||
<option value="22">22</option>
|
||||
<option value="23">23</option>
|
||||
<option value="24">24</option>
|
||||
<option value="25">25</option>
|
||||
<option value="26">26</option>
|
||||
<option value="27">27</option>
|
||||
<option value="28">28</option>
|
||||
<option value="29">29</option>
|
||||
<option value="30">30</option>
|
||||
<option value="31">31</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="statusBox"></div>
|
||||
</div>
|
||||
|
||||
<div class="card results" id="resultsCard">
|
||||
<h2 style="margin-bottom: 20px;">📊 Search Results</h2>
|
||||
<div id="statsBar"></div>
|
||||
<div id="resultsList"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Load API key from localStorage
|
||||
const apiKeyInput = document.getElementById('apiKey');
|
||||
const savedApiKey = localStorage.getItem('citelynq_api_key');
|
||||
if (savedApiKey) {
|
||||
apiKeyInput.value = savedApiKey;
|
||||
}
|
||||
|
||||
// Save API key when changed
|
||||
apiKeyInput.addEventListener('change', () => {
|
||||
localStorage.setItem('citelynq_api_key', apiKeyInput.value);
|
||||
});
|
||||
|
||||
// Allow Enter key to trigger search
|
||||
document.getElementById('query').addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
performSearch();
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle date fields based on search type
|
||||
function toggleDateFields() {
|
||||
const searchType = document.getElementById('searchType').value;
|
||||
const monthField = document.getElementById('monthField');
|
||||
const dayField = document.getElementById('dayField');
|
||||
|
||||
if (searchType === 'bydate') {
|
||||
monthField.style.display = 'block';
|
||||
dayField.style.display = 'block';
|
||||
} else {
|
||||
monthField.style.display = 'none';
|
||||
dayField.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function performSearch() {
|
||||
const apiKey = apiKeyInput.value.trim();
|
||||
const query = document.getElementById('query').value.trim();
|
||||
const searchType = document.getElementById('searchType').value;
|
||||
const sourceType = document.getElementById('sourceType').value;
|
||||
const minRelevance = document.getElementById('minRelevance').value;
|
||||
const searchBtn = document.getElementById('searchBtn');
|
||||
const resultsCard = document.getElementById('resultsCard');
|
||||
|
||||
if (!apiKey) {
|
||||
showStatus('error', '❌ Please enter your API key');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
showStatus('error', '❌ Please enter a search query');
|
||||
return;
|
||||
}
|
||||
|
||||
// Save API key
|
||||
localStorage.setItem('citelynq_api_key', apiKey);
|
||||
|
||||
// Reset UI
|
||||
searchBtn.disabled = true;
|
||||
resultsCard.classList.remove('show');
|
||||
showStatus('', '<div class="spinner"></div><p style="text-align: center; margin-top: 10px;">Searching...</p>');
|
||||
|
||||
try {
|
||||
// Build URL based on search type
|
||||
let url;
|
||||
if (searchType === 'semantic') {
|
||||
url = `/api/search/semantic?q=${encodeURIComponent(query)}&limit=20`;
|
||||
// Add min_similarity filter for semantic search
|
||||
if (minRelevance === 'high') {
|
||||
url += '&min_similarity=0.70';
|
||||
} else if (minRelevance === 'medium') {
|
||||
url += '&min_similarity=0.40';
|
||||
}
|
||||
} else if (searchType === 'bydate') {
|
||||
const month = document.getElementById('searchMonth').value;
|
||||
const day = document.getElementById('searchDay').value;
|
||||
url = `/api/search/bydate?q=${encodeURIComponent(query)}&month=${month}&day=${day}&limit=20`;
|
||||
// Add min_rank filter for date search
|
||||
if (minRelevance === 'high') {
|
||||
url += '&min_rank=0.15';
|
||||
} else if (minRelevance === 'medium') {
|
||||
url += '&min_rank=0.08';
|
||||
}
|
||||
} else {
|
||||
url = `/api/search?q=${encodeURIComponent(query)}&limit=20`;
|
||||
// Add min_rank filter for full-text search
|
||||
if (minRelevance === 'high') {
|
||||
url += '&min_rank=0.15';
|
||||
} else if (minRelevance === 'medium') {
|
||||
url += '&min_rank=0.08';
|
||||
}
|
||||
}
|
||||
|
||||
if (sourceType) {
|
||||
url += `&source=${encodeURIComponent(sourceType)}`;
|
||||
}
|
||||
|
||||
// Call API endpoint
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-API-Key': apiKey
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('API Error:', data);
|
||||
const errorMsg = data.error || data.message || data.title || response.statusText;
|
||||
const errorDetails = data.details || data.detail || '';
|
||||
showStatus('error', `❌ Error: ${errorMsg}${errorDetails ? '<br><small>' + errorDetails + '</small>' : ''}`);
|
||||
searchBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
showStatus('success', '✅ Search completed successfully!');
|
||||
displayResults(data, searchType);
|
||||
searchBtn.disabled = false;
|
||||
|
||||
} catch (error) {
|
||||
showStatus('error', `❌ Error: ${error.message}`);
|
||||
searchBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function displayResults(data, searchType) {
|
||||
const resultsCard = document.getElementById('resultsCard');
|
||||
const statsBar = document.getElementById('statsBar');
|
||||
const resultsList = document.getElementById('resultsList');
|
||||
|
||||
// Extract results (already filtered and sorted by backend)
|
||||
const results = data.results || [];
|
||||
const totalCount = data.count || results.length;
|
||||
|
||||
if (results.length === 0) {
|
||||
resultsCard.classList.add('show');
|
||||
statsBar.innerHTML = '';
|
||||
resultsList.innerHTML = `
|
||||
<div class="no-results">
|
||||
<div class="no-results-icon">📄</div>
|
||||
<h3>No results found</h3>
|
||||
<p>Try adjusting your search query or selecting a different source</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Show stats
|
||||
let statsHtml = `<span><strong>${results.length}</strong> results`;
|
||||
if (data.tokens_used) {
|
||||
statsHtml += ` (${data.tokens_used} tokens used)`;
|
||||
}
|
||||
statsHtml += `</span>`;
|
||||
statsBar.innerHTML = statsHtml;
|
||||
|
||||
// Build results HTML (results are already sorted by relevance from backend)
|
||||
resultsList.innerHTML = results.map(result => formatResult(result, searchType)).join('');
|
||||
|
||||
resultsCard.classList.add('show');
|
||||
}
|
||||
|
||||
function formatResult(result, searchType) {
|
||||
const citationStyle = document.getElementById('citationStyle').value;
|
||||
const title = result.title || 'Untitled';
|
||||
const snippet = result.snippet || result.chunk_text || '';
|
||||
const source = result.source || 'Unknown';
|
||||
const publishedAt = result.published_at || '';
|
||||
const sourceUrl = result.source_url || '#';
|
||||
|
||||
// Generate citation
|
||||
const citation = formatCitation(result, citationStyle);
|
||||
|
||||
// Get relevance score
|
||||
let scoreHtml = '';
|
||||
if (searchType === 'semantic' && result.similarity !== undefined) {
|
||||
const similarity = result.similarity * 100;
|
||||
const label = getRelevanceLabel(similarity, true);
|
||||
scoreHtml = `<span class="score-badge ${label.className}">${similarity.toFixed(0)}% ${label.text}</span>`;
|
||||
} else if (result.rank !== undefined) {
|
||||
const rank = result.rank;
|
||||
const label = getRelevanceLabel(rank, false);
|
||||
scoreHtml = `<span class="score-badge ${label.className}">${rank.toFixed(2)} ${label.text}</span>`;
|
||||
}
|
||||
|
||||
let html = '<div class="result-item">';
|
||||
html += `<div class="result-title">${escapeHtml(title)}</div>`;
|
||||
|
||||
html += '<div class="result-meta">';
|
||||
html += `<span class="meta-tag source">${escapeHtml(source)}</span>`;
|
||||
if (publishedAt) {
|
||||
html += `<span class="meta-tag">📅 ${formatDate(publishedAt)}</span>`;
|
||||
}
|
||||
if (scoreHtml) {
|
||||
html += scoreHtml;
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
if (snippet) {
|
||||
const truncated = snippet.length > 400 ? snippet.substring(0, 400) + '...' : snippet;
|
||||
html += `<div class="result-abstract">${truncated}</div>`;
|
||||
}
|
||||
|
||||
if (citation) {
|
||||
html += `<div class="citation-box">`;
|
||||
html += `<div class="citation-text">${escapeHtml(citation)}</div>`;
|
||||
html += `<button class="btn-copy" onclick="copyCitation('${escapeHtml(citation).replace(/'/g, "\\'")}')">📋 Copy</button>`;
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
html += `<div style="margin-top: 15px;">`;
|
||||
if (sourceUrl !== '#') {
|
||||
html += `<a href="${escapeHtml(sourceUrl)}" target="_blank" class="result-link">View Source →</a>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function formatCitation(result, style) {
|
||||
const title = result.title || 'Untitled';
|
||||
const source = result.source || 'Unknown';
|
||||
const date = result.published_at ? new Date(result.published_at).getFullYear() : 'n.d.';
|
||||
const id = result.document_id || result.id || '';
|
||||
const sourceName = source.charAt(0).toUpperCase() + source.slice(1).toLowerCase();
|
||||
|
||||
if (style === 'mla') {
|
||||
return `"${title}." ${sourceName}, ${date}. ${id}`;
|
||||
} else if (style === 'apa') {
|
||||
return `(${date}). ${title}. ${sourceName}. ${id}`;
|
||||
} else if (style === 'chicago') {
|
||||
return `"${title}." ${sourceName} (${date}). ${id}`;
|
||||
} else if (style === 'bluebook') {
|
||||
return `${title}, ${sourceName} (${date}). ${id}`;
|
||||
} else if (style === 'bibtex') {
|
||||
const cleanId = id.replace(/[^a-zA-Z0-9]/g, '');
|
||||
return `@article{${cleanId},\n title = {${title}},\n journal = {${sourceName}},\n year = {${date}},\n note = {${id}}\n}`;
|
||||
} else if (style === 'endnote') {
|
||||
return `%0 Journal Article\n%T ${title}\n%J ${sourceName}\n%D ${date}\n%U ${id}`;
|
||||
} else if (style === 'ris') {
|
||||
return `TY - JOUR\nTI - ${title}\nJO - ${sourceName}\nPY - ${date}\nUR - ${id}\nER -`;
|
||||
}
|
||||
return `"${title}." ${sourceName}, ${date}. ${id}`;
|
||||
}
|
||||
|
||||
function getRelevanceLabel(score, isSimilarity) {
|
||||
if (isSimilarity) {
|
||||
if (score >= 70) return { text: 'High', className: 'high' };
|
||||
if (score >= 40) return { text: 'Medium', className: 'medium' };
|
||||
return { text: 'Low', className: 'low' };
|
||||
} else {
|
||||
if (score >= 0.15) return { text: 'High', className: 'high' };
|
||||
if (score >= 0.08) return { text: 'Medium', className: 'medium' };
|
||||
return { text: 'Low', className: 'low' };
|
||||
}
|
||||
}
|
||||
|
||||
function copyCitation(citation) {
|
||||
navigator.clipboard.writeText(citation).then(() => {
|
||||
showStatus('success', '✅ Citation copied to clipboard!');
|
||||
setTimeout(() => showStatus('', ''), 2000);
|
||||
}).catch(err => {
|
||||
showStatus('error', '❌ Failed to copy citation');
|
||||
});
|
||||
}
|
||||
|
||||
function showStatus(type, message) {
|
||||
const statusBox = document.getElementById('statusBox');
|
||||
statusBox.className = `status-box ${type}`;
|
||||
statusBox.innerHTML = message;
|
||||
}
|
||||
|
||||
function formatDate(dateString) {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user