refactor: streamline plugin to focus on clipping catalog

This commit represents a focused version of a larger project,
stripped down to serve a single clear purpose: providing a catalog view
of clipped articles. Removed article summarization features to create
a lightweight, flexible tool that works with any clipping workflow.

Key changes:
- Simplified to core catalog features
- Works with any frontmatter URL property
- Compatible with all clipping methods
This commit is contained in:
Greg K. 2024-12-09 11:07:30 -08:00
parent ee04e2f81f
commit cfe5711e1c
13 changed files with 1803 additions and 259 deletions

171
README.md
View File

@ -1,94 +1,125 @@
# Obsidian Sample Plugin
# Obsidian Clipper Catalog
This is a sample plugin for Obsidian (https://obsidian.md).
An Obsidian plugin that provides a powerful catalog interface for all your clipped web articles and content. Easily organize, search, and manage your web clippings within your Obsidian vault.
This project uses TypeScript to provide type checking and documentation.
The repo depends on the latest plugin API (obsidian.d.ts) in TypeScript Definition format, which contains TSDoc comments describing what it does.
## Features
This sample plugin demonstrates some of the basic functionality the plugin API can do.
- Adds a ribbon icon, which shows a Notice when clicked.
- Adds a command "Open Sample Modal" which opens a Modal.
- Adds a plugin setting tab to the settings page.
- Registers a global click event and output 'click' to the console.
- Registers a global interval which logs 'setInterval' to the console.
🔍 Catalog all your clipped articles in one view
📱 Fully responsive design that works on desktop and mobile
🏷️ Tag-based organization and filtering
🔄 Real-time search and sorting capabilities
⚡ Command palette integration
🎨 Clean, modern UI with Obsidian theme integration
📂 Advanced directory filtering options
## First time developing plugins?
## Installation
Quick starting guide for new plugin devs:
### From Obsidian Community Plugins
1. Open Obsidian Settings
2. Go to Community Plugins and turn off Safe Mode
3. Click Browse and search for "Clipper Catalog"
4. Install the plugin and enable it
- Check if [someone already developed a plugin for what you want](https://obsidian.md/plugins)! There might be an existing plugin similar enough that you can partner up with.
- Make a copy of this repo as a template with the "Use this template" button (login to GitHub if you don't see it).
- Clone your repo to a local development folder. For convenience, you can place this folder in your `.obsidian/plugins/your-plugin-name` folder.
- Install NodeJS, then run `npm i` in the command line under your repo folder.
- Run `npm run dev` to compile your plugin from `main.ts` to `main.js`.
- Make changes to `main.ts` (or create new `.ts` files). Those changes should be automatically compiled into `main.js`.
- Reload Obsidian to load the new version of your plugin.
- Enable plugin in settings window.
- For updates to the Obsidian API run `npm update` in the command line under your repo folder.
### Manual Installation
1. Download the latest release from GitHub
2. Extract the zip archive into your vault's `.obsidian/plugins` directory
3. Enable the plugin in Obsidian's settings
## Releasing new releases
## Configuration
- Update your `manifest.json` with your new version number, such as `1.0.1`, and the minimum Obsidian version required for your latest release.
- Update your `versions.json` file with `"new-plugin-version": "minimum-obsidian-version"` so older versions of Obsidian can download an older version of your plugin that's compatible.
- Create new GitHub release using your new version number as the "Tag version". Use the exact version number, don't include a prefix `v`. See here for an example: https://github.com/obsidianmd/obsidian-sample-plugin/releases
- Upload the files `manifest.json`, `main.js`, `styles.css` as binary attachments. Note: The manifest.json file must be in two places, first the root path of your repository and also in the release.
- Publish the release.
1. Open the plugin settings in Obsidian
2. Configure the property name that identifies clipped content (default: 'source')
3. The catalog will display all notes that contain the specified property with a URL value
> You can simplify the version bump process by running `npm version patch`, `npm version minor` or `npm version major` after updating `minAppVersion` manually in `manifest.json`.
> The command will bump version in `manifest.json` and `package.json`, and add the entry for the new version to `versions.json`
## Usage
## Adding your plugin to the community plugin list
1. Open the Clipper Catalog view using:
- Command palette: Search for "Open Clipper Catalog"
- Or click the Clipper Catalog icon in the sidebar
2. Browse, search, and filter your clipped articles
3. Use advanced search options to exclude specific directories
4. Click on article titles to open them in your vault
5. Click on tags to filter by specific tags
6. Sort by date, title, or path
- Check the [plugin guidelines](https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines).
- Publish an initial version.
- Make sure you have a `README.md` file in the root of your repo.
- Make a pull request at https://github.com/obsidianmd/obsidian-releases to add your plugin.
## Features
## How to use
### Search and Filter
- Full-text search across titles and tags
- Tag-based filtering
- Directory exclusion options
- Real-time updates
- Clone this repo.
- Make sure your NodeJS is at least v16 (`node --version`).
- `npm i` or `yarn` to install dependencies.
- `npm run dev` to start compilation in watch mode.
### Organization
- Sort by multiple columns
- Advanced directory filtering
- Tag management
- Path-based organization
## Manually installing the plugin
### Interface
- Clean, modern design
- Responsive layout
- Theme-aware styling
- Keyboard navigation
- Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`.
## Support
## Improve code quality with eslint (optional)
- [ESLint](https://eslint.org/) is a tool that analyzes your code to quickly find problems. You can run ESLint against your plugin to find common bugs and ways to improve your code.
- To use eslint with this project, make sure to install eslint from terminal:
- `npm install -g eslint`
- To use eslint to analyze this project use this command:
- `eslint main.ts`
- eslint will then create a report with suggestions for code improvement by file and line number.
- If your source code is in a folder, such as `src`, you can use eslint with this command to analyze all files in that folder:
- `eslint .\src\`
- For bugs or feature requests, please [open an issue](https://github.com/soundslikeinfo/obsidian-clipper-catalog/issues)
## Funding URL
## Privacy
You can include funding URLs where people who use your plugin can financially support it.
This plugin only reads and organizes notes within your local Obsidian vault. No data is sent to external servers.
The simple way is to set the `fundingUrl` field to your link in your `manifest.json` file:
## Contributing
```json
{
"fundingUrl": "https://buymeacoffee.com"
}
1. Fork the repository
2. Create a feature branch
3. Commit your changes
4. Push to the branch
5. Create a Pull Request
## License
This project is licensed under the MIT License - see the LICENSE file for details.
## Frequently Asked Questions
### How does the plugin find my clipped articles?
The plugin looks for notes in your vault that contain a specified property (default: 'source') with a URL value in the frontmatter. This can be changed in the plugin's settings.
### Does it work on mobile?
Yes! The plugin is fully compatible with both desktop and mobile versions of Obsidian.
### Can I exclude certain folders from the catalog?
Yes! Use the advanced search options to exclude specific directories from your catalog view.
## Support
- For bugs or feature requests, please [open an issue](https://github.com/soundslikeinfo/obsidian-clipper-catalog/issues)
## Privacy
This plugin sends article content to OpenAI's API for processing. Please review OpenAI's privacy policy and ensure you're comfortable with their data handling practices.
## Contributing
1. Fork the repository
2. Create a feature branch
3. Commit your changes
4. Push to the branch
5. Create a Pull Request
### 🧠 Crafted with AI & Human Creativity
```
🎨 Design & Development
Greg K. (@soundslikeinfo)
🤖 AI Pair Programming
Claude 3.5 Sonnet by Anthropic
```
If you have multiple URLs, you can also do:
### 💝 Support the Project
```json
{
"fundingUrl": {
"Buy Me a Coffee": "https://buymeacoffee.com",
"GitHub Sponsor": "https://github.com/sponsors",
"Patreon": "https://www.patreon.com/"
}
}
```
[![GitHub Stars](https://img.shields.io/github/stars/soundslikeinfo/obsidian-clipper-catalog?style=social)](https://github.com/soundslikeinfo/obsidian-clipper-catalog)
[![Buy Me A Coffee](https://img.shields.io/badge/-buy_me_a%C2%A0coffee-gray?logo=buy-me-a-coffee)](https://www.buymeacoffee.com/soundslikeinfo)
## API Documentation
See https://github.com/obsidianmd/obsidian-api
Made with ❤️ for the Obsidian Community

View File

@ -1,6 +1,8 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
import { builtinModules } from 'module';
import fs from 'fs';
import path from 'path';
const banner =
`/*
@ -11,11 +13,35 @@ if you want to view the source, please visit the github repository of this plugi
const prod = (process.argv[2] === "production");
// Ensure the src/styles.css exists with Tailwind imports
const initStylesFile = () => {
const stylesDir = path.join(process.cwd(), 'src');
const stylesFile = path.join(stylesDir, 'styles.css');
if (!fs.existsSync(stylesDir)) {
fs.mkdirSync(stylesDir, { recursive: true });
}
if (!fs.existsSync(stylesFile)) {
const tailwindImports = `
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Your custom styles here */
`.trim();
fs.writeFileSync(stylesFile, tailwindImports);
}
};
initStylesFile();
const context = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ["main.ts"],
entryPoints: ["src/main.tsx"],
bundle: true,
external: [
"obsidian",
@ -31,19 +57,20 @@ const context = await esbuild.context({
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins],
...builtinModules,
],
format: "cjs",
target: "es2018",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "main.js",
minify: prod,
jsx: "automatic",
});
if (prod) {
await context.rebuild();
process.exit(0);
context.dispose();
} else {
await context.watch();
}

134
main.ts
View File

@ -1,134 +0,0 @@
import { App, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting } from 'obsidian';
// Remember to rename these classes and interfaces!
interface MyPluginSettings {
mySetting: string;
}
const DEFAULT_SETTINGS: MyPluginSettings = {
mySetting: 'default'
}
export default class MyPlugin extends Plugin {
settings: MyPluginSettings;
async onload() {
await this.loadSettings();
// This creates an icon in the left ribbon.
const ribbonIconEl = this.addRibbonIcon('dice', 'Sample Plugin', (evt: MouseEvent) => {
// Called when the user clicks the icon.
new Notice('This is a notice!');
});
// Perform additional things with the ribbon
ribbonIconEl.addClass('my-plugin-ribbon-class');
// This adds a status bar item to the bottom of the app. Does not work on mobile apps.
const statusBarItemEl = this.addStatusBarItem();
statusBarItemEl.setText('Status Bar Text');
// This adds a simple command that can be triggered anywhere
this.addCommand({
id: 'open-sample-modal-simple',
name: 'Open sample modal (simple)',
callback: () => {
new SampleModal(this.app).open();
}
});
// This adds an editor command that can perform some operation on the current editor instance
this.addCommand({
id: 'sample-editor-command',
name: 'Sample editor command',
editorCallback: (editor: Editor, view: MarkdownView) => {
console.log(editor.getSelection());
editor.replaceSelection('Sample Editor Command');
}
});
// This adds a complex command that can check whether the current state of the app allows execution of the command
this.addCommand({
id: 'open-sample-modal-complex',
name: 'Open sample modal (complex)',
checkCallback: (checking: boolean) => {
// Conditions to check
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (markdownView) {
// If checking is true, we're simply "checking" if the command can be run.
// If checking is false, then we want to actually perform the operation.
if (!checking) {
new SampleModal(this.app).open();
}
// This command will only show up in Command Palette when the check function returns true
return true;
}
}
});
// This adds a settings tab so the user can configure various aspects of the plugin
this.addSettingTab(new SampleSettingTab(this.app, this));
// If the plugin hooks up any global DOM events (on parts of the app that doesn't belong to this plugin)
// Using this function will automatically remove the event listener when this plugin is disabled.
this.registerDomEvent(document, 'click', (evt: MouseEvent) => {
console.log('click', evt);
});
// When registering intervals, this function will automatically clear the interval when the plugin is disabled.
this.registerInterval(window.setInterval(() => console.log('setInterval'), 5 * 60 * 1000));
}
onunload() {
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
}
class SampleModal extends Modal {
constructor(app: App) {
super(app);
}
onOpen() {
const {contentEl} = this;
contentEl.setText('Woah!');
}
onClose() {
const {contentEl} = this;
contentEl.empty();
}
}
class SampleSettingTab extends PluginSettingTab {
plugin: MyPlugin;
constructor(app: App, plugin: MyPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const {containerEl} = this;
containerEl.empty();
new Setting(containerEl)
.setName('Setting #1')
.setDesc('It\'s a secret')
.addText(text => text
.setPlaceholder('Enter your secret')
.setValue(this.plugin.settings.mySetting)
.onChange(async (value) => {
this.plugin.settings.mySetting = value;
await this.plugin.saveSettings();
}));
}
}

View File

@ -1,11 +1,13 @@
{
"id": "sample-plugin",
"name": "Sample Plugin",
"version": "1.0.0",
"id": "Clipper Catalog",
"name": "Clipper Catalog",
"version": "0.10.1",
"minAppVersion": "0.15.0",
"description": "Demonstrates some of the capabilities of the Obsidian API.",
"description": "This provides any Obsidian vault with a catalog of all the clippings gathered with a common source property.",
"author": "Obsidian",
"authorUrl": "https://obsidian.md",
"fundingUrl": "https://obsidian.md/pricing",
"authorUrl": "https://github.com/soundslikeinfo",
"fundingUrl": {
"Buy Me a Coffee": "https://www.buymeacoffee.com/soundslikeinfo"
},
"isDesktopOnly": false
}

View File

@ -1,24 +1,37 @@
{
"name": "obsidian-sample-plugin",
"name": "obsidian-clipper-catalog",
"version": "1.0.0",
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
"description": "This provides any Obsidian vault with a catalog of all the clippings gathered with a common source property.",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"build": "tailwindcss -i ./src/styles.css -o ./styles.css --minify && tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"css:dev": "tailwindcss -i ./src/styles.css -o ./styles.css --watch",
"version": "node version-bump.mjs && git add manifest.json versions.json"
},
"keywords": [],
"author": "",
"license": "MIT",
"bugs": {
"url": "https://github.com/soundslikeinfo/obsidian-clipper-catalog/issues"
},
"homepage": "https://github.com/soundslikeinfo/obsidian-clipper-catalog#readme",
"devDependencies": {
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"builtin-modules": "3.3.0",
"@types/react": "^18.0.27",
"@types/react-dom": "^18.0.10",
"autoprefixer": "^10.4.20",
"esbuild": "0.17.3",
"lucide-react": "^0.454.0",
"obsidian": "latest",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14",
"tslib": "2.4.0",
"typescript": "4.7.4"
},
"dependencies": {
"openai": "^3.2.1",
"react": "^18.2.0",
"react-dom": "^18.2.0"
}
}

7
postcss.config.js Normal file
View File

@ -0,0 +1,7 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

660
src/ClipperCatalog.tsx Normal file
View File

@ -0,0 +1,660 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Link, Search, RefreshCw, ChevronDown, ChevronRight, X } from 'lucide-react';
import { TFile, App } from 'obsidian';
import type ObsidianClipperCatalog from './main';
interface ClipperCatalogProps {
app: App;
plugin: ObsidianClipperCatalog;
}
interface Article {
title: string;
url: string;
path: string;
date: number;
tags: string[];
basename: string;
content: string;
}
interface SortConfig {
key: keyof Article;
direction: 'asc' | 'desc';
}
interface AdvancedSettings {
ignoredDirectories: string[];
isExpanded: boolean;
}
const ArticleTitle = ({ file, content, title }: { file: TFile, content: string, title: string }) => {
const isUntitled = /^Untitled( \d+)?$/.test(file.basename);
const headerMatch = content.match(/^#+ (.+)$/m);
if (isUntitled && headerMatch) {
return (
<div className="cc-flex cc-flex-col">
<span>{headerMatch[1].trim()}</span>
<span className="cc-text-xs cc-text-muted">({file.basename})</span>
</div>
);
}
return <span>{file.basename}</span>;
};
const ClipperCatalog: React.FC<ClipperCatalogProps> = ({ app, plugin }) => {
const [articles, setArticles] = useState<Article[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [sortConfig, setSortConfig] = useState<SortConfig>({ key: 'date', direction: 'desc' });
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [advancedSettings, setAdvancedSettings] = useState<AdvancedSettings>(() => {
// Try to load saved settings from localStorage
const savedSettings = localStorage.getItem('clipper-catalog-advanced-settings');
return savedSettings ? JSON.parse(savedSettings) : {
ignoredDirectories: [],
isExpanded: false
};
});
const [newDirectory, setNewDirectory] = useState('');
// Save advanced settings to localStorage whenever they change
useEffect(() => {
localStorage.setItem('clipper-catalog-advanced-settings', JSON.stringify(advancedSettings));
}, [advancedSettings]);
// Helper function to check if a path should be ignored
const isPathIgnored = (filePath: string): boolean => {
return advancedSettings.ignoredDirectories.some(dir => {
// Normalize both paths to use forward slashes and remove trailing slashes
const normalizedDir = dir.replace(/\\/g, '/').replace(/\/$/, '');
const normalizedPath = filePath.replace(/\\/g, '/');
// Split the paths into segments
const dirParts = normalizedDir.split('/');
const pathParts = normalizedPath.split('/');
// Check if the number of path parts is at least equal to directory parts
if (pathParts.length < dirParts.length) return false;
// Compare each segment
for (let i = 0; i < dirParts.length; i++) {
if (dirParts[i].toLowerCase() !== pathParts[i].toLowerCase()) {
return false;
}
}
// Only match if we've matched all segments exactly
return dirParts.length === pathParts.length - 1 || // Directory contains files
dirParts.length === pathParts.length; // Directory is exactly matched
});
};
const loadArticles = useCallback(async () => {
console.log("Starting to load articles...");
setIsRefreshing(true);
setError(null);
try {
const articleFiles: Article[] = [];
const files = app.vault.getMarkdownFiles();
console.log(`Found ${files.length} markdown files`);
for (const file of files) {
try {
if (isPathIgnored(file.parent?.path || '')) {
continue;
}
const content = await app.vault.read(file);
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (frontmatterMatch) {
const frontmatter = frontmatterMatch[1];
const sourcePropertyPattern = `^${plugin.settings.sourcePropertyName}:\\s*["']?([^"'\\s]+)["']?\\s*$`
const sourcePropertyRegex = new RegExp(sourcePropertyPattern, 'm')
const sourceMatch = frontmatter.match(sourcePropertyRegex);
if (sourceMatch) {
console.log(`Processing file: ${file.path}`);
const content = await app.vault.read(file);
const title = file.basename;
let tags: string[] = [];
// Get all hashtags from the entire content (including frontmatter)
const hashtagMatches = content.match(/#[\w\d-_/]+/g) || [];
// Add content tags (requiring # prefix)
tags = [...new Set(hashtagMatches.map(tag => tag.slice(1)))].filter(Boolean);
// Remove duplicates using Set
tags = [...new Set(tags)].filter(Boolean);
articleFiles.push({
title,
url: sourceMatch[1],
path: file.path,
date: file.stat.ctime,
tags,
basename: file.basename,
content: content
});
}
}
} catch (error) {
console.error(`Error processing file ${file.path}:`, error);
}
}
console.log(`Processed ${articleFiles.length} articles`);
setArticles(articleFiles);
} catch (error) {
console.error("Error loading articles:", error);
setError("Failed to load articles");
} finally {
setIsRefreshing(false);
setIsLoading(false);
}
}, [app.vault, advancedSettings.ignoredDirectories]);
// Initial load
useEffect(() => {
loadArticles();
}, [loadArticles, advancedSettings.ignoredDirectories]);
useEffect(() => {
const intervalId = setInterval(() => {
loadArticles();
}, 60000);
return () => clearInterval(intervalId);
}, [loadArticles]);
if (error) {
return (
<div className="cc-flex cc-justify-center cc-items-center cc-p-4 cc-text-red-400">
{error}
</div>
);
}
if (isLoading) {
return (
<div className="cc-flex cc-justify-center cc-items-center cc-p-4 cc-gap-2">
<div className="cc-animate-spin cc-h-4 cc-w-4">
<RefreshCw className="cc-h-4 cc-w-4" />
</div>
<span className="cc-text-sm">Loading articles...</span>
</div>
);
}
const handleRefresh = () => {
loadArticles();
};
const handleSort = (key: keyof Article) => {
setSortConfig(prevConfig => ({
key,
direction: prevConfig.key === key && prevConfig.direction === 'asc' ? 'desc' : 'asc'
}));
};
const sortedArticles = [...articles].sort((a, b) => {
if (sortConfig.key === 'date') {
return sortConfig.direction === 'asc'
? a.date - b.date
: b.date - a.date;
}
const aValue = String(a[sortConfig.key]).toLowerCase();
const bValue = String(b[sortConfig.key]).toLowerCase();
if (sortConfig.direction === 'asc') {
return aValue.localeCompare(bValue);
}
return bValue.localeCompare(aValue);
});
const filteredArticles = sortedArticles.filter(article =>
article.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
article.tags.some(tag => tag.toLowerCase().includes(searchTerm.toLowerCase())) ||
searchTerm.startsWith('#') && article.tags.some(tag =>
tag.toLowerCase() === searchTerm.slice(1).toLowerCase()
)
);
const getSortIcon = (key: keyof Article) => {
if (sortConfig.key === key) {
return sortConfig.direction === 'asc' ? '↑' : '↓';
}
return null;
};
const openArticle = (path: string) => {
const file = app.vault.getAbstractFileByPath(path);
if (file instanceof TFile) {
const leaf = app.workspace.getLeaf(false);
leaf.openFile(file);
}
};
const formatDate = (timestamp: number) => {
return new Date(timestamp).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
};
const handleAddDirectory = () => {
if (!newDirectory.trim()) return;
// Split by commas and clean up each directory path
const directoriesToAdd = newDirectory
.split(',')
.map(dir => dir.trim())
.filter(dir => dir.length > 0);
if (directoriesToAdd.length === 0) return;
setAdvancedSettings(prev => {
const updatedDirectories = [...prev.ignoredDirectories];
directoriesToAdd.forEach(dir => {
if (!updatedDirectories.includes(dir)) {
updatedDirectories.push(dir);
}
});
return {
...prev,
ignoredDirectories: updatedDirectories
};
});
setNewDirectory('');
};
const handleRemoveDirectory = (dir: string) => {
setAdvancedSettings(prev => ({
...prev,
ignoredDirectories: prev.ignoredDirectories.filter(d => d !== dir)
}));
// Articles will reload automatically due to the useEffect dependency on ignoredDirectories
};
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && newDirectory.trim()) {
handleAddDirectory();
}
};
const toggleAdvancedSettings = () => {
setAdvancedSettings(prev => ({
...prev,
isExpanded: !prev.isExpanded
}));
};
const handleClearAllDirectories = () => {
setAdvancedSettings(prev => ({
...prev,
ignoredDirectories: []
}));
};
const renderAdvancedSettingsHeader = () => {
const excludedCount = advancedSettings.ignoredDirectories.length;
return (
<div className="cc-flex cc-items-center cc-justify-between cc-w-full">
<button
onClick={toggleAdvancedSettings}
className="cc-flex cc-items-center cc-gap-1 cc-text-sm cc-font-medium hover:cc-underline cc-text-muted cc-transition-all"
>
{advancedSettings.isExpanded ? <ChevronDown className="cc-h-4 cc-w-4" /> : <ChevronRight className="cc-h-4 cc-w-4" />}
Advanced Search Options
</button>
{!advancedSettings.isExpanded && excludedCount > 0 && (
<em className="cc-text-xs cc-text-muted">
Note: There {excludedCount === 1 ? 'is' : 'are'} {excludedCount} path{excludedCount === 1 ? '' : 's'} excluded from showing up in the results
</em>
)}
</div>
);
};
return (
<div className="cc-flex cc-flex-col cc-gap-4">
<div className="cc-relative">
{/* Search input container */}
<div className="cc-flex cc-items-center cc-gap-2 cc-px-4 cc-py-2 cc-rounded-lg clipper-catalog-search">
<Search className="cc-h-4 cc-w-4 clipper-catalog-icon" />
<div className="cc-relative cc-flex-1">
<input
type="text"
placeholder="Search articles or tags..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="cc-w-full cc-bg-transparent cc-outline-none cc-text-sm cc-pr-16 clipper-catalog-input"
/>
{searchTerm && (
<div
onClick={() => setSearchTerm('')}
className="cc-absolute cc-right-2 cc-top-[20%] cc-flex cc-items-center cc-gap-1 cc-cursor-pointer cc-transition-colors clipper-catalog-clear-btn"
>
<svg className="cc-h-3.5 cc-w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<span className="cc-text-xs">clear</span>
</div>
)}
</div>
</div>
{/* Advanced Settings Section */}
<div className="cc-mt-2">
{renderAdvancedSettingsHeader()}
{advancedSettings.isExpanded && (
<div className="cc-mt-2 cc-px-4 cc-py-2 cc-rounded-lg clipper-catalog-advanced">
<div className="cc-flex cc-flex-col cc-gap-3">
<div className="cc-flex cc-flex-col cc-gap-1">
<div className="cc-flex cc-items-center cc-gap-2">
<input
type="text"
placeholder="Enter full paths to ignore (comma-separated, e.g., research/links/delago, work/expenses)"
value={newDirectory}
onChange={(e) => setNewDirectory(e.target.value)}
onKeyPress={handleKeyPress}
className="cc-flex-1 cc-px-2 cc-py-1 cc-text-sm cc-rounded clipper-catalog-input"
/>
<button
onClick={handleAddDirectory}
disabled={!newDirectory.trim()}
className="cc-px-3 cc-py-1 cc-text-sm cc-rounded cc-bg-accent-primary cc-text-on-accent cc-font-medium clipper-catalog-button hover:cc-opacity-90"
>
Add
</button>
</div>
<span className="cc-text-xs cc-text-muted">
Tip: You can enter multiple paths separated by commas
</span>
</div>
{advancedSettings.ignoredDirectories.length > 0 && (
<div className="cc-flex cc-flex-col cc-gap-2">
<div className="cc-flex cc-items-center cc-justify-between">
<span className="cc-text-xs cc-font-medium">Excluded Paths:</span>
<button
onClick={handleClearAllDirectories}
className="cc-px-3 cc-py-1 cc-text-xs cc-rounded cc-bg-accent-primary cc-text-on-accent cc-font-medium clipper-catalog-button hover:cc-opacity-90"
>
Clear All Excluded Paths
</button>
</div>
<div className="cc-flex cc-flex-wrap cc-gap-1.5">
{advancedSettings.ignoredDirectories.map((dir) => (
<button
key={dir}
onClick={() => handleRemoveDirectory(dir)}
className="cc-inline-flex cc-items-center cc-bg-chip cc-px-3 cc-py-1.5 cc-rounded-full cc-text-xs hover:cc-bg-chip-hover cc-transition-colors cc-cursor-pointer"
aria-label={`Remove ${dir} from excluded paths`}
>
<span className="cc-text-muted">{dir}</span>
<span className="cc-ml-2 cc-text-muted cc-opacity-60 cc-text-sm">×</span>
</button>
))}
</div>
</div>
)}
</div>
</div>
)}
</div>
{/* Refresh link */}
<div className="cc-absolute cc-right-2 cc-top-full cc-mt-1 cc-text-right">
<span
onClick={handleRefresh}
className="cc-flex cc-items-center cc-gap-1 cc-text-[10px] cc-cursor-pointer cc-transition-colors cc-justify-end clipper-catalog-refresh"
>
<RefreshCw className={`cc-h-2.5 cc-w-2.5 ${isRefreshing ? 'cc-animate-spin' : ''}`} />
<span className="cc-underline">refresh list</span>
</span>
</div>
</div>
<div className="cc-overflow-x-auto">
<table className="cc-w-full cc-text-sm">
<colgroup>
<col className="cc-w-[30%]" />
<col className="cc-w-[15%]" />
<col className="cc-w-[22%]" />
<col className="cc-w-[20%]" />
<col className="cc-w-[13%]" />
</colgroup>
<thead>
<tr className="clipper-catalog-header-row">
<th
onClick={() => handleSort('title')}
className="cc-px-4 cc-py-2 cc-text-left cc-cursor-pointer clipper-catalog-header-cell"
>
Note Title {getSortIcon('title')}
</th>
<th
onClick={() => handleSort('date')}
className="cc-px-4 cc-py-2 cc-text-left cc-cursor-pointer cc-whitespace-nowrap clipper-catalog-header-cell"
>
Date {getSortIcon('date')}
</th>
<th
onClick={() => handleSort('path')}
className="cc-px-4 cc-py-2 cc-text-left cc-cursor-pointer clipper-catalog-header-cell"
>
Path {getSortIcon('path')}
</th>
<th className="cc-px-4 cc-py-2 cc-text-left clipper-catalog-header-cell">
#Tags
</th>
<th className="cc-px-4 cc-py-2 cc-text-left clipper-catalog-header-cell">
Link
</th>
</tr>
</thead>
<tbody>
{filteredArticles.map((article) => (
<tr key={article.path} className="clipper-catalog-row">
<td className="cc-px-4 cc-py-2">
<span
onClick={() => openArticle(article.path)}
className="cc-flex cc-items-center cc-gap-2 cc-cursor-pointer cc-transition-colors cc-min-h-[1.5rem] clipper-catalog-title"
>
<svg
className="cc-h-4 cc-w-4 cc-flex-shrink-0 clipper-catalog-icon"
fill="none"
strokeWidth="2"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<ArticleTitle
file={app.vault.getAbstractFileByPath(article.path) as TFile}
content={article.content || ''}
title={article.title}
/>
</span>
</td>
<td className="cc-px-4 cc-py-2 clipper-catalog-muted">
{formatDate(article.date)}
</td>
<td className="cc-px-4 cc-py-2 clipper-catalog-muted">
{article.path.split('/').slice(0, -1).join('/')}
</td>
<td className="cc-px-4 cc-py-2">
<div className="cc-flex cc-gap-1 cc-flex-wrap">
{article.tags.map((tag, i) => (
<span
key={i}
onClick={() => setSearchTerm(`#${tag}`)}
className="cc-px-2 cc-py-1 cc-text-xs cc-rounded-full cc-cursor-pointer cc-transition-colors clipper-catalog-tag"
>
#{tag}
</span>
))}
</div>
</td>
<td className="cc-px-4 cc-py-2">
<a
href={article.url}
target="_blank"
rel="noopener noreferrer"
className="cc-inline-flex cc-items-center cc-gap-0.5 cc-transition-colors clipper-catalog-link"
title={`Go to ${article.url}`}
>
<Link className="cc-h-3 cc-w-3" />
<span className="cc-text-xs">Original</span>
</a>
</td>
</tr>
))}
</tbody>
</table>
</div>
{filteredArticles.length === 0 && (
<div className="cc-text-center cc-py-4 cc-flex cc-flex-col cc-gap-2">
<div className="clipper-catalog-muted">
No articles found matching your search.
</div>
<div className="cc-text-xs cc-text-muted">
Note: This catalog shows any markdown files containing a URL in their frontmatter under the property: "{plugin.settings.sourcePropertyName}".
You can change this property name in plugin settings to match your preferred clipping workflow.
</div>
</div>
)}
<style>{`
/* Container styles */
.clipper-catalog-advanced {
background-color: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
}
.clipper-catalog-search {
background-color: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
}
/* Button styles */
.clipper-catalog-button {
background-color: var(--interactive-accent) !important;
color: var(--text-on-accent) !important;
transition: opacity 0.2s ease;
}
.clipper-catalog-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.clipper-catalog-button:hover:not(:disabled) {
opacity: 0.9;
}
/* Directory chip styles */
.cc-bg-chip {
background-color: var(--background-modifier-form-field);
}
.cc-bg-chip-hover {
background-color: var(--background-secondary-alt);
}
.cc-text-close-icon {
color: var(--background-secondary);
}
/* For dark theme support */
.theme-dark .cc-text-close-icon {
color: var(--background-primary);
}
/* Table styles */
.clipper-catalog-header-row {
border-bottom: 1px solid var(--background-modifier-border);
}
.clipper-catalog-header-cell {
color: var(--text-normal);
}
.clipper-catalog-header-cell:hover {
background-color: var(--background-modifier-hover);
}
.clipper-catalog-row {
border-bottom: 1px solid var(--background-modifier-border);
}
.clipper-catalog-row:hover {
background-color: var(--background-modifier-hover);
}
/* Text and icon styles */
.clipper-catalog-input {
color: var(--text-normal);
}
.clipper-catalog-icon,
.clipper-catalog-clear-btn,
.clipper-catalog-refresh,
.clipper-catalog-muted {
color: var(--text-muted);
}
.clipper-catalog-title {
color: var(--text-normal);
}
/* Tag styles */
.clipper-catalog-tag {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
transition: opacity 0.2s ease;
}
.clipper-catalog-tag:hover {
opacity: 0.8;
}
/* Link styles */
.clipper-catalog-link {
color: var(--text-accent);
font-size: 0.75rem;
padding: 0.25rem 0;
transition: opacity 0.2s ease;
}
.clipper-catalog-link:hover {
opacity: 0.8;
text-decoration: underline;
}
`}</style>
</div>
);
};
export default ClipperCatalog;

