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:
parent
ee04e2f81f
commit
cfe5711e1c
171
README.md
171
README.md
|
@ -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/"
|
||||
}
|
||||
}
|
||||
```
|
||||
[](https://github.com/soundslikeinfo/obsidian-clipper-catalog)
|
||||
[](https://www.buymeacoffee.com/soundslikeinfo)
|
||||
|
||||
## API Documentation
|
||||
|
||||
See https://github.com/obsidianmd/obsidian-api
|
||||
Made with ❤️ for the Obsidian Community
|
||||
|
|
|
@ -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,39 +13,64 @@ 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"],
|
||||
bundle: true,
|
||||
external: [
|
||||
"obsidian",
|
||||
"electron",
|
||||
"@codemirror/autocomplete",
|
||||
"@codemirror/collab",
|
||||
"@codemirror/commands",
|
||||
"@codemirror/language",
|
||||
"@codemirror/lint",
|
||||
"@codemirror/search",
|
||||
"@codemirror/state",
|
||||
"@codemirror/view",
|
||||
"@lezer/common",
|
||||
"@lezer/highlight",
|
||||
"@lezer/lr",
|
||||
...builtins],
|
||||
format: "cjs",
|
||||
target: "es2018",
|
||||
logLevel: "info",
|
||||
sourcemap: prod ? false : "inline",
|
||||
treeShaking: true,
|
||||
outfile: "main.js",
|
||||
minify: prod,
|
||||
banner: {
|
||||
js: banner,
|
||||
},
|
||||
entryPoints: ["src/main.tsx"],
|
||||
bundle: true,
|
||||
external: [
|
||||
"obsidian",
|
||||
"electron",
|
||||
"@codemirror/autocomplete",
|
||||
"@codemirror/collab",
|
||||
"@codemirror/commands",
|
||||
"@codemirror/language",
|
||||
"@codemirror/lint",
|
||||
"@codemirror/search",
|
||||
"@codemirror/state",
|
||||
"@codemirror/view",
|
||||
"@lezer/common",
|
||||
"@lezer/highlight",
|
||||
"@lezer/lr",
|
||||
...builtinModules,
|
||||
],
|
||||
format: "cjs",
|
||||
target: "es2018",
|
||||
logLevel: "info",
|
||||
sourcemap: prod ? false : "inline",
|
||||
treeShaking: true,
|
||||
outfile: "main.js",
|
||||
jsx: "automatic",
|
||||
});
|
||||
|
||||
if (prod) {
|
||||
await context.rebuild();
|
||||
process.exit(0);
|
||||
await context.rebuild();
|
||||
context.dispose();
|
||||
} else {
|
||||
await context.watch();
|
||||
await context.watch();
|
||||
}
|
134
main.ts
134
main.ts
|
@ -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();
|
||||
}));
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
29
package.json
29
package.json
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
|
@ -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;
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
`
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
560
styles.css
560
styles.css
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
]
|
||||
};
|
|
@ -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"
|
||||
]
|
||||
}
|
Loading…
Reference in New Issue