388 lines
9.8 KiB
Markdown
388 lines
9.8 KiB
Markdown
---
|
|
apply: always
|
|
---
|
|
|
|
# Development Guidelines
|
|
|
|
## Styling
|
|
|
|
Every page has styling in a separate CSS file.
|
|
|
|
- Page-specific styles should be placed in separate CSS files in the `static/` directory
|
|
- Example: The media page has its styles in `static/media.css`
|
|
- General/shared styles are in `static/styles.css`
|
|
|
|
## Single Page Implementation
|
|
|
|
### Path constants
|
|
|
|
- Define path constants for the root paths of the page only (not for sub-routes).
|
|
|
|
```go
|
|
const MediaPath = "/media"
|
|
const MediaPathDe = "/medien"
|
|
```
|
|
|
|
### Page struct and initializer
|
|
|
|
- Define a `Page` struct that contains all routes and actions as fields.
|
|
- Initialize routes in the `NewPage` constructor function.
|
|
|
|
```go
|
|
type Page struct {
|
|
// Routes
|
|
ViewRoute HtmxRoute
|
|
StartRoute HtmxRoute
|
|
StopRoute HtmxRoute
|
|
|
|
}
|
|
|
|
func NewPage(server *Server) *Page {
|
|
p := &Page{}
|
|
|
|
// Initialize routes
|
|
p.ViewRoute = NewHtmxGetRoute(p.View, LanguagePaths{En: MediaPath, De: MediaPathDe}).SetActivePath(MediaPath)
|
|
p.StartRoute = NewHtmxPostRoute(p.Start, LanguagePaths{En: MediaPath + "/start", De: MediaPathDe + "/start"})
|
|
p.StopRoute = NewHtmxPostRoute(p.Stop, LanguagePaths{En: MediaPath + "/stop", De: MediaPathDe + "/stop"})
|
|
|
|
// Register routes
|
|
p.ViewRoute.Add(server)
|
|
p.StartRoute.Add(server)
|
|
p.StopRoute.Add(server)
|
|
|
|
return p
|
|
}
|
|
```
|
|
|
|
### Multilingual content (`I18nText`)
|
|
|
|
- Declare user-facing text with `I18nText`; avoid hardcoded strings.
|
|
- Create a separate `Texts` struct type and initialize it in a `createTexts()` function.
|
|
|
|
```go
|
|
var texts *Texts
|
|
|
|
type Texts struct {
|
|
PageTitle I18nText
|
|
PageDescription I18nText
|
|
HeroTitle I18nText
|
|
StartProcessing I18nText
|
|
}
|
|
|
|
func createTexts() {
|
|
texts = &Texts{
|
|
PageTitle: NewI18nText(map[Language]string{En: "Media Processing", De: "Medienverarbeitung"}),
|
|
PageDescription: NewI18nText(map[Language]string{En: "Process and organize your media files", De: "Verarbeiten und organisieren Sie Ihre Mediendateien"}),
|
|
HeroTitle: NewI18nText(map[Language]string{En: "Media Processing", De: "Medienverarbeitung"}),
|
|
StartProcessing: NewI18nText(map[Language]string{En: "Start Processing", De: "Verarbeitung starten"}),
|
|
}
|
|
}
|
|
|
|
func (p *Page) getMetaData() PageWebsiteMetaData {
|
|
return PageWebsiteMetaData{
|
|
Title: texts.PageTitle,
|
|
Lang: En,
|
|
Description: texts.PageDescription,
|
|
StyleSrcs: []string{"/static/media.css"},
|
|
}
|
|
}
|
|
|
|
func (p *Page) View(c HtmxContext) {
|
|
lang := c.GetLanguage()
|
|
content := Group{
|
|
Div(Class("hero"),
|
|
H1(Text(texts.HeroTitle.FromLang(lang))),
|
|
),
|
|
}
|
|
p.createPage(c, p.getMetaData(), content)
|
|
}
|
|
```
|
|
|
|
### Language-aware URLs and hx attributes
|
|
|
|
- Derive URLs from routes with the active language for all hx attributes.
|
|
|
|
```go
|
|
lang := c.GetLanguage()
|
|
|
|
Button(
|
|
Text(texts.StartProcessing.FromLang(lang)),
|
|
p.StartRoute.GetHtmx(lang),
|
|
hx.Target("body"),
|
|
)
|
|
|
|
// Pagination example with hx.PushURL to keep browser URL in sync
|
|
Button(
|
|
Text(texts.Next.FromLang(lang)),
|
|
hx.Get(p.RefreshRoute.ToUrlFromContext(c, lang)),
|
|
hx.PushURL(p.ViewRoute.ToUrlFromContext(c, lang)),
|
|
hx.Target("#queue-contents"),
|
|
hx.Swap("innerHTML"),
|
|
)
|
|
```
|
|
|
|
### Page assembly and styles
|
|
|
|
- Build pages with `createPage` and supply metadata plus styles.
|
|
- Create a `getMetaData()` function to centralize page metadata configuration.
|
|
- Keep shared CSS in `static/styles.css` and page-specific CSS in `static/<page>.css`; include via `StyleSrcs`.
|
|
|
|
```go
|
|
func (p *Page) getMetaData() PageWebsiteMetaData {
|
|
return PageWebsiteMetaData{
|
|
Title: texts.PageTitle,
|
|
Lang: En,
|
|
Description: texts.PageDescription,
|
|
StyleSrcs: []string{"/static/media.css"},
|
|
}
|
|
}
|
|
|
|
func (p *Page) View(c HtmxContext) {
|
|
lang := c.GetLanguage()
|
|
content := Group{
|
|
Div(Class("hero"), H1(Text(texts.HeroTitle.FromLang(lang)))),
|
|
// ... more components ...
|
|
}
|
|
|
|
p.createPage(c, p.getMetaData(), content)
|
|
}
|
|
```
|
|
|
|
### HTMX partials and polling
|
|
|
|
- Use HTMX forms and partial refreshes; combine `hx.Target`, `hx.Swap`, and periodic polling (`hx.Trigger("every 5s")`) with language-aware URLs.
|
|
|
|
```go
|
|
// Partial that refreshes every 5s when running
|
|
Group{
|
|
hx.Get(p.RefreshStatusRoute.ToUrlFromContext(c, lang)),
|
|
hx.Trigger("every 5s"),
|
|
hx.Target("#queue-status"),
|
|
hx.Swap("innerHTML"),
|
|
}
|
|
|
|
Form(
|
|
p.SaveSettingsRoute.GetHtmx(lang),
|
|
hx.Target("body"),
|
|
// form fields...
|
|
)
|
|
|
|
// Use GetHtmx() for form actions to ensure proper HTMX attributes
|
|
Form(
|
|
p.localLoginRoute.GetHtmx(language),
|
|
hx.Target("body"),
|
|
// form fields...
|
|
)
|
|
```
|
|
|
|
## HTMX Routes
|
|
|
|
**ALWAYS** prefer using `p.Route.GetHtmx()` over directly calling `hx.Get(p.Route.ToUrlFromContext())`.
|
|
|
|
**Example:**
|
|
- ✅ Preferred: `p.RefreshStatusRoute.GetHtmx(c, lang)`
|
|
- ❌ Avoid: `hx.Get(p.RefreshStatusRoute.ToUrlFromContext(c, lang))`
|
|
|
|
## Persisting Data with Ent Schemas
|
|
|
|
### Schema Definition
|
|
|
|
- Define schemas in `ent/schema/<entity>.go`
|
|
- Each schema is a struct that embeds `ent.Schema`
|
|
- Use the `TimeMixin` to automatically add `created_at` and `updated_at` fields
|
|
|
|
```go
|
|
package schema
|
|
|
|
import (
|
|
"entgo.io/ent"
|
|
"entgo.io/ent/schema/field"
|
|
"entgo.io/ent/schema/edge"
|
|
)
|
|
|
|
type Media struct {
|
|
ent.Schema
|
|
}
|
|
|
|
// Apply TimeMixin for automatic timestamp fields
|
|
func (Media) Mixin() []ent.Mixin {
|
|
return []ent.Mixin{
|
|
TimeMixin{},
|
|
}
|
|
}
|
|
|
|
// Define fields
|
|
func (Media) Fields() []ent.Field {
|
|
return []ent.Field{
|
|
field.String("filename"),
|
|
field.String("hash"),
|
|
field.String("description").Default(""),
|
|
field.Time("created_date").Optional().Nillable(),
|
|
field.Int("year").Optional().Nillable(),
|
|
field.Float("gps_latitude").Optional().Nillable(),
|
|
}
|
|
}
|
|
|
|
// Define relationships
|
|
func (Media) Edges() []ent.Edge {
|
|
return []ent.Edge{
|
|
edge.To("tags", Tag.Type), // Many-to-Many
|
|
}
|
|
}
|
|
```
|
|
|
|
### Field Types and Options
|
|
|
|
**Common field types:**
|
|
- `field.String("name")` - string field
|
|
- `field.Int("count")` - integer field
|
|
- `field.Float("latitude")` - float64 field
|
|
- `field.Bool("active")` - boolean field
|
|
- `field.Time("date")` - time.Time field
|
|
|
|
**Field modifiers:**
|
|
- `.Default(value)` - set default value
|
|
- `.Optional()` - field can be omitted on creation
|
|
- `.Nillable()` - field can be nil (must be used with Optional)
|
|
- `.Immutable()` - field cannot be updated after creation
|
|
- `.Unique()` - field value must be unique
|
|
|
|
### Generating Code
|
|
|
|
After modifying schemas, regenerate the ent code:
|
|
|
|
```bash
|
|
go run generate_schema.go
|
|
```
|
|
|
|
### Usage in Handlers
|
|
|
|
**Create:**
|
|
```go
|
|
media, err := server.Client.Media.Create().
|
|
SetFilename("photo.jpg").
|
|
SetHash("abc123").
|
|
SetDescription("Vacation photo").
|
|
Save(c.Request.Context())
|
|
```
|
|
|
|
**Query:**
|
|
```go
|
|
// Get by ID
|
|
media, err := server.Client.Media.Get(c.Request.Context(), id)
|
|
|
|
// Query with filters
|
|
medias, err := server.Client.Media.Query().
|
|
Where(media.YearEQ(2024)).
|
|
All(c.Request.Context())
|
|
|
|
// Query with edges
|
|
medias, err := server.Client.Media.Query().
|
|
WithTags(). // Load tags relationship
|
|
All(c.Request.Context())
|
|
```
|
|
|
|
**Update:**
|
|
```go
|
|
err := server.Client.Media.UpdateOneID(id).
|
|
SetDescription("Updated description").
|
|
Save(c.Request.Context())
|
|
```
|
|
|
|
**Delete:**
|
|
```go
|
|
err := server.Client.Media.DeleteOneID(id).
|
|
Exec(c.Request.Context())
|
|
```
|
|
|
|
### Relationships (Edges)
|
|
|
|
**One-to-Many:**
|
|
```go
|
|
edge.To("posts", Post.Type) // User has many posts
|
|
edge.From("owner", User.Type).Ref("posts").Unique() // Post belongs to one user
|
|
```
|
|
|
|
**Many-to-Many:**
|
|
```go
|
|
edge.To("tags", Tag.Type) // Media has many tags
|
|
edge.From("media", Media.Type).Ref("tags") // Tag has many media
|
|
```
|
|
|
|
## Workflows
|
|
|
|
### Defining Steps
|
|
|
|
- Define a workflow step using `NewStep` and `StepParams`.
|
|
- Each step requires a `Name`, `Identifier`, and a `Handler` function.
|
|
- The `Handler` function processes the job and returns a result and the next step (if any).
|
|
|
|
```go
|
|
lastStep := NewStep(&StepParams{
|
|
Name: "Last step",
|
|
Identifier: "last_step",
|
|
Description: "Last step",
|
|
Client: client,
|
|
MaxRetries: 3,
|
|
Handler: func(ctx context.Context, currentStep *Step, job queue.GeneralQueueJob) (queue.GeneralQueueHandlerResult, *Step, error) {
|
|
return queue.GeneralQueueHandlerResult{
|
|
ResultPayload: map[string]interface{}{
|
|
"lastStep": "hello",
|
|
},
|
|
}, nil, nil
|
|
},
|
|
})
|
|
|
|
firstStep := NewStep(&StepParams{
|
|
Name: "Initial step",
|
|
Identifier: "initial_step",
|
|
Description: "Initial step",
|
|
Client: client,
|
|
MaxRetries: 3,
|
|
Handler: func(ctx context.Context, currentStep *Step, job queue.GeneralQueueJob) (queue.GeneralQueueHandlerResult, *Step, error) {
|
|
return queue.GeneralQueueHandlerResult{
|
|
ResultPayload: map[string]interface{}{
|
|
"firstStep": "hello",
|
|
},
|
|
}, lastStep, nil // Return the next step to execute
|
|
},
|
|
})
|
|
```
|
|
|
|
### Creating Workflows
|
|
|
|
- Define a workflow using `NewWorkflow`, providing a name, identifier, starting step, and all steps in the workflow.
|
|
|
|
```go
|
|
w := NewWorkflow("Example", "example", firstStep, []*Step{firstStep, lastStep})
|
|
```
|
|
|
|
### Executing Workflows
|
|
|
|
- To start a workflow, call the `Execute` method with an initial payload.
|
|
- A unique `workflowId` is automatically generated and passed through each step.
|
|
|
|
```go
|
|
err := workflow.Execute(ctx, map[string]interface{}{
|
|
"input": "start data",
|
|
})
|
|
```
|
|
|
|
### Scheduled Workflows
|
|
|
|
- Use `NewCronTrigger` to run a workflow at regular intervals.
|
|
|
|
```go
|
|
NewCronTrigger(ctx, workflow, 1 * time.Hour)
|
|
```
|
|
|
|
## Important Instructions
|
|
|
|
Do what has been asked; nothing more, nothing less.
|
|
|
|
**NEVER** create files unless they're absolutely necessary for achieving your goal.
|
|
|
|
**ALWAYS** prefer editing an existing file to creating a new one.
|
|
|
|
**NEVER** proactively create documentation files (`*.md`) or README files. Only create documentation files if explicitly requested by the User.
|