seedorm 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -44
- package/dist/bin/seedorm.cjs +84 -55
- package/dist/bin/static/app.js +50 -2
- package/dist/bin/static/index.html +9 -1
- package/dist/bin/static/style.css +35 -0
- package/dist/index.cjs +63 -47
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +63 -47
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# seedorm
|
|
2
2
|
|
|
3
|
-
Development-first ORM that lets you start with a JSON file and migrate to any SQL database by changing one line of config. No rewrites.
|
|
3
|
+
Development-first ORM that lets you start with a JSON file and migrate to any SQL database by changing one line of config. No rewrites. No drama.
|
|
4
4
|
|
|
5
5
|
## Why
|
|
6
6
|
|
|
7
|
-
Every project starts the same way: you need to store data, but you don't want to set up a database just to prototype. SeedORM lets you start building immediately with a local JSON file, then switch to a real database when you're ready
|
|
7
|
+
Every project starts the same way: you need to store data, but you don't want to set up a database just to prototype. SeedORM lets you start building immediately with a local JSON file, then switch to a real database when you're ready. Your application code doesn't change. Not one line.
|
|
8
8
|
|
|
9
9
|
## Quick start
|
|
10
10
|
|
|
@@ -15,37 +15,41 @@ npm install seedorm
|
|
|
15
15
|
```typescript
|
|
16
16
|
import { SeedORM, FieldType } from "seedorm";
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
},
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
18
|
+
async function main() {
|
|
19
|
+
const db = new SeedORM();
|
|
20
|
+
await db.connect();
|
|
21
|
+
|
|
22
|
+
const User = db.model({
|
|
23
|
+
name: "User",
|
|
24
|
+
collection: "users",
|
|
25
|
+
schema: {
|
|
26
|
+
name: { type: FieldType.String, required: true },
|
|
27
|
+
email: { type: FieldType.String, unique: true },
|
|
28
|
+
role: { type: FieldType.String, enum: ["admin", "user"], default: "user" },
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
await User.init();
|
|
32
|
+
|
|
33
|
+
// Create
|
|
34
|
+
const alice = await User.create({ name: "Alice", email: "alice@example.com" });
|
|
35
|
+
|
|
36
|
+
// Query with MongoDB-style operators
|
|
37
|
+
const admins = await User.find({
|
|
38
|
+
filter: { role: { $eq: "admin" } },
|
|
39
|
+
sort: { name: 1 },
|
|
40
|
+
limit: 10,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Update
|
|
44
|
+
await User.update(alice.id, { role: "admin" });
|
|
45
|
+
|
|
46
|
+
// Delete
|
|
47
|
+
await User.delete(alice.id);
|
|
48
|
+
|
|
49
|
+
await db.disconnect();
|
|
50
|
+
}
|
|
47
51
|
|
|
48
|
-
|
|
52
|
+
main();
|
|
49
53
|
```
|
|
50
54
|
|
|
51
55
|
## Relations
|
|
@@ -87,8 +91,8 @@ const user = await User.findById("usr_abc123", {
|
|
|
87
91
|
});
|
|
88
92
|
|
|
89
93
|
// Manage many-to-many links
|
|
90
|
-
await User.associate("
|
|
91
|
-
await User.dissociate("
|
|
94
|
+
await User.associate("usr_abc123", "roles", "rol_editor");
|
|
95
|
+
await User.dissociate("usr_abc123", "roles", "rol_editor");
|
|
92
96
|
```
|
|
93
97
|
|
|
94
98
|
**Relation types:** `hasOne`, `hasMany`, `belongsTo`, `manyToMany`
|
|
@@ -123,14 +127,14 @@ SeedORM exports string enums for type-safe definitions. Plain strings also work
|
|
|
123
127
|
|
|
124
128
|
## Features
|
|
125
129
|
|
|
126
|
-
- **Zero-config start
|
|
127
|
-
- **Schema validation
|
|
128
|
-
- **Relations
|
|
129
|
-
- **Query operators
|
|
130
|
-
- **CLI tools
|
|
131
|
-
- **Migration engine
|
|
132
|
-
- **Pluggable adapters
|
|
133
|
-
- **TypeScript
|
|
130
|
+
- **Zero-config start**: data lives in a JSON file, no database setup needed
|
|
131
|
+
- **Schema validation**: type checking, required fields, unique constraints, min/max, enums
|
|
132
|
+
- **Relations**: `hasOne`, `hasMany`, `belongsTo`, `manyToMany` with eager loading via `include`
|
|
133
|
+
- **Query operators**: `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$in`, `$nin`, `$like`, `$exists`
|
|
134
|
+
- **CLI tools**: `seedorm init`, `seedorm start` (REST server), `seedorm studio` (visual UI)
|
|
135
|
+
- **Migration engine**: `migrate create`, `migrate up`, `migrate to` (SQL export)
|
|
136
|
+
- **Pluggable adapters**: PostgreSQL built-in, MySQL and SQLite coming soon. Drivers are lazy-loaded and optional.
|
|
137
|
+
- **TypeScript**: written in TypeScript with full type exports, dual CJS/ESM output
|
|
134
138
|
|
|
135
139
|
## CLI
|
|
136
140
|
|
|
@@ -165,7 +169,7 @@ When running `seedorm start`, the following endpoints are available:
|
|
|
165
169
|
| `GET` | `/api/:collection` | List documents (supports `?filter=`, `?sort=`, `?limit=`, `?offset=`) |
|
|
166
170
|
| `GET` | `/api/:collection/:id` | Get document by ID |
|
|
167
171
|
| `POST` | `/api/:collection` | Create document |
|
|
168
|
-
| `PATCH` | `/api/:collection/:id` | Update document |
|
|
172
|
+
| `PUT/PATCH` | `/api/:collection/:id` | Update document |
|
|
169
173
|
| `DELETE` | `/api/:collection/:id` | Delete document |
|
|
170
174
|
|
|
171
175
|
## Requirements
|
|
@@ -174,6 +178,14 @@ When running `seedorm start`, the following endpoints are available:
|
|
|
174
178
|
- `pg` (optional, only needed for PostgreSQL adapter)
|
|
175
179
|
- `mysql2` (optional, only needed for MySQL adapter)
|
|
176
180
|
|
|
181
|
+
## Links
|
|
182
|
+
|
|
183
|
+
- [Documentation](https://seedorm.io/docs)
|
|
184
|
+
- [Getting Started](https://seedorm.io/docs/getting-started)
|
|
185
|
+
- [API Reference](https://seedorm.io/docs/api)
|
|
186
|
+
- [CLI Reference](https://seedorm.io/docs/cli)
|
|
187
|
+
- [Tutorial](https://seedorm.io/docs/tutorial)
|
|
188
|
+
|
|
177
189
|
## License
|
|
178
190
|
|
|
179
|
-
Apache 2.0
|
|
191
|
+
Apache 2.0
|
package/dist/bin/seedorm.cjs
CHANGED
|
@@ -4369,6 +4369,11 @@ var Model = class {
|
|
|
4369
4369
|
}
|
|
4370
4370
|
async init() {
|
|
4371
4371
|
await this.adapter.createCollection(this.collection, this.schema);
|
|
4372
|
+
for (const rel of Object.values(this.relations)) {
|
|
4373
|
+
if (rel.type === "manyToMany" /* ManyToMany */ && rel.joinCollection) {
|
|
4374
|
+
await this.adapter.createCollection(rel.joinCollection, {});
|
|
4375
|
+
}
|
|
4376
|
+
}
|
|
4372
4377
|
}
|
|
4373
4378
|
generateId() {
|
|
4374
4379
|
return `${this.prefix}_${nanoid(12)}`;
|
|
@@ -4645,68 +4650,82 @@ var fs3 = __toESM(require("fs"), 1);
|
|
|
4645
4650
|
var path3 = __toESM(require("path"), 1);
|
|
4646
4651
|
var import_write_file_atomic = __toESM(require_lib(), 1);
|
|
4647
4652
|
var FileEngine = class {
|
|
4648
|
-
|
|
4649
|
-
data =
|
|
4653
|
+
dirPath;
|
|
4654
|
+
data = /* @__PURE__ */ new Map();
|
|
4655
|
+
dirty = /* @__PURE__ */ new Set();
|
|
4650
4656
|
writeQueue = Promise.resolve();
|
|
4651
|
-
|
|
4652
|
-
|
|
4653
|
-
this.filePath = path3.resolve(filePath);
|
|
4657
|
+
constructor(dirPath) {
|
|
4658
|
+
this.dirPath = path3.resolve(dirPath);
|
|
4654
4659
|
}
|
|
4655
4660
|
async load() {
|
|
4656
|
-
|
|
4657
|
-
|
|
4658
|
-
|
|
4659
|
-
|
|
4660
|
-
|
|
4661
|
-
|
|
4662
|
-
|
|
4663
|
-
|
|
4664
|
-
|
|
4661
|
+
if (!fs3.existsSync(this.dirPath)) {
|
|
4662
|
+
fs3.mkdirSync(this.dirPath, { recursive: true });
|
|
4663
|
+
}
|
|
4664
|
+
const legacyPath = path3.join(this.dirPath, "seedorm.json");
|
|
4665
|
+
if (fs3.existsSync(legacyPath)) {
|
|
4666
|
+
const raw = fs3.readFileSync(legacyPath, "utf-8");
|
|
4667
|
+
const legacy = JSON.parse(raw);
|
|
4668
|
+
for (const [collection, docs] of Object.entries(legacy)) {
|
|
4669
|
+
this.data.set(collection, docs);
|
|
4670
|
+
this.dirty.add(collection);
|
|
4665
4671
|
}
|
|
4672
|
+
await this.flush();
|
|
4673
|
+
fs3.unlinkSync(legacyPath);
|
|
4674
|
+
return;
|
|
4675
|
+
}
|
|
4676
|
+
const files = fs3.readdirSync(this.dirPath).filter((f) => f.endsWith(".json"));
|
|
4677
|
+
for (const file of files) {
|
|
4678
|
+
const collection = file.slice(0, -5);
|
|
4679
|
+
const raw = fs3.readFileSync(path3.join(this.dirPath, file), "utf-8");
|
|
4680
|
+
this.data.set(collection, JSON.parse(raw));
|
|
4666
4681
|
}
|
|
4667
|
-
}
|
|
4668
|
-
getData() {
|
|
4669
|
-
return this.data;
|
|
4670
4682
|
}
|
|
4671
4683
|
getCollection(name) {
|
|
4672
|
-
if (!this.data
|
|
4673
|
-
this.data
|
|
4684
|
+
if (!this.data.has(name)) {
|
|
4685
|
+
this.data.set(name, []);
|
|
4674
4686
|
}
|
|
4675
|
-
return this.data
|
|
4687
|
+
return this.data.get(name);
|
|
4676
4688
|
}
|
|
4677
4689
|
hasCollection(name) {
|
|
4678
|
-
return
|
|
4690
|
+
return this.data.has(name);
|
|
4679
4691
|
}
|
|
4680
4692
|
createCollection(name) {
|
|
4681
|
-
if (!this.data
|
|
4682
|
-
this.data
|
|
4693
|
+
if (!this.data.has(name)) {
|
|
4694
|
+
this.data.set(name, []);
|
|
4695
|
+
this.dirty.add(name);
|
|
4683
4696
|
}
|
|
4684
4697
|
}
|
|
4685
4698
|
dropCollection(name) {
|
|
4686
|
-
|
|
4699
|
+
this.data.delete(name);
|
|
4700
|
+
this.dirty.delete(name);
|
|
4701
|
+
const filePath = path3.join(this.dirPath, `${name}.json`);
|
|
4702
|
+
if (fs3.existsSync(filePath)) {
|
|
4703
|
+
fs3.unlinkSync(filePath);
|
|
4704
|
+
}
|
|
4687
4705
|
}
|
|
4688
4706
|
listCollections() {
|
|
4689
|
-
return
|
|
4707
|
+
return Array.from(this.data.keys());
|
|
4690
4708
|
}
|
|
4691
|
-
markDirty() {
|
|
4692
|
-
this.dirty
|
|
4709
|
+
markDirty(collection) {
|
|
4710
|
+
this.dirty.add(collection);
|
|
4693
4711
|
}
|
|
4694
4712
|
async flush() {
|
|
4713
|
+
const toWrite = new Set(this.dirty);
|
|
4714
|
+
this.dirty.clear();
|
|
4695
4715
|
this.writeQueue = this.writeQueue.then(async () => {
|
|
4696
|
-
const
|
|
4697
|
-
|
|
4698
|
-
|
|
4716
|
+
for (const collection of toWrite) {
|
|
4717
|
+
const docs = this.data.get(collection);
|
|
4718
|
+
if (docs === void 0) continue;
|
|
4719
|
+
await (0, import_write_file_atomic.default)(
|
|
4720
|
+
path3.join(this.dirPath, `${collection}.json`),
|
|
4721
|
+
JSON.stringify(docs)
|
|
4722
|
+
);
|
|
4699
4723
|
}
|
|
4700
|
-
await (0, import_write_file_atomic.default)(
|
|
4701
|
-
this.filePath,
|
|
4702
|
-
JSON.stringify(this.data, null, 2) + "\n"
|
|
4703
|
-
);
|
|
4704
|
-
this.dirty = false;
|
|
4705
4724
|
});
|
|
4706
4725
|
return this.writeQueue;
|
|
4707
4726
|
}
|
|
4708
4727
|
async flushIfDirty() {
|
|
4709
|
-
if (this.dirty) {
|
|
4728
|
+
if (this.dirty.size > 0) {
|
|
4710
4729
|
await this.flush();
|
|
4711
4730
|
}
|
|
4712
4731
|
}
|
|
@@ -4792,8 +4811,8 @@ var JsonAdapter = class {
|
|
|
4792
4811
|
engine;
|
|
4793
4812
|
indexer = new Indexer();
|
|
4794
4813
|
schemas = /* @__PURE__ */ new Map();
|
|
4795
|
-
constructor(
|
|
4796
|
-
this.engine = new FileEngine(
|
|
4814
|
+
constructor(dirPath) {
|
|
4815
|
+
this.engine = new FileEngine(dirPath);
|
|
4797
4816
|
}
|
|
4798
4817
|
async connect() {
|
|
4799
4818
|
await this.engine.load();
|
|
@@ -4829,7 +4848,7 @@ var JsonAdapter = class {
|
|
|
4829
4848
|
const docs = this.getCollectionOrThrow(collection);
|
|
4830
4849
|
this.indexer.onInsert(collection, doc);
|
|
4831
4850
|
docs.push(doc);
|
|
4832
|
-
this.engine.markDirty();
|
|
4851
|
+
this.engine.markDirty(collection);
|
|
4833
4852
|
await this.engine.flush();
|
|
4834
4853
|
return doc;
|
|
4835
4854
|
}
|
|
@@ -4853,7 +4872,7 @@ var JsonAdapter = class {
|
|
|
4853
4872
|
const newDoc = { ...oldDoc, ...data, id: oldDoc.id };
|
|
4854
4873
|
this.indexer.onUpdate(collection, oldDoc, newDoc);
|
|
4855
4874
|
docs[index] = newDoc;
|
|
4856
|
-
this.engine.markDirty();
|
|
4875
|
+
this.engine.markDirty(collection);
|
|
4857
4876
|
await this.engine.flush();
|
|
4858
4877
|
return newDoc;
|
|
4859
4878
|
}
|
|
@@ -4863,7 +4882,7 @@ var JsonAdapter = class {
|
|
|
4863
4882
|
if (index === -1) return false;
|
|
4864
4883
|
this.indexer.onDelete(collection, docs[index]);
|
|
4865
4884
|
docs.splice(index, 1);
|
|
4866
|
-
this.engine.markDirty();
|
|
4885
|
+
this.engine.markDirty(collection);
|
|
4867
4886
|
await this.engine.flush();
|
|
4868
4887
|
return true;
|
|
4869
4888
|
}
|
|
@@ -4879,7 +4898,7 @@ var JsonAdapter = class {
|
|
|
4879
4898
|
docs.length = 0;
|
|
4880
4899
|
docs.push(...remaining);
|
|
4881
4900
|
if (deleted > 0) {
|
|
4882
|
-
this.engine.markDirty();
|
|
4901
|
+
this.engine.markDirty(collection);
|
|
4883
4902
|
await this.engine.flush();
|
|
4884
4903
|
}
|
|
4885
4904
|
return deleted;
|
|
@@ -4908,11 +4927,8 @@ var SeedORM = class {
|
|
|
4908
4927
|
async createAdapter(adapterConfig) {
|
|
4909
4928
|
switch (adapterConfig.adapter) {
|
|
4910
4929
|
case "json" /* Json */: {
|
|
4911
|
-
const
|
|
4912
|
-
|
|
4913
|
-
"seedorm.json"
|
|
4914
|
-
);
|
|
4915
|
-
return new JsonAdapter(dbPath);
|
|
4930
|
+
const dirPath = path4.resolve(adapterConfig.path ?? "./data");
|
|
4931
|
+
return new JsonAdapter(dirPath);
|
|
4916
4932
|
}
|
|
4917
4933
|
case "postgres" /* Postgres */: {
|
|
4918
4934
|
const { PostgresAdapter: PostgresAdapter2 } = await Promise.resolve().then(() => (init_postgres_adapter(), postgres_adapter_exports));
|
|
@@ -5489,13 +5505,26 @@ function createApiHandler(db) {
|
|
|
5489
5505
|
const limitParam = url.searchParams.get("limit");
|
|
5490
5506
|
const offsetParam = url.searchParams.get("offset");
|
|
5491
5507
|
const sortParam = url.searchParams.get("sort");
|
|
5492
|
-
const
|
|
5493
|
-
|
|
5494
|
-
if (
|
|
5495
|
-
if (
|
|
5496
|
-
|
|
5497
|
-
|
|
5498
|
-
|
|
5508
|
+
const searchParam = url.searchParams.get("search");
|
|
5509
|
+
const findOpts = {};
|
|
5510
|
+
if (filterParam) findOpts.filter = JSON.parse(filterParam);
|
|
5511
|
+
if (sortParam) findOpts.sort = JSON.parse(sortParam);
|
|
5512
|
+
let allDocs = await adapter.find(collection, findOpts);
|
|
5513
|
+
if (searchParam) {
|
|
5514
|
+
const q = searchParam.toLowerCase();
|
|
5515
|
+
allDocs = allDocs.filter(
|
|
5516
|
+
(doc) => Object.values(doc).some((v) => {
|
|
5517
|
+
if (v == null) return false;
|
|
5518
|
+
const s = typeof v === "string" ? v : JSON.stringify(v);
|
|
5519
|
+
return s.toLowerCase().includes(q);
|
|
5520
|
+
})
|
|
5521
|
+
);
|
|
5522
|
+
}
|
|
5523
|
+
const total = allDocs.length;
|
|
5524
|
+
const offset = offsetParam ? parseInt(offsetParam, 10) : 0;
|
|
5525
|
+
const limit = limitParam ? parseInt(limitParam, 10) : void 0;
|
|
5526
|
+
let docs = allDocs.slice(offset);
|
|
5527
|
+
if (limit !== void 0) docs = docs.slice(0, limit);
|
|
5499
5528
|
return json2(res, 200, { data: docs, total });
|
|
5500
5529
|
}
|
|
5501
5530
|
case "POST": {
|
|
@@ -5650,7 +5679,7 @@ async function studioCommand(options) {
|
|
|
5650
5679
|
// src/cli/index.ts
|
|
5651
5680
|
function createCLI() {
|
|
5652
5681
|
const program3 = new Command();
|
|
5653
|
-
program3.name("seedorm").description("Development-first ORM \u2014 start with JSON, migrate to PostgreSQL/MySQL").version("0.1
|
|
5682
|
+
program3.name("seedorm").description("Development-first ORM \u2014 start with JSON, migrate to PostgreSQL/MySQL").version("0.2.1");
|
|
5654
5683
|
program3.command("init").description("Initialize a new seedorm project").option("-f, --force", "Overwrite existing config").action(initCommand);
|
|
5655
5684
|
program3.command("start").description("Start the development REST API server").option("-p, --port <port>", "Port to listen on", "4100").action(startCommand);
|
|
5656
5685
|
const migrate = program3.command("migrate").description("Migration commands");
|
package/dist/bin/static/app.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
const API = '/api';
|
|
2
|
+
const PAGE_SIZE = 50;
|
|
2
3
|
let currentCollection = null;
|
|
3
4
|
let currentDoc = null;
|
|
5
|
+
let currentPage = 0;
|
|
6
|
+
let totalDocs = 0;
|
|
7
|
+
let searchQuery = '';
|
|
8
|
+
let searchTimer = null;
|
|
4
9
|
|
|
5
10
|
async function api(path, opts = {}) {
|
|
6
11
|
const res = await fetch(`${API}${path}`, {
|
|
@@ -25,6 +30,9 @@ async function loadCollections() {
|
|
|
25
30
|
|
|
26
31
|
async function selectCollection(name) {
|
|
27
32
|
currentCollection = name;
|
|
33
|
+
currentPage = 0;
|
|
34
|
+
searchQuery = '';
|
|
35
|
+
document.getElementById('search-input').value = '';
|
|
28
36
|
document.getElementById('empty-state').classList.add('hidden');
|
|
29
37
|
document.getElementById('collection-view').classList.remove('hidden');
|
|
30
38
|
document.getElementById('collection-name').textContent = name;
|
|
@@ -34,15 +42,21 @@ async function selectCollection(name) {
|
|
|
34
42
|
|
|
35
43
|
async function loadDocuments() {
|
|
36
44
|
if (!currentCollection) return;
|
|
37
|
-
const
|
|
45
|
+
const offset = currentPage * PAGE_SIZE;
|
|
46
|
+
let url = `/data/${currentCollection}?limit=${PAGE_SIZE}&offset=${offset}`;
|
|
47
|
+
if (searchQuery) url += `&search=${encodeURIComponent(searchQuery)}`;
|
|
48
|
+
|
|
49
|
+
const { data, total } = await api(url);
|
|
50
|
+
totalDocs = total;
|
|
38
51
|
document.getElementById('doc-count').textContent = `${total} documents`;
|
|
39
52
|
|
|
40
53
|
const thead = document.getElementById('table-head');
|
|
41
54
|
const tbody = document.getElementById('table-body');
|
|
42
55
|
|
|
43
56
|
if (!data || data.length === 0) {
|
|
44
|
-
thead.innerHTML = '
|
|
57
|
+
thead.innerHTML = `<th>${searchQuery ? 'No matching documents' : 'No documents'}</th>`;
|
|
45
58
|
tbody.innerHTML = '';
|
|
59
|
+
updatePagination();
|
|
46
60
|
return;
|
|
47
61
|
}
|
|
48
62
|
|
|
@@ -63,6 +77,23 @@ async function loadDocuments() {
|
|
|
63
77
|
tbody.querySelectorAll('tr').forEach(tr => {
|
|
64
78
|
tr.onclick = () => editDocument(data.find(d => d.id === tr.dataset.id));
|
|
65
79
|
});
|
|
80
|
+
|
|
81
|
+
updatePagination();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function updatePagination() {
|
|
85
|
+
const totalPages = Math.max(1, Math.ceil(totalDocs / PAGE_SIZE));
|
|
86
|
+
const pag = document.getElementById('pagination');
|
|
87
|
+
|
|
88
|
+
if (totalPages <= 1) {
|
|
89
|
+
pag.classList.add('hidden');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
pag.classList.remove('hidden');
|
|
94
|
+
document.getElementById('page-info').textContent = `Page ${currentPage + 1} of ${totalPages}`;
|
|
95
|
+
document.getElementById('btn-prev').disabled = currentPage === 0;
|
|
96
|
+
document.getElementById('btn-next').disabled = currentPage >= totalPages - 1;
|
|
66
97
|
}
|
|
67
98
|
|
|
68
99
|
function editDocument(doc) {
|
|
@@ -122,5 +153,22 @@ document.getElementById('modal').onclick = (e) => {
|
|
|
122
153
|
if (e.target === document.getElementById('modal')) closeModal();
|
|
123
154
|
};
|
|
124
155
|
|
|
156
|
+
document.getElementById('btn-prev').onclick = () => {
|
|
157
|
+
if (currentPage > 0) { currentPage--; loadDocuments(); }
|
|
158
|
+
};
|
|
159
|
+
document.getElementById('btn-next').onclick = () => {
|
|
160
|
+
const totalPages = Math.ceil(totalDocs / PAGE_SIZE);
|
|
161
|
+
if (currentPage < totalPages - 1) { currentPage++; loadDocuments(); }
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
document.getElementById('search-input').oninput = (e) => {
|
|
165
|
+
clearTimeout(searchTimer);
|
|
166
|
+
searchTimer = setTimeout(() => {
|
|
167
|
+
searchQuery = e.target.value.trim();
|
|
168
|
+
currentPage = 0;
|
|
169
|
+
loadDocuments();
|
|
170
|
+
}, 250);
|
|
171
|
+
};
|
|
172
|
+
|
|
125
173
|
// Initial load
|
|
126
174
|
loadCollections();
|
|
@@ -25,12 +25,20 @@
|
|
|
25
25
|
<div class="toolbar">
|
|
26
26
|
<h2 id="collection-name"></h2>
|
|
27
27
|
<span id="doc-count" class="dim"></span>
|
|
28
|
-
<
|
|
28
|
+
<div class="toolbar-right">
|
|
29
|
+
<input type="text" id="search-input" placeholder="Search..." autocomplete="off">
|
|
30
|
+
<button id="btn-add" class="btn">+ Add Document</button>
|
|
31
|
+
</div>
|
|
29
32
|
</div>
|
|
30
33
|
<table id="docs-table">
|
|
31
34
|
<thead><tr id="table-head"></tr></thead>
|
|
32
35
|
<tbody id="table-body"></tbody>
|
|
33
36
|
</table>
|
|
37
|
+
<div id="pagination" class="pagination hidden">
|
|
38
|
+
<button id="btn-prev" class="btn">← Prev</button>
|
|
39
|
+
<span id="page-info" class="dim"></span>
|
|
40
|
+
<button id="btn-next" class="btn">Next →</button>
|
|
41
|
+
</div>
|
|
34
42
|
</div>
|
|
35
43
|
</section>
|
|
36
44
|
</main>
|
|
@@ -90,6 +90,27 @@ aside li.active .count { background: rgba(255,255,255,0.2); }
|
|
|
90
90
|
|
|
91
91
|
.toolbar h2 { font-size: 1.2rem; }
|
|
92
92
|
|
|
93
|
+
.toolbar-right {
|
|
94
|
+
margin-left: auto;
|
|
95
|
+
display: flex;
|
|
96
|
+
align-items: center;
|
|
97
|
+
gap: 0.5rem;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
#search-input {
|
|
101
|
+
padding: 0.4rem 0.75rem;
|
|
102
|
+
background: var(--surface);
|
|
103
|
+
border: 1px solid var(--border);
|
|
104
|
+
border-radius: 6px;
|
|
105
|
+
color: var(--text);
|
|
106
|
+
font-size: 0.85rem;
|
|
107
|
+
width: 200px;
|
|
108
|
+
outline: none;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
#search-input:focus { border-color: var(--accent); }
|
|
112
|
+
#search-input::placeholder { color: var(--dim); }
|
|
113
|
+
|
|
93
114
|
.btn {
|
|
94
115
|
padding: 0.4rem 0.8rem;
|
|
95
116
|
border: 1px solid var(--border);
|
|
@@ -135,6 +156,20 @@ td {
|
|
|
135
156
|
tr:hover td { background: var(--surface); }
|
|
136
157
|
tr { cursor: pointer; }
|
|
137
158
|
|
|
159
|
+
.pagination {
|
|
160
|
+
display: flex;
|
|
161
|
+
align-items: center;
|
|
162
|
+
justify-content: center;
|
|
163
|
+
gap: 1rem;
|
|
164
|
+
padding: 1rem 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.pagination .btn:disabled {
|
|
168
|
+
opacity: 0.3;
|
|
169
|
+
cursor: default;
|
|
170
|
+
border-color: var(--border);
|
|
171
|
+
}
|
|
172
|
+
|
|
138
173
|
.hidden { display: none !important; }
|
|
139
174
|
|
|
140
175
|
#empty-state {
|