Compare commits
40 Commits
dev-2
..
a9851847fc
| Author | SHA1 | Date | |
|---|---|---|---|
| a9851847fc | |||
| d1c896acf4 | |||
| f99aadee83 | |||
| 8cd80c016f | |||
| ef29e4d261 | |||
| 0725366dd5 | |||
| a2bcd10607 | |||
| 37ed966ac3 | |||
| 085c307137 | |||
| d961d910d8 | |||
| 1dc6d541cc | |||
| 7fef89b778 | |||
| 6b713e4ffb | |||
| b52a91bf52 | |||
| e8d8340448 | |||
| 81371c41a7 | |||
| 3cf5f0173b | |||
| 65844e030d | |||
| ba7e4ab151 | |||
| ec4e578aee | |||
| 1a5f300a78 | |||
| a04cdd753d | |||
| daa4b268a6 | |||
| a5161cb6b4 | |||
| 48e32bfdcb | |||
| 639ee895cd | |||
| 8cf1dd9bfd | |||
| fcadc8d7a1 | |||
| 1e2f941f47 | |||
| eeee2afc05 | |||
| 98efb76f7b | |||
| dff3748623 | |||
| 93a16ee916 | |||
| 8b3a3964a5 | |||
| 43dd36e20e | |||
| 5587e8b4b6 | |||
| 16e50e2d49 | |||
| bd01e5c32c | |||
| e058ceadee | |||
| 4a7eb217a1 |
@@ -6,3 +6,4 @@ front/node_modules
|
||||
front/npm-debug.log
|
||||
/.idea
|
||||
/.vscode
|
||||
/.claude
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"project_name": "Lucent Package",
|
||||
"languages": {
|
||||
"PHP": {
|
||||
"language_servers": ["phpactor"],
|
||||
},
|
||||
},
|
||||
}
|
||||
+3
-9
@@ -6,18 +6,13 @@
|
||||
"require": {
|
||||
"ext-xml": "*",
|
||||
"ext-zip": "*",
|
||||
"ext-sqlite3": "*",
|
||||
"ext-imagick": "*",
|
||||
"ext-pdo": "*",
|
||||
"php": "^8.3",
|
||||
"guzzlehttp/guzzle": "^7.2",
|
||||
"intervention/image": "^2.7",
|
||||
"php": "^8.4",
|
||||
"phpoption/phpoption": "^1.9",
|
||||
"spatie/image-optimizer": "^1.6",
|
||||
"spatie/image-optimizer": "^1.8",
|
||||
"staudenmeir/laravel-cte": "^1.0",
|
||||
"mustache/mustache": "^2.14",
|
||||
"yosymfony/toml": "^1.0"
|
||||
|
||||
"intervention/image": "^4.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/phpstan": "^1.8",
|
||||
@@ -41,5 +36,4 @@
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true
|
||||
|
||||
}
|
||||
|
||||
Generated
+724
-1392
File diff suppressed because it is too large
Load Diff
@@ -1,111 +0,0 @@
|
||||
CREATE TABLE "channels" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"locales" TEXT NOT NULL,
|
||||
PRIMARY KEY("id")
|
||||
)
|
||||
|
||||
CREATE TABLE "edges" (
|
||||
"id" TEXT,
|
||||
"from" TEXT NOT NULL,
|
||||
"to" TEXT NOT NULL,
|
||||
"field_id" TEXT NOT NULL,
|
||||
"rank" INTEGER NOT NULL,
|
||||
"locale" TEXT NOT NULL,
|
||||
"mode" TEXT NOT NULL,
|
||||
"" INTEGER,
|
||||
UNIQUE("from","to","field_id","locale","mode"),
|
||||
PRIMARY KEY("id")
|
||||
)
|
||||
|
||||
CREATE TABLE "fields" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"props" TEXT NOT NULL,
|
||||
"schema_id" TEXT NOT NULL,
|
||||
"alias" TEXT NOT NULL,
|
||||
"rank" INTEGER NOT NULL DEFAULT 0,
|
||||
"translatable" INTEGER NOT NULL DEFAULT 0,
|
||||
"help" TEXT NOT NULL DEFAULT "",
|
||||
"required" INTEGER NOT NULL DEFAULT 0,
|
||||
"readonly" INTEGER NOT NULL DEFAULT 0,
|
||||
"hidden" INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY("id")
|
||||
)
|
||||
|
||||
CREATE TABLE "files" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"size" INTEGER NOT NULL,
|
||||
"width" INTEGER NOT NULL DEFAULT 0,
|
||||
"height" INTEGER NOT NULL DEFAULT 0,
|
||||
"mime" TEXT NOT NULL,
|
||||
"checksum" TEXT NOT NULL,
|
||||
"record_id" TEXT,
|
||||
"is_shared" INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY("id")
|
||||
)
|
||||
|
||||
CREATE TABLE "records" (
|
||||
"id" TEXT NOT NULL,
|
||||
"schema_id" TEXT NOT NULL,
|
||||
"created_at" TEXT NOT NULL,
|
||||
"created_by" TEXT NOT NULL,
|
||||
"published_at" TEXT,
|
||||
"published_by" TEXT,
|
||||
"trashed_at" TEXT,
|
||||
"trashed_by" INTEGER,
|
||||
"scheduled_at" TEXT,
|
||||
"scheduled_by" TEXT,
|
||||
"title_field_id" TEXT NOT NULL DEFAULT 'as',
|
||||
PRIMARY KEY("id")
|
||||
)
|
||||
|
||||
CREATE TABLE "records_data" (
|
||||
"id" TEXT NOT NULL,
|
||||
"locale" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"updated_at" TEXT NOT NULL,
|
||||
"updated_by" TEXT NOT NULL,
|
||||
"mode" TEXT NOT NULL,
|
||||
"record_id" TEXT NOT NULL,
|
||||
"field_id" TEXT NOT NULL,
|
||||
PRIMARY KEY("id"),
|
||||
UNIQUE("locale","mode","record_id","field_id")
|
||||
)
|
||||
|
||||
CREATE TABLE "records_files" (
|
||||
"id" TEXT NOT NULL,
|
||||
"record_id" TEXT NOT NULL,
|
||||
"file_id" TEXT NOT NULL,
|
||||
"mode" TEXT NOT NULL,
|
||||
"locale" TEXT NOT NULL,
|
||||
"rank" INTEGER NOT NULL,
|
||||
"field_id" TEXT NOT NULL,
|
||||
PRIMARY KEY("id"),
|
||||
UNIQUE("record_id","file_id","mode","locale")
|
||||
)
|
||||
|
||||
CREATE TABLE "schemas" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"revisions" INTEGER NOT NULL,
|
||||
"alias" TEXT NOT NULL,
|
||||
"title_id" TEXT NOT NULL,
|
||||
PRIMARY KEY("id")
|
||||
)
|
||||
|
||||
CREATE TABLE "users" (
|
||||
"id" varchar NOT NULL,
|
||||
"name" varchar,
|
||||
"email" varchar NOT NULL,
|
||||
"password" varchar,
|
||||
"status" varchar NOT NULL,
|
||||
"roles" TEXT NOT NULL,
|
||||
"createdAt" varchar NOT NULL,
|
||||
"updatedAt" varchar NOT NULL,
|
||||
"loggedInAt" varchar,
|
||||
"mailToken" varchar,
|
||||
PRIMARY KEY("id")
|
||||
)
|
||||
+145
-298
@@ -5,374 +5,221 @@ include_toc: true
|
||||
|
||||
# Fields
|
||||
|
||||
Fields are similar to a table's columns in a relational databases.
|
||||
Fields define the columns of a schema. Each field has a `ui` type that controls both storage and the admin UI component rendered.
|
||||
|
||||
## Available fields for Collections and Files
|
||||
## Common Optional Properties
|
||||
|
||||
Most fields share these optional properties:
|
||||
|
||||
| Property | Description |
|
||||
|---|---|
|
||||
| `required` | Whether the field must have a value to save as `published` |
|
||||
| `nullable` | Allow saving as `null` |
|
||||
| `help` | Help text shown below the input |
|
||||
| `default` | Default value when creating a new record |
|
||||
| `readonly` | Prevent editing from the UI |
|
||||
| `group` | Tab group this field belongs to |
|
||||
|
||||
|
||||
## Field Types
|
||||
|
||||
### text
|
||||
One-line text input
|
||||
|
||||
required
|
||||
One-line text input.
|
||||
|
||||
- **name**: The id of the field
|
||||
- **label**: The friendly name of the field
|
||||
**Required:** `name`, `label`
|
||||
|
||||
optional
|
||||
| Property | Description |
|
||||
|---|---|
|
||||
| `min` | Minimum character count |
|
||||
| `max` | Maximum character count |
|
||||
| `selectOptions` | Array of options. Strings or `[{value, label}]` objects |
|
||||
| `optionsFrom` | Schema name to load options from |
|
||||
| `optionsField` | Field from `optionsFrom` to use as the value |
|
||||
| `optionsSuggest` | Allow typing new values not in the options list |
|
||||
|
||||
- **required**: Is the field required to save the record
|
||||
- **nullable**: Can the field be saved as null
|
||||
- **min**: Minimum characters
|
||||
- **max**: Maximum characters
|
||||
- **help**: Help text
|
||||
- **default**: Default value when creating new record
|
||||
- **readonly**: Cannot edit this value from the UI
|
||||
- **optionsFrom**: Schema to choose options from
|
||||
- **optionsField**: Field's value to insert
|
||||
- **optionsSuggest**: Allow to insert new values
|
||||
- **selectOptions**: Array of options to select from. Or array of objects `[{value,label}]`
|
||||
- **group**: The group that this field belongs to
|
||||
---
|
||||
|
||||
### textarea
|
||||
textarea input
|
||||
|
||||
required
|
||||
Multi-line text input.
|
||||
|
||||
- **name**: The id of the field
|
||||
- **label**: The friendly name of the field
|
||||
**Required:** `name`, `label`
|
||||
|
||||
optional
|
||||
| Property | Description |
|
||||
|---|---|
|
||||
| `min` | Minimum character count |
|
||||
| `max` | Maximum character count |
|
||||
|
||||
- **required**: Is the field required to save the record
|
||||
- **nullable**: Can the field be saved as null
|
||||
- **min**: Minimum characters
|
||||
- **max**: Maximum characters
|
||||
- **help**: Help text
|
||||
- **default**: Default value when creating new record
|
||||
- **readonly**: Cannot edit this value from the UI
|
||||
- **group**: The group that this field belongs to
|
||||
---
|
||||
|
||||
### slug
|
||||
Slug input. Generates automatically if left empty
|
||||
|
||||
required
|
||||
Slug input. Auto-generates from a source field if left empty.
|
||||
|
||||
- **name**: The id of the field
|
||||
- **label**: The friendly name of the field
|
||||
- **source**: The source field from which it generates
|
||||
**Required:** `name`, `label`, `source`
|
||||
|
||||
optional
|
||||
| Property | Description |
|
||||
|---|---|
|
||||
| `source` | Field name to generate the slug from |
|
||||
| `min` | Minimum character count |
|
||||
| `max` | Maximum character count |
|
||||
|
||||
- **required**: Is the field required to save the record
|
||||
- **nullable**: Can the field be saved as null
|
||||
- **min**: Minimum characters
|
||||
- **max**: Maximum characters
|
||||
- **help**: Help text
|
||||
- **default**: Default value when creating new record
|
||||
- **readonly**: Cannot edit this value from the UI
|
||||
- **group**: The group that this field belongs to
|
||||
---
|
||||
|
||||
### rich
|
||||
WYSIWYG editor
|
||||
|
||||
required
|
||||
WYSIWYG rich text editor.
|
||||
|
||||
- **name**: The id of the field
|
||||
- **label**: The friendly name of the field
|
||||
**Required:** `name`, `label`
|
||||
|
||||
optional
|
||||
| Property | Description |
|
||||
|---|---|
|
||||
| `min` | Minimum character count |
|
||||
| `max` | Maximum character count |
|
||||
|
||||
- **required**: Is the field required to save the record
|
||||
- **nullable**: Can the field be saved as null
|
||||
- **min**: Minimum characters
|
||||
- **max**: Maximum characters
|
||||
- **help**: Help text
|
||||
- **default**: Default value when creating new record
|
||||
- **readonly**: Cannot edit this value from the UI
|
||||
- **group**: The group that this field belongs to
|
||||
---
|
||||
|
||||
### markdown
|
||||
|
||||
Markdown editor.
|
||||
|
||||
**Required:** `name`, `label`
|
||||
|
||||
| Property | Description |
|
||||
|---|---|
|
||||
| `min` | Minimum character count |
|
||||
| `max` | Maximum character count |
|
||||
|
||||
---
|
||||
|
||||
### number
|
||||
Any numeric value
|
||||
|
||||
required
|
||||
Numeric input.
|
||||
|
||||
- **name**: The id of the field
|
||||
- **label**: The friendly name of the field
|
||||
**Required:** `name`, `label`
|
||||
|
||||
optional
|
||||
| Property | Description |
|
||||
|---|---|
|
||||
| `decimals` | Number of decimal places. Default: `0` |
|
||||
| `min` | Minimum value |
|
||||
| `max` | Maximum value |
|
||||
| `optionsFrom` | Schema name to load options from |
|
||||
| `optionsField` | Field from `optionsFrom` to use as the value |
|
||||
| `optionsSuggest` | Allow typing new values not in the options list |
|
||||
|
||||
- **decimals**: default is 0
|
||||
- **required**: Is the field required to save the record
|
||||
- **nullable**: Can the field be saved as null
|
||||
- **min**: Minimum characters
|
||||
- **max**: Maximum characters
|
||||
- **help**: Help text
|
||||
- **default**: Default value when creating new record
|
||||
- **readonly**: Cannot edit this value from the UI
|
||||
- **optionsFrom**: Schema to choose options from
|
||||
- **optionsField**: Field's value to insert
|
||||
- **optionsSuggest**: Allow to insert new values
|
||||
- **group**: The group that this field belongs to
|
||||
---
|
||||
|
||||
### checkbox
|
||||
True or false
|
||||
|
||||
required
|
||||
Boolean true/false toggle.
|
||||
|
||||
- **name**: The id of the field
|
||||
- **label**: The friendly name of the field
|
||||
**Required:** `name`, `label`
|
||||
|
||||
optional
|
||||
|
||||
- **required**: Is the field required to save the record
|
||||
- **nullable**: Can the field be saved as null
|
||||
- **help**: Help text
|
||||
- **default**: Default value when creating new record
|
||||
- **readonly**: Cannot edit this value from the UI
|
||||
- **group**: The group that this field belongs to
|
||||
---
|
||||
|
||||
### color
|
||||
Color picker
|
||||
|
||||
required
|
||||
Color picker.
|
||||
|
||||
- **name**: The id of the field
|
||||
- **label**: The friendly name of the field
|
||||
**Required:** `name`, `label`
|
||||
|
||||
optional
|
||||
| Property | Description |
|
||||
|---|---|
|
||||
| `selectOptions` | Restrict to a predefined palette |
|
||||
| `optionsFrom` | Schema name to load options from |
|
||||
| `optionsField` | Field from `optionsFrom` to use as the value |
|
||||
| `optionsSuggest` | Allow typing new values not in the options list |
|
||||
|
||||
- **required**: Is the field required to save the record
|
||||
- **nullable**: Can the field be saved as null
|
||||
- **help**: Help text
|
||||
- **default**: Default value when creating new record
|
||||
- **readonly**: Cannot edit this value from the UI
|
||||
- **optionsFrom**: Schema to choose options from
|
||||
- **optionsField**: Field's value to insert
|
||||
- **optionsSuggest**: Allow to insert new values
|
||||
- **selectOptions**: Array of options to select from. Or array of objects `[{value,label}]`
|
||||
- **group**: The group that this field belongs to
|
||||
---
|
||||
|
||||
### date
|
||||
Date select
|
||||
|
||||
required
|
||||
Date selector. Stores as a date string.
|
||||
|
||||
- **name**: The id of the field
|
||||
- **label**: The friendly name of the field
|
||||
**Required:** `name`, `label`
|
||||
|
||||
optional
|
||||
| Property | Description |
|
||||
|---|---|
|
||||
| `min` | Minimum date |
|
||||
| `max` | Maximum date |
|
||||
| `selectOptions` | Predefined date options |
|
||||
| `optionsFrom` | Schema name to load options from |
|
||||
| `optionsField` | Field from `optionsFrom` to use as the value |
|
||||
|
||||
- **required**: Is the field required to save the record
|
||||
- **nullable**: Can the field be saved as null
|
||||
- **min**: Minimum date
|
||||
- **max**: Maximum date
|
||||
- **help**: Help text
|
||||
- **default**: Default value when creating new record
|
||||
- **readonly**: Cannot edit this value from the UI
|
||||
- **optionsFrom**: Schema to choose options from
|
||||
- **optionsField**: Field's value to insert
|
||||
- **optionsSuggest**: Allow to insert new values
|
||||
- **selectOptions**: Array of options to select from. Or array of objects `[{value,label}]`
|
||||
- **group**: The group that this field belongs to
|
||||
---
|
||||
|
||||
### datetime
|
||||
Date and time selector
|
||||
|
||||
required
|
||||
Date and time selector. Stores as an ISO 8601 string.
|
||||
|
||||
- **name**: The id of the field
|
||||
- **label**: The friendly name of the field
|
||||
**Required:** `name`, `label`
|
||||
|
||||
optional
|
||||
| Property | Description |
|
||||
|---|---|
|
||||
| `min` | Minimum datetime |
|
||||
| `max` | Maximum datetime |
|
||||
| `selectOptions` | Predefined datetime options |
|
||||
| `optionsFrom` | Schema name to load options from |
|
||||
| `optionsField` | Field from `optionsFrom` to use as the value |
|
||||
|
||||
- **required**: Is the field required to save the record
|
||||
- **nullable**: Can the field be saved as null
|
||||
- **min**: Minimum date
|
||||
- **max**: Maximum date
|
||||
- **help**: Help text
|
||||
- **default**: Default value when creating new record
|
||||
- **readonly**: Cannot edit this value from the UI
|
||||
- **optionsFrom**: Schema to choose options from
|
||||
- **optionsField**: Field's value to insert
|
||||
- **optionsSuggest**: Allow to insert new values
|
||||
- **selectOptions**: Array of options to select from. Or array of objects `[{value,label}]`
|
||||
- **group**: The group that this field belongs to
|
||||
---
|
||||
|
||||
### uuid
|
||||
|
||||
UUID text field. Stores an arbitrary UUID string. Typically used as a read-only external reference ID.
|
||||
|
||||
**Required:** `name`, `label`
|
||||
|
||||
---
|
||||
|
||||
### json
|
||||
Json data
|
||||
|
||||
required
|
||||
Raw JSON data field. Accepts either a JSON string or a plain array — both are stored as JSON.
|
||||
|
||||
- **name**: The id of the field
|
||||
- **label**: The friendly name of the field
|
||||
**Required:** `name`, `label`
|
||||
|
||||
optional
|
||||
|
||||
- **required**: Is the field required to save the record
|
||||
- **nullable**: Can the field be saved as null
|
||||
- **help**: Help text
|
||||
- **default**: Default value when creating new record
|
||||
- **readonly**: Cannot edit this value from the UI
|
||||
- **group**: The group that this field belongs to
|
||||
|
||||
### markdown
|
||||
Markdown editor
|
||||
|
||||
required
|
||||
|
||||
- **name**: The id of the field
|
||||
- **label**: The friendly name of the field
|
||||
|
||||
optional
|
||||
|
||||
- **required**: Is the field required to save the record
|
||||
- **nullable**: Can the field be saved as null
|
||||
- **min**: Minimum characters
|
||||
- **max**: Maximum characters
|
||||
- **help**: Help text
|
||||
- **default**: Default value when creating new record
|
||||
- **readonly**: Cannot edit this value from the UI
|
||||
- **group**: The group that this field belongs to
|
||||
---
|
||||
|
||||
### file
|
||||
Upload or select files
|
||||
|
||||
required
|
||||
Upload or select files from a files schema. Files can be uploaded directly or browsed from previously uploaded files. Image files (jpeg, png, webp, gif, tiff) automatically get a 300×300 thumbnail generated on upload.
|
||||
|
||||
- **name**: The id of the field
|
||||
- **label**: The friendly name of the field
|
||||
- **collections**: File collections to choose from
|
||||
**Required:** `name`, `label`, `collections`
|
||||
|
||||
optional
|
||||
|
||||
- **mime**: The mime types allowed to select
|
||||
- **nullable**: Can the field be saved as null
|
||||
- **min**: Minimum files
|
||||
- **max**: Maximum files
|
||||
- **help**: Help text
|
||||
- **group**: The group that this field belongs to
|
||||
| Property | Description |
|
||||
|---|---|
|
||||
| `collections` | Array of file schema names to choose from |
|
||||
| `mime` | Allowed MIME types e.g. `["image/*"]` |
|
||||
| `min` | Minimum number of files |
|
||||
| `max` | Maximum number of files |
|
||||
|
||||
---
|
||||
|
||||
### reference
|
||||
Reference other records
|
||||
|
||||
required
|
||||
Reference records from another collection.
|
||||
|
||||
- **name**: The id of the field
|
||||
- **label**: The friendly name of the field
|
||||
- **collections**: Collections to choose from
|
||||
**Required:** `name`, `label`, `collections`
|
||||
|
||||
optional
|
||||
| Property | Description |
|
||||
|---|---|
|
||||
| `collections` | Array of collection schema names to reference |
|
||||
| `min` | Minimum number of references |
|
||||
| `max` | Maximum number of references |
|
||||
|
||||
- **nullable**: Can the field be saved as null
|
||||
- **min**: Minimum files
|
||||
- **max**: Maximum files
|
||||
- **help**: Help text
|
||||
- **group**: The group that this field belongs to
|
||||
---
|
||||
|
||||
## Example Field
|
||||
|
||||
### block
|
||||
The block editor
|
||||
|
||||
required
|
||||
|
||||
- **name**: The id of the field
|
||||
- **label**: The friendly name of the field
|
||||
- **schema**: The block schema name
|
||||
|
||||
optional
|
||||
|
||||
- **required**: Is the field required to save the record
|
||||
- **nullable**: Can the field be saved as null
|
||||
- **help**: Help text
|
||||
- **default**: Default value when creating new record
|
||||
- **readonly**: Cannot edit this value from the UI
|
||||
- **group**: The group that this field belongs to
|
||||
|
||||
)
|
||||
## Available fields for the Block Editor
|
||||
|
||||
### heading
|
||||
Single-line text
|
||||
|
||||
required
|
||||
|
||||
- **name**: The id of the field
|
||||
- **label**: The friendly name of the field
|
||||
|
||||
optional
|
||||
|
||||
- **min**: Minimum date
|
||||
- **max**: Maximum date
|
||||
- **default**: Default value when creating new record
|
||||
|
||||
### textarea
|
||||
Multiline text
|
||||
|
||||
required
|
||||
|
||||
- **name**: The id of the field
|
||||
- **label**: The friendly name of the field
|
||||
|
||||
optional
|
||||
|
||||
- **min**: Minimum date
|
||||
- **max**: Maximum date
|
||||
- **default**: Default value when creating new record
|
||||
|
||||
### rich
|
||||
WYSIWYG editor
|
||||
|
||||
required
|
||||
|
||||
- **name**: The id of the field
|
||||
- **label**: The friendly name of the field
|
||||
|
||||
optional
|
||||
|
||||
- **min**: Minimum date
|
||||
- **max**: Maximum date
|
||||
- **default**: Default value when creating new record
|
||||
|
||||
|
||||
### markdown
|
||||
Markdown editor
|
||||
|
||||
required
|
||||
|
||||
- **name**: The id of the field
|
||||
- **label**: The friendly name of the field
|
||||
|
||||
optional
|
||||
|
||||
- **min**: Minimum date
|
||||
- **max**: Maximum date
|
||||
- **default**: Default value when creating new record
|
||||
|
||||
### file
|
||||
Choose files
|
||||
|
||||
required
|
||||
|
||||
- **name**: The id of the field
|
||||
- **label**: The friendly name of the field
|
||||
- **collections**: File collections to choose from
|
||||
|
||||
optional
|
||||
- **mime**: The mime types allowed to select
|
||||
- **min**: Minimum date
|
||||
- **max**: Maximum date
|
||||
- **default**: Default value when creating new record
|
||||
|
||||
### reference
|
||||
Choose files
|
||||
|
||||
required
|
||||
|
||||
- **name**: The id of the field
|
||||
- **label**: The friendly name of the field
|
||||
- **collections**: Collections to choose from
|
||||
|
||||
optional
|
||||
- **min**: Minimum date
|
||||
- **max**: Maximum date
|
||||
- **default**: Default value when creating new record
|
||||
```json
|
||||
{
|
||||
"ui": "text",
|
||||
"name": "title",
|
||||
"label": "Title",
|
||||
"required": true,
|
||||
"min": 3,
|
||||
"max": 200,
|
||||
"help": "The main title of the post"
|
||||
}
|
||||
```
|
||||
|
||||
+133
-52
@@ -5,88 +5,169 @@ include_toc: true
|
||||
|
||||
# Queries
|
||||
|
||||
## Graph or Tree
|
||||
|
||||
Queries can return results in 2 formats. A graph or a tree.
|
||||
|
||||
Graphs results are a collection of records (nodes) and a collection of edges. This format is more useful for network visualization.
|
||||
|
||||
The tree format is more straightforward as it returns a collection of records. Each record has a **_children** and a **_parents** field and the tree can continue upwards or downwards according to the depth you have requested.
|
||||
|
||||
For example to request records with their children and their children's children:
|
||||
The `Query` class is the main way to fetch records. Inject it via the Laravel container.
|
||||
|
||||
```php
|
||||
$query->childrenDepth(2);
|
||||
public function __construct(private Query $query) {}
|
||||
```
|
||||
Maybe you only want to get a specific type of relationship:
|
||||
|
||||
```php
|
||||
$query->childrenDepth(2)->childrenFields(["categories"]);
|
||||
```
|
||||
## Return Formats
|
||||
|
||||
Queries return results in two formats:
|
||||
|
||||
**Graph** — via `->run()`. Returns a `Graph` object with `records` and `edges` collections. Useful for network-style data.
|
||||
|
||||
**Tree** — via `->tree()`. Returns a flat `Collection` of records where each record has `_children` and `_parents` arrays populated based on the requested depth. This is the most common format.
|
||||
|
||||
## Chaining Methods
|
||||
|
||||
All methods return `$this` and can be chained:
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `->filter(array)` | Add an AND filter |
|
||||
| `->orFilter(array)` | Add an OR filter (grouped) |
|
||||
| `->limit(int)` | Max number of root records to return |
|
||||
| `->skip(int)` | Offset for pagination |
|
||||
| `->sort(string)` | Sort by field. Prefix with `-` for descending e.g. `"-_sys.updatedAt"` |
|
||||
| `->status(array)` | Filter by status array e.g. `["published", "draft"]` |
|
||||
| `->onlyPublished()` | Shorthand for `->status(["published"])` |
|
||||
| `->childrenDepth(int)` | How many levels of children to load |
|
||||
| `->childrenLimit(int)` | Max children per record |
|
||||
| `->childrenFields(array)` | Only follow specific relationship fields |
|
||||
| `->parentsDepth(int)` | How many levels of parents to load |
|
||||
| `->parentsLimit(int)` | Max parents per record |
|
||||
| `->parentFields(array)` | Only follow specific relationship fields |
|
||||
| `->notLinked(string)` | Return only records with no parents |
|
||||
| `->run()` | Execute and return a `Graph` |
|
||||
| `->tree()` | Execute and return a `Collection` (tree format) |
|
||||
| `->runWithCount()` | Execute and return a `Graph` with a `total` count |
|
||||
|
||||
|
||||
## Filters
|
||||
|
||||
You can filter your query with the following format:
|
||||
Filter keys use the format `field_operator`. When no operator suffix is given, `eq` is assumed.
|
||||
|
||||
```php
|
||||
$query->filter(["field_operator" => "value"]);
|
||||
|
||||
// example:
|
||||
// No operator = eq
|
||||
$query->filter(["schema" => "blogPosts"]);
|
||||
|
||||
$query->filter(["date_lt" => "2020-09-15"]);
|
||||
// With operator
|
||||
$query->filter(["_sys.updatedAt_gte" => "2024-01-01"]);
|
||||
```
|
||||
|
||||
Or filters are also available:
|
||||
### Operator Reference
|
||||
|
||||
| Operator | Description |
|
||||
|---|---|
|
||||
| _(none)_ or `eq` | Equals (string) |
|
||||
| `ne` | Not equals (string) |
|
||||
| `eqnum` | Equals (numeric) |
|
||||
| `neqnum` | Not equals (numeric) |
|
||||
| `eqtrue` | Equals `true` |
|
||||
| `eqfalse` | Equals `false` |
|
||||
| `netrue` | Not equals `true` |
|
||||
| `nefalse` | Not equals `false` |
|
||||
| `regex` | Regular expression match |
|
||||
| `in` | Value is in array (strings) |
|
||||
| `nin` | Value is not in array (strings) |
|
||||
| `innum` | Value is in array (numeric) |
|
||||
| `ninnum` | Value is not in array (numeric) |
|
||||
| `lt` | Less than |
|
||||
| `lte` | Less than or equal |
|
||||
| `gt` | Greater than |
|
||||
| `gte` | Greater than or equal |
|
||||
| `null` | Field is null |
|
||||
| `nnull` | Field is not null |
|
||||
| `exists` | Field key exists |
|
||||
| `nexists` | Field key does not exist |
|
||||
| `filter` | Raw filter passthrough |
|
||||
|
||||
### OR Filters
|
||||
|
||||
```php
|
||||
$query
|
||||
->filter(["price_eqn" => 10])
|
||||
->orFilter(["title_regex" => "search", "slug_regex" => "search"])
|
||||
;
|
||||
->filter(["schema" => "blogPosts"])
|
||||
->orFilter(["title_regex" => "search", "slug_regex" => "search"]);
|
||||
```
|
||||
## Operator List
|
||||
|
||||
- regex
|
||||
- eq
|
||||
- ne
|
||||
- eqnum
|
||||
- neqnum
|
||||
- filter
|
||||
- eqtrue
|
||||
- eqfalse
|
||||
- netrue
|
||||
- nefalse
|
||||
- in:
|
||||
- innum
|
||||
- nin
|
||||
- ninnum
|
||||
- lt
|
||||
- lte
|
||||
- gt
|
||||
- gte
|
||||
- null
|
||||
- nnull
|
||||
- exists
|
||||
- nexists
|
||||
Each `orFilter` call groups its arguments with OR between them. Multiple `orFilter` / `filter` calls are AND-ed together.
|
||||
|
||||
## Example
|
||||
### Cross-Schema (Children) Filters
|
||||
|
||||
Get 10 posts from the sports category as a tree
|
||||
You can filter records by properties of their related records using the `children.` prefix:
|
||||
|
||||
```php
|
||||
$query->filter([
|
||||
"schema" => "blogPosts",
|
||||
"children.categories.data.slug" => "sports",
|
||||
]);
|
||||
```
|
||||
|
||||
This returns `blogPosts` that have a child in `categories` where `slug` equals `sports`.
|
||||
|
||||
|
||||
## Nested Data Fields
|
||||
|
||||
Use dot notation to filter on JSON data fields and system fields:
|
||||
|
||||
```php
|
||||
// Filter on a data field
|
||||
$query->filter(["data.status_eq" => "draft"]);
|
||||
|
||||
// Filter on a system field
|
||||
$query->filter(["_sys.updatedAt_gte" => "2024-01-01"]);
|
||||
```
|
||||
|
||||
|
||||
## Examples
|
||||
|
||||
**Get published blog posts, newest first:**
|
||||
|
||||
```php
|
||||
$posts = $query
|
||||
->filter(["schema" => "blogPosts"])
|
||||
->onlyPublished()
|
||||
->sort("-_sys.updatedAt")
|
||||
->limit(10)
|
||||
->tree();
|
||||
```
|
||||
|
||||
**Get posts from a specific category with children loaded:**
|
||||
|
||||
```php
|
||||
$posts = $query
|
||||
->filter([
|
||||
"schema" => "posts",
|
||||
"schema" => "blogPosts",
|
||||
"children.categories.data.slug" => "sports",
|
||||
]
|
||||
)
|
||||
])
|
||||
->childrenDepth(1)
|
||||
->childrenFields(["categories"])
|
||||
->limit(10)
|
||||
->tree();
|
||||
|
||||
$posts->map(...)->toArray();
|
||||
|
||||
```
|
||||
|
||||
**Paginate records:**
|
||||
|
||||
```php
|
||||
$graph = $query
|
||||
->filter(["schema" => "blogPosts"])
|
||||
->limit(20)
|
||||
->skip(40)
|
||||
->runWithCount();
|
||||
|
||||
$posts = $graph->tree();
|
||||
$total = $graph->total;
|
||||
```
|
||||
|
||||
**Search across fields:**
|
||||
|
||||
```php
|
||||
$results = $query
|
||||
->filter(["schema" => "blogPosts"])
|
||||
->orFilter(["title_regex" => $term, "slug_regex" => $term])
|
||||
->limit(10)
|
||||
->tree();
|
||||
```
|
||||
|
||||
+77
-38
@@ -5,70 +5,106 @@ include_toc: true
|
||||
|
||||
# Schemas
|
||||
|
||||
Schemas define both the shape of your data and how the UI on the admin will behave.
|
||||
Schemas define both the shape of your data and how the admin UI behaves.
|
||||
|
||||
There are 3 types of schemas
|
||||
There are 2 types of schemas:
|
||||
|
||||
- Collections: Normal data
|
||||
- Files: Images and files
|
||||
- Block: Used in the block editor
|
||||
- **collection** — Regular data records
|
||||
- **files** — Images and file uploads
|
||||
|
||||
|
||||
## Collection Reference
|
||||
|
||||
- **name**: The ID of the collection. Camelcase and plural is the recommended format ex. blogPosts
|
||||
- **label**: The friendly name of the schema
|
||||
- **type**: The type of the collection. Should be "collection"
|
||||
- **visible**: An array of field id to show on the content browser _optional_
|
||||
- **groups**: A list if group ids to separate your field in different tabs _optional_
|
||||
- **fields**: The list of your fields. Look the field reference for more
|
||||
- **isEntry**: If this schema is important, it will show be visible on the main the sidebar. Default: false _optional_
|
||||
- **sortBy**: The default sorting in the content browser _optional_
|
||||
- **cardTitle**: Mustache code to customize the preview field _optional_
|
||||
- **cardImage**: Field name of image you want to use as a preview image _optional_
|
||||
- **revisions**: How many revisions are going to be kept for each record _optional_
|
||||
- **read**: Array of user groups that have read permissions _optional_
|
||||
- **write**: Array of user groups that have write permissions _optional_
|
||||
| Field | Required | Description |
|
||||
|---|---|---|
|
||||
| `name` | yes | Unique ID. Use camelCase plural e.g. `blogPosts` |
|
||||
| `label` | yes | Friendly display name |
|
||||
| `type` | yes | Must be `"collection"` |
|
||||
| `fields` | yes | Array of field definitions. See [Fields](Fields.md) |
|
||||
| `visible` | no | Field IDs to show in the content browser list |
|
||||
| `groups` | no | Group IDs to split fields into tabs |
|
||||
| `isEntry` | no | Show in sidebar. Default: `false` |
|
||||
| `sortBy` | no | Default sort in browser. Prefix with `-` for descending e.g. `-_sys.updatedAt` |
|
||||
| `cardTitle` | no | Mustache template for the record preview title e.g. `{{name}} - {{slug}}` |
|
||||
| `cardImage` | no | Field name to use as the preview image |
|
||||
| `revisions` | no | Number of revisions to keep per record. Default: `0` (disabled) |
|
||||
| `read` | no | Roles with read access. Empty means all roles can read |
|
||||
| `write` | no | Roles with write access. Empty means all roles can write |
|
||||
|
||||
|
||||
## Files Reference
|
||||
|
||||
- **name**: The ID of the collection. Camelcase and plural is the recommended format ex. blogPosts
|
||||
- **label**: The friendly name of the schema
|
||||
- **type**: The type of the collection. Should be "files"
|
||||
- **path**: The relative directory that these files will be stored.
|
||||
- **groups**: A list if group ids to separate your field in different tabs _optional_
|
||||
- **fields**: The list of your fields. Look the field reference for more
|
||||
- **isEntry**: If this schema is important, it will show be visible on the main the sidebar _optional_
|
||||
- **sortBy**: The default sorting in the content browser _optional_
|
||||
- **cardTitle**: Mustache code to customize the preview field _optional_
|
||||
- **revisions**: How many revisions are going to be kept for each record _optional_
|
||||
- **read**: Array of user groups that have read permissions _optional_
|
||||
- **write**: Array of user groups that have write permissions _optional_
|
||||
| Field | Required | Description |
|
||||
|---|---|---|
|
||||
| `name` | yes | Unique ID. Use camelCase plural e.g. `heroImages` |
|
||||
| `label` | yes | Friendly display name |
|
||||
| `type` | yes | Must be `"files"` |
|
||||
| `fields` | yes | Array of field definitions. See [Fields](Fields.md) |
|
||||
| `groups` | no | Group IDs to split fields into tabs |
|
||||
| `isEntry` | no | Show in sidebar. Default: `false` |
|
||||
| `sortBy` | no | Default sort in browser |
|
||||
| `cardTitle` | no | Mustache template for the record preview title |
|
||||
| `cardImage` | no | Field name to use as the preview image |
|
||||
| `revisions` | no | Number of revisions to keep per record |
|
||||
| `read` | no | Roles with read access |
|
||||
| `write` | no | Roles with write access |
|
||||
|
||||
### File Metadata
|
||||
|
||||
Every uploaded file is automatically stored with the following metadata (stored in `lucent_files`):
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `id` | Unique file ID |
|
||||
| `recordId` | ID of the record this file belongs to |
|
||||
| `originalName` | Original filename as uploaded |
|
||||
| `mime` | MIME type e.g. `image/webp` |
|
||||
| `path` | Storage path e.g. `files/{recordId}/{filename}` |
|
||||
| `size` | File size in bytes |
|
||||
| `width` | Image width in pixels (0 for non-images) |
|
||||
| `height` | Image height in pixels (0 for non-images) |
|
||||
| `checksum` | SHA-1 hash of the file contents |
|
||||
|
||||
Image files (jpeg, png, webp, gif, tiff) also get a 300×300 thumbnail generated automatically at `thumbs/{path}`, plus any image filter presets configured in `lucent.imageFilters`.
|
||||
|
||||
|
||||
A full Collection example without the fields:
|
||||
## System Fields
|
||||
|
||||
Every record automatically has these read-only system fields available in queries:
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `_sys.createdAt` | ISO 8601 creation timestamp |
|
||||
| `_sys.updatedAt` | ISO 8601 last update timestamp |
|
||||
| `_sys.createdBy` | User ID who created the record |
|
||||
| `_sys.updatedBy` | User ID who last updated the record |
|
||||
| `_sys.version` | Revision version number |
|
||||
| `status` | `draft` or `published` |
|
||||
|
||||
|
||||
## Example
|
||||
|
||||
```json
|
||||
{
|
||||
"label": "Departments",
|
||||
"name": "departments",
|
||||
"schemas": [
|
||||
{
|
||||
"label": "Blog Posts",
|
||||
"name": "blogPosts",
|
||||
"isEntry": true,
|
||||
"type": "collection",
|
||||
"visible": [
|
||||
"title",
|
||||
"slug",
|
||||
"cover",
|
||||
"_sys.updatedAt",
|
||||
"status"
|
||||
],
|
||||
"groups": [
|
||||
"Extra Info",
|
||||
"Metadata",
|
||||
"Content",
|
||||
"SEO"
|
||||
],
|
||||
"sortBy": "-_sys.createdAt",
|
||||
"schemaTitle": "{{name}} {{slug}}",
|
||||
"schemaImage": "cover",
|
||||
"cardTitle": "{{title}}",
|
||||
"cardImage": "cover",
|
||||
"revisions": 15,
|
||||
"read": [
|
||||
"admin",
|
||||
@@ -81,4 +117,7 @@ A full Collection example without the fields:
|
||||
],
|
||||
"fields": []
|
||||
}
|
||||
],
|
||||
"roles": ["admin", "editors", "reviewers"]
|
||||
}
|
||||
```
|
||||
+87
-28
@@ -5,51 +5,94 @@ To generate the static content of the website, lucent provides a helper class.
|
||||
|
||||
## Laravel Command
|
||||
|
||||
Just create a command and name it as you like. Make sure to inject the StaticGenerator class.
|
||||
Create an Artisan command and inject `StaticGenerator`:
|
||||
|
||||
```php
|
||||
class GenerateStatic extends Command
|
||||
{
|
||||
protected $signature = 'generate:static';
|
||||
|
||||
public function __construct(
|
||||
public StaticGenerator $staticGenerator,
|
||||
public Context $ctx,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
```
|
||||
|
||||
## Redirect command
|
||||
|
||||
There are cases which is useful to create a redirect like when you have a multilingual website:
|
||||
|
||||
```php
|
||||
$this->staticGenerator->run(function ($writer) {
|
||||
$writer->createRedirect("/", "/el");
|
||||
});
|
||||
```
|
||||
|
||||
## The Writer save command
|
||||
|
||||
In order to create an html file, you have to use the writer's save command
|
||||
|
||||
The first argument is the url and the second is the rendered HTML. That's where the page classes do come handy.
|
||||
|
||||
```php
|
||||
$this->staticGenerator->run(function ($writer) {
|
||||
public function handle(): void
|
||||
{
|
||||
$this->staticGenerator->run('generate:static', function ($writer) {
|
||||
$writer->save("/", $this->ctx->render("homepage"));
|
||||
$writer->save("/about", $this->ctx->render("about"));
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`run(string $signature, callable $callback)` — the first argument must match the command's artisan signature. This is used to stream live build logs in the Lucent UI. The callback receives a `Writer` instance.
|
||||
|
||||
|
||||
## Writer: save
|
||||
|
||||
Writes an HTML file at the given path:
|
||||
|
||||
```php
|
||||
$writer->save("/blog/my-post", $html);
|
||||
// writes to: storage/lucent/build/blog/my-post/index.html
|
||||
```
|
||||
|
||||
An optional third argument changes the file extension (default `"html"`).
|
||||
|
||||
|
||||
## Writer: createRedirect
|
||||
|
||||
Generates an HTML meta-refresh redirect. Useful for root-level locale redirects:
|
||||
|
||||
```php
|
||||
$writer->createRedirect("/", "/el");
|
||||
$writer->createRedirect("/", "/el", "Redirecting", "Please wait...");
|
||||
```
|
||||
|
||||
Arguments: `from`, `to`, `title` (default `"Redirecting"`), `message` (default `"Redirecting Soon..."`).
|
||||
|
||||
|
||||
## Writer: recordIterator
|
||||
|
||||
Iterates over all records in paginated batches — useful when there are too many records to load at once:
|
||||
|
||||
```php
|
||||
$writer->recordIterator(
|
||||
query: fn(int $limit, int $skip) => $query
|
||||
->filter(["schema" => "blogPosts"])
|
||||
->onlyPublished()
|
||||
->limit($limit)
|
||||
->skip($skip)
|
||||
->tree(),
|
||||
parser: function ($records) use ($writer) {
|
||||
foreach ($records as $record) {
|
||||
$writer->save(
|
||||
"/blog/" . $record->data->slug,
|
||||
$this->ctx->render("blogPost", $record),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
Signature: `recordIterator(callable $query, callable $parser, int $skip = 0, int $limit = 100): int`
|
||||
|
||||
The `$query` callable receives `($limit, $skip)` and must return a collection. Iteration stops automatically when a batch returns zero records.
|
||||
|
||||
|
||||
## Storage and Permissions
|
||||
|
||||
All the generated html is stored on `storage/lucent/live`
|
||||
In order to make it accessible you have to create symlink:
|
||||
All generated HTML is written to `storage/lucent/build` during the run, then atomically swapped to `storage/lucent/live` on completion. Create the public symlink with:
|
||||
|
||||
```bash
|
||||
php artisan lucent:livelink
|
||||
```
|
||||
|
||||
Now your static website is accessible in http://localhost:8000/live
|
||||
|
||||
But it would not be nice to have the **live** prefix everywhere. That's why you need to make the following tweak in the nginx vhost
|
||||
The static site is then accessible at `http://localhost:8000/live`. To serve it without the `/live` prefix, add this to your nginx vhost:
|
||||
|
||||
```nginxconf
|
||||
location / {
|
||||
@@ -57,14 +100,30 @@ location / {
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## Artisan Commands
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `lucent:setup-db` | Create all Lucent database tables |
|
||||
| `lucent:schemas` | Compile schema JSON files |
|
||||
| `lucent:livelink` | Create the `public/live` symlink |
|
||||
| `lucent:rebuild:thumbnails` | Regenerate thumbnails for all uploaded images |
|
||||
| `lucent:removeOrphanEdges` | Remove edges pointing to deleted records |
|
||||
| `lucent:generate:collection {name}` | Scaffold a new collection schema JSON file |
|
||||
|
||||
|
||||
## Build from the Lucent UI
|
||||
|
||||
In your lucent.php config file you can define your command that is used to generate the static files:
|
||||
Register your generate command in `config/lucent.php` so admin users can trigger it from the UI:
|
||||
|
||||
```php
|
||||
"generateCommand" => "generate:static"
|
||||
"commands" => [
|
||||
"generate:static" => "Generate Static Site",
|
||||
],
|
||||
```
|
||||
That way, the users will be able to initiate the build command through the user interface.
|
||||
|
||||
Only roles listed in `canBuild` can trigger commands from the UI.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -99,3 +99,113 @@ class Context
|
||||
```
|
||||
|
||||
Add the middleware inside the HTTP Kernel file.
|
||||
|
||||
## Configuration
|
||||
|
||||
After publishing the config (`php artisan vendor:publish --tag=lucent-config`), configure `config/lucent.php` via `.env`:
|
||||
|
||||
| Key | Env var | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `env` | `LUCENT_ENV` | `production` | `production` or `development` |
|
||||
| `auth` | `LUCENT_AUTH` | `lucent` | Auth driver: `lucent` or `lunar` |
|
||||
| `disk` | `LUCENT_DISK` | `public` | Laravel filesystem disk for uploads |
|
||||
| `schemas_path` | `LUCENT_SCHEMAS_PATH` | `resources/lucent/schemas` | Where schema JSON files live |
|
||||
| `database` | `LUCENT_DB_CONNECTION` | `DB_CONNECTION` | Database connection name |
|
||||
| `name` | `LUCENT_NAME` | `Lucent` | CMS display name |
|
||||
| `url` | `LUCENT_URL` | `APP_URL` | Base URL of the CMS |
|
||||
| `previewTarget` | `LUCENT_PREVIEW_TARGET` | `previewTarget` | Preview route parameter |
|
||||
| `commands` | — | `[]` | Artisan commands exposed to admin users (see [Static Generator](Static%20Generator.md)) |
|
||||
| `imageFilters` | — | `[]` | Image filter presets applied to uploads (see below) |
|
||||
| `canInvite` | — | `["admin"]` | Roles that can invite new users |
|
||||
| `canBuild` | — | `["admin"]` | Roles that can trigger static builds |
|
||||
| `systemUserId` | — | `""` | User ID used for console-initiated record writes |
|
||||
|
||||
|
||||
## Image Filters
|
||||
|
||||
Image filters are Intervention Image filter classes applied to every uploaded image, producing additional versions (e.g. a cropped or watermarked variant). Each preset is stored at `templates/{name}/{original-path}` on the configured disk.
|
||||
|
||||
Define filters in `config/lucent.php`:
|
||||
|
||||
```php
|
||||
"imageFilters" => [
|
||||
"hero" => \App\ImageFilters\HeroFilter::class,
|
||||
"thumb" => \App\ImageFilters\ThumbFilter::class,
|
||||
],
|
||||
```
|
||||
|
||||
A filter class must implement `Intervention\Image\Filters\FilterInterface`:
|
||||
|
||||
```php
|
||||
namespace App\ImageFilters;
|
||||
|
||||
use Intervention\Image\Filters\FilterInterface;
|
||||
use Intervention\Image\Image;
|
||||
|
||||
class HeroFilter implements FilterInterface
|
||||
{
|
||||
public function applyFilter(Image $image): Image
|
||||
{
|
||||
return $image->fit(1200, 600)->encode('webp', 80);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Every uploaded image automatically gets a 300×300 WebP thumbnail at `thumbs/{path}` in addition to any configured presets.
|
||||
|
||||
|
||||
## Authentication
|
||||
|
||||
Lucent uses **email-link (magic link) login** — no passwords. Users receive a time-limited link by email.
|
||||
|
||||
### Auth modes
|
||||
|
||||
Set `LUCENT_AUTH` to choose how users are stored:
|
||||
|
||||
| Value | Description |
|
||||
|---|---|
|
||||
| `lucent` | Users stored in the `lucent_users` table. Supports roles. |
|
||||
| `lunar` | Delegates to Lunar's `lunar_staff` table. Roles not supported. |
|
||||
|
||||
### Login flow
|
||||
|
||||
1. User submits their email at `/lucent/login`
|
||||
2. A 32-character token is stored against the user with a timestamp
|
||||
3. Lucent emails a login link containing the token
|
||||
4. User clicks the link → token is validated (must be used within **1 hour**)
|
||||
5. Session is established; token is cleared
|
||||
|
||||
### First-time setup
|
||||
|
||||
The `/lucent/register` route is only available when **no users exist** in the system. Once the first admin registers, the route returns a redirect to `/lucent/login`. Registration automatically assigns the `admin` role and sends a login link.
|
||||
|
||||
### Middleware
|
||||
|
||||
Two middleware aliases are available for your routes:
|
||||
|
||||
| Alias | Description |
|
||||
|---|---|
|
||||
| `lucent.auth` | Requires an active Lucent session |
|
||||
| `lucent.guest` | Redirects authenticated users away |
|
||||
|
||||
### Roles
|
||||
|
||||
Roles are defined in your channel config. Only roles listed there are valid — anything else is silently stripped on assignment. The `canInvite` and `canBuild` config keys control which roles can invite users and trigger builds respectively.
|
||||
|
||||
On console commands and non-`/lucent` routes, `currentUserId()` returns `config("lucent.systemUserId")` instead of the session user.
|
||||
|
||||
|
||||
## Database Tables
|
||||
|
||||
All Lucent-managed tables use the `lucent_` prefix:
|
||||
|
||||
| Table | Description |
|
||||
|---|---|
|
||||
| `lucent_records` | All collection and file schema records |
|
||||
| `lucent_files` | Uploaded file metadata |
|
||||
| `lucent_edges` | Relationships between records |
|
||||
| `lucent_revisions` | Record revision history |
|
||||
| `lucent_users` | Users (when using `lucent` auth mode) |
|
||||
| `lucent_command_logs` | Background command execution logs |
|
||||
|
||||
These are created automatically by running `php artisan lucent:setup`.
|
||||
@@ -1,16 +0,0 @@
|
||||
@import "./pico.min.css";
|
||||
|
||||
main {
|
||||
display: flex;
|
||||
gap: 40px;
|
||||
aside {
|
||||
width: 250px;
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background-color: #d93526;
|
||||
border-color: #d93526;
|
||||
color: white;
|
||||
}
|
||||
Vendored
-4
File diff suppressed because one or more lines are too long
Vendored
-342
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+213
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"main.js": {
|
||||
"file": "assets/main-BJyanQ7P.js",
|
||||
"file": "assets/main-DtbuHUXl.js",
|
||||
"name": "main",
|
||||
"src": "main.js",
|
||||
"isEntry": true,
|
||||
"css": [
|
||||
"assets/main-Dk7njt4m.css"
|
||||
"assets/main-BadhVKbO.css"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
let app;
|
||||
let channel;
|
||||
|
||||
export function createApp(channelData) {
|
||||
channel = channelData;
|
||||
|
||||
app = {
|
||||
url: (path) => {
|
||||
return channel.lucentUrl + "/" + path;
|
||||
},
|
||||
};
|
||||
return app;
|
||||
}
|
||||
|
||||
export function getApp() {
|
||||
return app;
|
||||
}
|
||||
Vendored
-51
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* We'll load the axios HTTP library which allows us to easily issue requests
|
||||
* to our Laravel back-end. This library automatically handles sending the
|
||||
* CSRF token as a header based on the value of the "XSRF" token cookie.
|
||||
*/
|
||||
|
||||
import axios from "axios";
|
||||
import {loadHtmxFormsBehaviour} from "./htmx-form.js";
|
||||
|
||||
loadHtmxFormsBehaviour();
|
||||
window.axios = axios;
|
||||
export const axiosInstance = axios;
|
||||
|
||||
window.axios.defaults.headers.common["X-Requested-With"] = "XMLHttpRequest";
|
||||
window.axios.interceptors.request.use(
|
||||
function (config) {
|
||||
let list;
|
||||
list = document.querySelectorAll(".btn-spinner");
|
||||
for (let i = 0; i < list.length; ++i) {
|
||||
list[i].classList.add("spinner-on");
|
||||
list[i].disabled = true;
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
function (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
window.axios.interceptors.response.use(
|
||||
function (response) {
|
||||
let list;
|
||||
list = document.querySelectorAll(".btn-spinner");
|
||||
for (let i = 0; i < list.length; ++i) {
|
||||
list[i].classList.remove("spinner-on");
|
||||
list[i].disabled = false;
|
||||
}
|
||||
return response;
|
||||
},
|
||||
function (error) {
|
||||
let list;
|
||||
list = document.querySelectorAll(".btn-spinner");
|
||||
for (let i = 0; i < list.length; ++i) {
|
||||
list[i].classList.remove("spinner-on");
|
||||
list[i].disabled = false;
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
<script>
|
||||
let { onDelete, text } = $props();
|
||||
let isClicked = $state(false);
|
||||
|
||||
function handleTryDelete(e) {
|
||||
e.preventDefault();
|
||||
isClicked = true;
|
||||
}
|
||||
|
||||
function handleCancel(e) {
|
||||
e.preventDefault();
|
||||
isClicked = false;
|
||||
}
|
||||
|
||||
function handleRealDelete(e) {
|
||||
e.preventDefault();
|
||||
onDelete();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !isClicked}
|
||||
<form onsubmit={handleTryDelete}>
|
||||
<button class="danger" type="submit">
|
||||
{@render text()}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
{#if isClicked}
|
||||
Are you sure?
|
||||
<form onsubmit={handleCancel}>
|
||||
<button class="secondary" type="submit">No</button>
|
||||
</form>
|
||||
<form onsubmit={handleRealDelete}>
|
||||
<button class="danger" type="submit">Yes</button>
|
||||
</form>
|
||||
{/if}
|
||||
@@ -1,159 +0,0 @@
|
||||
<script>
|
||||
const icons = {
|
||||
"trash-can": {
|
||||
path: '<path d="M135.2 17.69C140.6 6.848 151.7 0 163.8 0H284.2C296.3 0 307.4 6.848 312.8 17.69L320 32H416C433.7 32 448 46.33 448 64C448 81.67 433.7 96 416 96H32C14.33 96 0 81.67 0 64C0 46.33 14.33 32 32 32H128L135.2 17.69zM31.1 128H416V448C416 483.3 387.3 512 352 512H95.1C60.65 512 31.1 483.3 31.1 448V128zM111.1 208V432C111.1 440.8 119.2 448 127.1 448C136.8 448 143.1 440.8 143.1 432V208C143.1 199.2 136.8 192 127.1 192C119.2 192 111.1 199.2 111.1 208zM207.1 208V432C207.1 440.8 215.2 448 223.1 448C232.8 448 240 440.8 240 432V208C240 199.2 232.8 192 223.1 192C215.2 192 207.1 199.2 207.1 208zM304 208V432C304 440.8 311.2 448 320 448C328.8 448 336 440.8 336 432V208C336 199.2 328.8 192 320 192C311.2 192 304 199.2 304 208z"/>',
|
||||
viewBox: "0 0 448 512",
|
||||
},
|
||||
"circle-chevron-down": {
|
||||
path: '<path d="M256 0C114.6 0 0 114.6 0 256c0 141.4 114.6 256 256 256s256-114.6 256-256C512 114.6 397.4 0 256 0zM390.6 246.6l-112 112C272.4 364.9 264.2 368 256 368s-16.38-3.125-22.62-9.375l-112-112c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L256 290.8l89.38-89.38c12.5-12.5 32.75-12.5 45.25 0S403.1 234.1 390.6 246.6z"/>',
|
||||
viewBox: "0 0 512 512",
|
||||
},
|
||||
"circle-chevron-up": {
|
||||
path: '<path d="M256 0C114.6 0 0 114.6 0 256c0 141.4 114.6 256 256 256s256-114.6 256-256C512 114.6 397.4 0 256 0zM390.6 310.6c-12.5 12.5-32.75 12.5-45.25 0L256 221.3L166.6 310.6c-12.5 12.5-32.75 12.5-45.25 0s-12.5-32.75 0-45.25l112-112C239.6 147.1 247.8 144 256 144s16.38 3.125 22.62 9.375l112 112C403.1 277.9 403.1 298.1 390.6 310.6z"/>',
|
||||
viewBox: "0 0 512 512",
|
||||
},
|
||||
ellipsis: {
|
||||
path: '<path d="M120 256C120 286.9 94.93 312 64 312C33.07 312 8 286.9 8 256C8 225.1 33.07 200 64 200C94.93 200 120 225.1 120 256zM280 256C280 286.9 254.9 312 224 312C193.1 312 168 286.9 168 256C168 225.1 193.1 200 224 200C254.9 200 280 225.1 280 256zM328 256C328 225.1 353.1 200 384 200C414.9 200 440 225.1 440 256C440 286.9 414.9 312 384 312C353.1 312 328 286.9 328 256z"/>',
|
||||
viewBox: "0 0 448 512",
|
||||
},
|
||||
"ellipsis-vertical": {
|
||||
path: '<path d="M64 360C94.93 360 120 385.1 120 416C120 446.9 94.93 472 64 472C33.07 472 8 446.9 8 416C8 385.1 33.07 360 64 360zM64 200C94.93 200 120 225.1 120 256C120 286.9 94.93 312 64 312C33.07 312 8 286.9 8 256C8 225.1 33.07 200 64 200zM64 152C33.07 152 8 126.9 8 96C8 65.07 33.07 40 64 40C94.93 40 120 65.07 120 96C120 126.9 94.93 152 64 152z"/>',
|
||||
viewBox: "0 0 128 512",
|
||||
},
|
||||
"angles-down": {
|
||||
path: '<path d="M169.4 278.6C175.6 284.9 183.8 288 192 288s16.38-3.125 22.62-9.375l160-160c12.5-12.5 12.5-32.75 0-45.25s-32.75-12.5-45.25 0L192 210.8L54.63 73.38c-12.5-12.5-32.75-12.5-45.25 0s-12.5 32.75 0 45.25L169.4 278.6zM329.4 265.4L192 402.8L54.63 265.4c-12.5-12.5-32.75-12.5-45.25 0s-12.5 32.75 0 45.25l160 160C175.6 476.9 183.8 480 192 480s16.38-3.125 22.62-9.375l160-160c12.5-12.5 12.5-32.75 0-45.25S341.9 252.9 329.4 265.4z"/>',
|
||||
viewBox: "0 0 384 512",
|
||||
},
|
||||
"angle-right": {
|
||||
path: '<path d="M64 448c-8.188 0-16.38-3.125-22.62-9.375c-12.5-12.5-12.5-32.75 0-45.25L178.8 256L41.38 118.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0l160 160c12.5 12.5 12.5 32.75 0 45.25l-160 160C80.38 444.9 72.19 448 64 448z"/>',
|
||||
viewBox: "0 0 256 512",
|
||||
},
|
||||
"photo-film": {
|
||||
path: '<path d="M352 432c0 8.836-7.164 16-16 16H176c-8.838 0-16-7.164-16-16L160 128H48C21.49 128 .0003 149.5 .0003 176v288c0 26.51 21.49 48 48 48h416c26.51 0 48-21.49 48-48L512 384h-160L352 432zM104 439c0 4.969-4.031 9-9 9h-30c-4.969 0-9-4.031-9-9v-30c0-4.969 4.031-9 9-9h30c4.969 0 9 4.031 9 9V439zM104 335c0 4.969-4.031 9-9 9h-30c-4.969 0-9-4.031-9-9v-30c0-4.969 4.031-9 9-9h30c4.969 0 9 4.031 9 9V335zM104 231c0 4.969-4.031 9-9 9h-30c-4.969 0-9-4.031-9-9v-30C56 196 60.03 192 65 192h30c4.969 0 9 4.031 9 9V231zM408 409c0-4.969 4.031-9 9-9h30c4.969 0 9 4.031 9 9v30c0 4.969-4.031 9-9 9h-30c-4.969 0-9-4.031-9-9V409zM591.1 0H239.1C213.5 0 191.1 21.49 191.1 48v256c0 26.51 21.49 48 48 48h352c26.51 0 48-21.49 48-48v-256C640 21.49 618.5 0 591.1 0zM303.1 64c17.68 0 32 14.33 32 32s-14.32 32-32 32C286.3 128 271.1 113.7 271.1 96S286.3 64 303.1 64zM574.1 279.6C571.3 284.8 565.9 288 560 288H271.1C265.1 288 260.5 284.6 257.7 279.3C255 273.9 255.5 267.4 259.1 262.6l70-96C332.1 162.4 336.9 160 341.1 160c5.11 0 9.914 2.441 12.93 6.574l22.35 30.66l62.74-94.11C442.1 98.67 447.1 96 453.3 96c5.348 0 10.34 2.672 13.31 7.125l106.7 160C576.6 268 576.9 274.3 574.1 279.6z"/>',
|
||||
viewBox: "0 0 640 512",
|
||||
},
|
||||
file: {
|
||||
path: '<path d="M0 64C0 28.65 28.65 0 64 0H224V128C224 145.7 238.3 160 256 160H384V448C384 483.3 355.3 512 320 512H64C28.65 512 0 483.3 0 448V64zM256 128V0L384 128H256z"/>',
|
||||
viewBox: "0 0 384 512",
|
||||
},
|
||||
"circle-info": {
|
||||
path: '<path d="M256 0C114.6 0 0 114.6 0 256s114.6 256 256 256s256-114.6 256-256S397.4 0 256 0zM256 128c17.67 0 32 14.33 32 32c0 17.67-14.33 32-32 32S224 177.7 224 160C224 142.3 238.3 128 256 128zM296 384h-80C202.8 384 192 373.3 192 360s10.75-24 24-24h16v-64H224c-13.25 0-24-10.75-24-24S210.8 224 224 224h32c13.25 0 24 10.75 24 24v88h16c13.25 0 24 10.75 24 24S309.3 384 296 384z"/>',
|
||||
viewBox: "0 0 512 512",
|
||||
},
|
||||
"table-columns": {
|
||||
path: '<path d="M0 96C0 60.65 28.65 32 64 32H448C483.3 32 512 60.65 512 96V416C512 451.3 483.3 480 448 480H64C28.65 480 0 451.3 0 416V96zM64 416H224V160H64V416zM448 160H288V416H448V160z"/>',
|
||||
viewBox: "0 0 512 512",
|
||||
},
|
||||
"arrow-down-a-z": {
|
||||
path: '<path d="M239.6 373.1c11.94-13.05 11.06-33.31-1.969-45.27c-13.55-12.42-33.76-10.52-45.22 1.973L160 366.1V64.03c0-17.7-14.33-32.03-32-32.03S96 46.33 96 64.03v302l-32.4-35.39C51.64 317.7 31.39 316.7 18.38 328.7c-13.03 11.95-13.9 32.22-1.969 45.27l87.1 96.09c12.12 13.26 35.06 13.26 47.19 0L239.6 373.1zM448 416h-50.75l73.38-73.38c9.156-9.156 11.89-22.91 6.938-34.88S460.9 288 447.1 288H319.1C302.3 288 288 302.3 288 320s14.33 32 32 32h50.75l-73.38 73.38c-9.156 9.156-11.89 22.91-6.938 34.88S307.1 480 319.1 480h127.1C465.7 480 480 465.7 480 448S465.7 416 448 416zM492.6 209.3l-79.99-160.1c-10.84-21.81-46.4-21.81-57.24 0L275.4 209.3c-7.906 15.91-1.5 35.24 14.31 43.19c15.87 7.922 35.04 1.477 42.93-14.4l7.154-14.39h88.43l7.154 14.39c6.174 12.43 23.97 23.87 42.93 14.4C494.1 244.6 500.5 225.2 492.6 209.3zM367.8 167.4L384 134.7l16.22 32.63H367.8z"/>',
|
||||
viewBox: "0 0 512 512",
|
||||
},
|
||||
"arrow-up-short-wide": {
|
||||
path: '<path d="M544 416h-223.1c-17.67 0-32 14.33-32 32s14.33 32 32 32H544c17.67 0 32-14.33 32-32S561.7 416 544 416zM320 96h32c17.67 0 31.1-14.33 31.1-32s-14.33-32-31.1-32h-32c-17.67 0-32 14.33-32 32S302.3 96 320 96zM320 224H416c17.67 0 32-14.33 32-32s-14.33-32-32-32h-95.1c-17.67 0-32 14.33-32 32S302.3 224 320 224zM320 352H480c17.67 0 32-14.33 32-32s-14.33-32-32-32h-159.1c-17.67 0-32 14.33-32 32S302.3 352 320 352zM151.6 41.95c-12.12-13.26-35.06-13.26-47.19 0l-87.1 96.09C4.475 151.1 5.35 171.4 18.38 183.3c6.141 5.629 13.89 8.414 21.61 8.414c8.672 0 17.3-3.504 23.61-10.39L96 145.9v302C96 465.7 110.3 480 128 480s32-14.33 32-32.03V145.9L192.4 181.3C204.4 194.3 224.6 195.3 237.6 183.3c13.03-11.95 13.9-32.22 1.969-45.27L151.6 41.95z"/>',
|
||||
viewBox: "0 0 576 512",
|
||||
},
|
||||
"arrow-down-wide-short": {
|
||||
path: '<path d="M416 288h-95.1c-17.67 0-32 14.33-32 32s14.33 32 32 32H416c17.67 0 32-14.33 32-32S433.7 288 416 288zM544 32h-223.1c-17.67 0-32 14.33-32 32s14.33 32 32 32H544c17.67 0 32-14.33 32-32S561.7 32 544 32zM352 416h-32c-17.67 0-32 14.33-32 32s14.33 32 32 32h32c17.67 0 31.1-14.33 31.1-32S369.7 416 352 416zM480 160h-159.1c-17.67 0-32 14.33-32 32s14.33 32 32 32H480c17.67 0 32-14.33 32-32S497.7 160 480 160zM192.4 330.7L160 366.1V64.03C160 46.33 145.7 32 128 32S96 46.33 96 64.03v302L63.6 330.7c-6.312-6.883-14.94-10.38-23.61-10.38c-7.719 0-15.47 2.781-21.61 8.414c-13.03 11.95-13.9 32.22-1.969 45.27l87.1 96.09c12.12 13.26 35.06 13.26 47.19 0l87.1-96.09c11.94-13.05 11.06-33.31-1.969-45.27C224.6 316.8 204.4 317.7 192.4 330.7z"/>',
|
||||
viewBox: "0 0 576 512",
|
||||
},
|
||||
filter: {
|
||||
path: '<path d="M3.853 54.87C10.47 40.9 24.54 32 40 32H472C487.5 32 501.5 40.9 508.1 54.87C514.8 68.84 512.7 85.37 502.1 97.33L320 320.9V448C320 460.1 313.2 471.2 302.3 476.6C291.5 482 278.5 480.9 268.8 473.6L204.8 425.6C196.7 419.6 192 410.1 192 400V320.9L9.042 97.33C-.745 85.37-2.765 68.84 3.854 54.87L3.853 54.87z"/>',
|
||||
viewBox: "0 0 512 512",
|
||||
},
|
||||
calendar: {
|
||||
path: '<path d="M96 32C96 14.33 110.3 0 128 0C145.7 0 160 14.33 160 32V64H288V32C288 14.33 302.3 0 320 0C337.7 0 352 14.33 352 32V64H400C426.5 64 448 85.49 448 112V160H0V112C0 85.49 21.49 64 48 64H96V32zM448 464C448 490.5 426.5 512 400 512H48C21.49 512 0 490.5 0 464V192H448V464z"/>',
|
||||
viewBox: "0 0 448 512",
|
||||
},
|
||||
pencil: {
|
||||
path: '<path d="M421.7 220.3L188.5 453.4L154.6 419.5L158.1 416H112C103.2 416 96 408.8 96 400V353.9L92.51 357.4C87.78 362.2 84.31 368 82.42 374.4L59.44 452.6L137.6 429.6C143.1 427.7 149.8 424.2 154.6 419.5L188.5 453.4C178.1 463.8 165.2 471.5 151.1 475.6L30.77 511C22.35 513.5 13.24 511.2 7.03 504.1C.8198 498.8-1.502 489.7 .976 481.2L36.37 360.9C40.53 346.8 48.16 333.9 58.57 323.5L291.7 90.34L421.7 220.3zM492.7 58.75C517.7 83.74 517.7 124.3 492.7 149.3L444.3 197.7L314.3 67.72L362.7 19.32C387.7-5.678 428.3-5.678 453.3 19.32L492.7 58.75z"/>',
|
||||
viewBox: "0 0 512 512",
|
||||
},
|
||||
database: {
|
||||
path: '<path d="M448 80V128C448 172.2 347.7 208 224 208C100.3 208 0 172.2 0 128V80C0 35.82 100.3 0 224 0C347.7 0 448 35.82 448 80zM393.2 214.7C413.1 207.3 433.1 197.8 448 186.1V288C448 332.2 347.7 368 224 368C100.3 368 0 332.2 0 288V186.1C14.93 197.8 34.02 207.3 54.85 214.7C99.66 230.7 159.5 240 224 240C288.5 240 348.3 230.7 393.2 214.7V214.7zM54.85 374.7C99.66 390.7 159.5 400 224 400C288.5 400 348.3 390.7 393.2 374.7C413.1 367.3 433.1 357.8 448 346.1V432C448 476.2 347.7 512 224 512C100.3 512 0 476.2 0 432V346.1C14.93 357.8 34.02 367.3 54.85 374.7z"/>',
|
||||
viewBox: "0 0 448 512",
|
||||
},
|
||||
dice: {
|
||||
path: '<path d="M447.1 224c0-12.56-4.781-25.13-14.35-34.76l-174.9-174.9C249.1 4.786 236.5 0 223.1 0C211.4 0 198.9 4.786 189.2 14.35L14.35 189.2C4.783 198.9-.0011 211.4-.0011 223.1c0 12.56 4.785 25.17 14.35 34.8l174.9 174.9c9.625 9.562 22.19 14.35 34.75 14.35s25.13-4.783 34.75-14.35l174.9-174.9C443.2 249.1 447.1 236.6 447.1 224zM96 248c-13.25 0-23.1-10.75-23.1-23.1s10.75-23.1 23.1-23.1S120 210.8 120 224S109.3 248 96 248zM224 376c-13.25 0-23.1-10.75-23.1-23.1s10.75-23.1 23.1-23.1s23.1 10.75 23.1 23.1S237.3 376 224 376zM224 248c-13.25 0-23.1-10.75-23.1-23.1s10.75-23.1 23.1-23.1S248 210.8 248 224S237.3 248 224 248zM224 120c-13.25 0-23.1-10.75-23.1-23.1s10.75-23.1 23.1-23.1s23.1 10.75 23.1 23.1S237.3 120 224 120zM352 248c-13.25 0-23.1-10.75-23.1-23.1s10.75-23.1 23.1-23.1s23.1 10.75 23.1 23.1S365.3 248 352 248zM591.1 192l-118.7 0c4.418 10.27 6.604 21.25 6.604 32.23c0 20.7-7.865 41.38-23.63 57.14l-136.2 136.2v46.37C320 490.5 341.5 512 368 512h223.1c26.5 0 47.1-21.5 47.1-47.1V240C639.1 213.5 618.5 192 591.1 192zM479.1 376c-13.25 0-23.1-10.75-23.1-23.1s10.75-23.1 23.1-23.1s23.1 10.75 23.1 23.1S493.2 376 479.1 376z"/>',
|
||||
viewBox: "0 0 640 512",
|
||||
},
|
||||
|
||||
"triangle-exclamation": {
|
||||
path: '<path d="M506.3 417l-213.3-364c-16.33-28-57.54-28-73.98 0l-213.2 364C-10.59 444.9 9.849 480 42.74 480h426.6C502.1 480 522.6 445 506.3 417zM232 168c0-13.25 10.75-24 24-24S280 154.8 280 168v128c0 13.25-10.75 24-23.1 24S232 309.3 232 296V168zM256 416c-17.36 0-31.44-14.08-31.44-31.44c0-17.36 14.07-31.44 31.44-31.44s31.44 14.08 31.44 31.44C287.4 401.9 273.4 416 256 416z"/>',
|
||||
viewBox: "0 0 512 512",
|
||||
},
|
||||
eye: {
|
||||
path: '<path d="M279.6 160.4C282.4 160.1 285.2 160 288 160C341 160 384 202.1 384 256C384 309 341 352 288 352C234.1 352 192 309 192 256C192 253.2 192.1 250.4 192.4 247.6C201.7 252.1 212.5 256 224 256C259.3 256 288 227.3 288 192C288 180.5 284.1 169.7 279.6 160.4zM480.6 112.6C527.4 156 558.7 207.1 573.5 243.7C576.8 251.6 576.8 260.4 573.5 268.3C558.7 304 527.4 355.1 480.6 399.4C433.5 443.2 368.8 480 288 480C207.2 480 142.5 443.2 95.42 399.4C48.62 355.1 17.34 304 2.461 268.3C-.8205 260.4-.8205 251.6 2.461 243.7C17.34 207.1 48.62 156 95.42 112.6C142.5 68.84 207.2 32 288 32C368.8 32 433.5 68.84 480.6 112.6V112.6zM288 112C208.5 112 144 176.5 144 256C144 335.5 208.5 400 288 400C367.5 400 432 335.5 432 256C432 176.5 367.5 112 288 112z"/>',
|
||||
viewBox: "0 0 576 512",
|
||||
},
|
||||
"circle-plus": {
|
||||
path: '<path d="M0 256C0 114.6 114.6 0 256 0C397.4 0 512 114.6 512 256C512 397.4 397.4 512 256 512C114.6 512 0 397.4 0 256zM256 368C269.3 368 280 357.3 280 344V280H344C357.3 280 368 269.3 368 256C368 242.7 357.3 232 344 232H280V168C280 154.7 269.3 144 256 144C242.7 144 232 154.7 232 168V232H168C154.7 232 144 242.7 144 256C144 269.3 154.7 280 168 280H232V344C232 357.3 242.7 368 256 368z"/>',
|
||||
viewBox: "0 0 512 512",
|
||||
},
|
||||
"magnifying-glass": {
|
||||
path: '<path d="M500.3 443.7l-119.7-119.7c27.22-40.41 40.65-90.9 33.46-144.7C401.8 87.79 326.8 13.32 235.2 1.723C99.01-15.51-15.51 99.01 1.724 235.2c11.6 91.64 86.08 166.7 177.6 178.9c53.8 7.189 104.3-6.236 144.7-33.46l119.7 119.7c15.62 15.62 40.95 15.62 56.57 0C515.9 484.7 515.9 459.3 500.3 443.7zM79.1 208c0-70.58 57.42-128 128-128s128 57.42 128 128c0 70.58-57.42 128-128 128S79.1 278.6 79.1 208z"/>',
|
||||
viewBox: "0 0 512 512",
|
||||
},
|
||||
expand: {
|
||||
path: '<path d="M128 32H32C14.31 32 0 46.31 0 64v96c0 17.69 14.31 32 32 32s32-14.31 32-32V96h64c17.69 0 32-14.31 32-32S145.7 32 128 32zM416 32h-96c-17.69 0-32 14.31-32 32s14.31 32 32 32h64v64c0 17.69 14.31 32 32 32s32-14.31 32-32V64C448 46.31 433.7 32 416 32zM128 416H64v-64c0-17.69-14.31-32-32-32s-32 14.31-32 32v96c0 17.69 14.31 32 32 32h96c17.69 0 32-14.31 32-32S145.7 416 128 416zM416 320c-17.69 0-32 14.31-32 32v64h-64c-17.69 0-32 14.31-32 32s14.31 32 32 32h96c17.69 0 32-14.31 32-32v-96C448 334.3 433.7 320 416 320z"/>',
|
||||
viewBox: "0 0 448 512",
|
||||
},
|
||||
compress: {
|
||||
path: '<path d="M128 320H32c-17.69 0-32 14.31-32 32s14.31 32 32 32h64v64c0 17.69 14.31 32 32 32s32-14.31 32-32v-96C160 334.3 145.7 320 128 320zM416 320h-96c-17.69 0-32 14.31-32 32v96c0 17.69 14.31 32 32 32s32-14.31 32-32v-64h64c17.69 0 32-14.31 32-32S433.7 320 416 320zM320 192h96c17.69 0 32-14.31 32-32s-14.31-32-32-32h-64V64c0-17.69-14.31-32-32-32s-32 14.31-32 32v96C288 177.7 302.3 192 320 192zM128 32C110.3 32 96 46.31 96 64v64H32C14.31 128 0 142.3 0 160s14.31 32 32 32h96c17.69 0 32-14.31 32-32V64C160 46.31 145.7 32 128 32z"/>',
|
||||
viewBox: "0 0 448 512",
|
||||
},
|
||||
check: {
|
||||
path: '<path d="M438.6 105.4C451.1 117.9 451.1 138.1 438.6 150.6L182.6 406.6C170.1 419.1 149.9 419.1 137.4 406.6L9.372 278.6C-3.124 266.1-3.124 245.9 9.372 233.4C21.87 220.9 42.13 220.9 54.63 233.4L159.1 338.7L393.4 105.4C405.9 92.88 426.1 92.88 438.6 105.4H438.6z"/>',
|
||||
viewBox: "0 0 448 512",
|
||||
},
|
||||
close: {
|
||||
path: '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18 17.94 6M18 18 6.06 6"/>',
|
||||
viewBox: "0 0 24 24",
|
||||
},
|
||||
"arrow-left": {
|
||||
path: '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12l4-4m-4 4 4 4"/>',
|
||||
viewBox: "0 0 24 24",
|
||||
},
|
||||
list: {
|
||||
path: '<path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M9 8h10M9 12h10M9 16h10M4.99 8H5m-.02 4h.01m0 4H5"/>',
|
||||
viewBox: "0 0 24 24",
|
||||
},
|
||||
"ordered-list": {
|
||||
path: '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6h8m-8 6h8m-8 6h8M4 16a2 2 0 1 1 3.321 1.5L4 20h5M4 5l2-1v6m-2 0h4"/>',
|
||||
viewBox: "0 0 24 24",
|
||||
},
|
||||
italic: {
|
||||
path: '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m8.874 19 6.143-14M6 19h6.33m-.66-14H18"/>',
|
||||
viewBox: "0 0 24 24",
|
||||
},
|
||||
};
|
||||
|
||||
let {
|
||||
width = 16,
|
||||
height = 16,
|
||||
icon,
|
||||
fill = "currentColor",
|
||||
stroke = "currentColor",
|
||||
} = $props();
|
||||
|
||||
let selectedIcon = icons[icon];
|
||||
</script>
|
||||
|
||||
<svg
|
||||
class="bi"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{width}
|
||||
{height}
|
||||
viewBox={selectedIcon.viewBox}
|
||||
aria-labelledby={icon}
|
||||
role="presentation"
|
||||
{stroke}
|
||||
{fill}
|
||||
>
|
||||
{@html selectedIcon.path}
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
svg {
|
||||
vertical-align: text-top;
|
||||
}
|
||||
</style>
|
||||
@@ -1,150 +0,0 @@
|
||||
<script>
|
||||
import { arrayMoveElement } from "../helpers";
|
||||
import { flip } from "svelte/animate";
|
||||
|
||||
let {
|
||||
sortableClass,
|
||||
onUpdate,
|
||||
items,
|
||||
itemView,
|
||||
itemCssClass,
|
||||
itemKey = "id",
|
||||
type = "list",
|
||||
handleClass = null,
|
||||
disabled = false,
|
||||
} = $props();
|
||||
// let sortableInstance = $state();
|
||||
let sortableContainer = $state();
|
||||
let draggedItem = $state();
|
||||
|
||||
function handleDragStart(event, item) {
|
||||
if (disabled) {
|
||||
return false;
|
||||
}
|
||||
draggedItem = item;
|
||||
// Set data required for drag operation
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData("text/plain", item.id);
|
||||
// Set a ghost drag image
|
||||
event.currentTarget.classList.add("dragging");
|
||||
}
|
||||
|
||||
// Handle drag over another item
|
||||
function handleDragOver(event) {
|
||||
event.preventDefault();
|
||||
event.target.closest(".draggable-item").classList.add("dragover");
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
}
|
||||
|
||||
// Handle dropping the item
|
||||
function handleDrop(event, targetItem) {
|
||||
event.preventDefault();
|
||||
|
||||
if (draggedItem === targetItem) return;
|
||||
// Find positions of dragged and target items
|
||||
const draggedIndex = items.findIndex(
|
||||
(item) => getItem(item, itemKey) === getItem(draggedItem, itemKey),
|
||||
);
|
||||
const targetIndex = items.findIndex(
|
||||
(item) => getItem(item, itemKey) === getItem(targetItem, itemKey),
|
||||
);
|
||||
|
||||
onUpdate(
|
||||
arrayMoveElement([...items], draggedIndex, targetIndex),
|
||||
draggedIndex,
|
||||
targetIndex,
|
||||
);
|
||||
}
|
||||
|
||||
function getItem(item, key) {
|
||||
if (key === null) {
|
||||
return item;
|
||||
}
|
||||
if (key.includes(".")) {
|
||||
return key.split(".").reduce((a, b) => a[b] ?? null, item);
|
||||
}
|
||||
return item[key];
|
||||
}
|
||||
|
||||
function handleDragEnd(event) {
|
||||
event.target.classList.remove("dragging");
|
||||
sortableContainer
|
||||
.querySelector(".dragover")
|
||||
?.classList.remove("dragover");
|
||||
draggedItem = null;
|
||||
const draggableItem = event.target.closest(".draggable-item");
|
||||
draggableItem.draggable = false;
|
||||
}
|
||||
|
||||
function handleDragLeave(event) {
|
||||
// event.target.classList.remove("dragover");
|
||||
const draggableItem = event.target.closest(".draggable-item");
|
||||
draggableItem.classList.remove("dragover");
|
||||
draggableItem.draggable = false;
|
||||
}
|
||||
|
||||
function handleMouseDown(e) {
|
||||
const handleEl = e.target.closest("." + handleClass);
|
||||
if (!handleEl) {
|
||||
return true;
|
||||
}
|
||||
const draggableItem = e.target.closest(".draggable-item");
|
||||
draggableItem.draggable = true;
|
||||
}
|
||||
|
||||
function handleMouseUp(e) {
|
||||
const handleEl = e.target.closest("." + handleClass);
|
||||
if (!handleEl) {
|
||||
return true;
|
||||
}
|
||||
const draggableItem = e.target.closest(".draggable-item");
|
||||
draggableItem.draggable = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if type === "table"}
|
||||
<tbody
|
||||
class="sortable-container {sortableClass}"
|
||||
bind:this={sortableContainer}
|
||||
>
|
||||
{#each items as item (getItem(item, itemKey))}
|
||||
<tr
|
||||
animate:flip={{ duration: 100 }}
|
||||
class="draggable-item {itemCssClass}"
|
||||
draggable="false"
|
||||
onmousedown={handleMouseDown}
|
||||
onmouseup={handleMouseUp}
|
||||
ondragstart={(e) => handleDragStart(e, item)}
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={(e) => handleDrop(e, item)}
|
||||
ondragend={handleDragEnd}
|
||||
>
|
||||
{@render itemView(item)}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
{:else}
|
||||
<div
|
||||
class="sortable-container {sortableClass}"
|
||||
bind:this={sortableContainer}
|
||||
>
|
||||
{#each items as item (getItem(item, itemKey))}
|
||||
<div
|
||||
animate:flip={{ duration: 100 }}
|
||||
role="listitem"
|
||||
class="draggable-item {itemCssClass}"
|
||||
draggable="false"
|
||||
onmousedown={handleMouseDown}
|
||||
onmouseup={handleMouseUp}
|
||||
ondragstart={(e) => handleDragStart(e, item)}
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={(e) => handleDrop(e, item)}
|
||||
ondragend={handleDragEnd}
|
||||
>
|
||||
{@render itemView(item)}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,124 +0,0 @@
|
||||
<script>
|
||||
import ChannelLayout from "../../layouts/ChannelLayout.svelte";
|
||||
import { post } from "../../modules/remote";
|
||||
import { getApp } from "../../app";
|
||||
import Table from "./Table.svelte";
|
||||
import Tools from "./Tools.svelte";
|
||||
|
||||
// import Tools from "./tools/Tools.svelte";
|
||||
// import Pagination from "./pagination/Pagination.svelte";
|
||||
// import ActionsOnSelected from "./ActionsOnSelected.svelte";
|
||||
// import Table from "./Table.svelte";
|
||||
let { channel, user, data } = $props();
|
||||
let newRecordName = $state("");
|
||||
const app = getApp();
|
||||
// export let schema;
|
||||
// export let users;
|
||||
// export let records;
|
||||
// export let graph;
|
||||
// // export let visibleFields;
|
||||
// export let systemFields;
|
||||
// export let sortParam;
|
||||
// export let sortField;
|
||||
// export let operators;
|
||||
// export let filter;
|
||||
// export let limit;
|
||||
// export let skip;
|
||||
// export let total;
|
||||
let inModal = false;
|
||||
// export let modalUrl;
|
||||
// export let selected = [];
|
||||
// export let isWritable = false;
|
||||
|
||||
// function refresh(e) {
|
||||
// const newUrl = e.detail;
|
||||
// axios
|
||||
// .get(newUrl)
|
||||
// .then((response) => {
|
||||
// records = response.data.records;
|
||||
// sortParam = response.data.sortParam;
|
||||
// sortField = response.data.sortField;
|
||||
// operators = response.data.operators;
|
||||
// filter = response.data.filter;
|
||||
// skip = response.data.skip;
|
||||
// limit = response.data.limit;
|
||||
// total = response.data.total;
|
||||
// modalUrl = response.data.modalUrl;
|
||||
// document.querySelector("dialog h3").scrollIntoView();
|
||||
// })
|
||||
// .catch((error) => {
|
||||
// console.log(error);
|
||||
// });
|
||||
// }
|
||||
//
|
||||
|
||||
function handleRecordCreate(e) {
|
||||
e.preventDefault();
|
||||
post(
|
||||
channel.lucentUrl + "/records",
|
||||
{
|
||||
schemaId: data.schema.id,
|
||||
title: newRecordName,
|
||||
},
|
||||
(data, err) => {
|
||||
if (err.isEmpty()) {
|
||||
Turbo.visit(app.url("records/" + data.id));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<ChannelLayout {body} {channel} schemas={data.schemas} {user}></ChannelLayout>
|
||||
{#snippet body()}
|
||||
<div class="">
|
||||
<div class={inModal ? "mt-0" : "mt-5"}>
|
||||
<h3 class="header-normal mb-5">
|
||||
{data.schema.name}
|
||||
</h3>
|
||||
<details style="max-width: 400px;">
|
||||
<summary>New Record</summary>
|
||||
<form onsubmit={handleRecordCreate}>
|
||||
<fieldset>
|
||||
<input
|
||||
bind:value={newRecordName}
|
||||
placeholder="Record title"
|
||||
required
|
||||
/>
|
||||
</fieldset>
|
||||
<button type="submit">Create</button>
|
||||
</form>
|
||||
</details>
|
||||
<!-- {#if selected.length > 0 && !inModal && isWritable}
|
||||
<ActionsOnSelected {schema} {selected} {filter}/>
|
||||
{:else}
|
||||
<Tools
|
||||
bind:schema
|
||||
bind:records
|
||||
{systemFields}
|
||||
{sortParam}
|
||||
{sortField}
|
||||
{operators}
|
||||
{filter}
|
||||
{graph}
|
||||
{inModal}
|
||||
{modalUrl}
|
||||
{isWritable}
|
||||
on:refresh={refresh}
|
||||
/>
|
||||
{/if}
|
||||
-->
|
||||
<Tools fields={data.fields}></Tools>
|
||||
<Table records={data.records} fields={data.fields}></Table>
|
||||
</div>
|
||||
<!--
|
||||
<Pagination
|
||||
{limit}
|
||||
{skip}
|
||||
{total}
|
||||
on:refresh={refresh}
|
||||
{inModal}
|
||||
{modalUrl}
|
||||
/> -->
|
||||
</div>
|
||||
{/snippet}
|
||||
@@ -1,126 +0,0 @@
|
||||
<script>
|
||||
// import RecordRow from "./RecordRow.svelte";
|
||||
// import { previewTitle } from "../records/Preview";
|
||||
// import { usernameById } from "../account/users";
|
||||
// import Avatar from "../../common/Avatar.svelte";
|
||||
// import { selectRecord, toggleAll } from "./functions/recordSelect.js";
|
||||
// import Checkbox from "../common/Checkbox.svelte";
|
||||
// import Preview from "../files/Preview.svelte";
|
||||
// import { fileurl } from "../files/imageserver.js";
|
||||
import { getApp } from "../../app";
|
||||
|
||||
let { channel, records = [], fields = [], inModal = false } = $props();
|
||||
let params = new URLSearchParams(document.location.search);
|
||||
let columns = $state(params.get("columns")?.split(",") || []);
|
||||
let visibleFields = $derived(fields.filter((f) => columns.includes(f.id)));
|
||||
// export let schema;
|
||||
// export let users;
|
||||
// export let records;
|
||||
// export let graph;
|
||||
// export let systemFields;
|
||||
// export let sortParam;
|
||||
// export let sortField;
|
||||
// export let inModal;
|
||||
// export let isWritable;
|
||||
// export let selected = [];
|
||||
const app = getApp();
|
||||
|
||||
// function eventToggleAll(e) {
|
||||
// selected = toggleAll(e, records, selected);
|
||||
// }
|
||||
|
||||
// function select(record) {
|
||||
// selected = selectRecord(record, selected);
|
||||
// }
|
||||
|
||||
// $: visibleColumns = schema.fields.filter(
|
||||
// (c) => schema.visible?.includes(c.name) ?? [],
|
||||
// );
|
||||
</script>
|
||||
|
||||
<div class="table mt-5">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<!-- <th>
|
||||
<Checkbox
|
||||
value=""
|
||||
on:change={eventToggleAll}
|
||||
indeterminate={selected.length > 0 &&
|
||||
selected.length < records.length}
|
||||
checked={selected.length === records.length}
|
||||
></Checkbox>
|
||||
</th> -->
|
||||
<th>Title</th>
|
||||
|
||||
{#each visibleFields as field}
|
||||
<th scope="col" title={field.help}>{field.name}</th>
|
||||
{/each}
|
||||
<!-- {#each systemFields.filter( (c) => schema.visible?.includes(c.name), ) as sysField}
|
||||
<th class:is-sort={sysField.name === sortField.name}
|
||||
>{sysField.label}</th
|
||||
>
|
||||
{/each} -->
|
||||
<!-- <th></th> -->
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each records as qRecord (qRecord.record.id)}
|
||||
<tr>
|
||||
<td class="title-td">
|
||||
<div class="title-td-contents">
|
||||
<!-- <Checkbox
|
||||
on:change={() => select(record)}
|
||||
checked={selected.find(
|
||||
(r) => r.id === record.id,
|
||||
)}
|
||||
value={record}
|
||||
></Checkbox> -->
|
||||
|
||||
<a
|
||||
href="{app.url('records/')}{qRecord.record.id}"
|
||||
target={inModal ? "_blank" : "_self"}
|
||||
>
|
||||
<!-- {#if record.status === "draft"}
|
||||
<span
|
||||
style="text-transform: uppercase;font-size:10px"
|
||||
>{record.status}</span
|
||||
>
|
||||
{/if} -->
|
||||
<!-- {previewTitle(channel.schemas, record, graph)} -->
|
||||
{qRecord.recordPreview.title}
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
{#each visibleFields as field}
|
||||
<td>
|
||||
{qRecord.data.find((f) => f.fieldId === field.id)
|
||||
?.value}
|
||||
|
||||
<ul>
|
||||
{#each qRecord.children.filter((e) => e.edge.fieldId === field.id) as child}
|
||||
<li>{child.recordPreview.title}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</td>
|
||||
{/each}
|
||||
<!-- <RecordRow
|
||||
{record}
|
||||
{graph}
|
||||
{schema}
|
||||
{visibleColumns}
|
||||
{sortParam}
|
||||
{sortField}
|
||||
{users}
|
||||
/> -->
|
||||
<!-- <td>
|
||||
<Avatar
|
||||
name={usernameById(users, record._sys.updatedBy)}
|
||||
side={24}
|
||||
/>
|
||||
</td> -->
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -1,76 +0,0 @@
|
||||
<script>
|
||||
let { fields } = $props();
|
||||
|
||||
let params = new URLSearchParams(document.location.search);
|
||||
|
||||
let sortBy = $state(params.get("sortBy") + "_" + params.get("sortDir"));
|
||||
let columns = $state(params.get("columns")?.split(",") || []);
|
||||
|
||||
function handleSortAsc() {
|
||||
params.set("sortBy", sortBy.replace("_asc", ""));
|
||||
params.set("sortDir", "asc");
|
||||
Turbo.visit(window.location.pathname + `?` + params.toString());
|
||||
}
|
||||
|
||||
function handleSortDesc() {
|
||||
params.set("sortBy", sortBy.replace("_desc", ""));
|
||||
params.set("sortDir", "desc");
|
||||
Turbo.visit(window.location.pathname + `?` + params.toString());
|
||||
}
|
||||
|
||||
function handleToggleColumn() {
|
||||
params.set("columns", columns.join(","));
|
||||
Turbo.visit(window.location.pathname + `?` + params.toString());
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Radios -->
|
||||
<details class="dropdown">
|
||||
<summary> Sort by </summary>
|
||||
<ul>
|
||||
{#each fields as field}
|
||||
<li>
|
||||
<label>
|
||||
<input
|
||||
bind:group={sortBy}
|
||||
type="radio"
|
||||
value={field.id + "_asc"}
|
||||
onchange={handleSortAsc}
|
||||
/>
|
||||
{field.name}
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<label>
|
||||
<input
|
||||
bind:group={sortBy}
|
||||
type="radio"
|
||||
value={field.id + "_desc"}
|
||||
onchange={handleSortDesc}
|
||||
/>
|
||||
{field.name} desc
|
||||
</label>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<!-- Checkboxes -->
|
||||
<details class="dropdown">
|
||||
<summary> Show/Hide columns </summary>
|
||||
<ul>
|
||||
{#each fields as field}
|
||||
<li>
|
||||
<label>
|
||||
<input
|
||||
bind:group={columns}
|
||||
type="checkbox"
|
||||
value={field.id}
|
||||
onchange={handleToggleColumn}
|
||||
/>
|
||||
{field.name}
|
||||
</label>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</details>
|
||||
@@ -1,65 +0,0 @@
|
||||
<script>
|
||||
import SchemaLayout from "../../layouts/SchemaLayout.svelte";
|
||||
import { post } from "../../modules/remote";
|
||||
import { getApp } from "../../app";
|
||||
let { channel, user, data, newRank } = $props();
|
||||
let name = $state("");
|
||||
let alias = $state("");
|
||||
const app = getApp();
|
||||
|
||||
function handleCreate(e) {
|
||||
e.preventDefault();
|
||||
post(
|
||||
app.url("fields"),
|
||||
{
|
||||
schemaId: data.schema.id,
|
||||
name: name,
|
||||
alias: alias,
|
||||
fieldType: data.type,
|
||||
},
|
||||
(data, err) => {
|
||||
if (err.isEmpty()) {
|
||||
Turbo.visit(app.url("fields/edit/" + data.field.id));
|
||||
} else {
|
||||
console.log(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<SchemaLayout {body} {channel} {user}></SchemaLayout>
|
||||
{#snippet body()}
|
||||
<h3 class="header-small mb-4 mt-5">Create a <em>{data.type}</em> field</h3>
|
||||
|
||||
<form onsubmit={handleCreate}>
|
||||
<fieldset>
|
||||
<label>
|
||||
Name
|
||||
<input
|
||||
bind:value={name}
|
||||
placeholder="ex. Description"
|
||||
minlength="2"
|
||||
maxlength="30"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Alias
|
||||
<input
|
||||
bind:value={alias}
|
||||
placeholder="ex. description"
|
||||
minlength="2"
|
||||
maxlength="30"
|
||||
required
|
||||
aria-describedby="alias-helper"
|
||||
/>
|
||||
<small id="alias-helper">
|
||||
Developers will use this to reference the field
|
||||
</small>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<button type="submit">Create</button>
|
||||
</form>
|
||||
{/snippet}
|
||||
@@ -1,160 +0,0 @@
|
||||
<script>
|
||||
import SchemaLayout from "../../layouts/SchemaLayout.svelte";
|
||||
import TextFieldProps from "./TextFieldProps.svelte";
|
||||
import RelationFieldProps from "./RelationFieldProps.svelte";
|
||||
import FileFieldProps from "./FileFieldProps.svelte";
|
||||
import DeleteButton from "../../common/DeleteButton.svelte";
|
||||
import { post } from "../../modules/remote";
|
||||
import { getApp } from "../../app";
|
||||
let { channel, user, data } = $props();
|
||||
|
||||
const app = getApp();
|
||||
|
||||
function handleUpdate(e) {
|
||||
e.preventDefault();
|
||||
post(
|
||||
app.url("fields/update"),
|
||||
{
|
||||
field: data.field,
|
||||
},
|
||||
(data, err) => {
|
||||
if (err.isEmpty()) {
|
||||
Turbo.visit(app.url("schemas"));
|
||||
} else {
|
||||
console.log(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
post(
|
||||
app.url("fields/delete"),
|
||||
{
|
||||
fieldId: data.field.id,
|
||||
},
|
||||
(data, err) => {
|
||||
if (err.isEmpty()) {
|
||||
Turbo.visit(app.url("schemas"));
|
||||
} else {
|
||||
console.log(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<SchemaLayout {body} {channel} {user}></SchemaLayout>
|
||||
{#snippet body()}
|
||||
<h3 class="header-small mb-4 mt-5">
|
||||
Edit <em>{data.field.type}</em> field {data.field.name}
|
||||
</h3>
|
||||
|
||||
{#if data.field.alias == "_title"}
|
||||
{@render titleField()}
|
||||
{:else}
|
||||
{@render normalfield()}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet normalfield()}
|
||||
<form onsubmit={handleUpdate}>
|
||||
<fieldset>
|
||||
<label>
|
||||
Name
|
||||
<input
|
||||
bind:value={data.field.name}
|
||||
placeholder="ex. Description"
|
||||
minlength="2"
|
||||
maxlength="30"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Alias
|
||||
<input
|
||||
bind:value={data.field.alias}
|
||||
placeholder="ex. description"
|
||||
minlength="2"
|
||||
maxlength="30"
|
||||
required
|
||||
aria-describedby="alias-helper"
|
||||
/>
|
||||
<small id="alias-helper">
|
||||
Developers will use this to reference the field
|
||||
</small>
|
||||
</label>
|
||||
<label>
|
||||
Help text
|
||||
<input bind:value={data.field.help} />
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
bind:checked={data.field.translatable}
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
/>
|
||||
Is Translatable
|
||||
</label>
|
||||
<fieldset>
|
||||
<label>
|
||||
<input
|
||||
bind:checked={data.field.required}
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
/>
|
||||
Required
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
bind:checked={data.field.readonly}
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
/>
|
||||
Readonly
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
bind:checked={data.field.hidden}
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
/>
|
||||
Hidden
|
||||
</label>
|
||||
</fieldset>
|
||||
</fieldset>
|
||||
|
||||
{#if data.field.type === "text"}
|
||||
<TextFieldProps field={data.field}></TextFieldProps>
|
||||
{:else if data.field.type === "relation"}
|
||||
<RelationFieldProps field={data.field} schemas={data.schemas}
|
||||
></RelationFieldProps>
|
||||
{:else if data.field.type === "file"}
|
||||
<FileFieldProps field={data.field}></FileFieldProps>
|
||||
{/if}
|
||||
|
||||
<button type="submit">Update</button>
|
||||
</form>
|
||||
<DeleteButton onDelete={handleDelete}>
|
||||
{#snippet text()}
|
||||
Delete field
|
||||
{/snippet}
|
||||
</DeleteButton>
|
||||
{/snippet}
|
||||
|
||||
{#snippet titleField()}
|
||||
<form onsubmit={handleUpdate}>
|
||||
<fieldset>
|
||||
<label>
|
||||
<input
|
||||
bind:checked={data.field.translatable}
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
/>
|
||||
Is Translatable
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<button type="submit">Update</button>
|
||||
</form>
|
||||
{/snippet}
|
||||
@@ -1,15 +0,0 @@
|
||||
<script>
|
||||
let { field } = $props();
|
||||
</script>
|
||||
|
||||
<fieldset>
|
||||
<label>
|
||||
Min items
|
||||
<input type="number" bind:value={field.props.min} />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Max items
|
||||
<input type="number" bind:value={field.props.max} />
|
||||
</label>
|
||||
</fieldset>
|
||||
@@ -1,29 +0,0 @@
|
||||
<script>
|
||||
let { field, schemas } = $props();
|
||||
</script>
|
||||
|
||||
<fieldset>
|
||||
<label>
|
||||
Schemas
|
||||
<select
|
||||
bind:value={field.props.schemas}
|
||||
aria-label="Select allowed schemas"
|
||||
multiple
|
||||
size="6"
|
||||
>
|
||||
<option disabled>Select allowed schemas </option>
|
||||
{#each schemas as schema}
|
||||
<option value={schema.id}>{schema.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Min items
|
||||
<input type="number" bind:value={field.props.min} />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Max items
|
||||
<input type="number" bind:value={field.props.max} />
|
||||
</label>
|
||||
</fieldset>
|
||||
@@ -1,20 +0,0 @@
|
||||
<script>
|
||||
let { field } = $props();
|
||||
</script>
|
||||
|
||||
<fieldset>
|
||||
<label>
|
||||
Default
|
||||
<input bind:value={field.props.default} />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Min characters
|
||||
<input type="number" bind:value={field.props.min} />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Max characters
|
||||
<input type="number" bind:value={field.props.max} />
|
||||
</label>
|
||||
</fieldset>
|
||||
@@ -1,36 +0,0 @@
|
||||
<script>
|
||||
import { getContext, onMount } from "svelte";
|
||||
import RecordRow from "./RecordRow.svelte";
|
||||
import ChannelLayout from "../../layouts/ChannelLayout.svelte";
|
||||
import { get } from "../../modules/remote";
|
||||
let { channel, user, data } = $props();
|
||||
let records = $state([]);
|
||||
let graph = $state(null);
|
||||
let users = $state([]);
|
||||
onMount(() => {
|
||||
get(channel.lucentUrl + "/home/records", {}, (data) => {
|
||||
records = data.records;
|
||||
graph = data.graph;
|
||||
users = data.users;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<ChannelLayout {body} {channel} schemas={data.schemas} {user}></ChannelLayout>
|
||||
{#snippet body()}
|
||||
<h3 class="header-small mb-4 mt-5">Latest Content changes</h3>
|
||||
{#if records.length > 0}
|
||||
<div class="table">
|
||||
<table class="">
|
||||
<tbody>
|
||||
{#each records as record (record.id)}
|
||||
<tr>
|
||||
<RecordRow {channel} {graph} {record} {users}
|
||||
></RecordRow>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
@@ -1,41 +0,0 @@
|
||||
<script>
|
||||
import { formatDistanceToNow, parseJSON } from "date-fns";
|
||||
import Avatar from "../../common/Avatar.svelte";
|
||||
import { previewTitle } from "../../svelte/records/Preview";
|
||||
import Preview from "../../svelte/files/Preview.svelte";
|
||||
import { usernameById } from "../../svelte/account/users";
|
||||
|
||||
let { channel, users, record, graph } = $props();
|
||||
let schema = $derived(
|
||||
channel.schemas.find((s) => s.name === record.schema),
|
||||
);
|
||||
let frieldlyUpdatedAt = formatDistanceToNow(
|
||||
parseJSON(record._sys.updatedAt),
|
||||
{ addSuffix: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<td>
|
||||
<div class="row-name">
|
||||
{#if record.status === "draft"}
|
||||
<span class="status">DRAFT</span>
|
||||
{/if}
|
||||
{#if schema.type === "files"}
|
||||
<!-- <Preview {record} size="tiny" showFilename={true} /> -->
|
||||
{:else}
|
||||
<a href="{channel.lucentUrl}/records/{record.id}">
|
||||
<!-- {previewTitle(channel.schemas, record, graph)} -->
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td><a href="{channel.lucentUrl}/content/{schema.name}">{schema.label}</a> </td>
|
||||
|
||||
<td>
|
||||
<div style="display: flex;gap: 14px">
|
||||
<Avatar name={usernameById(users, record._sys.updatedBy)} side={24} />
|
||||
<div class="ms-2">
|
||||
{frieldlyUpdatedAt}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -1,53 +0,0 @@
|
||||
<script>
|
||||
import AccountLayout from "../../layouts/AccountLayout.svelte";
|
||||
import { post } from "../../modules/remote";
|
||||
let { channel } = $props();
|
||||
let email = $state("");
|
||||
let message = $state("");
|
||||
let isLoading = $state(false);
|
||||
$inspect(channel);
|
||||
function login(e) {
|
||||
e.preventDefault();
|
||||
isLoading = true;
|
||||
post(
|
||||
channel.lucentUrl + "/login",
|
||||
{
|
||||
email: email,
|
||||
},
|
||||
(data, err) => {
|
||||
isLoading = false;
|
||||
message = "You will receive an email with a login link";
|
||||
},
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<AccountLayout {body} {channel}></AccountLayout>
|
||||
{#snippet body()}
|
||||
<div class="wrapper-tiny">
|
||||
{#if message}
|
||||
<div class="alert alert-info" role="alert">
|
||||
{message}
|
||||
</div>
|
||||
{:else}
|
||||
<form onsubmit={login}>
|
||||
<div class="mb-3">
|
||||
<label for="emailaddress" class="form-label"
|
||||
>Email address</label
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
bind:value={email}
|
||||
class="form-control"
|
||||
id="emailaddress"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-5 d-block">
|
||||
<button aria-busy={isLoading}>Login</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
@@ -1,32 +0,0 @@
|
||||
<script>
|
||||
import { getSelectedLocales, getLocaleName } from "./locale.svelte.js";
|
||||
let { channel, onLocaleChange } = $props();
|
||||
let selectedLocales = $state(getSelectedLocales());
|
||||
|
||||
let selectedLocaleNames = $derived(
|
||||
selectedLocales.map((id) => getLocaleName(channel, id)),
|
||||
);
|
||||
function handleChange() {
|
||||
localStorage.setItem("selectedLocales", selectedLocales);
|
||||
onLocaleChange();
|
||||
}
|
||||
</script>
|
||||
|
||||
<details class="dropdown">
|
||||
<summary>Locales: {selectedLocaleNames.join(", ")}</summary>
|
||||
<ul>
|
||||
{#each channel.locales as locale}
|
||||
<li>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:group={selectedLocales}
|
||||
onchange={handleChange}
|
||||
value={locale.id}
|
||||
/>
|
||||
{locale.name}
|
||||
</label>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</details>
|
||||
@@ -1,60 +0,0 @@
|
||||
<script>
|
||||
import { getApp } from "../../app";
|
||||
import { post } from "../../modules/remote";
|
||||
import Icon from "./../../common/Icon.svelte";
|
||||
let { record, status } = $props();
|
||||
let date = $state(null);
|
||||
const app = getApp();
|
||||
function handlePublish() {
|
||||
post(app.url("records/publish"), { id: record.id }, (data, err) => {
|
||||
Turbo.visit(window.location.href);
|
||||
});
|
||||
}
|
||||
|
||||
function handleTrash() {
|
||||
post(app.url("records/trash"), { id: record.id }, (data, err) => {
|
||||
Turbo.visit(window.location.href);
|
||||
});
|
||||
}
|
||||
function handleSchedule() {
|
||||
post(
|
||||
app.url("records/schedule"),
|
||||
{ id: record.id, date: date },
|
||||
(data, err) => {
|
||||
Turbo.visit(window.location.href);
|
||||
},
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div style="display: flex; gap:20px">
|
||||
{#if status != "trashed"}
|
||||
<button onclick={handlePublish}>Publish Now</button>
|
||||
{/if}
|
||||
<details class="dropdown">
|
||||
<summary role="button" class="secondary"
|
||||
><Icon icon="ellipsis-vertical"></Icon></summary
|
||||
>
|
||||
<ul>
|
||||
<li>
|
||||
<form onsubmit={handleSchedule}>
|
||||
<fieldset role="group">
|
||||
<input
|
||||
bind:value={date}
|
||||
type="datetime-local"
|
||||
aria-label="Datetime local"
|
||||
required
|
||||
/>
|
||||
<button>Schedule</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</li>
|
||||
<li><a href="#">View Revisions</a></li>
|
||||
{#if status != "trashed"}
|
||||
<li>
|
||||
<button onclick={handleTrash}>Move to trash</button>
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
@@ -1,74 +0,0 @@
|
||||
<script>
|
||||
import ChannelLayout from "../../layouts/ChannelLayout.svelte";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
import LocaleChooser from "./LocaleChooser.svelte";
|
||||
import RecordForm from "./RecordForm.svelte";
|
||||
import RecordStatus from "./RecordStatus.svelte";
|
||||
import PublishingOptions from "./PublishingOptions.svelte";
|
||||
import { getSelectedLocales } from "./locale.svelte.js";
|
||||
let { channel, user, data } = $props();
|
||||
let selectedLocales = $state(getSelectedLocales());
|
||||
let record = $state(data.record);
|
||||
let showPublished = $state(false);
|
||||
|
||||
function handleLocaleChange() {
|
||||
selectedLocales = getSelectedLocales();
|
||||
}
|
||||
function toggleLiveData() {
|
||||
if (!showPublished) {
|
||||
// to avoid state sync
|
||||
Turbo.visit(window.location.href);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- <svelte:window on:beforeunload={beforeUnload} /> -->
|
||||
<ChannelLayout {body} {channel} schemas={data.schemas} {user}></ChannelLayout>
|
||||
{#snippet body()}
|
||||
<RecordStatus {channel} {record} status={data.recordStatus}></RecordStatus>
|
||||
<div style="display:flex;gap:20px;justify-content: space-between;">
|
||||
<LocaleChooser {channel} onLocaleChange={handleLocaleChange}
|
||||
></LocaleChooser>
|
||||
{#if record.publishedBy}
|
||||
<label>
|
||||
<input
|
||||
bind:checked={showPublished}
|
||||
onchange={toggleLiveData}
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
/>
|
||||
Show Live Data
|
||||
</label>
|
||||
{/if}
|
||||
<PublishingOptions {record} status={data.recordStatus}
|
||||
></PublishingOptions>
|
||||
</div>
|
||||
{#if !showPublished}
|
||||
<fieldset disabled={data.recordStatus === "trashed"}>
|
||||
<RecordForm
|
||||
{channel}
|
||||
fields={data.fields}
|
||||
edgeRecordPreviews={data.edgeRecordPreviewsDraft}
|
||||
filesPreviews={data.filesPreviewsDraft}
|
||||
{record}
|
||||
{selectedLocales}
|
||||
validationErrors={data.validationErrors}
|
||||
fieldData={data.draftData}
|
||||
></RecordForm>
|
||||
</fieldset>
|
||||
{:else}
|
||||
<fieldset disabled={true}>
|
||||
<RecordForm
|
||||
{channel}
|
||||
fields={data.fields}
|
||||
edgeRecordPreviews={data.edgeRecordPreviewsLive}
|
||||
filesPreviews={data.filesPreviewsLive}
|
||||
{record}
|
||||
{selectedLocales}
|
||||
validationErrors={data.validationErrors}
|
||||
fieldData={data.liveData}
|
||||
></RecordForm>
|
||||
</fieldset>
|
||||
{/if}
|
||||
{/snippet}
|
||||
@@ -1,101 +0,0 @@
|
||||
<script>
|
||||
import TextField from "./fields/TextField.svelte";
|
||||
import RelationField from "./fields/RelationField.svelte";
|
||||
import FileField from "./fields/FileField.svelte";
|
||||
let {
|
||||
fields,
|
||||
record,
|
||||
channel,
|
||||
validationErrors,
|
||||
fieldData,
|
||||
edgeRecordPreviews,
|
||||
filesPreviews,
|
||||
selectedLocales,
|
||||
} = $props();
|
||||
const findFieldValidationError = (field, locale) => {
|
||||
return validationErrors.find(
|
||||
(f) => f.fieldId === field.id && f.locale === locale,
|
||||
);
|
||||
};
|
||||
const findDataField = (field, locale) => {
|
||||
return fieldData.find(
|
||||
(f) => f.fieldId === field.id && f.locale === locale,
|
||||
);
|
||||
};
|
||||
const findFieldEdges = (field, locale) => {
|
||||
return edgeRecordPreviews.filter(
|
||||
(e) => e.edge.fieldId === field.id && e.edge.locale === locale,
|
||||
);
|
||||
};
|
||||
const findFieldFiles = (field, locale) => {
|
||||
return filesPreviews.filter(
|
||||
(f) =>
|
||||
f.recordFile.fieldId === field.id &&
|
||||
f.recordFile.locale === locale,
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
{#each fields as field}
|
||||
<div style="display:flex;gap:20px;">
|
||||
{#if field.type === "text"}
|
||||
{@render textField(field, "main")}
|
||||
{#if field.translatable}
|
||||
{#each selectedLocales as locale (locale)}
|
||||
{@render textField(field, locale)}
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
{#if field.type === "relation"}
|
||||
{@render relationField(field, "main")}
|
||||
{#if field.translatable}
|
||||
{#each selectedLocales as locale (locale)}
|
||||
{@render relationField(field, locale)}
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
{#if field.type === "file"}
|
||||
{@render fileField(field, "main")}
|
||||
{#if field.translatable}
|
||||
{#each selectedLocales as locale (locale)}
|
||||
{@render fileField(field, locale)}
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#snippet textField(field, locale)}
|
||||
<TextField
|
||||
{channel}
|
||||
{record}
|
||||
validationError={findFieldValidationError(field, locale)}
|
||||
schemaField={field}
|
||||
{locale}
|
||||
dataField={findDataField(field, locale)}
|
||||
></TextField>
|
||||
{/snippet}
|
||||
|
||||
{#snippet relationField(field, locale)}
|
||||
<RelationField
|
||||
{channel}
|
||||
{record}
|
||||
validationError={findFieldValidationError(field, locale)}
|
||||
schemaField={field}
|
||||
{locale}
|
||||
dataField={findDataField(field, locale)}
|
||||
edgeRecordPreviews={findFieldEdges(field, locale)}
|
||||
></RelationField>
|
||||
{/snippet}
|
||||
|
||||
{#snippet fileField(field, locale)}
|
||||
<FileField
|
||||
{channel}
|
||||
{record}
|
||||
validationError={findFieldValidationError(field, locale)}
|
||||
schemaField={field}
|
||||
{locale}
|
||||
dataField={findDataField(field, locale)}
|
||||
filesPreviews={findFieldFiles(field, locale)}
|
||||
></FileField>
|
||||
{/snippet}
|
||||
@@ -1,84 +0,0 @@
|
||||
<script>
|
||||
import { getApp } from "../../app";
|
||||
import { post } from "../../modules/remote";
|
||||
let { record, status } = $props();
|
||||
const app = getApp();
|
||||
|
||||
function handleUntrash() {
|
||||
post(app.url("records/untrash"), { id: record.id }, (data, err) => {
|
||||
Turbo.visit(window.location.href);
|
||||
});
|
||||
}
|
||||
function handleUnschedule() {
|
||||
post(app.url("records/unschedule"), { id: record.id }, (data, err) => {
|
||||
Turbo.visit(window.location.href);
|
||||
});
|
||||
}
|
||||
function handleUnpublish() {
|
||||
post(app.url("records/unpublish"), { id: record.id }, (data, err) => {
|
||||
Turbo.visit(window.location.href);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="record-status record-status-{status}"
|
||||
style="display: flex; gap:20px"
|
||||
>
|
||||
{#if status === "trashed"}
|
||||
<span>This record is Trashed</span>
|
||||
<button onclick={handleUntrash}>Restore</button>
|
||||
{:else if status === "scheduled_and_published"}
|
||||
<div>
|
||||
<div>
|
||||
<span>
|
||||
This record was published at {record.publishedAt}
|
||||
</span>
|
||||
<button onclick={handleUnpublish}>Unpublish</button>
|
||||
</div>
|
||||
<div>
|
||||
<span>
|
||||
It is scheduled to be republished at {record.scheduledAt}
|
||||
</span>
|
||||
<button onclick={handleUnschedule}>Unschedule</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if status === "published"}
|
||||
<span>
|
||||
This record was published at {record.publishedAt}
|
||||
</span>
|
||||
<button onclick={handleUnpublish}>Cancel</button>
|
||||
{:else if status === "scheduled"}
|
||||
<span>
|
||||
This record is scheduled to be published at {record.scheduledAt}
|
||||
</span>
|
||||
<button onclick={handleUnschedule}>Cancel</button>
|
||||
{:else}
|
||||
<span>This record is a draft. Not yet published</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.record-status {
|
||||
border: 1px solid;
|
||||
border-radius: 7px;
|
||||
padding: 10px 20px;
|
||||
margin: 0 0 30px;
|
||||
font-size: 20px;
|
||||
}
|
||||
.record-status-trashed {
|
||||
border-color: red;
|
||||
}
|
||||
.record-status-scheduled_and_published {
|
||||
border-color: blue;
|
||||
}
|
||||
.record-status-published {
|
||||
border-color: green;
|
||||
}
|
||||
.record-status-scheduled {
|
||||
border-color: orange;
|
||||
}
|
||||
.record-status-draft {
|
||||
border-color: gray;
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +0,0 @@
|
||||
<script>
|
||||
let { schemaField, validationError } = $props();
|
||||
let hasError = $derived(!!validationError);
|
||||
</script>
|
||||
|
||||
{#if hasError}
|
||||
<small id={schemaField.id + "-help"}>{validationError.message}</small>
|
||||
{:else if schemaField.help != ""}
|
||||
<small id={schemaField.id + "-help"}>{schemaField.help}</small>
|
||||
{/if}
|
||||
@@ -1,9 +0,0 @@
|
||||
<script>
|
||||
import { getLocaleName } from "../locale.svelte.js";
|
||||
let { channel, schemaField, locale } = $props();
|
||||
</script>
|
||||
|
||||
{#if locale !== "main"}
|
||||
{getLocaleName(channel, locale)} >
|
||||
{/if}
|
||||
{schemaField.name} <br />
|
||||
@@ -1,222 +0,0 @@
|
||||
<script>
|
||||
import { get, post } from "../../../modules/remote";
|
||||
import { uploadFile } from "../../../modules/upload";
|
||||
import { getApp } from "../../../app";
|
||||
import FieldLabel from "./FieldLabel.svelte";
|
||||
import FieldError from "./FieldError.svelte";
|
||||
import Sortable from "../../../common/Sortable.svelte";
|
||||
let {
|
||||
channel,
|
||||
record,
|
||||
schemaField,
|
||||
dataField,
|
||||
locale,
|
||||
validationError,
|
||||
edgeRecordPreviews,
|
||||
filesPreviews,
|
||||
} = $props();
|
||||
let originalValue = dataField?.value ?? schemaField.props.default;
|
||||
let newValue = $state(originalValue);
|
||||
let valuesChanged = $derived(newValue !== originalValue);
|
||||
let errorMessage = $state("");
|
||||
|
||||
let filesInProgress = $state([]);
|
||||
let uploadInProgress = $derived(filesInProgress.length > 0);
|
||||
// let validationErrorState = $state(validationError);
|
||||
const app = getApp();
|
||||
|
||||
let suggestionsLoaded = $state(false);
|
||||
let suggestions = $state([]);
|
||||
let selectedFilesIds = $state([]);
|
||||
let dialog = $state();
|
||||
|
||||
function handleModalOpen(e) {
|
||||
// Add logic to handle adding a record
|
||||
dialog.showModal();
|
||||
if (suggestionsLoaded) {
|
||||
return;
|
||||
}
|
||||
get(app.url("records/files"), { recordId: record.id }, (data, err) => {
|
||||
suggestionsLoaded = true;
|
||||
suggestions = data;
|
||||
});
|
||||
}
|
||||
|
||||
function handleModalClose(e) {
|
||||
dialog.close();
|
||||
}
|
||||
|
||||
function handleInsertSelected() {
|
||||
suggestionsLoaded = false;
|
||||
post(
|
||||
app.url("records/files"),
|
||||
{
|
||||
toIds: selectedFilesIds,
|
||||
from: record.id,
|
||||
fieldId: schemaField.id,
|
||||
locale: locale,
|
||||
},
|
||||
(data, err) => {
|
||||
suggestionsLoaded = true;
|
||||
dialog.close();
|
||||
edgeRecordPreviews = data.edgeRecordPreviews;
|
||||
validationError = data.validationError;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handleSortUpdate(updatedEdges) {
|
||||
// let updatedFieldIds = updatedFields.map((f) => f.id);
|
||||
// fields = fields.filter((f) => !updatedFieldIds.includes(f.id));
|
||||
// fields = [...fields, ...updatedFields];
|
||||
post(
|
||||
app.url("records/sort-edges"),
|
||||
{
|
||||
ids: updatedEdges.map((e) => e.edge.id),
|
||||
},
|
||||
(data, err) => {
|
||||
edgeRecordPreviews = updatedEdges;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handleRemoveEdge(edgeId) {
|
||||
post(
|
||||
app.url("edges/delete"),
|
||||
{
|
||||
id: edgeId,
|
||||
from: record.id,
|
||||
fieldId: schemaField.id,
|
||||
locale: locale,
|
||||
},
|
||||
(data, err) => {
|
||||
edgeRecordPreviews = edgeRecordPreviews.filter(
|
||||
(e) => e.edge.id !== edgeId,
|
||||
);
|
||||
validationError = data.validationError;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handleFilesUpload(e) {
|
||||
let files = e.target.files ? [...e.target.files] : [];
|
||||
|
||||
let filesUploaded = files.map((file) => {
|
||||
let fileInProgress = {
|
||||
pct: 0,
|
||||
hasFailed: false,
|
||||
name: file.name,
|
||||
};
|
||||
filesInProgress.push(fileInProgress);
|
||||
|
||||
const progress = ({ pct, isComplete }) => {
|
||||
filesInProgress.find((f) => f.name === file.name).pct = pct;
|
||||
if (isComplete) {
|
||||
filesInProgress = filesInProgress.filter(
|
||||
(f) => f.name !== file.name,
|
||||
);
|
||||
}
|
||||
};
|
||||
const error = (errorMessage) => {
|
||||
filesInProgress.find((f) => f.name === file.name).hasFailed =
|
||||
true;
|
||||
};
|
||||
|
||||
uploadFile(
|
||||
file,
|
||||
record.id,
|
||||
schemaField.id,
|
||||
locale,
|
||||
progress,
|
||||
error,
|
||||
);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div style="min-width: 400px;">
|
||||
<label>
|
||||
<FieldLabel {locale} {channel} {schemaField}></FieldLabel>
|
||||
</label>
|
||||
<FieldError {schemaField} {validationError}></FieldError>
|
||||
<button onclick={handleModalOpen}>Choose files</button>
|
||||
|
||||
<dialog bind:this={dialog}>
|
||||
<article>
|
||||
<header>
|
||||
<button onclick={handleModalClose} aria-label="Close" rel="prev"
|
||||
></button>
|
||||
<p>
|
||||
<strong>Records</strong>
|
||||
</p>
|
||||
</header>
|
||||
{#if suggestionsLoaded}
|
||||
<form>
|
||||
<button onclick={handleInsertSelected}>
|
||||
Insert selected
|
||||
</button>
|
||||
|
||||
<input
|
||||
oninput={handleFilesUpload}
|
||||
type="file"
|
||||
multiple
|
||||
disabled={uploadInProgress}
|
||||
/>
|
||||
{#each filesInProgress as fileInProgress}
|
||||
<div>
|
||||
<span>{fileInProgress.name}</span>
|
||||
<progress value={fileInProgress.pct} max="100" />
|
||||
{#if fileInProgress.hasFailed}
|
||||
<span>Error</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<table>
|
||||
<tbody>
|
||||
{#each suggestions as suggestion}
|
||||
<tr>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
value={suggestion.id}
|
||||
bind:group={selectedFilesIds}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<a href="#">{suggestion.name}</a>
|
||||
</td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</form>
|
||||
{:else}
|
||||
<progress />
|
||||
{/if}
|
||||
</article>
|
||||
</dialog>
|
||||
<div>
|
||||
{#if filesPreviews.length == 0}
|
||||
No files exist
|
||||
{:else}
|
||||
<Sortable
|
||||
onUpdate={handleSortUpdate}
|
||||
items={filesPreviews}
|
||||
itemKey="recordFile.id"
|
||||
>
|
||||
{#snippet itemView(filesPreview)}
|
||||
<div>
|
||||
<a href="#">{filesPreview.file.name}</a>
|
||||
<button
|
||||
onclick={(e) =>
|
||||
handleRemoveEdge(filesPreview.recordFile.id)}
|
||||
>remove</button
|
||||
>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Sortable>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,174 +0,0 @@
|
||||
<script>
|
||||
import { get, post } from "../../../modules/remote";
|
||||
import { getApp } from "../../../app";
|
||||
import FieldLabel from "./FieldLabel.svelte";
|
||||
import FieldError from "./FieldError.svelte";
|
||||
import Sortable from "../../../common/Sortable.svelte";
|
||||
let {
|
||||
channel,
|
||||
record,
|
||||
schemaField,
|
||||
dataField,
|
||||
locale,
|
||||
validationError,
|
||||
edgeRecordPreviews,
|
||||
} = $props();
|
||||
let originalValue = dataField?.value ?? schemaField.props.default;
|
||||
let newValue = $state(originalValue);
|
||||
let valuesChanged = $derived(newValue !== originalValue);
|
||||
let errorMessage = $state("");
|
||||
// let validationErrorState = $state(validationError);
|
||||
const app = getApp();
|
||||
|
||||
let suggestionsLoaded = $state(false);
|
||||
let suggestions = $state([]);
|
||||
let selectedRecordIds = $state([]);
|
||||
let dialog = $state();
|
||||
|
||||
function handleModalOpen(e) {
|
||||
// Add logic to handle adding a record
|
||||
dialog.showModal();
|
||||
if (suggestionsLoaded) {
|
||||
return;
|
||||
}
|
||||
get(
|
||||
app.url("records/suggest"),
|
||||
{ schemas: schemaField.props.schemas },
|
||||
(data, err) => {
|
||||
suggestionsLoaded = true;
|
||||
suggestions = data;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handleModalClose(e) {
|
||||
dialog.close();
|
||||
}
|
||||
|
||||
function handleInsertSelected() {
|
||||
suggestionsLoaded = false;
|
||||
post(
|
||||
app.url("edges/many"),
|
||||
{
|
||||
toIds: selectedRecordIds,
|
||||
from: record.id,
|
||||
fieldId: schemaField.id,
|
||||
locale: locale,
|
||||
},
|
||||
(data, err) => {
|
||||
suggestionsLoaded = true;
|
||||
dialog.close();
|
||||
edgeRecordPreviews = data.edgeRecordPreviews;
|
||||
validationError = data.validationError;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handleSortUpdate(updatedEdges) {
|
||||
// let updatedFieldIds = updatedFields.map((f) => f.id);
|
||||
// fields = fields.filter((f) => !updatedFieldIds.includes(f.id));
|
||||
// fields = [...fields, ...updatedFields];
|
||||
post(
|
||||
app.url("records/sort-edges"),
|
||||
{
|
||||
ids: updatedEdges.map((e) => e.edge.id),
|
||||
},
|
||||
(data, err) => {
|
||||
edgeRecordPreviews = updatedEdges;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handleRemoveEdge(edgeId) {
|
||||
post(
|
||||
app.url("edges/delete"),
|
||||
{
|
||||
id: edgeId,
|
||||
from: record.id,
|
||||
fieldId: schemaField.id,
|
||||
locale: locale,
|
||||
},
|
||||
(data, err) => {
|
||||
edgeRecordPreviews = edgeRecordPreviews.filter(
|
||||
(e) => e.edge.id !== edgeId,
|
||||
);
|
||||
validationError = data.validationError;
|
||||
},
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div style="min-width: 400px;">
|
||||
<label>
|
||||
<FieldLabel {locale} {channel} {schemaField}></FieldLabel>
|
||||
</label>
|
||||
<FieldError {schemaField} {validationError}></FieldError>
|
||||
<button onclick={handleModalOpen}>Choose record</button>
|
||||
|
||||
<dialog bind:this={dialog}>
|
||||
<article>
|
||||
<header>
|
||||
<button onclick={handleModalClose} aria-label="Close" rel="prev"
|
||||
></button>
|
||||
<p>
|
||||
<strong>Records</strong>
|
||||
</p>
|
||||
</header>
|
||||
{#if suggestionsLoaded}
|
||||
<form>
|
||||
<button onclick={handleInsertSelected}>
|
||||
Insert selected
|
||||
</button>
|
||||
<table>
|
||||
<tbody>
|
||||
{#each suggestions as suggestion}
|
||||
<tr>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
value={suggestion.id}
|
||||
bind:group={selectedRecordIds}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<a href="#">{suggestion.title}</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="#">
|
||||
{suggestion.schemaName}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</form>
|
||||
{:else}
|
||||
<progress />
|
||||
{/if}
|
||||
</article>
|
||||
</dialog>
|
||||
<div>
|
||||
{#if edgeRecordPreviews.length == 0}
|
||||
No relations exist
|
||||
{:else}
|
||||
<Sortable
|
||||
onUpdate={handleSortUpdate}
|
||||
items={edgeRecordPreviews}
|
||||
itemKey="edge.id"
|
||||
>
|
||||
{#snippet itemView(edgeRecordPreview)}
|
||||
<div>
|
||||
<a href="#">{edgeRecordPreview.recordPreview.title}</a>
|
||||
{edgeRecordPreview.recordPreview.schemaName}
|
||||
<button
|
||||
onclick={(e) =>
|
||||
handleRemoveEdge(edgeRecordPreview.edge.id)}
|
||||
>remove</button
|
||||
>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Sortable>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,102 +0,0 @@
|
||||
<script>
|
||||
import { post } from "../../../modules/remote";
|
||||
import { getApp } from "../../../app";
|
||||
import FieldLabel from "./FieldLabel.svelte";
|
||||
import FieldError from "./FieldError.svelte";
|
||||
let { channel, record, schemaField, dataField, locale, validationError } =
|
||||
$props();
|
||||
let originalValue = dataField?.value ?? schemaField.props.default;
|
||||
let newValue = $state(originalValue);
|
||||
let valuesChanged = $derived(newValue !== originalValue);
|
||||
// let validationErrorState = $state(validationError);
|
||||
const app = getApp();
|
||||
|
||||
function save() {
|
||||
if (!valuesChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
post(
|
||||
app.url("records/fields"),
|
||||
{
|
||||
recordId: record.id,
|
||||
id: schemaField.id,
|
||||
locale: locale,
|
||||
value: newValue,
|
||||
},
|
||||
(data, err) => {
|
||||
if (err.isNotEmpty()) {
|
||||
errorMessage = err.first();
|
||||
} else {
|
||||
validationError = data.validationError;
|
||||
originalValue = newValue;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
let delayMs = 1000;
|
||||
let timer = $state(undefined);
|
||||
let isComposing = $state(false);
|
||||
|
||||
const schedule = () => {
|
||||
if (isComposing) {
|
||||
return;
|
||||
}
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
timer = setTimeout(flush, delayMs);
|
||||
};
|
||||
|
||||
const flush = () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
// value = inputValue;
|
||||
save();
|
||||
};
|
||||
|
||||
const handleInput = () => {
|
||||
schedule();
|
||||
};
|
||||
|
||||
const handleKeydown = (event) => {
|
||||
if (event.key === "Enter") {
|
||||
flush();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
flush();
|
||||
};
|
||||
|
||||
const handleCompositionStart = () => {
|
||||
isComposing = true;
|
||||
};
|
||||
|
||||
const handleCompositionEnd = () => {
|
||||
isComposing = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<div style="min-width: 400px;">
|
||||
<label>
|
||||
<FieldLabel {locale} {channel} {schemaField}></FieldLabel>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newValue}
|
||||
autocomplete="off"
|
||||
readonly={schemaField.readonly}
|
||||
aria-describedby={schemaField.id + "-help"}
|
||||
oninput={handleInput}
|
||||
onkeydown={handleKeydown}
|
||||
onblur={handleBlur}
|
||||
oncompositionstart={handleCompositionStart}
|
||||
oncompositionend={handleCompositionEnd}
|
||||
/>
|
||||
<!-- aria-invalid={hasError ? "true" : ""} -->
|
||||
<FieldError {schemaField} {validationError}></FieldError>
|
||||
</label>
|
||||
</div>
|
||||
@@ -1,11 +0,0 @@
|
||||
export function getSelectedLocales() {
|
||||
let value = $state(localStorage.getItem("selectedLocales"));
|
||||
if (value == "" || !value) {
|
||||
return [];
|
||||
}
|
||||
return value.split(",");
|
||||
}
|
||||
|
||||
export function getLocaleName(channel, id) {
|
||||
return channel.locales.find((locale) => locale.id === id).name;
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
<script>
|
||||
import SchemaLayout from "../../layouts/SchemaLayout.svelte";
|
||||
import Sortable from "../../common/Sortable.svelte";
|
||||
import DeleteButton from "../../common/DeleteButton.svelte";
|
||||
import { post } from "../../modules/remote";
|
||||
import { getApp } from "../../app";
|
||||
let { channel, user, data } = $props();
|
||||
const app = getApp();
|
||||
|
||||
function handleSchemaCreate(e) {
|
||||
e.preventDefault();
|
||||
post(
|
||||
channel.lucentUrl + "/schemas/update",
|
||||
{
|
||||
id: data.schema.id,
|
||||
name: data.schema.name,
|
||||
alias: data.schema.alias,
|
||||
revisions: data.schema.revisions,
|
||||
},
|
||||
(data, err) => {
|
||||
if (err.isEmpty()) {
|
||||
Turbo.visit(channel.lucentUrl + "/schemas");
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
post(
|
||||
app.url("schemas/delete"),
|
||||
{
|
||||
schemaId: data.schema.id,
|
||||
},
|
||||
(data, err) => {
|
||||
if (err.isEmpty()) {
|
||||
Turbo.visit(app.url("schemas"));
|
||||
} else {
|
||||
console.log(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<SchemaLayout {body} {channel} {user}></SchemaLayout>
|
||||
{#snippet body()}
|
||||
<h3>Edit Schema</h3>
|
||||
|
||||
<form onsubmit={handleSchemaCreate}>
|
||||
<fieldset>
|
||||
<label>
|
||||
Name
|
||||
<input
|
||||
bind:value={data.schema.name}
|
||||
placeholder="ex. Blog Posts"
|
||||
minlength="2"
|
||||
maxlength="30"
|
||||
required
|
||||
/>
|
||||
<small id="alias-helper">Plural is recommended</small>
|
||||
</label>
|
||||
<label>
|
||||
Alias
|
||||
<input
|
||||
bind:value={data.schema.alias}
|
||||
placeholder="ex. blog_posts"
|
||||
minlength="2"
|
||||
maxlength="30"
|
||||
required
|
||||
aria-describedby="alias-helper"
|
||||
/>
|
||||
<small id="alias-helper">
|
||||
Developers will use this to reference the field
|
||||
</small>
|
||||
</label>
|
||||
<label>
|
||||
Revision number
|
||||
<input
|
||||
bind:value={data.schema.revisions}
|
||||
type="number"
|
||||
required
|
||||
aria-describedby="revision-helper"
|
||||
/>
|
||||
<small id="revision-helper">
|
||||
How many revisions per document will be kept
|
||||
</small>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<button type="submit">Update</button>
|
||||
</form>
|
||||
|
||||
<DeleteButton onDelete={handleDelete}>
|
||||
{#snippet text()}
|
||||
Delete schema
|
||||
{/snippet}
|
||||
</DeleteButton>
|
||||
{/snippet}
|
||||
@@ -1,138 +0,0 @@
|
||||
<script>
|
||||
import SchemaLayout from "../../layouts/SchemaLayout.svelte";
|
||||
import Sortable from "../../common/Sortable.svelte";
|
||||
import { post } from "../../modules/remote";
|
||||
import { getApp } from "../../app";
|
||||
let { channel, user, data } = $props();
|
||||
let newSchemaName = $state("");
|
||||
let newSchemaAlias = $state("");
|
||||
let fields = $state(data.fields);
|
||||
const app = getApp();
|
||||
const createFieldUrl = (schema, type) =>
|
||||
app.url(`fields/create?schema=${schema.id}&type=${type}`);
|
||||
function handleSchemaCreate(e) {
|
||||
e.preventDefault();
|
||||
post(
|
||||
channel.lucentUrl + "/schemas",
|
||||
{
|
||||
name: newSchemaName,
|
||||
alias: newSchemaAlias,
|
||||
},
|
||||
(data, err) => {
|
||||
if (err.isEmpty()) {
|
||||
Turbo.visit(window.location.href);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handleSortUpdate(updatedFields) {
|
||||
let updatedFieldIds = updatedFields.map((f) => f.id);
|
||||
fields = fields.filter((f) => !updatedFieldIds.includes(f.id));
|
||||
fields = [...fields, ...updatedFields];
|
||||
|
||||
post(
|
||||
channel.lucentUrl + "/fields/reorder",
|
||||
{
|
||||
ids: updatedFieldIds,
|
||||
},
|
||||
(data, err) => {},
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<SchemaLayout {body} {channel} {user}></SchemaLayout>
|
||||
{#snippet body()}
|
||||
<h3 class="header-small mb-4 mt-5">Schemas</h3>
|
||||
|
||||
<details style="max-width: 400px;">
|
||||
<summary>Create Schema</summary>
|
||||
<form onsubmit={handleSchemaCreate}>
|
||||
<fieldset>
|
||||
<label>
|
||||
Name
|
||||
<input
|
||||
bind:value={newSchemaName}
|
||||
placeholder="ex. Blog Posts"
|
||||
minlength="2"
|
||||
maxlength="30"
|
||||
required
|
||||
/>
|
||||
<small id="alias-helper">Plural is recommended</small>
|
||||
</label>
|
||||
<label>
|
||||
Alias
|
||||
<input
|
||||
bind:value={newSchemaAlias}
|
||||
placeholder="ex. blog_posts"
|
||||
minlength="2"
|
||||
maxlength="30"
|
||||
required
|
||||
aria-describedby="alias-helper"
|
||||
/>
|
||||
<small id="alias-helper">
|
||||
Developers will use this to reference the field
|
||||
</small>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<button type="submit">Create</button>
|
||||
</form>
|
||||
</details>
|
||||
<div style="display: flex;gap:20px">
|
||||
{#each data.schemas as schema}
|
||||
<article style="min-width: 300px;">
|
||||
<header>
|
||||
<a href={app.url("schemas/edit/" + schema.id)}
|
||||
>{schema.name}</a
|
||||
>
|
||||
</header>
|
||||
<details>
|
||||
<summary>Fields</summary>
|
||||
<table>
|
||||
<Sortable
|
||||
type="table"
|
||||
onUpdate={handleSortUpdate}
|
||||
items={fields.filter(
|
||||
(field) => field.schemaId === schema.id,
|
||||
)}
|
||||
>
|
||||
{#snippet itemView(field)}
|
||||
<td
|
||||
><a
|
||||
href={app.url(
|
||||
"fields/edit/" + field.id,
|
||||
)}>{field.name}</a
|
||||
></td
|
||||
>
|
||||
<td>{field.type}</td>
|
||||
{/snippet}
|
||||
</Sortable>
|
||||
</table>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Add field</summary>
|
||||
<ul>
|
||||
<li>
|
||||
<a href={createFieldUrl(schema, "text")}>Text</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a href={createFieldUrl(schema, "file")}>File</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href={createFieldUrl(schema, "relation")}>
|
||||
Relation
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
<footer>
|
||||
<small> Id: {schema.id}</small><br />
|
||||
<small> Alias: {schema.alias}</small><br />
|
||||
<small> Revisions: {schema.revisions}</small><br />
|
||||
</footer>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
@@ -1,20 +0,0 @@
|
||||
<script>
|
||||
import AccountLayout from "../../layouts/AccountLayout.svelte";
|
||||
import Step from "./Step.svelte";
|
||||
let { channel, data } = $props();
|
||||
</script>
|
||||
|
||||
<AccountLayout {body} {channel}></AccountLayout>
|
||||
{#snippet body()}
|
||||
<div class="wrapper-tiny">
|
||||
{#each data.steps as step}
|
||||
<Step {step}></Step>
|
||||
{/each}
|
||||
|
||||
<div style="text-align: center;margin-top: 30px;">
|
||||
{#if data.allSuccess}
|
||||
<a href="/lucent/register" class="bt">Create the first user</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
@@ -1,61 +0,0 @@
|
||||
<script>
|
||||
import Icon from "../../common/Icon.svelte";
|
||||
|
||||
let { step } = $props();
|
||||
</script>
|
||||
|
||||
<div class="step step-{step.status}">
|
||||
<div style="width:100%">
|
||||
<details>
|
||||
<summary>
|
||||
{#if step.status === "success"}
|
||||
<Icon icon="check"></Icon> {step.name}
|
||||
{:else}
|
||||
<Icon icon="close"></Icon> {step.name}
|
||||
{/if}
|
||||
</summary>
|
||||
<code class="instructions">{step.instructions}</code>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.step-success .step-icon {
|
||||
background: var(--suc10);
|
||||
color: var(--suc100);
|
||||
}
|
||||
|
||||
.step-fail .step-icon {
|
||||
background: var(--err10);
|
||||
color: var(--err100);
|
||||
}
|
||||
|
||||
.step-icon {
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.step {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: start;
|
||||
gap: 10px;
|
||||
justify-content: space-between;
|
||||
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
details {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.instructions {
|
||||
margin-top: 20px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
background: var(--p10);
|
||||
white-space: break-spaces;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -1,35 +0,0 @@
|
||||
<script>
|
||||
import AccountLayout from "../../layouts/AccountLayout.svelte";
|
||||
import { post } from "../../modules/remote";
|
||||
let { channel, data } = $props();
|
||||
let isLoading = $state(false);
|
||||
function login(e) {
|
||||
e.preventDefault();
|
||||
isLoading = true;
|
||||
post(
|
||||
channel.lucentUrl + "/verify",
|
||||
{
|
||||
email: data.email,
|
||||
token: data.token,
|
||||
},
|
||||
(data, err) => {
|
||||
window.location = channel.lucentUrl;
|
||||
},
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<AccountLayout {body} {channel}></AccountLayout>
|
||||
{#snippet body()}
|
||||
<div class="wrapper-tiny">
|
||||
<form onsubmit={login}>
|
||||
<div class="mb-3 text-center">
|
||||
<h3>Login as {data.email}</h3>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-5 d-block">
|
||||
<button aria-busy={isLoading}>Enter</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{/snippet}
|
||||
+62
-37
@@ -47,44 +47,48 @@ export function clickOutside(node) {
|
||||
};
|
||||
}
|
||||
|
||||
export function arrayUnique(array) {
|
||||
return array.filter((value, index) => array.indexOf(value) === index);
|
||||
export function apiFetch(url, options = {}) {
|
||||
return fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-TOKEN": document.querySelector('meta[name="csrf-token"]').content,
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function arrayUniqueBy(items, uniqueBy) {
|
||||
const ids = new Set(items.map((item) => item[uniqueBy]));
|
||||
return [...ids].map((id) => items.find((i) => i[uniqueBy] === id));
|
||||
export function apiPost(url, body, options = {}) {
|
||||
return fetch(url, {
|
||||
...options,
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
"X-CSRF-TOKEN": document.querySelector('meta[name="csrf-token"]').content,
|
||||
...options.headers,
|
||||
},
|
||||
}).then((r) => r.json());
|
||||
}
|
||||
|
||||
export function arrayMoveElement(array, from, to) {
|
||||
if (from === to) {
|
||||
return array;
|
||||
}
|
||||
|
||||
const item = array.find((v, i) => i === from);
|
||||
const arrayWithout = array.filter((v, i) => i !== from);
|
||||
if (from > to) {
|
||||
return arrayWithout.reduce((c, v, i) => {
|
||||
if (i === to) {
|
||||
return [...c, item, v];
|
||||
}
|
||||
return [...c, v];
|
||||
}, []);
|
||||
}
|
||||
|
||||
return arrayWithout.reduce((c, v, i) => {
|
||||
if (i + 1 === to) {
|
||||
return [...c, v, item];
|
||||
}
|
||||
return [...c, v];
|
||||
}, []);
|
||||
export function apiGet(url, options = {}) {
|
||||
return fetch(url, {
|
||||
...options,
|
||||
method: "GET",
|
||||
headers: {
|
||||
"X-CSRF-TOKEN": document.querySelector('meta[name="csrf-token"]').content,
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
...options.headers,
|
||||
},
|
||||
}).then((r) => r.json());
|
||||
}
|
||||
|
||||
export function isEqual(db, ed) {
|
||||
let isObject = (x) =>
|
||||
typeof x === "object" && !Array.isArray(x) && x !== null;
|
||||
let isArray = (x) => x?.constructor === Array;
|
||||
let isEmpty = (x) => x === null || x === undefined || x == [];
|
||||
let isEmpty = (x) => x === null || x === undefined;
|
||||
const db_value = db ?? null;
|
||||
const ed_value = ed ?? null;
|
||||
|
||||
@@ -98,11 +102,14 @@ export function isEqual(db, ed) {
|
||||
}, true);
|
||||
}
|
||||
if (isArray(db_value)) {
|
||||
if (!isArray(ed_value) || db_value.length !== ed_value.length) {
|
||||
return false;
|
||||
}
|
||||
return db_value.reduce((c, v, i) => {
|
||||
if (c === false) {
|
||||
return false;
|
||||
}
|
||||
return isEqual(v, ed_value?.[i]);
|
||||
return isEqual(v, ed_value[i]);
|
||||
}, true);
|
||||
}
|
||||
|
||||
@@ -115,12 +122,30 @@ export function isEqual(db, ed) {
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
// const ok = Object.keys,
|
||||
// tx = typeof x,
|
||||
// ty = typeof y;
|
||||
// return x && y && tx === "object" && tx === ty
|
||||
// ? ok(x).length === ok(y).length &&
|
||||
// ok(x).every((key) => isEqual(x[key], y[key]))
|
||||
// : x === y;
|
||||
}
|
||||
|
||||
export function debounce(fn, delay) {
|
||||
let timer;
|
||||
return (...args) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => fn(...args), delay);
|
||||
};
|
||||
}
|
||||
|
||||
export function arrayUnique(array) {
|
||||
return array.filter((value, index) => array.indexOf(value) === index);
|
||||
}
|
||||
|
||||
export function arrayUniqueBy(items, uniqueBy) {
|
||||
const ids = new Set(items.map((item) => item[uniqueBy]));
|
||||
return [...ids].map((id) => items.find((i) => i[uniqueBy] === id));
|
||||
}
|
||||
export function arrayUniqueCb(items, aFilter) {
|
||||
const cache = new Set();
|
||||
return items.filter((item) => {
|
||||
const cacheValue = aFilter(item);
|
||||
if (cache.has(cacheValue)) return false;
|
||||
cache.add(cacheValue);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
export function loadHtmxFormsBehaviour(){
|
||||
document.querySelectorAll(".form").forEach(el => {
|
||||
initHtmxForm(el);
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
function initHtmxForm(el){
|
||||
el.addEventListener("htmx:responseError", (e) => {
|
||||
el.querySelector(".form-errors").innerHTML = e.detail.xhr.response;
|
||||
});
|
||||
|
||||
const formEl = el.querySelector("form");
|
||||
|
||||
if(!formEl.getAttribute("hx-redirect")){
|
||||
return;
|
||||
}
|
||||
el.addEventListener("htmx:afterOnLoad", (e) => {
|
||||
if(e.detail.successful){
|
||||
return window.location.href = formEl.getAttribute("hx-redirect");
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
<script>
|
||||
let { body, channel, user } = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
style="text-align: center;background: var(--p20);padding: 20px;color: var(--p90)"
|
||||
>
|
||||
<h1>
|
||||
<a class="text-decoration-none" href={channel.url}
|
||||
>{channel.name ?? "Lucent Setup"}</a
|
||||
>
|
||||
</h1>
|
||||
</div>
|
||||
<div>
|
||||
{@render body()}
|
||||
</div>
|
||||
@@ -1,15 +0,0 @@
|
||||
<script>
|
||||
import Header from "./Header.svelte";
|
||||
import Navbar from "./Navbar.svelte";
|
||||
let { body, channel, user, schemas } = $props();
|
||||
</script>
|
||||
|
||||
<Header {channel} {user}></Header>
|
||||
<main>
|
||||
<aside class="sidebar-content">
|
||||
<Navbar {channel} {schemas}></Navbar>
|
||||
</aside>
|
||||
<div class="main-content">
|
||||
{@render body()}
|
||||
</div>
|
||||
</main>
|
||||
@@ -1,12 +0,0 @@
|
||||
<script>
|
||||
import { getApp } from "../app";
|
||||
let { channel, schemas } = $props();
|
||||
const app = getApp();
|
||||
</script>
|
||||
|
||||
<details name="example" open>
|
||||
<summary>Content</summary>
|
||||
{#each schemas as schema}
|
||||
<div><a href={app.url("content/" + schema.id)}>{schema.name}</a></div>
|
||||
{/each}
|
||||
</details>
|
||||
@@ -1,34 +0,0 @@
|
||||
<script>
|
||||
import {getContext} from "svelte";
|
||||
import Icon from "../common/Icon.svelte";
|
||||
|
||||
const channel = getContext("channel");
|
||||
export let schemas;
|
||||
export let title;
|
||||
export let schema;
|
||||
export let expanded = false;
|
||||
|
||||
if(schemas.find(s => s.name === schema?.name)){
|
||||
expanded = true;
|
||||
}
|
||||
|
||||
function toggleExpand(){
|
||||
expanded = !expanded;
|
||||
}
|
||||
</script>
|
||||
|
||||
<button class="sidebar-header" tabindex="0" on:click={toggleExpand}>
|
||||
{title}
|
||||
{#if expanded}
|
||||
<Icon icon="circle-chevron-up"></Icon>
|
||||
{:else}
|
||||
<Icon icon="circle-chevron-down"></Icon>
|
||||
{/if}
|
||||
</button>
|
||||
{#if expanded}
|
||||
{#each schemas as aschema}
|
||||
<a class="sidebar-item" class:active={aschema.name === schema?.name}
|
||||
aria-current="page"
|
||||
href="{channel.lucentUrl}/content/{aschema.name}">{aschema.label}</a>
|
||||
{/each}
|
||||
{/if}
|
||||
@@ -1,11 +0,0 @@
|
||||
<script>
|
||||
import Header from "./Header.svelte";
|
||||
let { body, channel, user } = $props();
|
||||
</script>
|
||||
|
||||
<Header {channel} {user}></Header>
|
||||
<main>
|
||||
<div class="main-content">
|
||||
{@render body()}
|
||||
</div>
|
||||
</main>
|
||||
+24
-49
@@ -1,48 +1,22 @@
|
||||
import * as Turbo from "@hotwired/turbo";
|
||||
// import "../sass/app.scss";
|
||||
import { mount, unmount } from "svelte";
|
||||
import "../css/app.css";
|
||||
import Register from "./svelte/account/Register.svelte";
|
||||
import LoginEntry from "./entry/LoginEntry/LoginEntry.svelte";
|
||||
import VerifyEntry from "./entry/VerifyEntry/VerifyEntry.svelte";
|
||||
import Profile from "./svelte/account/Profile.svelte";
|
||||
import SetupEntry from "./entry/SetupEntry/SetupEntry.svelte";
|
||||
import Members from "./svelte/members/Members.svelte";
|
||||
import RecordNotFound from "./svelte/records/NotFound.svelte";
|
||||
import RecordEditEntry from "./entry/RecordEditEntry/RecordEditEntry.svelte";
|
||||
import ContentEntry from "./entry/ContentEntry/ContentEntry.svelte";
|
||||
import HomeEntry from "./entry/HomeEntry/HomeEntry.svelte";
|
||||
import SchemaEntry from "./entry/SchemaEntry/SchemaEntry.svelte";
|
||||
import FieldCreateEntry from "./entry/FieldCreateEntry/FieldCreateEntry.svelte";
|
||||
import FieldEditEntry from "./entry/FieldEditEntry/FieldEditEntry.svelte";
|
||||
import SchemaEditEntry from "./entry/SchemaEditEntry/SchemaEditEntry.svelte";
|
||||
import BuildReport from "./svelte/build/Report.svelte";
|
||||
import { createApp } from "./app";
|
||||
import "../sass/app.scss";
|
||||
import Account from "./svelte/Account.svelte";
|
||||
import Channel from "./svelte/Channel.svelte";
|
||||
// import Mustache from "mustache";
|
||||
|
||||
// Mustache.escape = function (value) {
|
||||
// return value;
|
||||
// };
|
||||
|
||||
// Define all components
|
||||
const entryComponents = {
|
||||
members: Members,
|
||||
recordEdit: RecordEditEntry,
|
||||
recordNotFound: RecordNotFound,
|
||||
contentIndex: ContentEntry,
|
||||
homeIndex: HomeEntry,
|
||||
buildReport: BuildReport,
|
||||
register: Register,
|
||||
login: LoginEntry,
|
||||
verify: VerifyEntry,
|
||||
profile: Profile,
|
||||
setup: SetupEntry,
|
||||
schemas: SchemaEntry,
|
||||
fieldCreate: FieldCreateEntry,
|
||||
fieldEdit: FieldEditEntry,
|
||||
schemaEdit: SchemaEditEntry,
|
||||
account: Account,
|
||||
channel: Channel,
|
||||
};
|
||||
Turbo.start();
|
||||
|
||||
let loadedComponents = [];
|
||||
|
||||
let loadSvelte = function () {
|
||||
Turbo.cache.clear();
|
||||
loadedComponents.map((comp) => unmount(comp));
|
||||
loadedComponents.map((comp) => comp.$destroy());
|
||||
loadedComponents = [];
|
||||
|
||||
const elements = document.body.querySelectorAll(".lucent-component");
|
||||
@@ -50,24 +24,25 @@ let loadSvelte = function () {
|
||||
return;
|
||||
}
|
||||
const loadElement = function (element) {
|
||||
const jsonData = document.getElementById("json-data").innerHTML;
|
||||
|
||||
const props = JSON.parse(jsonData);
|
||||
const [__, view] = Object.entries(entryComponents).find(
|
||||
([key, _]) => props.view === key,
|
||||
const componentId = element.attributes["data-layout"].value;
|
||||
const [_, component] = Object.entries(entryComponents).find(
|
||||
([key, _]) => componentId === key,
|
||||
);
|
||||
|
||||
if (!view) {
|
||||
if (!component) {
|
||||
return [];
|
||||
}
|
||||
// props.axios = axiosInstance;
|
||||
createApp(props.channel);
|
||||
|
||||
const jsonData = document.getElementById("json-" + componentId).innerHTML;
|
||||
const props = JSON.parse(jsonData);
|
||||
const compOptions = {
|
||||
target: element,
|
||||
props: props,
|
||||
};
|
||||
loadedComponents = [...loadedComponents, mount(view, compOptions)];
|
||||
|
||||
loadedComponents = [...loadedComponents, new component(compOptions)];
|
||||
};
|
||||
Array.from(elements).map(loadElement);
|
||||
};
|
||||
document.addEventListener("turbo:load", loadSvelte);
|
||||
|
||||
// document.addEventListener("turbo:load", loadSvelte);
|
||||
document.addEventListener("DOMContentLoaded", loadSvelte);
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import axios from "axios";
|
||||
|
||||
class Errors {
|
||||
constructor(data) {
|
||||
this.data = data ?? [];
|
||||
}
|
||||
|
||||
first() {
|
||||
return this.data[0] ?? null;
|
||||
}
|
||||
|
||||
all() {
|
||||
return this.data;
|
||||
}
|
||||
|
||||
isEmpty() {
|
||||
return this.data.length === 0;
|
||||
}
|
||||
|
||||
isNotEmpty() {
|
||||
return !this.isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
function makeErrors(errs) {
|
||||
return new Errors(errs);
|
||||
}
|
||||
|
||||
export function post(url, postData, callback) {
|
||||
axios
|
||||
.post(url, postData)
|
||||
.then((res) => {
|
||||
if (res.data.redirect !== undefined) {
|
||||
// Turbo.visit(link(res.data.redirect));
|
||||
return;
|
||||
}
|
||||
const errors = makeErrors(res.data?.errors ?? []);
|
||||
if (errors.isNotEmpty()) {
|
||||
callback?.(null, errors);
|
||||
} else {
|
||||
callback?.(res.data, errors);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
const errors = makeErrors(["something went wrong"]);
|
||||
callback?.(null, errors);
|
||||
});
|
||||
}
|
||||
|
||||
export function get(url, urlParams, callback) {
|
||||
let params = url.endsWith("query")
|
||||
? {
|
||||
params: { query: JSON.stringify(urlParams) },
|
||||
paramsSerializer: (params) => serializeParams(params),
|
||||
}
|
||||
: {
|
||||
params: urlParams,
|
||||
};
|
||||
|
||||
return axios
|
||||
.get(url, params)
|
||||
.then((res) => {
|
||||
callback(res.data);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
let errors = makeErrors(["something went wrong"]);
|
||||
if (!err) {
|
||||
errors = makeErrors([]);
|
||||
}
|
||||
callback(null, errors);
|
||||
});
|
||||
}
|
||||
|
||||
function serializeParams(obj, prefix = "") {
|
||||
const params = [];
|
||||
|
||||
for (const key in obj) {
|
||||
if (obj.hasOwnProperty(key)) {
|
||||
const value = obj[key];
|
||||
const paramKey = prefix ? `${prefix}[${key}]` : key;
|
||||
|
||||
if (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value)
|
||||
) {
|
||||
// Nested object
|
||||
params.push(serializeParams(value, paramKey));
|
||||
} else if (Array.isArray(value)) {
|
||||
// Array
|
||||
value.forEach((item, index) => {
|
||||
if (typeof item === "object" && item !== null) {
|
||||
params.push(serializeParams(item, `${paramKey}[${index}]`));
|
||||
} else {
|
||||
params.push(
|
||||
`${encodeURIComponent(`${paramKey}[${index}]`)}=${encodeURIComponent(item)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Simple value
|
||||
params.push(
|
||||
`${encodeURIComponent(paramKey)}=${encodeURIComponent(value)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return params.flat().join("&");
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { getApp } from "../app";
|
||||
|
||||
export function uploadFile(file, recordId, fieldId, locale, progress, error) {
|
||||
const app = getApp();
|
||||
const csrf = document.querySelector('meta[name="csrf-token"]').content;
|
||||
const chunkSize = 2 * 1024 * 1024; // 2MB
|
||||
const totalChunks = Math.ceil(file.size / chunkSize);
|
||||
|
||||
// Start the recursive process
|
||||
sendChunk(0, null);
|
||||
|
||||
function sendChunk(currentChunk, fileId) {
|
||||
const start = currentChunk * chunkSize;
|
||||
const end = Math.min(start + chunkSize, file.size);
|
||||
const chunk = file.slice(start, end);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", chunk);
|
||||
if (fileId) {
|
||||
formData.append("fileId", fileId);
|
||||
}
|
||||
|
||||
formData.append("recordId", recordId);
|
||||
formData.append("fieldId", fieldId);
|
||||
formData.append("isLast", currentChunk === totalChunks - 1);
|
||||
formData.append("filename", file.name);
|
||||
formData.append("locale", locale);
|
||||
formData.append("_token", csrf);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", app.url("upload"), true);
|
||||
|
||||
// Success Callback
|
||||
xhr.onload = function () {
|
||||
if (xhr.status === 200) {
|
||||
const response = JSON.parse(xhr.responseText);
|
||||
let fileId = response.fileId;
|
||||
const nextChunk = currentChunk + 1;
|
||||
if (nextChunk < totalChunks) {
|
||||
progress({
|
||||
pct: Math.round((nextChunk / totalChunks) * 100),
|
||||
isComplete: false,
|
||||
});
|
||||
sendChunk(nextChunk, fileId);
|
||||
} else {
|
||||
progress({
|
||||
pct: 100,
|
||||
isComplete: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
error("Upload failed at chunk " + currentChunk);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
import Login from "./account/Login.svelte";
|
||||
import Verify from "./account/Verify.svelte";
|
||||
import Profile from "./account/Profile.svelte";
|
||||
import SetupIndex from "./setup/Index.svelte";
|
||||
import { setContext } from "svelte";
|
||||
|
||||
const components = {
|
||||
@@ -11,7 +10,6 @@
|
||||
login: Login,
|
||||
verify: Verify,
|
||||
profile: Profile,
|
||||
setup: SetupIndex,
|
||||
};
|
||||
|
||||
export let title;
|
||||
@@ -23,10 +21,7 @@
|
||||
setContext("channel", channel);
|
||||
setContext("user", user);
|
||||
</script>
|
||||
<div style="text-align: center;background: var(--p20);padding: 20px;color: var(--p90)">
|
||||
<h1><a class="text-decoration-none" href="{channel.lucentUrl}">{channel.name ?? "Lucent Setup"}</a></h1>
|
||||
</div>
|
||||
<div>
|
||||
<svelte:component this={components[view]} {title} {...data}/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<svelte:component this={components[view]} {channel} {title} {...data} />
|
||||
</div>
|
||||
|
||||
@@ -25,10 +25,8 @@
|
||||
// export let layout;
|
||||
export let channel;
|
||||
|
||||
export let axios;
|
||||
export let readableSchemas;
|
||||
|
||||
setContext("axios", axios);
|
||||
setContext("channel", channel);
|
||||
setContext(
|
||||
"readableSchemas",
|
||||
|
||||
@@ -1,39 +1,48 @@
|
||||
<script>
|
||||
import { getContext } from "svelte";
|
||||
import SpinnerButton from "../common/SpinnerButton.svelte";
|
||||
import { apiPost } from "../../helpers";
|
||||
|
||||
const channel = getContext("channel");
|
||||
let email = "";
|
||||
let message = "";
|
||||
let submitted = false;
|
||||
|
||||
function login(e) {
|
||||
e.preventDefault();
|
||||
|
||||
axios
|
||||
.post(channel.lucentUrl + "/login", {
|
||||
email: email,
|
||||
apiPost(channel.lucentUrl + "/login", { email: email })
|
||||
.then(() => {
|
||||
submitted = true;
|
||||
})
|
||||
.then((response) => {
|
||||
console.log(response)
|
||||
message = "You will receive an email with a login link"
|
||||
})
|
||||
.catch((error) => {
|
||||
});
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<div class="wrapper-tiny">
|
||||
{#if message}
|
||||
<div class="scope-login">
|
||||
<div class="bg-image"></div>
|
||||
<div class="login-form">
|
||||
{#if submitted}
|
||||
<div class="alert alert-info" role="alert">
|
||||
{message}
|
||||
<p>
|
||||
You will receive an email with a login link at <b>{email}</b
|
||||
>.
|
||||
</p>
|
||||
<p>Check your spam folder</p>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<div class="form">
|
||||
<h2 class="mb-5">Enter Lucent</h2>
|
||||
|
||||
<form on:submit={login}>
|
||||
<div class="mb-3">
|
||||
<label for="emailaddress" class="form-label">Email address</label>
|
||||
<p>
|
||||
Submit your email address and you will receive a <b
|
||||
>login link</b
|
||||
> to your email
|
||||
</p>
|
||||
<p>Don't forget to check your spam folder</p>
|
||||
<div class="mt-5 mb-3">
|
||||
<label for="emailaddress" class="form-label"
|
||||
>Email address</label
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
bind:value={email}
|
||||
@@ -43,12 +52,17 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="text-center mt-5 d-block">
|
||||
<SpinnerButton label="Login"/>
|
||||
</div>
|
||||
|
||||
|
||||
<button class="bt bt-primary">
|
||||
Send email
|
||||
<img
|
||||
alt="indicator"
|
||||
id="indicator"
|
||||
class="htmx-indicator"
|
||||
src="/img/spinner.svg"
|
||||
/>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script>
|
||||
import ErrorAlert from "../common/ErrorAlert.svelte";
|
||||
import SpinnerButton from "../common/SpinnerButton.svelte";
|
||||
import Avatar from "./../../common/Avatar.svelte";
|
||||
import Avatar from "./Avatar.svelte";
|
||||
import { getContext } from "svelte";
|
||||
import SuccessAlert from "../common/SuccessAlert.svelte";
|
||||
import { apiPost } from "../../helpers";
|
||||
|
||||
const user = getContext("user");
|
||||
const channel = getContext("channel");
|
||||
@@ -16,8 +17,7 @@
|
||||
e.preventDefault();
|
||||
errorMessage = "";
|
||||
|
||||
axios
|
||||
.post(channel.lucentUrl + "/account/update-name", {
|
||||
apiPost(channel.lucentUrl + "/account/update-name", {
|
||||
name: name,
|
||||
})
|
||||
.then((response) => {
|
||||
@@ -33,8 +33,7 @@
|
||||
e.preventDefault();
|
||||
errorMessage = "";
|
||||
|
||||
axios
|
||||
.post(channel.lucentUrl + "/account/update-email", {
|
||||
apiPost(channel.lucentUrl + "/account/update-email", {
|
||||
email: email,
|
||||
})
|
||||
.then((response) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script>
|
||||
import { apiPost } from "../../helpers";
|
||||
import ErrorAlert from "../common/ErrorAlert.svelte";
|
||||
import SpinnerButton from "../common/SpinnerButton.svelte";
|
||||
import { getContext } from "svelte";
|
||||
@@ -12,8 +13,7 @@
|
||||
e.preventDefault();
|
||||
errorMessage = "";
|
||||
|
||||
axios
|
||||
.post(channel.lucentUrl + "/register", {
|
||||
apiPost(channel.lucentUrl + "/register", {
|
||||
name: name,
|
||||
email: email,
|
||||
})
|
||||
@@ -50,10 +50,8 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="text-center mt-5 d-block">
|
||||
<SpinnerButton label="Register" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,43 +1,42 @@
|
||||
<script>
|
||||
import { getContext } from "svelte";
|
||||
import SpinnerButton from "../common/SpinnerButton.svelte";
|
||||
import SuccessAlert from "../common/SuccessAlert.svelte";
|
||||
import { apiPost } from "../../helpers";
|
||||
|
||||
const channel = getContext("channel");
|
||||
export let email;
|
||||
export let token;
|
||||
let successAlert;
|
||||
|
||||
function login(e) {
|
||||
e.preventDefault();
|
||||
|
||||
axios
|
||||
.post(channel.lucentUrl + "/verify", {
|
||||
apiPost(channel.lucentUrl + "/verify", {
|
||||
email: email,
|
||||
token: token,
|
||||
})
|
||||
.then((response) => {
|
||||
window.location = channel.lucentUrl;
|
||||
})
|
||||
.catch((error) => {
|
||||
});
|
||||
.catch((error) => {});
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<SuccessAlert bind:this={successAlert}/>
|
||||
<div class="wrapper-tiny">
|
||||
|
||||
<div class="scope-login">
|
||||
<div class="bg-image"></div>
|
||||
<div class="login-form">
|
||||
<div class="form">
|
||||
<h2 class="mb-5">Welcome to Lucent</h2>
|
||||
<form on:submit={login}>
|
||||
<div class="mb-3 text-center">
|
||||
<h3>Login as {email}</h3>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="text-center mt-5 d-block">
|
||||
<SpinnerButton label="Enter"/>
|
||||
</div>
|
||||
<button class="bt bt-primary">
|
||||
Enter as {email}
|
||||
<img
|
||||
alt="indicator"
|
||||
id="indicator"
|
||||
class="htmx-indicator"
|
||||
src="/img/spinner.svg"
|
||||
/>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="form-errors"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script>
|
||||
|
||||
import Selectlist from "./Selectlist.svelte";
|
||||
import Icon from "../../common/Icon.svelte";
|
||||
import Icon from "../common/Icon.svelte";
|
||||
|
||||
let searchEl;
|
||||
let search;
|
||||
@@ -9,10 +10,11 @@
|
||||
|
||||
function handleSelect(){
|
||||
searchEl.focus();
|
||||
searchEl.blur();
|
||||
searchEl.blur()
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<div class="autocomplete">
|
||||
<input
|
||||
type="search"
|
||||
@@ -22,7 +24,12 @@
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div class="autocomplete-results">
|
||||
<Selectlist {field} bind:value bind:search on:selected={handleSelect} />
|
||||
<Selectlist
|
||||
{field}
|
||||
bind:value
|
||||
bind:search
|
||||
on:selected={handleSelect}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{#if value}
|
||||
@@ -40,5 +47,6 @@
|
||||
>
|
||||
<Icon width={12} height={12} icon="close"></Icon>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,7 +1,6 @@
|
||||
<script>
|
||||
import { getContext, onMount } from "svelte";
|
||||
import axios from "axios";
|
||||
|
||||
import { apiPost } from "../../helpers";
|
||||
const channel = getContext("channel");
|
||||
export let title;
|
||||
export let command;
|
||||
@@ -12,59 +11,57 @@
|
||||
let inProgress = false;
|
||||
|
||||
function connect() {
|
||||
const eventSource = new EventSource(channel.lucentUrl + "/command-report-source/" + command.signature );
|
||||
const eventSource = new EventSource(
|
||||
channel.lucentUrl + "/command-report-source/" + command.signature,
|
||||
);
|
||||
|
||||
eventSource.onmessage = function (event) {
|
||||
inProgress = true;
|
||||
const data = JSON.parse(event.data);
|
||||
date = data.date;
|
||||
logs = data.logs;
|
||||
anchorEl.scrollIntoView()
|
||||
}
|
||||
anchorEl.scrollIntoView();
|
||||
};
|
||||
eventSource.onerror = (e) => {
|
||||
console.log(e)
|
||||
console.log(e);
|
||||
eventSource.close();
|
||||
inProgress = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function buildWebsite(e) {
|
||||
e.preventDefault();
|
||||
inProgress = true;
|
||||
axios.post(channel.lucentUrl + "/command/" + command.signature).then(response => {
|
||||
connect()
|
||||
})
|
||||
|
||||
apiPost(channel.lucentUrl + "/command/" + command.signature).then(
|
||||
(response) => {
|
||||
connect();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
connect()
|
||||
})
|
||||
|
||||
connect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="common-wrapper">
|
||||
<div class="lx-card mt-5">
|
||||
|
||||
<h3 class="header-small mb-5">{title}</h3>
|
||||
|
||||
<button on:click={buildWebsite} class="button primary mb-3" disabled={inProgress}>Start
|
||||
<button
|
||||
on:click={buildWebsite}
|
||||
class="button primary mb-3"
|
||||
disabled={inProgress}
|
||||
>Start
|
||||
</button>
|
||||
|
||||
|
||||
<div class="mb-3">
|
||||
{#if inProgress}
|
||||
<span class="badge text-bg-warning">
|
||||
Action in progress
|
||||
</span>
|
||||
<span class="badge text-bg-warning"> Action in progress </span>
|
||||
{/if}
|
||||
{#if !inProgress && logs}
|
||||
<span class="badge text-bg-info">
|
||||
Action completed
|
||||
</span>
|
||||
<span class="badge text-bg-info"> Action completed </span>
|
||||
{/if}
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<pre class="logs">{logs}
|
||||
@@ -72,6 +69,7 @@
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.logs {
|
||||
max-height: 70vh;
|
||||
|
||||
@@ -125,6 +125,10 @@
|
||||
path: '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m8.874 19 6.143-14M6 19h6.33m-.66-14H18"/>',
|
||||
viewBox: "0 0 24 24",
|
||||
},
|
||||
upload: {
|
||||
path: '<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5"/> <path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708z"/>',
|
||||
viewBox: "0 0 16 16",
|
||||
},
|
||||
};
|
||||
|
||||
export let width = 16;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script>
|
||||
import { getContext } from "svelte";
|
||||
import { apiPost } from "../../helpers";
|
||||
|
||||
const channel = getContext("channel");
|
||||
export let selected;
|
||||
@@ -8,8 +9,7 @@
|
||||
|
||||
function deleteRecords(e) {
|
||||
e.preventDefault();
|
||||
axios
|
||||
.post(channel.lucentUrl + "/records/delete", {
|
||||
apiPost(channel.lucentUrl + "/records/delete", {
|
||||
ids: selected.map((s) => s.id),
|
||||
})
|
||||
.then((response) => {
|
||||
@@ -21,10 +21,9 @@
|
||||
}
|
||||
|
||||
function changeStatus(e, status) {
|
||||
axios
|
||||
.post(channel.lucentUrl + "/records/status/" + status, {
|
||||
apiPost(channel.lucentUrl + "/records/status/" + status, {
|
||||
schemaName: schema.name,
|
||||
records: selected
|
||||
records: selected,
|
||||
})
|
||||
.then((response) => {
|
||||
window.location.reload();
|
||||
@@ -40,42 +39,42 @@
|
||||
<button
|
||||
on:click|preventDefault={(e) => changeStatus(e, "published")}
|
||||
type="button"
|
||||
class="button">Publish
|
||||
</button
|
||||
>
|
||||
class="button"
|
||||
>Publish
|
||||
</button>
|
||||
<button
|
||||
on:click|preventDefault={(e) => changeStatus(e, "draft")}
|
||||
type="button"
|
||||
class="button">Make Draft
|
||||
</button
|
||||
>
|
||||
class="button"
|
||||
>Make Draft
|
||||
</button>
|
||||
{#if filter["status_in"] === "trashed"}
|
||||
<button
|
||||
on:click|preventDefault={(e) => changeStatus(e, "published")}
|
||||
type="button"
|
||||
class="button">Publish
|
||||
</button
|
||||
>
|
||||
class="button"
|
||||
>Publish
|
||||
</button>
|
||||
{#if schema.hasDrafts}
|
||||
<button
|
||||
on:click|preventDefault={(e) => changeStatus(e, "draft")}
|
||||
type="button"
|
||||
class="button">Make Draft
|
||||
</button
|
||||
>
|
||||
class="button"
|
||||
>Make Draft
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
on:click|preventDefault={deleteRecords}
|
||||
type="button"
|
||||
class="button">Delete forever
|
||||
</button
|
||||
>
|
||||
class="button"
|
||||
>Delete forever
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
on:click|preventDefault={(e) => changeStatus(e, "trashed")}
|
||||
class="button">Move to trash
|
||||
</button
|
||||
>
|
||||
class="button"
|
||||
>Move to trash
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
<script>
|
||||
import {getContext} from "svelte";
|
||||
import Preview from "../files/Preview.svelte";
|
||||
import {selectRecord} from "./functions/recordSelect.js";
|
||||
|
||||
const channel = getContext("channel");
|
||||
|
||||
export let schema;
|
||||
export let records;
|
||||
export let isWritable;
|
||||
export let selected = [];
|
||||
|
||||
function select(record) {
|
||||
selected = selectRecord(record, selected)
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
<div class="row" style="max-width:1000px">
|
||||
{#each records as record (record.id)}
|
||||
<div class="col-6 col-md-4">
|
||||
<div
|
||||
class="file-wrapper rounded p-2 mb-4 bg-light"
|
||||
class:selected={selected.includes(record)}
|
||||
>
|
||||
{#if isWritable}
|
||||
<div class="form-check">
|
||||
<input
|
||||
on:change={() => select(record)}
|
||||
class="form-check-input "
|
||||
type="checkbox"
|
||||
checked={selected.find(
|
||||
(r) => r.id === record.id
|
||||
)}
|
||||
value={record}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="d-flex justify-content-center">
|
||||
<Preview {record} size="medium"/>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="{channel.lucentUrl}/records/{record.id}"
|
||||
title={record._file.path}
|
||||
class="d-block text-center overflow-hidden text-nowrap my-2 "
|
||||
style="
|
||||
text-overflow: ellipsis;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
">{record._file.path}</a
|
||||
>
|
||||
<span
|
||||
class="lx-small-text text-muted d-block text-center"
|
||||
>{record._file.mime}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
|
||||
<style>
|
||||
.form-check {
|
||||
display: inline-block;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -4,8 +4,8 @@
|
||||
import ActionsOnSelected from "./ActionsOnSelected.svelte";
|
||||
import Table from "./Table.svelte";
|
||||
import { getContext } from "svelte";
|
||||
import { apiGet } from "../../helpers";
|
||||
|
||||
const axios = getContext("axios");
|
||||
export let schema;
|
||||
export let users;
|
||||
export let records;
|
||||
@@ -26,18 +26,17 @@
|
||||
|
||||
function refresh(e) {
|
||||
const newUrl = e.detail;
|
||||
axios
|
||||
.get(newUrl)
|
||||
apiGet(newUrl)
|
||||
.then((response) => {
|
||||
records = response.data.records;
|
||||
sortParam = response.data.sortParam;
|
||||
sortField = response.data.sortField;
|
||||
operators = response.data.operators;
|
||||
filter = response.data.filter;
|
||||
skip = response.data.skip;
|
||||
limit = response.data.limit;
|
||||
total = response.data.total;
|
||||
modalUrl = response.data.modalUrl;
|
||||
records = response.records;
|
||||
sortParam = response.sortParam;
|
||||
sortField = response.sortField;
|
||||
operators = response.operators;
|
||||
filter = response.filter;
|
||||
skip = response.skip;
|
||||
limit = response.limit;
|
||||
total = response.total;
|
||||
modalUrl = response.modalUrl;
|
||||
document.querySelector("dialog h3").scrollIntoView();
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -47,7 +46,7 @@
|
||||
</script>
|
||||
|
||||
<div class="">
|
||||
<div class="{inModal ? 'mt-0' : 'mt-5'}">
|
||||
<div class={inModal ? "mt-0" : "mt-5"}>
|
||||
<h3 class="header-normal mb-5">
|
||||
{schema.label}
|
||||
</h3>
|
||||
@@ -82,7 +81,6 @@
|
||||
{isWritable}
|
||||
bind:selected
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
@@ -94,4 +92,3 @@
|
||||
{modalUrl}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script>
|
||||
import RenderField from "./RenderField.svelte";
|
||||
import Avatar from "../../common/Avatar.svelte";
|
||||
import Avatar from "../account/Avatar.svelte";
|
||||
import Status from "../records/Status.svelte";
|
||||
import { usernameById } from "../account/users";
|
||||
import { friendlyDate } from "../../helpers";
|
||||
@@ -22,7 +22,7 @@
|
||||
<RenderField {record} {schema} {graph} {field} />
|
||||
</td>
|
||||
{/each}
|
||||
{#if schema.visible?.includes("status")}
|
||||
{#if schema.visible?.includes("_sys.status")}
|
||||
<td
|
||||
class="text-center"
|
||||
class:is-sort={"-status" == sortParam || "status" == sortParam}
|
||||
@@ -36,7 +36,7 @@
|
||||
class:is-sort={"-_sys.createdBy" == sortParam ||
|
||||
"_sys.createdBy" == sortParam}
|
||||
>
|
||||
<Avatar name={usernameById(users, record._sys.createdBy)} side={24} />
|
||||
<Avatar name={usernameById(users, record.createdBy)} side={24} />
|
||||
</td>
|
||||
{/if}
|
||||
{#if schema.visible?.includes("_sys.updatedBy")}
|
||||
@@ -45,7 +45,7 @@
|
||||
class:is-sort={"-_sys.updatedBy" == sortParam ||
|
||||
"_sys.updatedBy" == sortParam}
|
||||
>
|
||||
<Avatar name={usernameById(users, record._sys.updatedBy)} side={24} />
|
||||
<Avatar name={usernameById(users, record.updatedBy)} side={24} />
|
||||
</td>
|
||||
{/if}
|
||||
{#if schema.visible?.includes("_sys.createdAt")}
|
||||
@@ -53,7 +53,7 @@
|
||||
class:is-sort={"-_sys.createdAt" == sortParam ||
|
||||
"_sys.createdAt" == sortParam}
|
||||
>
|
||||
{friendlyDate(record._sys.createdAt)}
|
||||
{friendlyDate(record.createdAt)}
|
||||
</td>
|
||||
{/if}
|
||||
{#if schema.visible?.includes("_sys.updatedAt")}
|
||||
@@ -61,6 +61,6 @@
|
||||
class:is-sort={"-_sys.updatedAt" == sortParam ||
|
||||
"_sys.updatedAt" == sortParam}
|
||||
>
|
||||
{friendlyDate(record._sys.updatedAt)}
|
||||
{friendlyDate(record.updatedAt)}
|
||||
</td>
|
||||
{/if}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
<script>
|
||||
import RecordRow from "./RecordRow.svelte";
|
||||
import { previewTitle } from "../records/Preview";
|
||||
import { usernameById } from "../account/users";
|
||||
import { getContext } from "svelte";
|
||||
import Avatar from "../../common/Avatar.svelte";
|
||||
import Avatar from "../account/Avatar.svelte";
|
||||
import { selectRecord, toggleAll } from "./functions/recordSelect.js";
|
||||
import Checkbox from "../common/Checkbox.svelte";
|
||||
import Preview from "../files/Preview.svelte";
|
||||
import { fileurl } from "../files/imageserver.js";
|
||||
|
||||
const channel = getContext("channel");
|
||||
|
||||
@@ -29,7 +26,7 @@
|
||||
function select(record) {
|
||||
selected = selectRecord(record, selected);
|
||||
}
|
||||
|
||||
console.log(schema);
|
||||
$: visibleColumns = schema.fields.filter(
|
||||
(c) => schema.visible?.includes(c.name) ?? [],
|
||||
);
|
||||
@@ -81,56 +78,7 @@
|
||||
value={record}
|
||||
></Checkbox>
|
||||
{/if}
|
||||
{#if record._file?.path}
|
||||
<div class="file-table-row">
|
||||
<Preview
|
||||
{record}
|
||||
size={record._file?.width > 0
|
||||
? "medium"
|
||||
: "small"}
|
||||
/>
|
||||
|
||||
<div>
|
||||
{#if record.status === "draft"}
|
||||
<span
|
||||
style="text-transform: uppercase;font-size:10px"
|
||||
>{record.status}</span
|
||||
>
|
||||
{/if}
|
||||
<a
|
||||
href="{channel.lucentUrl}/records/{record.id}"
|
||||
target={inModal
|
||||
? "_blank"
|
||||
: "_self"}
|
||||
>
|
||||
{previewTitle(
|
||||
channel.schemas,
|
||||
record,
|
||||
graph,
|
||||
)}
|
||||
</a>
|
||||
<span
|
||||
>{(
|
||||
record._file.size / 1024
|
||||
).toFixed(1)}kB</span
|
||||
>
|
||||
|
||||
{#if record._file.width > 0}
|
||||
<span
|
||||
>{record._file.width +
|
||||
"x" +
|
||||
record._file.height}</span
|
||||
>
|
||||
{/if}
|
||||
<a
|
||||
href={fileurl(channel, record)}
|
||||
target="_blank"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<a
|
||||
href="{channel.lucentUrl}/records/{record.id}"
|
||||
target={inModal ? "_blank" : "_self"}
|
||||
@@ -141,13 +89,8 @@
|
||||
>{record.status}</span
|
||||
>
|
||||
{/if}
|
||||
{previewTitle(
|
||||
channel.schemas,
|
||||
record,
|
||||
graph,
|
||||
)}
|
||||
{record.data.name}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<RecordRow
|
||||
@@ -161,7 +104,7 @@
|
||||
/>
|
||||
<td>
|
||||
<Avatar
|
||||
name={usernameById(users, record._sys.updatedBy)}
|
||||
name={usernameById(users, record.updatedBy)}
|
||||
side={24}
|
||||
/>
|
||||
</td>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from "svelte";
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
import NavItem from "./NavItem.svelte";
|
||||
export let inModal;
|
||||
export let modalUrl;
|
||||
@@ -10,7 +11,11 @@
|
||||
|
||||
$: totalPages = Math.ceil(total / limit);
|
||||
$: currentPage = Math.ceil((skip - 1) / limit) + 1;
|
||||
|
||||
const range = (start, end, step = 1) =>
|
||||
Array.from(
|
||||
{ length: Math.ceil((end - start) / step) },
|
||||
(_, i) => start + i * step,
|
||||
);
|
||||
$: pageRange = range(currentPage - 3, currentPage + 4).filter((i) => {
|
||||
return i > 0 && i <= totalPages;
|
||||
});
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<script>
|
||||
import { createEventDispatcher, getContext } from "svelte";
|
||||
import {previewTitle} from "../../records/Preview";
|
||||
import Icon from "../../common/Icon.svelte";
|
||||
|
||||
const channel = getContext("channel");
|
||||
const dispatch = createEventDispatcher();
|
||||
export let schema;
|
||||
export let operators;
|
||||
@@ -19,13 +17,10 @@
|
||||
isReference: key.startsWith("children"),
|
||||
};
|
||||
|
||||
filter = [
|
||||
extractOperator(key),
|
||||
extractLabel(schema, key),
|
||||
].reduce((mem, fn) => fn(mem), filter);
|
||||
|
||||
|
||||
|
||||
filter = [extractOperator(key), extractLabel(schema, key)].reduce(
|
||||
(mem, fn) => fn(mem),
|
||||
filter,
|
||||
);
|
||||
|
||||
function extractOperator(key) {
|
||||
return (filter) => {
|
||||
@@ -50,18 +45,16 @@
|
||||
const filterField = schema.fields.find((f) => f.name === fieldName);
|
||||
filter.label = filterField?.label ?? fieldName;
|
||||
return filter;
|
||||
}
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
const filterRecord = extractFilterRecord(graph, value);
|
||||
|
||||
function extractFilterRecord(graph, value) {
|
||||
|
||||
if (!filter.isReference) {
|
||||
return null;
|
||||
}
|
||||
return graph.records.find(r => r.id === value);
|
||||
return graph.records.find((r) => r.id === value);
|
||||
}
|
||||
|
||||
function removeFilter(k) {
|
||||
@@ -78,11 +71,14 @@
|
||||
</script>
|
||||
|
||||
<span class="applied-filter">
|
||||
|
||||
{#if filter.isReference && filterRecord}
|
||||
{filter.label} is {previewTitle(channel.schemas, filterRecord)}
|
||||
{filter.label} is {filterRecord.data.name}
|
||||
{:else}
|
||||
{filter.label} {operators.find((o) => o.name === filter.operator)?.symbol ?? ""} {operators.find((o) => o.name === filter.operator)?.hasValue ? value : ""}
|
||||
{filter.label}
|
||||
{operators.find((o) => o.name === filter.operator)?.symbol ?? ""}
|
||||
{operators.find((o) => o.name === filter.operator)?.hasValue
|
||||
? value
|
||||
: ""}
|
||||
{/if}
|
||||
|
||||
<button
|
||||
@@ -90,7 +86,6 @@
|
||||
type="button"
|
||||
class="button-text"
|
||||
aria-label="Close"
|
||||
><Icon width={12} height={12} icon="close"></Icon></button>
|
||||
><Icon width={12} height={12} icon="close"></Icon></button
|
||||
>
|
||||
</span>
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script>
|
||||
import {createEventDispatcher, getContext} from "svelte";
|
||||
import Icon from "../../../common/Icon.svelte";
|
||||
import Icon from "../../common/Icon.svelte";
|
||||
|
||||
const channel = getContext("channel");
|
||||
const dispatch = createEventDispatcher();
|
||||
@@ -9,6 +9,7 @@
|
||||
const url = new URL(modalUrl ?? window.location.href);
|
||||
|
||||
function removeFilter(k) {
|
||||
|
||||
const url = new URL(modalUrl ?? window.location.href);
|
||||
url.searchParams.set("skip", "0");
|
||||
url.searchParams.delete("notlinked");
|
||||
@@ -19,9 +20,9 @@
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if url.searchParams.get("notlinked")}
|
||||
<span class="applied-filter">
|
||||
|
||||
Not linked
|
||||
|
||||
<button
|
||||
@@ -29,7 +30,6 @@
|
||||
type="button"
|
||||
class="button-text"
|
||||
aria-label="Close"
|
||||
><Icon width={12} height={12} icon="close"></Icon></button
|
||||
>
|
||||
><Icon width={12} height={12} icon="close"></Icon></button>
|
||||
</span>
|
||||
{/if}
|
||||
@@ -1,6 +1,6 @@
|
||||
<script>
|
||||
import { createEventDispatcher, getContext } from "svelte";
|
||||
import { previewTitle } from "../../records/Preview";
|
||||
import { apiGet, debounce } from "../../../helpers";
|
||||
|
||||
const channel = getContext("channel");
|
||||
const dispatch = createEventDispatcher();
|
||||
@@ -12,8 +12,7 @@
|
||||
$: searchOptions = [];
|
||||
|
||||
const updateResults = debounce((e) => {
|
||||
axios
|
||||
.get(channel.lucentUrl + "/records/suggestions", {
|
||||
apiGet(channel.lucentUrl + "/records/suggestions", {
|
||||
params: {
|
||||
schema: field.collections[0],
|
||||
field: "search",
|
||||
@@ -22,7 +21,7 @@
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
searchOptions = response.data;
|
||||
searchOptions = response;
|
||||
})
|
||||
.catch((error) => {
|
||||
searchOptions = [];
|
||||
@@ -57,7 +56,7 @@
|
||||
on:click={(e) => apply(e, option)}
|
||||
on:keypress={(e) => apply(e, option)}
|
||||
>
|
||||
{previewTitle(channel.schemas, option)}
|
||||
{option.data.name}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="start-typing">Start typing...</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import Icon from "../../../common/Icon.svelte";
|
||||
import Icon from "../../common/Icon.svelte";
|
||||
import {createEventDispatcher} from "svelte";
|
||||
import Dropdown from "../../common/Dropdown.svelte";
|
||||
|
||||
@@ -12,22 +12,14 @@
|
||||
export let systemFields = [];
|
||||
|
||||
$: sortableFields = schema.fields.filter(
|
||||
(f) =>
|
||||
![
|
||||
"reference",
|
||||
"file",
|
||||
"json",
|
||||
"id",
|
||||
"rich",
|
||||
"markdown",
|
||||
"block",
|
||||
].includes(f.info.name),
|
||||
(f) => !["reference", "file", "json", "id", "rich", "markdown", "block"].includes(f.info.name)
|
||||
);
|
||||
$: systemFieldsFiltered = systemFields;
|
||||
$: if (schema.type === "collection") {
|
||||
systemFieldsFiltered = systemFields.filter((f) => f.files === false);
|
||||
}
|
||||
|
||||
|
||||
function triggerSortField(fieldSort) {
|
||||
const url = new URL(modalUrl ?? window.location.href);
|
||||
url.searchParams.set("sort", fieldSort);
|
||||
@@ -40,21 +32,18 @@
|
||||
|
||||
function sortAsc(e, field) {
|
||||
e.preventDefault();
|
||||
let prefix = systemFields.map((el) => el.name).includes(field.name)
|
||||
? ""
|
||||
: "data.";
|
||||
let prefix = systemFields.map((el) => el.name).includes(field.name) ? "" : "data.";
|
||||
return triggerSortField(prefix + field.name);
|
||||
}
|
||||
|
||||
function sortDesc(e, field) {
|
||||
e.preventDefault();
|
||||
let prefix = systemFields.map((el) => el.name).includes(field.name)
|
||||
? ""
|
||||
: "data.";
|
||||
let prefix = systemFields.map((el) => el.name).includes(field.name) ? "" : "data.";
|
||||
return triggerSortField("-" + prefix + field.name);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<Dropdown>
|
||||
<div slot="button">
|
||||
{#if sortParam.startsWith("-")}
|
||||
@@ -70,21 +59,23 @@
|
||||
<button
|
||||
on:click={(e) => sortAsc(e, field)}
|
||||
title="Sort Ascending"
|
||||
class="button button-icon {field.name == sortField.name &&
|
||||
!sortParam.startsWith('-')
|
||||
class="button button-icon {field.name == sortField.name && !sortParam.startsWith("-")
|
||||
? 'active'
|
||||
: ''} "
|
||||
>
|
||||
|
||||
|
||||
<Icon icon="arrow-up-short-wide"/>
|
||||
</button>
|
||||
<button
|
||||
on:click={(e) => sortDesc(e, field)}
|
||||
title="Sort Descending"
|
||||
class="button button-icon {field.name == sortField.name &&
|
||||
sortParam.startsWith('-')
|
||||
class="button button-icon {field.name == sortField.name && sortParam.startsWith("-")
|
||||
? 'active'
|
||||
: ''} "
|
||||
>
|
||||
|
||||
|
||||
<Icon icon="arrow-down-wide-short"/>
|
||||
</button>
|
||||
<button
|
||||
@@ -99,6 +90,7 @@
|
||||
<h6 class="dropdown-header">System</h6>
|
||||
{#each systemFieldsFiltered as field}
|
||||
<div class="dropdown-item">
|
||||
|
||||
<button
|
||||
on:click={(e) => sortAsc(e, field)}
|
||||
title="Sort Ascending"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script>
|
||||
import FilterFields from "./FilterFields.svelte";
|
||||
import Uploader from "../../files/Uploader.svelte";
|
||||
import Icon from "../../../common/Icon.svelte";
|
||||
import Icon from "../../common/Icon.svelte";
|
||||
import SortFields from "./SortFields.svelte";
|
||||
import AppliedFilter from "./AppliedFilter.svelte";
|
||||
import { createEventDispatcher, getContext } from "svelte";
|
||||
@@ -42,10 +41,6 @@
|
||||
window.location = url;
|
||||
}
|
||||
}
|
||||
|
||||
function uploadComplete(e) {
|
||||
records = e.detail;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="toolbar">
|
||||
@@ -82,7 +77,6 @@
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items: center;gap:4px">
|
||||
{#if schema.type === "collection"}
|
||||
{#if !inModal && isWritable}
|
||||
<a
|
||||
href="{channel.lucentUrl}/records/new?schema={schema.name}"
|
||||
@@ -91,11 +85,7 @@
|
||||
New Record
|
||||
</a>
|
||||
{/if}
|
||||
{:else}
|
||||
<div>
|
||||
<Uploader {schema} on:uploadComplete={uploadComplete} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !inModal}
|
||||
<Dropdown orientation="right">
|
||||
<div slot="button">
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script>
|
||||
import { createEventDispatcher, getContext } from "svelte";
|
||||
import Icon from "../../common/Icon.svelte";
|
||||
import Icon from "../common/Icon.svelte";
|
||||
import Index from "../content/Index.svelte";
|
||||
import axios from "axios";
|
||||
import { apiGet } from "../../helpers";
|
||||
|
||||
let dialogEl;
|
||||
|
||||
@@ -24,10 +24,9 @@
|
||||
}
|
||||
|
||||
function load(schema) {
|
||||
axios
|
||||
.get(channel.lucentUrl + "/content/" + schema)
|
||||
apiGet(channel.lucentUrl + "/content/" + schema)
|
||||
.then((response) => {
|
||||
data = response.data;
|
||||
data = response;
|
||||
})
|
||||
.catch((error) => console.log(error));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script>
|
||||
import Icon from "../../common/Icon.svelte";
|
||||
|
||||
import Icon from "../common/Icon.svelte";
|
||||
|
||||
let dialogEl;
|
||||
|
||||
@@ -9,14 +10,15 @@
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
dialogEl.close();
|
||||
dialogEl.close()
|
||||
}
|
||||
|
||||
export function open() {
|
||||
dialogEl.showModal();
|
||||
dialogEl.showModal()
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
</script>
|
||||
<dialog bind:this={dialogEl}>
|
||||
<div class="dialog-header">
|
||||
<button
|
||||
@@ -32,4 +34,5 @@
|
||||
<div class="dialog-body" style="min-width: 900px">
|
||||
<slot/>
|
||||
</div>
|
||||
|
||||
</dialog>
|
||||
@@ -0,0 +1,102 @@
|
||||
<script>
|
||||
import { createEventDispatcher, getContext } from "svelte";
|
||||
import Icon from "../common/Icon.svelte";
|
||||
import FileIndex from "./FileIndex.svelte";
|
||||
import Dropdown from "../common/Dropdown.svelte";
|
||||
|
||||
let dialogEl;
|
||||
export let presetMode = false;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const channel = getContext("channel");
|
||||
$: files = [];
|
||||
$: selectedFiles = [];
|
||||
// onMount(() => {
|
||||
// load();
|
||||
// });
|
||||
|
||||
export function close(e) {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
dialogEl.close();
|
||||
selectedFiles = [];
|
||||
}
|
||||
|
||||
function load(recordId) {
|
||||
fetch(channel.lucentUrl + "/records/files/?recordId=" + recordId)
|
||||
.then((response) => response.json())
|
||||
.then((json) => {
|
||||
files = json;
|
||||
})
|
||||
.catch((error) => console.log(error));
|
||||
}
|
||||
|
||||
function insert(e, preset) {
|
||||
e.preventDefault();
|
||||
dispatch("insert_files", { files: selectedFiles, preset: preset });
|
||||
}
|
||||
|
||||
function replace(e) {
|
||||
e.preventDefault();
|
||||
dispatch("replace_files", selectedFiles);
|
||||
}
|
||||
|
||||
export function open(recordId) {
|
||||
dialogEl.showModal();
|
||||
load(recordId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog bind:this={dialogEl}>
|
||||
<div class="dialog-header">
|
||||
{#if presetMode}
|
||||
<Dropdown>
|
||||
<div slot="button">Insert Preset</div>
|
||||
{#each channel.imagePresets as preset}
|
||||
<button
|
||||
class=" dropdown-item button"
|
||||
on:click={(e) => insert(e, preset)}
|
||||
>{preset.name}</button
|
||||
>
|
||||
{/each}
|
||||
</Dropdown>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="button"
|
||||
on:click={insert}
|
||||
disabled={selectedFiles.length === 0}
|
||||
>
|
||||
Insert
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="button"
|
||||
on:click={replace}
|
||||
disabled={selectedFiles.length === 0}
|
||||
>
|
||||
Replace
|
||||
</button>
|
||||
{/if}
|
||||
{#if selectedFiles.length > 0}
|
||||
<span class="">
|
||||
{selectedFiles.length} records selected
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
on:click|preventDefault={close}
|
||||
type="button"
|
||||
class="button close"
|
||||
aria-label="Close"
|
||||
>
|
||||
<Icon icon="close"></Icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="dialog-body">
|
||||
<FileIndex {files} bind:selected={selectedFiles}></FileIndex>
|
||||
</div>
|
||||
</dialog>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script>
|
||||
import { getContext } from "svelte";
|
||||
import Icon from "../common/Icon.svelte";
|
||||
import Checkbox from "../common/Checkbox.svelte";
|
||||
import Preview from "../files/Preview.svelte";
|
||||
import { fileurl } from "../files/imageserver";
|
||||
|
||||
const channel = getContext("channel");
|
||||
|
||||
export let files = [];
|
||||
export let selected = [];
|
||||
export let isWritable = true;
|
||||
|
||||
function eventToggleAll(e) {
|
||||
selected = toggleAll(e, files, selected);
|
||||
}
|
||||
|
||||
function select(file) {
|
||||
selected = selectFile(file, selected);
|
||||
}
|
||||
|
||||
export const toggleAll = (e, files, selected) => {
|
||||
if (selected.length === files.length) {
|
||||
return [];
|
||||
}
|
||||
e.currentTarget.checked = selected.length > 0;
|
||||
return files;
|
||||
};
|
||||
|
||||
export const selectFile = (file, selected) => {
|
||||
let fileExists = selected.find((r) => r.id === file.id);
|
||||
if (fileExists) {
|
||||
return selected.filter((r) => r.id !== file.id);
|
||||
}
|
||||
return [...selected, file];
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="table mt-5">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
{#if isWritable}
|
||||
<th>
|
||||
<Checkbox
|
||||
value=""
|
||||
on:change={eventToggleAll}
|
||||
indeterminate={selected.length > 0 &&
|
||||
selected.length < files.length}
|
||||
checked={selected.length === files.length}
|
||||
></Checkbox>
|
||||
</th>
|
||||
{/if}
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each files as file (file.id)}
|
||||
<tr>
|
||||
<td class="title-td">
|
||||
<div class="title-td-contents">
|
||||
{#if isWritable}
|
||||
<Checkbox
|
||||
on:change={() => select(file)}
|
||||
checked={selected.find(
|
||||
(s) => s.id === file.id,
|
||||
)}
|
||||
value={file}
|
||||
></Checkbox>
|
||||
{/if}
|
||||
<div class="file-table-row">
|
||||
<Preview
|
||||
{file}
|
||||
size={file.width > 0 ? "medium" : "small"}
|
||||
/>
|
||||
|
||||
<div>
|
||||
{file.filename}
|
||||
<span
|
||||
>{(file.size / 1024).toFixed(1)}kB</span
|
||||
>
|
||||
|
||||
{#if file.width > 0}
|
||||
<span
|
||||
>{file.width +
|
||||
"x" +
|
||||
file.height}</span
|
||||
>
|
||||
{/if}
|
||||
<a
|
||||
href={fileurl(channel, file)}
|
||||
target="_blank"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -1,19 +1,23 @@
|
||||
|
||||
|
||||
|
||||
export function sortByField(from, to, edges, fieldName, references) {
|
||||
if (from === to) {
|
||||
return edges;
|
||||
}
|
||||
let referenceIds = references.map(r => r.id);
|
||||
let edgesTosort = edges?.filter((ed) => ed.field === fieldName && ed.depth === 1 && referenceIds.includes(ed.target)) ?? [];
|
||||
let remainingEdge = edges?.filter((ed) => !(ed.field === fieldName && ed.depth === 1)) ?? [];
|
||||
let referenceIds = references.map((r) => r.id);
|
||||
let edgesTosort =
|
||||
edges?.filter(
|
||||
(ed) =>
|
||||
ed.field === fieldName &&
|
||||
ed.depth === 1 &&
|
||||
referenceIds.includes(ed.target),
|
||||
) ?? [];
|
||||
let remainingEdge =
|
||||
edges?.filter((ed) => !(ed.field === fieldName && ed.depth === 1)) ?? [];
|
||||
|
||||
edgesTosort = array_move(edgesTosort, from, to);
|
||||
return [...remainingEdge, ...edgesTosort];
|
||||
}
|
||||
|
||||
function array_move(arr, old_index, new_index) {
|
||||
export function array_move(arr, old_index, new_index) {
|
||||
if (new_index >= arr.length) {
|
||||
var k = new_index - arr.length + 1;
|
||||
while (k--) {
|
||||
@@ -22,4 +26,4 @@ function array_move(arr, old_index, new_index) {
|
||||
}
|
||||
arr.splice(new_index, 0, arr.splice(old_index, 1)[0]);
|
||||
return arr; // for testing
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
<script>
|
||||
import Icon from "../../common/Icon.svelte";
|
||||
import { imgurl } from "./imageserver.js";
|
||||
import Icon from "../common/Icon.svelte";
|
||||
import { fileurl, imgurl } from "./imageserver.js";
|
||||
import { getContext } from "svelte";
|
||||
|
||||
export let record;
|
||||
export let file;
|
||||
const channel = getContext("channel");
|
||||
export let size = "small";
|
||||
export let showFilename = false;
|
||||
let imageSide;
|
||||
let fileSide;
|
||||
let fontSize;
|
||||
|
||||
if (size == "large") {
|
||||
imageSide = 256;
|
||||
fileSide = 32;
|
||||
@@ -30,40 +31,41 @@
|
||||
</script>
|
||||
|
||||
<div style="display: flex;align-items: center;gap: 5px;">
|
||||
{#if record}
|
||||
{#if record._file.mime.startsWith("image")}
|
||||
{#if file}
|
||||
{#if file.mime.startsWith("image")}
|
||||
<!-- href={imgurl(record)} -->
|
||||
<a
|
||||
href="{channel.lucentUrl}/records/{record.id}"
|
||||
title={record._file.originalName}
|
||||
target="_blank"
|
||||
href={fileurl(channel, file)}
|
||||
title={file.filename}
|
||||
style="width:{imageSide}px;height:{imageSide}px"
|
||||
>
|
||||
<img
|
||||
class="rounded w-100"
|
||||
src={imgurl(channel, record)}
|
||||
alt={record._file.path}
|
||||
src={imgurl(channel, file)}
|
||||
alt={file.path}
|
||||
/>
|
||||
</a>
|
||||
{:else}
|
||||
<a
|
||||
href="{channel.lucentUrl}/records/{record.id}"
|
||||
title={record._file.path}
|
||||
href="{channel.lucentUrl}/files/{file.id}"
|
||||
title={file.path}
|
||||
class="file-preview-small"
|
||||
style="width:{imageSide}px;height:{imageSide}px"
|
||||
>
|
||||
<Icon icon="file" width={fileSide} height={fileSide} />
|
||||
<span class="ms-2"
|
||||
>.{record._file.path.split(".").pop().toLowerCase()}</span
|
||||
>.{file.path.split(".").pop().toLowerCase()}</span
|
||||
>
|
||||
</a>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if showFilename}
|
||||
<a
|
||||
href="{channel.lucentUrl}/records/{record.id}"
|
||||
title={record._file.path}
|
||||
href="{channel.lucentUrl}/files/{file.id}"
|
||||
title={file.path}
|
||||
class="preview-file-filename lx-small-text text-decoration-none"
|
||||
>{record._file.path}
|
||||
>{file.path}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script>
|
||||
import { createEventDispatcher, getContext } from "svelte";
|
||||
import Icon from "../common/Icon.svelte";
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
const channel = getContext("channel");
|
||||
export let schema;
|
||||
export let recordId;
|
||||
let mimeTypes = "";
|
||||
let files = [];
|
||||
let isLoading = false;
|
||||
@@ -17,34 +18,35 @@
|
||||
files = e.target.files ? [...e.target.files] : [];
|
||||
let formData = new FormData();
|
||||
|
||||
formData.append("schema", schema.name);
|
||||
formData.append("recordId", recordId);
|
||||
Array.from(files).forEach(function (file) {
|
||||
formData.append("files[]", file);
|
||||
});
|
||||
dispatch("beforeUpload", files);
|
||||
axios
|
||||
.post(channel.lucentUrl + "/files/upload", formData, {
|
||||
fetch(channel.lucentUrl + "/files/upload", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
"X-CSRF-TOKEN": document.querySelector(
|
||||
'meta[name="csrf-token"]',
|
||||
).content,
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.data.error) {
|
||||
dispatch("uploadError", response.data.error);
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
dispatch("uploadError", data.error);
|
||||
} else {
|
||||
dispatch("uploadComplete", response.data);
|
||||
dispatch("uploadComplete", data);
|
||||
}
|
||||
isLoading = false;
|
||||
})
|
||||
.catch((error) => {
|
||||
isLoading = false;
|
||||
console.log(error.response.data);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<fieldset class="upload-button" disabled={isLoading}>
|
||||
<label class="button primary btn-spinner">
|
||||
<Icon icon="upload"></Icon>
|
||||
<span
|
||||
class="spinner-border spinner-border-sm"
|
||||
role="status"
|
||||
|
||||
@@ -1,29 +1,38 @@
|
||||
export function imgurl(channel, record) {
|
||||
if (record._file.mime === "image/svg+xml") {
|
||||
return fileurl(channel, record);
|
||||
export function imgurl(channel, file) {
|
||||
if (file.mime === "image/svg+xml") {
|
||||
return fileurl(channel, file);
|
||||
}
|
||||
return channel.disks[record._file.disk] + `/thumbs/${record._file.path}`;
|
||||
const webpPath = file.path.slice(0, file.path.lastIndexOf(".")) + ".webp";
|
||||
return channel.filesUrl + `/thumbs/${webpPath}`;
|
||||
}
|
||||
|
||||
export function fileurl(channel, record) {
|
||||
return channel.disks[record._file.disk] + `/${record._file.path}`;
|
||||
export function presetUrl(channel, file, preset) {
|
||||
if (file.mime === "image/svg+xml") {
|
||||
return fileurl(channel, file);
|
||||
}
|
||||
const webpPath = file.path.slice(0, file.path.lastIndexOf(".")) + ".webp";
|
||||
return channel.filesUrl + `/templates/${preset}/${webpPath}`;
|
||||
}
|
||||
|
||||
export function htmlurl(channel, record, preset) {
|
||||
export function fileurl(channel, file) {
|
||||
return channel.filesUrl + `/${file.path}`;
|
||||
}
|
||||
|
||||
export function htmlurl(channel, file, preset) {
|
||||
let html = "";
|
||||
let url = fileurl(channel, record)
|
||||
let url = fileurl(channel, file);
|
||||
|
||||
if (record._file.width > 0) {
|
||||
if (file.width > 0) {
|
||||
let presetUrl = url;
|
||||
if (preset) {
|
||||
presetUrl = channel.disks[record._file.disk] + `/templates/${preset}/${record._file.path}`;
|
||||
const webpPath = file.path.slice(0, file.path.lastIndexOf(".")) + ".webp";
|
||||
presetUrl = channel.filesUrl + `/templates/${preset}/${webpPath}`;
|
||||
}
|
||||
html = `<img src="${presetUrl}" alt="${record._file.path}" />`
|
||||
} else if (record._file.mime === "image/svg+xml") {
|
||||
html = `<img src="${url}" alt="${record._file.path}"/>`
|
||||
html = `<img src="${presetUrl}" alt="${file.path}" />`;
|
||||
} else if (file.mime === "image/svg+xml") {
|
||||
html = `<img src="${url}" alt="${file.path}"/>`;
|
||||
} else {
|
||||
html = `<a href="${url}">${record._file.originalName}</a>`
|
||||
html = `<a href="${url}">${file.filename}</a>`;
|
||||
}
|
||||
|
||||
return html;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<script>
|
||||
import { getContext, onMount } from "svelte";
|
||||
import RecordRow from "./RecordRow.svelte";
|
||||
import { apiGet } from "../../helpers";
|
||||
|
||||
const channel = getContext("channel");
|
||||
let records = [];
|
||||
let graph = null;
|
||||
let users = [];
|
||||
onMount(() => {
|
||||
apiGet(channel.lucentUrl + "/home/records")
|
||||
.then((data) => {
|
||||
records = data.records;
|
||||
users = data.users;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<h3 class="header-small mb-4 mt-5">Latest Content changes</h3>
|
||||
{#if records.length > 0}
|
||||
<div class="table">
|
||||
<table class="">
|
||||
<tbody>
|
||||
{#each records as record (record.id)}
|
||||
<tr>
|
||||
<RecordRow {record} {users} />
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,40 @@
|
||||
<script>
|
||||
import { formatDistanceToNow, parseJSON } from "date-fns";
|
||||
import Avatar from "../account/Avatar.svelte";
|
||||
import Preview from "../files/Preview.svelte";
|
||||
import { usernameById } from "../account/users";
|
||||
import { getContext } from "svelte";
|
||||
|
||||
const channel = getContext("channel");
|
||||
export let users;
|
||||
export let record;
|
||||
let schema = channel.schemas.find((s) => s.name === record.schema);
|
||||
let frieldlyUpdatedAt = formatDistanceToNow(parseJSON(record.updatedAt), {
|
||||
addSuffix: true,
|
||||
});
|
||||
</script>
|
||||
|
||||
<td>
|
||||
<div class="row-name">
|
||||
{#if record.status === "draft"}
|
||||
<span class="status">DRAFT</span>
|
||||
{/if}
|
||||
{#if schema.type === "files"}
|
||||
<Preview {record} size="tiny" showFilename={true} />
|
||||
{:else}
|
||||
<a href="{channel.lucentUrl}/records/{record.id}">
|
||||
{record.data.name}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td><a href="{channel.lucentUrl}/content/{schema.name}">{schema.label}</a> </td>
|
||||
|
||||
<td>
|
||||
<div style="display: flex;gap: 14px">
|
||||
<Avatar name={usernameById(users, record.updatedBy)} side={24} />
|
||||
<div class="ms-2">
|
||||
{frieldlyUpdatedAt}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -1,15 +1,19 @@
|
||||
<script>
|
||||
import Avatar from "../common/Avatar.svelte";
|
||||
// import Dropdown from "../svelte/common/Dropdown.svelte";
|
||||
let { channel, user } = $props();
|
||||
import Avatar from "../account/Avatar.svelte";
|
||||
import { getContext } from "svelte";
|
||||
import Dropdown from "../common/Dropdown.svelte";
|
||||
|
||||
const channel = getContext("channel");
|
||||
const user = getContext("user");
|
||||
</script>
|
||||
|
||||
<div class="top-nav">
|
||||
{#if channel.auth == "lucent"}
|
||||
<a class="top-nav-item" href="{channel.lucentUrl}/members">Members</a>
|
||||
<a class="top-nav-item" href="{channel.lucentUrl}/">Content</a>
|
||||
<a class="top-nav-item" href="{channel.lucentUrl}/schemas">Schemas</a>
|
||||
|
||||
<!-- {#if channel.commands.length > 0}
|
||||
{:else}
|
||||
<a href="/lunar">Store admin</a>
|
||||
{/if}
|
||||
{#if channel.commands.length > 0}
|
||||
<Dropdown>
|
||||
<div slot="button">Actions</div>
|
||||
{#each channel.commands as command}
|
||||
@@ -19,14 +23,18 @@
|
||||
>
|
||||
{/each}
|
||||
</Dropdown>
|
||||
{/if} -->
|
||||
{/if}
|
||||
<!-- <div>-->
|
||||
<!-- <form method="GET">-->
|
||||
<!-- <input type="search" name="filter[search_regex]" placeholder="Search"-->
|
||||
<!-- class="form-control" required/>-->
|
||||
<!-- </form>-->
|
||||
<!-- </div>-->
|
||||
{#if channel.auth == "lucent"}
|
||||
<a href="{channel.lucentUrl}/profile">
|
||||
<Avatar side="28" name={user.name} />
|
||||
</a>
|
||||
{:else}
|
||||
<Avatar side="28" name={user.name} />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script>
|
||||
import { getContext } from "svelte";
|
||||
|
||||
export let schema;
|
||||
const channel = getContext("channel");
|
||||
const readableSchemas = getContext("readableSchemas");
|
||||
</script>
|
||||
|
||||
<div class="sidebar-top">
|
||||
<a class="logo" href={channel.lucentUrl}>{channel.name}</a>
|
||||
</div>
|
||||
<div class="sidebar">
|
||||
{#each readableSchemas as aschema}
|
||||
<a
|
||||
class="sidebar-item"
|
||||
class:active={aschema.name === schema?.name}
|
||||
aria-current="page"
|
||||
href="{channel.lucentUrl}/content/{aschema.name}">{aschema.label}</a
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -1,5 +1,4 @@
|
||||
<script>
|
||||
|
||||
// https://codesandbox.io/s/codemirror-remark-editor-4m4z9?file=/src/CodeEditor.js:374-387
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { basicSetup, EditorView } from "codemirror";
|
||||
@@ -9,19 +8,25 @@
|
||||
import { indentWithTab } from "@codemirror/commands";
|
||||
import { markdown } from "@codemirror/lang-markdown";
|
||||
import { lintKeymap } from "@codemirror/lint";
|
||||
import { fileurl, presetUrl } from "../files/imageserver";
|
||||
|
||||
let parentElement;
|
||||
let codeMirrorView;
|
||||
export let value;
|
||||
export let editable = true;
|
||||
|
||||
export function insertMedia(info) {
|
||||
let insertText = "";
|
||||
if (info.record._file.width > 0) {
|
||||
insertText = ``;
|
||||
} else {
|
||||
insertText = `[${info.record._file.originalName}](${info.originalUrl})`;
|
||||
}
|
||||
export function insertMedia(channel, files, presetPath) {
|
||||
const insertText = files.reduce((text, aFile) => {
|
||||
const url =
|
||||
aFile.width > 0
|
||||
? presetUrl(channel, aFile, presetPath)
|
||||
: fileurl(channel, aFile);
|
||||
|
||||
let addTest = ``;
|
||||
|
||||
return text + "\n" + addTest;
|
||||
}, "");
|
||||
|
||||
const cursor = codeMirrorView.state.selection.main.head;
|
||||
const transaction = codeMirrorView.state.update({
|
||||
changes: {
|
||||
@@ -46,11 +51,7 @@
|
||||
doc: value,
|
||||
extensions: [
|
||||
basicSetup,
|
||||
keymap.of([
|
||||
indentWithTab,
|
||||
...lintKeymap,
|
||||
...completionKeymap
|
||||
]),
|
||||
keymap.of([indentWithTab, ...lintKeymap, ...completionKeymap]),
|
||||
language.of(markdown()),
|
||||
markdown(),
|
||||
autocompletion(),
|
||||
@@ -63,17 +64,14 @@
|
||||
}
|
||||
}),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.contentAttributes.of({spellcheck: "true"})
|
||||
EditorView.contentAttributes.of({ spellcheck: "true" }),
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
codeMirrorView = new EditorView({
|
||||
state,
|
||||
parent: parentElement,
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
<script>
|
||||
import {onDestroy, onMount} from "svelte";
|
||||
|
||||
import tinymce from "tinymce/tinymce";
|
||||
import "tinymce/models/dom";
|
||||
import "tinymce/icons/default";
|
||||
import "tinymce/themes/silver";
|
||||
import "tinymce/skins/ui/oxide/skin.css";
|
||||
import contentUiSkinCss from "tinymce/skins/ui/oxide/content.css?inline";
|
||||
import customcss from "./tinymce.css?inline";
|
||||
|
||||
import "tinymce/plugins/link";
|
||||
import "tinymce/plugins/code";
|
||||
import "tinymce/plugins/image";
|
||||
import "tinymce/plugins/table";
|
||||
import "tinymce/plugins/codesample";
|
||||
import "tinymce/plugins/media";
|
||||
|
||||
import "tinymce/plugins/lists";
|
||||
import "tinymce/plugins/autoresize";
|
||||
import "tinymce/plugins/wordcount";
|
||||
|
||||
export let value = "";
|
||||
export let additionalConfig = {};
|
||||
let lastVal = "";
|
||||
let textareaEl;
|
||||
let activeEditor;
|
||||
let editorWrapper;
|
||||
const plugins = [
|
||||
"autoresize",
|
||||
"code",
|
||||
"image",
|
||||
"table",
|
||||
"codesample",
|
||||
"link",
|
||||
"lists",
|
||||
"media",
|
||||
"wordcount",
|
||||
];
|
||||
const toolbar =
|
||||
"bold italic underline strikethrough removeformat | link | subscript superscript bullist numlist media image codesample table code wordcount blockquote indent outdent blocks";
|
||||
|
||||
onDestroy(() => {
|
||||
if (activeEditor) {
|
||||
activeEditor.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const config = {
|
||||
target: textareaEl,
|
||||
toolbar_mode: "sliding",
|
||||
toolbar_sticky: true,
|
||||
skin: false,
|
||||
content_css: false,
|
||||
content_style: contentUiSkinCss.toString() + customcss.toString(),
|
||||
branding: false,
|
||||
inline: false,
|
||||
plugins: plugins,
|
||||
contextmenu: false,
|
||||
menubar: false,
|
||||
statusbar: false,
|
||||
entity_encoding: "raw",
|
||||
convert_urls: false,
|
||||
toolbar: toolbar,
|
||||
image_caption: true,
|
||||
relative_urls: false,
|
||||
browser_spellcheck: true,
|
||||
max_height: 600,
|
||||
// media_poster: false,
|
||||
|
||||
setup: function (editor) {
|
||||
activeEditor = editor;
|
||||
|
||||
editor.on("init", function (e) {
|
||||
editor.setContent(value ?? "");
|
||||
});
|
||||
|
||||
// editor.on("blur", function (e) {
|
||||
// let content = setImageDimensions(editor.getContent());
|
||||
// dispatch("editorBlur", content);
|
||||
// editorWrapper.classList.remove("editorFocus");
|
||||
// // return false;
|
||||
// });
|
||||
|
||||
// editor.on("focus", function (e) {
|
||||
// editorWrapper.classList.add("editorFocus");
|
||||
// // return false;
|
||||
// });
|
||||
|
||||
editor.on("change input undo redo", function (e) {
|
||||
lastVal = editor.getContent();
|
||||
if (lastVal !== value) {
|
||||
value = lastVal;
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
tinymce.init({...config, ...additionalConfig});
|
||||
});
|
||||
|
||||
export function insertMedia(info){
|
||||
activeEditor.execCommand('InsertHTML', false, info.html);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<div bind:this={editorWrapper} class="tox-wrapper">
|
||||
<div class="form-control" bind:this={textareaEl}>
|
||||
{@html value}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(.tox:not(.tox-tinymce-inline) .tox-editor-header) {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #ced4da;
|
||||
box-shadow: none;
|
||||
padding: 4px 0;
|
||||
transition: box-shadow 0.5s;
|
||||
}
|
||||
|
||||
:global(.tox-tinymce) {
|
||||
border: 1px solid #ced4da;
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
@@ -1,61 +1,61 @@
|
||||
<script>
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import Trix from "trix"
|
||||
import "trix/dist/trix.css"
|
||||
import Trix from "trix";
|
||||
import "trix/dist/trix.css";
|
||||
|
||||
export let value = "";
|
||||
export let field;
|
||||
let editor;
|
||||
|
||||
|
||||
function updateValue(e) {
|
||||
value = e.target.value;
|
||||
}
|
||||
|
||||
export function insertMedia(info){
|
||||
if(info.record._file.width > 0){
|
||||
var attachment = new Trix.Attachment({ content: info.html })
|
||||
editor.editor.insertAttachment(attachment)
|
||||
}else{
|
||||
editor.editor.insertHTML(`<a href="${info.originalUrl}">${info.record._file.originalName}</a>`)
|
||||
|
||||
}
|
||||
|
||||
|
||||
export function insertMedia(html) {
|
||||
console.log({ html });
|
||||
var attachment = new Trix.Attachment({ content: html });
|
||||
editor.editor.insertAttachment(attachment);
|
||||
// if (info.file.width > 0) {
|
||||
// var attachment = new Trix.Attachment({ content: html });
|
||||
// editor.editor.insertAttachment(attachment);
|
||||
// } else {
|
||||
// editor.editor.insertHTML(html);
|
||||
// }
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
editor.addEventListener("trix-file-accept", (e) => {
|
||||
e.preventDefault();
|
||||
})
|
||||
});
|
||||
|
||||
editor.addEventListener("trix-before-initialize", (e) => {
|
||||
Trix.config.blockAttributes.heading1.tagName = 'h2';
|
||||
const { toolbarElement } = e.target
|
||||
const h1Button = toolbarElement.querySelector("[data-trix-attribute=heading1]")
|
||||
h1Button.insertAdjacentHTML("afterend", `<button style="text-indent: initial;padding: 14px 10px !important;" type="button" class="trix-button trix-button--icon" data-trix-attribute="heading3" title="Heading 3" tabindex="-1" data-trix-active="">H3</button>`)
|
||||
})
|
||||
|
||||
|
||||
})
|
||||
Trix.config.blockAttributes.heading1.tagName = "h2";
|
||||
const { toolbarElement } = e.target;
|
||||
const h1Button = toolbarElement.querySelector(
|
||||
"[data-trix-attribute=heading1]",
|
||||
);
|
||||
h1Button.insertAdjacentHTML(
|
||||
"afterend",
|
||||
`<button style="text-indent: initial;padding: 14px 10px !important;" type="button" class="trix-button trix-button--icon" data-trix-attribute="heading3" title="Heading 3" tabindex="-1" data-trix-active="">H3</button>`,
|
||||
);
|
||||
});
|
||||
});
|
||||
// onDestroy(() => {
|
||||
// editor.removeEventListener("trix-before-initialize")
|
||||
// })
|
||||
|
||||
|
||||
Trix.config.blockAttributes.default.breakOnReturn = false
|
||||
Trix.config.blockAttributes.default.breakOnReturn = false;
|
||||
Trix.config.blockAttributes.heading3 = {
|
||||
tagName: 'h3',
|
||||
tagName: "h3",
|
||||
terminal: true,
|
||||
breakOnReturn: true,
|
||||
group: false
|
||||
}
|
||||
group: false,
|
||||
};
|
||||
// console.log(Trix.config)
|
||||
|
||||
</script>
|
||||
|
||||
<div class="tox-wrapper">
|
||||
<input id="x-{field.name}" {value} type="hidden">
|
||||
<input id="x-{field.name}" {value} type="hidden" />
|
||||
<trix-editor
|
||||
bind:this={editor}
|
||||
class=" content"
|
||||
@@ -63,6 +63,5 @@
|
||||
role="textbox"
|
||||
tabindex="0"
|
||||
on:trix-change={updateValue}
|
||||
|
||||
></trix-editor>
|
||||
</div>
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
.mce-content-body .img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.mce-content-body{
|
||||
font-size: 16px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.mce-content-body p{
|
||||
|
||||
margin-bottom: 14px;
|
||||
&:last-child{
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.mce-content-body ul {
|
||||
padding: 0 0 0 16px;
|
||||
list-style: none outside none;
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
.mce-content-body li::before {
|
||||
content: "—";
|
||||
opacity: .5;
|
||||
font-size: 12px;
|
||||
padding-right: 6px;
|
||||
vertical-align: 10%;
|
||||
}
|
||||
|
||||
.mce-content-body li {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import Avatar from "../../common/Avatar.svelte";
|
||||
import Avatar from "../account/Avatar.svelte";
|
||||
import {fly} from "svelte/transition";
|
||||
import {createEventDispatcher} from "svelte";
|
||||
import Dropdown from "../common/Dropdown.svelte";
|
||||
@@ -8,6 +8,7 @@
|
||||
export let member;
|
||||
export let roles;
|
||||
|
||||
|
||||
function removeFrom(e, aRole) {
|
||||
e.preventDefault();
|
||||
let newRoles = member.roles.filter((r) => r !== aRole);
|
||||
@@ -21,22 +22,23 @@
|
||||
e.preventDefault();
|
||||
|
||||
let newRoles = [...member.roles, aRole];
|
||||
console.log(member.roles);
|
||||
console.log(aRole);
|
||||
console.log(newRoles);
|
||||
console.log(member.roles)
|
||||
console.log(aRole)
|
||||
console.log(newRoles)
|
||||
dispatch("update", {
|
||||
user: member.id,
|
||||
roles: newRoles,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<div transition:fly={{ duration: 200 }} class="member-item">
|
||||
<div
|
||||
class="member-name status-{member.roles.includes('removed')
|
||||
? 'removed'
|
||||
: 'active'}"
|
||||
transition:fly={{ duration: 200 }}
|
||||
class="member-item"
|
||||
>
|
||||
<div class="member-name status-{member.roles.includes('removed') ? 'removed' : 'active'}">
|
||||
<Avatar name={member.name ?? "" } side={32}/>
|
||||
<div>
|
||||
<div>
|
||||
@@ -48,7 +50,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<Dropdown orientation="right">
|
||||
<div slot="button">Roles</div>
|
||||
<div slot="button">
|
||||
Roles
|
||||
</div>
|
||||
<h6 class="dropdown-header">Remove role</h6>
|
||||
{#each roles as role}
|
||||
{#if member.roles.includes(role)}
|
||||
@@ -61,6 +65,7 @@
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
|
||||
<h6 class="dropdown-header">Add role</h6>
|
||||
{#each roles as role}
|
||||
{#if !member.roles.includes(role)}
|
||||
@@ -72,11 +77,14 @@
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
</Dropdown>
|
||||
|
||||
</div>
|
||||
<style>
|
||||
.status-removed {
|
||||
opacity: 0.5;
|
||||
opacity: .5;
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
import SpinnerButton from "../common/SpinnerButton.svelte";
|
||||
import MemberSettingsCard from "./MemberSettingsCard.svelte";
|
||||
import { getContext } from "svelte";
|
||||
import axios from "axios";
|
||||
|
||||
import { apiPost } from "../../helpers";
|
||||
|
||||
const channel = getContext("channel");
|
||||
export let users;
|
||||
@@ -23,15 +22,14 @@
|
||||
function invite(newName, newEmail, newRole) {
|
||||
errorMessage = "";
|
||||
|
||||
axios
|
||||
.post(channel.lucentUrl + "/members/invite", {
|
||||
apiPost(channel.lucentUrl + "/members/invite", {
|
||||
name: newName,
|
||||
email: newEmail,
|
||||
roles: [newRole],
|
||||
})
|
||||
.then((response) => {
|
||||
successAlert.show("User was invited");
|
||||
users = [...users, response.data.user];
|
||||
users = [...users, response.user];
|
||||
name = null;
|
||||
email = null;
|
||||
role = null;
|
||||
@@ -45,14 +43,13 @@
|
||||
e.preventDefault();
|
||||
errorMessage = "";
|
||||
|
||||
axios
|
||||
.post(channel.lucentUrl + "/members/update", {
|
||||
apiPost(channel.lucentUrl + "/members/update", {
|
||||
id: e.detail.user,
|
||||
roles: e.detail.roles,
|
||||
})
|
||||
.then((response) => {
|
||||
successAlert.show("Users updated");
|
||||
users = response.data.users;
|
||||
users = response.users;
|
||||
})
|
||||
.catch((error) => {
|
||||
errorMessage = error.response?.data?.error ?? "";
|
||||
@@ -68,9 +65,7 @@
|
||||
|
||||
<form on:submit={submitInvite}>
|
||||
<div class="mb-3">
|
||||
<label for="inviteeName" class="form-label"
|
||||
>Invitee Name</label
|
||||
>
|
||||
<label for="inviteeName" class="form-label">Invitee Name</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={name}
|
||||
@@ -97,10 +92,7 @@
|
||||
<div class="me-3">
|
||||
<select bind:value={role}>
|
||||
{#each channel.roles.filter((r) => r !== "removed") as arole}
|
||||
<option
|
||||
value={arole}
|
||||
|
||||
>{arole}</option>
|
||||
<option value={arole}>{arole}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
<script>
|
||||
import { afterUpdate, getContext, onMount } from "svelte";
|
||||
import axios from "axios";
|
||||
import EditHeader from "./header/EditHeader.svelte";
|
||||
import FilePreview from "./FilePreview.svelte";
|
||||
import ContentTabs from "./header/ContentTabs.svelte";
|
||||
import FormField from "./FormField.svelte";
|
||||
import Graph from "./Graph.svelte";
|
||||
import Info from "./Info.svelte";
|
||||
import ErrorAlert from "../common/ErrorAlert.svelte";
|
||||
import Title from "./header/Title.svelte";
|
||||
import { apiPost, isEqual } from "../../helpers";
|
||||
|
||||
const channel = getContext("channel");
|
||||
|
||||
@@ -41,10 +40,7 @@
|
||||
function setOriginalContent() {
|
||||
originalContent = {
|
||||
data: JSON.parse(JSON.stringify(record.data)),
|
||||
schema: record.schema,
|
||||
status: record.status,
|
||||
_sys: JSON.parse(JSON.stringify(record._sys)),
|
||||
_file: JSON.parse(JSON.stringify(record._file)),
|
||||
edges: JSON.parse(JSON.stringify(graph.edges)),
|
||||
};
|
||||
}
|
||||
@@ -73,12 +69,10 @@
|
||||
if (isCreateMode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !isEqual(originalContent, {
|
||||
data: record.data,
|
||||
schema: record.schema,
|
||||
status: record.status,
|
||||
_sys: record._sys,
|
||||
_file: record._file,
|
||||
edges: graph.edges,
|
||||
});
|
||||
}
|
||||
@@ -103,8 +97,7 @@
|
||||
graph.edges = graph.edges?.filter(
|
||||
(edge) => !edge._isTrashed && edge.source === record.id,
|
||||
);
|
||||
axios
|
||||
.post(channel.lucentUrl + "/records", {
|
||||
apiPost(channel.lucentUrl + "/records", {
|
||||
record: record,
|
||||
edges: graph.edges,
|
||||
isCreateMode: isCreateMode,
|
||||
@@ -116,14 +109,14 @@
|
||||
window.location =
|
||||
channel.lucentUrl + "/records/" + record.id;
|
||||
} else {
|
||||
record = response.data.records[0] ?? null;
|
||||
record = response.records[0] ?? null;
|
||||
if (!record) {
|
||||
// means trashed
|
||||
hasUnsavedData = false;
|
||||
window.location = channel.lucentUrl;
|
||||
return;
|
||||
}
|
||||
graph = response.data;
|
||||
graph = response;
|
||||
setOriginalContent();
|
||||
}
|
||||
|
||||
@@ -185,7 +178,6 @@
|
||||
<div class=" mt-4" style="margin-bottom:150px;position:relative;">
|
||||
<ContentTabs {schema} {isCreateMode} bind:active={activeContentTab} />
|
||||
{#if !["_graph", "_info"].includes(activeContentTab)}
|
||||
<FilePreview {record} {schema} />
|
||||
{#each activeFields as field (field.name)}
|
||||
{#if activeContentTab === field.group}
|
||||
<FormField
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user