View File

@ -0,0 +1,52 @@
import { ItemView, WorkspaceLeaf } from 'obsidian';
import { createRoot, Root } from 'react-dom/client';
import ClipperCatalog from './ClipperCatalog';
import React from 'react';
import { ICON_NAME } from './main';
import type ObsidianClipperCatalog from './main';
export const VIEW_TYPE_CLIPPER_CATALOG = "clipper-catalog";
export class ClipperCatalogView extends ItemView {
plugin: ObsidianClipperCatalog;
root: Root | null = null;
constructor(leaf: WorkspaceLeaf, plugin: ObsidianClipperCatalog) {
super(leaf);
this.plugin = plugin;
}
getViewType() {
return VIEW_TYPE_CLIPPER_CATALOG;
}
// Specify the icon here
getIcon(): string {
return ICON_NAME || 'document';
}
getDisplayText() {
return "Clipper Catalog";
}
async onOpen() {
const container = this.containerEl.children[1];
container.empty();
const reactContainer = container.createDiv({ cls: 'clipper-catalog-container' });
this.root = createRoot(reactContainer);
this.root.render(
React.createElement(ClipperCatalog, {
app: this.app,
plugin: this.plugin
})
);
}
async onClose() {
if (this.root) {
this.root.unmount();
}
}
}

134
src/main.tsx Normal file
View File

@ -0,0 +1,134 @@
import * as React from 'react';
import { App, Plugin, WorkspaceLeaf, addIcon, PluginSettingTab, Setting } from 'obsidian';
import { createRoot, Root } from 'react-dom/client';
import { ClipperCatalogView, VIEW_TYPE_CLIPPER_CATALOG } from './ClipperCatalogView';
interface ObsidianClipperCatalogSettings {
sourcePropertyName: string;
}
// Add this before the ObsidianClipperCatalog class definition
export const ICON_NAME = 'clipper-catalog';
const DEFAULT_SETTINGS: ObsidianClipperCatalogSettings = {
sourcePropertyName: 'source'
}
export default class ObsidianClipperCatalog extends Plugin {
settings: ObsidianClipperCatalogSettings;
async onload() {
await this.loadSettings();
// Register the custom icon
addIcon(ICON_NAME, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<rect x="3" y="3" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.5"/>
<line x1="3" y1="9" x2="21" y2="9" stroke="currentColor" stroke-width="1"/>
<line x1="3" y1="15" x2="21" y2="15" stroke="currentColor" stroke-width="1"/>
<line x1="7" y1="3" x2="7" y2="21" stroke="currentColor" stroke-width="1"/>
</svg>`);
this.registerView(
VIEW_TYPE_CLIPPER_CATALOG,
(leaf: WorkspaceLeaf) => new ClipperCatalogView(leaf, this)
);
// Add ribbon icon
this.addRibbonIcon(ICON_NAME, 'Clipper Catalog', (evt: MouseEvent) => {
// Get active leaf or create new one in center
const leaf = this.app.workspace.getLeaf('tab');
if (leaf) {
leaf.setViewState({
type: VIEW_TYPE_CLIPPER_CATALOG,
active: true
});
this.app.workspace.revealLeaf(leaf);
}
});
this.addCommand({
id: 'open-clipper-catalog',
name: 'Open Clipper Catalog',
callback: () => {
// Get active leaf or create new one in center
const leaf = this.app.workspace.getLeaf('tab');
if (leaf) {
leaf.setViewState({
type: VIEW_TYPE_CLIPPER_CATALOG,
active: true,
});
this.app.workspace.revealLeaf(leaf);
}
}
});
this.addSettingTab(new ClipperCatalogSettingTab(this.app, this));
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
}
class ClipperCatalogSettingTab extends PluginSettingTab {
plugin: ObsidianClipperCatalog;
settingsHeaderEl: HTMLDivElement;
settingsHeaderContentEl: HTMLDivElement;
advancedSettingsEl: HTMLDetailsElement;
advancedContentEl: HTMLDivElement;
constructor(app: App, plugin: ObsidianClipperCatalog) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const {containerEl} = this;
containerEl.empty();
containerEl.addClass('clipper-catalog-plugin');
containerEl.addClass('clipper-catalog-settings');
// Create Header section using <div> element
this.settingsHeaderEl = containerEl.createEl('div', {
cls: 'settings-title',
});
const settingsHeader = this.settingsHeaderEl.createEl('div', {
cls: 'settings-clipper-catalog-name',
text: 'Clipper Catalog Settings'
});
// Create a container for advanced settings content
this.settingsHeaderContentEl = this.settingsHeaderEl.createEl('div', {
cls: 'advanced-settings-name'
});
// Add some styling to the heading element
settingsHeader.style.padding = '10px 0';
settingsHeader.style.fontWeight = 'bold';
new Setting(containerEl)
.setName('Property Name')
.setDesc('Specify which frontmatter property contains your clipped URLs (e.g., "source", "url", "link").')
.addText(text => text
.setValue(this.plugin.settings.sourcePropertyName)
.onChange(async (value) => {
this.plugin.settings.sourcePropertyName = value;
await this.plugin.saveSettings();
}));
// Add CSS
containerEl.createEl('style', {
text: `
.text-sm{
font-size:.75rem;
}
`
});
}
}

109
src/styles.css Normal file
View File

@ -0,0 +1,109 @@
/* Remove @tailwind base to prevent global style reset */
@tailwind components;
@tailwind utilities;
/* Define keyframes for spin animation */
@keyframes spinner-rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Scoped plugin styles */
.clipper-catalog-plugin {
/* Add minimal reset only for our plugin elements */
& * {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* Loading spinner styles */
.loading-spinner-container {
display: flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
}
.loading-spinner {
width: 1rem;
height: 1rem;
animation: spinner-rotate 1s linear infinite;
}
/* Error states */
.error-text {
font-size: 0.75rem;
color: #ef4444;
}
.input-error {
border-color: #ef4444;
}
/* Settings styles */
&.clipper-catalog-settings {
.setting-item {
border-top: none;
padding: 18px 0;
}
.setting-item-name {
color: var(--text-normal);
font-size: 1em;
font-weight: 500;
}
.setting-item-description {
color: var(--text-muted);
font-size: 0.85em;
line-height: 1.5;
max-width: 500px;
margin-right: 2em;
}
textarea,
input[type="text"],
input[type="password"],
select {
background-color: var(--background-modifier-form-field);
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
padding: 6px 12px;
color: var(--text-normal);
transition: all 200ms ease-in-out;
}
textarea:focus,
input[type="text"]:focus,
input[type="password"]:focus,
select:focus {
border-color: var(--interactive-accent);
box-shadow: 0 0 0 2px var(--background-modifier-border-focus);
}
textarea {
min-height: 100px;
width: 100%;
resize: vertical;
}
input[type="text"],
input[type="password"] {
height: 32px;
width: 100%;
max-width: 150px;
}
select {
height: 32px;
width: 100%;
max-width: 325px;
}
}
}

View File

@ -1,8 +1,558 @@
/*
/* Remove @tailwind base to prevent global style reset */
This CSS file will be included with your plugin, and
available in the app when your plugin is enabled.
.cc-absolute {
position: absolute;
}
If your plugin does not need CSS, delete this file.
.cc-relative {
position: relative;
}
*/
.cc-right-2 {
right: 0.5rem;
}
.cc-top-\[20\%\] {
top: 20%;
}
.cc-top-full {
top: 100%;
}
.cc-ml-2 {
margin-left: 0.5rem;
}
.cc-mt-1 {
margin-top: 0.25rem;
}
.cc-mt-2 {
margin-top: 0.5rem;
}
.cc-mt-4 {
margin-top: 1rem;
}
.cc-flex {
display: flex;
}
.cc-inline-flex {
display: inline-flex;
}
.cc-h-2\.5 {
height: 0.625rem;
}
.cc-h-3 {
height: 0.75rem;
}
.cc-h-3\.5 {
height: 0.875rem;
}
.cc-h-4 {
height: 1rem;
}
.cc-min-h-\[1\.5rem\] {
min-height: 1.5rem;
}
.cc-w-2\.5 {
width: 0.625rem;
}
.cc-w-3 {
width: 0.75rem;
}
.cc-w-3\.5 {
width: 0.875rem;
}
.cc-w-4 {
width: 1rem;
}
.cc-w-\[13\%\] {
width: 13%;
}
.cc-w-\[15\%\] {
width: 15%;
}
.cc-w-\[20\%\] {
width: 20%;
}
.cc-w-\[22\%\] {
width: 22%;
}
.cc-w-\[30\%\] {
width: 30%;
}
.cc-w-full {
width: 100%;
}
.cc-flex-1 {
flex: 1 1 0%;
}
.cc-flex-shrink-0 {
flex-shrink: 0;
}
@keyframes cc-spin {
to {
transform: rotate(360deg);
}
}
.cc-animate-spin {
animation: cc-spin 1s linear infinite;
}
.cc-cursor-pointer {
cursor: pointer;
}
.cc-flex-col {
flex-direction: column;
}
.cc-flex-wrap {
flex-wrap: wrap;
}
.cc-items-center {
align-items: center;
}
.cc-justify-end {
justify-content: flex-end;
}
.cc-justify-center {
justify-content: center;
}
.cc-justify-between {
justify-content: space-between;
}
.cc-gap-0\.5 {
gap: 0.125rem;
}
.cc-gap-1 {
gap: 0.25rem;
}
.cc-gap-1\.5 {
gap: 0.375rem;
}
.cc-gap-2 {
gap: 0.5rem;
}
.cc-gap-3 {
gap: 0.75rem;
}
.cc-gap-4 {
gap: 1rem;
}
.cc-overflow-x-auto {
overflow-x: auto;
}
.cc-whitespace-nowrap {
white-space: nowrap;
}
.cc-rounded {
border-radius: 0.25rem;
}
.cc-rounded-full {
border-radius: 9999px;
}
.cc-rounded-lg {
border-radius: 0.5rem;
}
.cc-border-gray-200 {
--tw-border-opacity: 1;
border-color: rgb(229 231 235 / var(--tw-border-opacity, 1));
}
.cc-bg-blue-100 {
--tw-bg-opacity: 1;
background-color: rgb(219 234 254 / var(--tw-bg-opacity, 1));
}
.cc-bg-gray-100 {
--tw-bg-opacity: 1;
background-color: rgb(243 244 246 / var(--tw-bg-opacity, 1));
}
.cc-bg-transparent {
background-color: transparent;
}
.cc-p-4 {
padding: 1rem;
}
.cc-px-2 {
padding-left: 0.5rem;
padding-right: 0.5rem;
}
.cc-px-3 {
padding-left: 0.75rem;
padding-right: 0.75rem;
}
.cc-px-4 {
padding-left: 1rem;
padding-right: 1rem;
}
.cc-py-1 {
padding-top: 0.25rem;
padding-bottom: 0.25rem;
}
.cc-py-1\.5 {
padding-top: 0.375rem;
padding-bottom: 0.375rem;
}
.cc-py-2 {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.cc-py-4 {
padding-top: 1rem;
padding-bottom: 1rem;
}
.cc-pr-16 {
padding-right: 4rem;
}
.cc-text-left {
text-align: left;
}
.cc-text-center {
text-align: center;
}
.cc-text-right {
text-align: right;
}
.cc-text-\[10px\] {
font-size: 10px;
}
.cc-text-sm {
font-size: 0.875rem;
line-height: 1.25rem;
}
.cc-text-xs {
font-size: 0.75rem;
line-height: 1rem;
}
.cc-font-medium {
font-weight: 500;
}
.cc-text-blue-600 {
--tw-text-opacity: 1;
color: rgb(37 99 235 / var(--tw-text-opacity, 1));
}
.cc-text-blue-700 {
--tw-text-opacity: 1;
color: rgb(29 78 216 / var(--tw-text-opacity, 1));
}
.cc-text-gray-600 {
--tw-text-opacity: 1;
color: rgb(75 85 99 / var(--tw-text-opacity, 1));
}
.cc-text-gray-800 {
--tw-text-opacity: 1;
color: rgb(31 41 55 / var(--tw-text-opacity, 1));
}
.cc-text-gray-900 {
--tw-text-opacity: 1;
color: rgb(17 24 39 / var(--tw-text-opacity, 1));
}
.cc-text-red-400 {
--tw-text-opacity: 1;
color: rgb(248 113 113 / var(--tw-text-opacity, 1));
}
.cc-underline {
text-decoration-line: underline;
}
.cc-opacity-0 {
opacity: 0;
}
.cc-opacity-100 {
opacity: 1;
}
.cc-opacity-50 {
opacity: 0.5;
}
.cc-opacity-60 {
opacity: 0.6;
}
.cc-outline-none {
outline: 2px solid transparent;
outline-offset: 2px;
}
.cc-transition-all {
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.cc-transition-colors {
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.cc-transition-opacity {
transition-property: opacity;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.cc-duration-200 {
transition-duration: 200ms;
}
.cc-duration-300 {
transition-duration: 300ms;
}
.cc-ease-in-out {
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
/* Define keyframes for spin animation */
@keyframes spinner-rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Scoped plugin styles */
.clipper-catalog-plugin {
/* Add minimal reset only for our plugin elements */
& * {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* Loading spinner styles */
.loading-spinner-container {
display: flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
}
.loading-spinner {
width: 1rem;
height: 1rem;
animation: spinner-rotate 1s linear infinite;
}
/* Error states */
.error-text {
font-size: 0.75rem;
color: #ef4444;
}
.input-error {
border-color: #ef4444;
}
/* Settings styles */
&.clipper-catalog-settings {
.setting-item {
border-top: none;
padding: 18px 0;
}
.setting-item-name {
color: var(--text-normal);
font-size: 1em;
font-weight: 500;
}
.setting-item-description {
color: var(--text-muted);
font-size: 0.85em;
line-height: 1.5;
max-width: 500px;
margin-right: 2em;
}
textarea,
input[type="text"],
input[type="password"],
select {
background-color: var(--background-modifier-form-field);
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
padding: 6px 12px;
color: var(--text-normal);
transition: all 200ms ease-in-out;
}
textarea:focus,
input[type="text"]:focus,
input[type="password"]:focus,
select:focus {
border-color: var(--interactive-accent);
box-shadow: 0 0 0 2px var(--background-modifier-border-focus);
}
textarea {
min-height: 100px;
width: 100%;
resize: vertical;
}
input[type="text"],
input[type="password"] {
height: 32px;
width: 100%;
max-width: 150px;
}
select {
height: 32px;
width: 100%;
max-width: 325px;
}
}
}
.hover\:cc-bg-blue-200:hover {
--tw-bg-opacity: 1;
background-color: rgb(191 219 254 / var(--tw-bg-opacity, 1));
}
.hover\:cc-bg-gray-100:hover {
--tw-bg-opacity: 1;
background-color: rgb(243 244 246 / var(--tw-bg-opacity, 1));
}
.hover\:cc-text-gray-600:hover {
--tw-text-opacity: 1;
color: rgb(75 85 99 / var(--tw-text-opacity, 1));
}
.hover\:cc-text-gray-800:hover {
--tw-text-opacity: 1;
color: rgb(31 41 55 / var(--tw-text-opacity, 1));
}
.hover\:cc-underline:hover {
text-decoration-line: underline;
}
.hover\:cc-opacity-90:hover {
opacity: 0.9;
}
.dark\:cc-border-gray-700:is(.cc-dark *) {
--tw-border-opacity: 1;
border-color: rgb(55 65 81 / var(--tw-border-opacity, 1));
}
.dark\:cc-bg-blue-900\/50:is(.cc-dark *) {
background-color: rgb(30 58 138 / 0.5);
}
.dark\:cc-bg-gray-800\/50:is(.cc-dark *) {
background-color: rgb(31 41 55 / 0.5);
}
.dark\:cc-text-blue-200:is(.cc-dark *) {
--tw-text-opacity: 1;
color: rgb(191 219 254 / var(--tw-text-opacity, 1));
}
.dark\:cc-text-blue-400:is(.cc-dark *) {
--tw-text-opacity: 1;
color: rgb(96 165 250 / var(--tw-text-opacity, 1));
}
.dark\:cc-text-gray-300:is(.cc-dark *) {
--tw-text-opacity: 1;
color: rgb(209 213 219 / var(--tw-text-opacity, 1));
}
.dark\:cc-text-gray-400:is(.cc-dark *) {
--tw-text-opacity: 1;
color: rgb(156 163 175 / var(--tw-text-opacity, 1));
}
.dark\:cc-text-gray-500:is(.cc-dark *) {
--tw-text-opacity: 1;
color: rgb(107 114 128 / var(--tw-text-opacity, 1));
}
.dark\:cc-text-white:is(.cc-dark *) {
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity, 1));
}
.dark\:hover\:cc-bg-blue-800\/50:hover:is(.cc-dark *) {
background-color: rgb(30 64 175 / 0.5);
}
.dark\:hover\:cc-bg-gray-800\/50:hover:is(.cc-dark *) {
background-color: rgb(31 41 55 / 0.5);
}
.dark\:hover\:cc-text-blue-300:hover:is(.cc-dark *) {
--tw-text-opacity: 1;
color: rgb(147 197 253 / var(--tw-text-opacity, 1));
}
.dark\:hover\:cc-text-gray-300:hover:is(.cc-dark *) {
--tw-text-opacity: 1;
color: rgb(209 213 219 / var(--tw-text-opacity, 1));
}

89
tailwind.config.js Normal file
View File

@ -0,0 +1,89 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./src/**/*.{js,jsx,ts,tsx}"],
darkMode: 'class',
theme: {
extend: {
keyframes: {
'cc-spin': {
'from': { transform: 'rotate(0deg)' },
'to': { transform: 'rotate(360deg)' }
}
},
animation: {
'cc-spin': 'cc-spin 1s linear infinite'
}
},
},
plugins: [],
prefix: 'cc-',
corePlugins: {
preflight: false,
},
safelist: [
// Basic utility classes
'cc-opacity-0',
'cc-opacity-50',
'cc-opacity-100',
'cc-transition-opacity',
'cc-duration-200',
'cc-duration-300',
'cc-ease-in-out',
'cc-animate-spin',
'cc-w-4',
'cc-h-4',
'cc-gap-2',
'cc-gap-3',
'cc-items-center',
'cc-justify-end',
'cc-flex',
'cc-flex-col',
'cc-mt-4',
'cc-text-xs',
'cc-text-sm',
'cc-font-medium',
'cc-px-4',
'cc-py-2',
'cc-rounded-lg',
// Dark mode classes
'dark:cc-bg-gray-800/50',
'dark:cc-text-white',
'dark:cc-text-gray-300',
'dark:cc-text-gray-400',
'dark:cc-text-gray-500',
'dark:hover:cc-bg-gray-800/50',
'dark:hover:cc-text-gray-300',
'dark:cc-border-gray-700',
// Background colors
'cc-bg-gray-100',
'cc-bg-blue-100',
'dark:cc-bg-blue-900/50',
// Text colors
'cc-text-gray-800',
'cc-text-gray-900',
'cc-text-gray-600',
'cc-text-blue-600',
'cc-text-blue-700',
'dark:cc-text-blue-200',
'dark:cc-text-blue-400',
// Hover states
'hover:cc-bg-gray-100',
'hover:cc-text-gray-600',
'hover:cc-text-gray-800',
'hover:cc-bg-blue-200',
'dark:hover:cc-bg-blue-800/50',
'dark:hover:cc-text-blue-300',
// Border colors
'cc-border-gray-200',
// Component specific classes
'loading-spinner',
'loading-spinner-container',
'cc-animate-spin'
]
};

View File

@ -1,6 +1,6 @@
{
"compilerOptions": {
"baseUrl": ".",
"baseUrl": "src",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
@ -16,9 +16,13 @@
"ES5",
"ES6",
"ES7"
]
],
"jsx": "react-jsx",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true
},
"include": [
"**/*.ts"
"**/*.ts",
"**/*.tsx"
]
}