mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 13:28:57 +00:00
Add move item between collections with field migration
Full-stack feature: move items between collections (e.g., idea → task)
with automatic field migration.
Backend:
- Field migration engine (items/migrate.go) maps matching fields,
handles type conversions, drops incompatible fields, applies defaults
- Store method updates collection_id and assigns new item_number
- POST /api/v1/workspaces/{ws}/items/{slug}/move endpoint
- Activity logging with "moved" action and from/to metadata
- 6 migration unit tests covering type matching, conversion, and edge cases
CLI:
- pad move <slug> <target-collection> [--field key=value ...]
- Accepts singular collection names (task, idea, bug, etc.)
Web UI:
- "Move to..." dropdown on item detail page
- Shows all collections except current with icons
- Redirects to the item's new URL after move
Field migration rules:
- Same type: transfer directly (validate select options)
- Compatible types (text↔url, number→text, select→text): auto-convert
- Incompatible types: drop silently
- Missing required target fields: apply defaults or error
This commit is contained in:
@@ -61,6 +61,7 @@ func main() {
|
||||
showCmd(),
|
||||
updateCmd(),
|
||||
deleteCmd(),
|
||||
moveCmd(),
|
||||
searchCmd(),
|
||||
statusCmd(),
|
||||
nextCmd(),
|
||||
@@ -1530,6 +1531,57 @@ func deleteCmd() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
// --- move ---
|
||||
|
||||
func moveCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "move <slug> <target-collection>",
|
||||
Short: "Move an item to a different collection",
|
||||
Long: `Move an item to a different collection with automatic field migration.
|
||||
|
||||
Fields with matching names and compatible types transfer automatically.
|
||||
Incompatible fields are dropped. Use --field to set values for target-specific fields.
|
||||
|
||||
Examples:
|
||||
pad move fix-oauth bugs # Move to bugs collection
|
||||
pad move my-idea tasks --field priority=high # Move idea to tasks with priority`,
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, _ := getClient()
|
||||
ws := getWorkspace()
|
||||
|
||||
input := map[string]any{
|
||||
"target_collection": normalizeCollectionSlug(args[1]),
|
||||
"actor": "user",
|
||||
"source": "cli",
|
||||
}
|
||||
|
||||
// Parse field overrides
|
||||
fieldFlags, _ := cmd.Flags().GetStringArray("field")
|
||||
if len(fieldFlags) > 0 {
|
||||
overrides := map[string]any{}
|
||||
for _, f := range fieldFlags {
|
||||
parts := strings.SplitN(f, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
overrides[parts[0]] = parts[1]
|
||||
}
|
||||
}
|
||||
input["field_overrides"] = overrides
|
||||
}
|
||||
|
||||
moved, err := client.MoveItem(ws, args[0], input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Moved %q to %s\n", moved.Title, args[1])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringArray("field", nil, "set field values in target collection (key=value)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// --- comments ---
|
||||
|
||||
func commentCmd() *cobra.Command {
|
||||
|
||||
@@ -8,4 +8,4 @@ var WebUI embed.FS
|
||||
//go:embed skills/pad/SKILL.md
|
||||
var PadSkill []byte
|
||||
|
||||
// embed cache bust: 1774673423
|
||||
// embed cache bust: 1774674060
|
||||
|
||||
@@ -133,6 +133,11 @@ func (c *Client) DeleteItem(wsSlug, itemSlug string) error {
|
||||
return c.delete("/workspaces/" + wsSlug + "/items/" + itemSlug)
|
||||
}
|
||||
|
||||
func (c *Client) MoveItem(wsSlug, itemSlug string, input map[string]any) (*models.Item, error) {
|
||||
var result models.Item
|
||||
return &result, c.post("/workspaces/"+wsSlug+"/items/"+itemSlug+"/move", input, &result)
|
||||
}
|
||||
|
||||
// --- Links ---
|
||||
|
||||
func (c *Client) GetItemLinks(wsSlug, itemSlug string) ([]models.ItemLink, error) {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package items
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/xarmian/pad/internal/models"
|
||||
)
|
||||
|
||||
// MigrateResult holds the outcome of field migration between collection schemas.
|
||||
type MigrateResult struct {
|
||||
// Fields contains the migrated field values for the target schema.
|
||||
Fields map[string]any
|
||||
// Dropped lists field keys that were dropped during migration (no matching target field or incompatible types).
|
||||
Dropped []string
|
||||
// Errors lists required target fields that have no value after migration.
|
||||
Errors []string
|
||||
}
|
||||
|
||||
// MigrateFields maps field values from a source schema to a target schema.
|
||||
// Fields with matching keys and compatible types are transferred.
|
||||
// Incompatible or missing fields are dropped. Required target fields without
|
||||
// values after migration are reported as errors.
|
||||
func MigrateFields(
|
||||
currentFields map[string]any,
|
||||
sourceSchema []models.FieldDef,
|
||||
targetSchema []models.FieldDef,
|
||||
) MigrateResult {
|
||||
result := MigrateResult{
|
||||
Fields: make(map[string]any),
|
||||
}
|
||||
|
||||
// Build lookup of target fields by key
|
||||
targetDefs := make(map[string]models.FieldDef)
|
||||
for _, f := range targetSchema {
|
||||
targetDefs[f.Key] = f
|
||||
}
|
||||
|
||||
// Build lookup of source fields by key
|
||||
sourceDefs := make(map[string]models.FieldDef)
|
||||
for _, f := range sourceSchema {
|
||||
sourceDefs[f.Key] = f
|
||||
}
|
||||
|
||||
// Migrate each current field value
|
||||
for key, value := range currentFields {
|
||||
targetField, exists := targetDefs[key]
|
||||
if !exists {
|
||||
result.Dropped = append(result.Dropped, key)
|
||||
continue
|
||||
}
|
||||
|
||||
sourceField := sourceDefs[key]
|
||||
migrated, ok := migrateValue(value, sourceField.Type, targetField)
|
||||
if ok {
|
||||
result.Fields[key] = migrated
|
||||
} else {
|
||||
result.Dropped = append(result.Dropped, key)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply defaults for target fields not yet present
|
||||
for _, f := range targetSchema {
|
||||
if _, exists := result.Fields[f.Key]; exists {
|
||||
continue
|
||||
}
|
||||
if f.Default != nil && f.Default != "" {
|
||||
result.Fields[f.Key] = f.Default
|
||||
} else if f.Required {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("required field %q has no value", f.Key))
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// migrateValue attempts to convert a value from sourceType to the targetField's type.
|
||||
// Returns the migrated value and true if successful, or zero-value and false if incompatible.
|
||||
func migrateValue(value any, sourceType string, target models.FieldDef) (any, bool) {
|
||||
targetType := target.Type
|
||||
|
||||
// Same type — validate further for select fields
|
||||
if sourceType == targetType {
|
||||
if targetType == "select" && target.Options != nil {
|
||||
strVal := fmt.Sprintf("%v", value)
|
||||
for _, opt := range target.Options {
|
||||
if opt == strVal {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
// Value not in target options — drop it
|
||||
return nil, false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
// Compatible type conversions
|
||||
strVal := fmt.Sprintf("%v", value)
|
||||
switch {
|
||||
case sourceType == "text" && targetType == "url":
|
||||
return value, true
|
||||
case sourceType == "url" && targetType == "text":
|
||||
return value, true
|
||||
case sourceType == "number" && targetType == "text":
|
||||
return strVal, true
|
||||
case sourceType == "select" && targetType == "text":
|
||||
return strVal, true
|
||||
case sourceType == "checkbox" && targetType == "text":
|
||||
return strVal, true
|
||||
case sourceType == "text" && targetType == "number":
|
||||
if _, err := strconv.ParseFloat(strVal, 64); err == nil {
|
||||
return value, true
|
||||
}
|
||||
return nil, false
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package items
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/xarmian/pad/internal/models"
|
||||
)
|
||||
|
||||
func TestMigrateFields_MatchingTypes(t *testing.T) {
|
||||
source := []models.FieldDef{
|
||||
{Key: "status", Type: "select", Options: []string{"open", "done"}},
|
||||
{Key: "priority", Type: "select", Options: []string{"low", "high"}},
|
||||
}
|
||||
target := []models.FieldDef{
|
||||
{Key: "status", Type: "select", Options: []string{"open", "closed"}, Required: true},
|
||||
{Key: "priority", Type: "select", Options: []string{"low", "medium", "high"}},
|
||||
}
|
||||
fields := map[string]any{"status": "open", "priority": "high"}
|
||||
|
||||
result := MigrateFields(fields, source, target)
|
||||
|
||||
if result.Fields["status"] != "open" {
|
||||
t.Errorf("status: got %v, want 'open'", result.Fields["status"])
|
||||
}
|
||||
if result.Fields["priority"] != "high" {
|
||||
t.Errorf("priority: got %v, want 'high'", result.Fields["priority"])
|
||||
}
|
||||
if len(result.Dropped) != 0 {
|
||||
t.Errorf("dropped: got %v, want none", result.Dropped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateFields_SelectValueNotInTarget(t *testing.T) {
|
||||
source := []models.FieldDef{
|
||||
{Key: "status", Type: "select", Options: []string{"open", "in-progress", "done"}},
|
||||
}
|
||||
target := []models.FieldDef{
|
||||
{Key: "status", Type: "select", Options: []string{"todo", "doing", "done"}, Required: true, Default: "todo"},
|
||||
}
|
||||
fields := map[string]any{"status": "in-progress"}
|
||||
|
||||
result := MigrateFields(fields, source, target)
|
||||
|
||||
// "in-progress" is not in target options, should be dropped and default applied
|
||||
if result.Fields["status"] != "todo" {
|
||||
t.Errorf("status: got %v, want 'todo' (default after drop)", result.Fields["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateFields_DropsExtraFields(t *testing.T) {
|
||||
source := []models.FieldDef{
|
||||
{Key: "severity", Type: "select", Options: []string{"low", "high"}},
|
||||
{Key: "browser", Type: "text"},
|
||||
}
|
||||
target := []models.FieldDef{
|
||||
{Key: "priority", Type: "select", Options: []string{"low", "high"}},
|
||||
}
|
||||
fields := map[string]any{"severity": "high", "browser": "Chrome"}
|
||||
|
||||
result := MigrateFields(fields, source, target)
|
||||
|
||||
if len(result.Dropped) != 2 {
|
||||
t.Errorf("dropped: got %d, want 2", len(result.Dropped))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateFields_TypeConversion(t *testing.T) {
|
||||
source := []models.FieldDef{
|
||||
{Key: "count", Type: "number"},
|
||||
{Key: "status", Type: "select", Options: []string{"open"}},
|
||||
}
|
||||
target := []models.FieldDef{
|
||||
{Key: "count", Type: "text"},
|
||||
{Key: "status", Type: "text"},
|
||||
}
|
||||
fields := map[string]any{"count": 42, "status": "open"}
|
||||
|
||||
result := MigrateFields(fields, source, target)
|
||||
|
||||
if result.Fields["count"] != "42" {
|
||||
t.Errorf("count: got %v, want '42'", result.Fields["count"])
|
||||
}
|
||||
if result.Fields["status"] != "open" {
|
||||
t.Errorf("status: got %v, want 'open'", result.Fields["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateFields_RequiredFieldMissing(t *testing.T) {
|
||||
source := []models.FieldDef{}
|
||||
target := []models.FieldDef{
|
||||
{Key: "status", Type: "select", Options: []string{"open"}, Required: true},
|
||||
}
|
||||
fields := map[string]any{}
|
||||
|
||||
result := MigrateFields(fields, source, target)
|
||||
|
||||
if len(result.Errors) != 1 {
|
||||
t.Errorf("errors: got %d, want 1", len(result.Errors))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateFields_DefaultApplied(t *testing.T) {
|
||||
source := []models.FieldDef{}
|
||||
target := []models.FieldDef{
|
||||
{Key: "status", Type: "select", Options: []string{"open", "done"}, Required: true, Default: "open"},
|
||||
}
|
||||
fields := map[string]any{}
|
||||
|
||||
result := MigrateFields(fields, source, target)
|
||||
|
||||
if result.Fields["status"] != "open" {
|
||||
t.Errorf("status: got %v, want 'open'", result.Fields["status"])
|
||||
}
|
||||
if len(result.Errors) != 0 {
|
||||
t.Errorf("errors: got %v, want none", result.Errors)
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import "time"
|
||||
|
||||
// Valid actions
|
||||
var ValidActions = []string{
|
||||
"created", "updated", "archived", "restored", "read", "searched",
|
||||
"created", "updated", "archived", "restored", "moved", "read", "searched",
|
||||
}
|
||||
|
||||
type Activity struct {
|
||||
|
||||
@@ -318,6 +318,123 @@ func (s *Server) handleRestoreItem(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, restored)
|
||||
}
|
||||
|
||||
// handleMoveItem moves an item to a different collection with field migration.
|
||||
func (s *Server) handleMoveItem(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID, ok := s.getWorkspaceID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
itemSlug := chi.URLParam(r, "itemSlug")
|
||||
item, err := s.store.ResolveItem(workspaceID, itemSlug)
|
||||
if err != nil || item == nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
||||
return
|
||||
}
|
||||
|
||||
var input struct {
|
||||
TargetCollection string `json:"target_collection"`
|
||||
FieldOverrides map[string]any `json:"field_overrides"`
|
||||
Actor string `json:"actor"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_body", "Invalid JSON body")
|
||||
return
|
||||
}
|
||||
if input.TargetCollection == "" {
|
||||
writeError(w, http.StatusBadRequest, "missing_field", "target_collection is required")
|
||||
return
|
||||
}
|
||||
if input.Actor == "" {
|
||||
input.Actor = "user"
|
||||
}
|
||||
if input.Source == "" {
|
||||
input.Source = "web"
|
||||
}
|
||||
|
||||
// Get target collection
|
||||
targetColl, err := s.store.GetCollectionBySlug(workspaceID, input.TargetCollection)
|
||||
if err != nil || targetColl == nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_collection", "Target collection not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Don't move to the same collection
|
||||
if targetColl.ID == item.CollectionID {
|
||||
writeError(w, http.StatusBadRequest, "same_collection", "Item is already in this collection")
|
||||
return
|
||||
}
|
||||
|
||||
// Get source collection for schema
|
||||
sourceColl, err := s.store.GetCollection(item.CollectionID)
|
||||
if err != nil || sourceColl == nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get source collection")
|
||||
return
|
||||
}
|
||||
|
||||
// Parse schemas
|
||||
var sourceSchema, targetSchema models.CollectionSchema
|
||||
if err := json.Unmarshal([]byte(sourceColl.Schema), &sourceSchema); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to parse source schema")
|
||||
return
|
||||
}
|
||||
if err := json.Unmarshal([]byte(targetColl.Schema), &targetSchema); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to parse target schema")
|
||||
return
|
||||
}
|
||||
|
||||
// Parse current fields
|
||||
var currentFields map[string]any
|
||||
if err := json.Unmarshal([]byte(item.Fields), ¤tFields); err != nil {
|
||||
currentFields = make(map[string]any)
|
||||
}
|
||||
|
||||
// Migrate fields
|
||||
result := items.MigrateFields(currentFields, sourceSchema.Fields, targetSchema.Fields)
|
||||
|
||||
// Apply overrides
|
||||
for k, v := range input.FieldOverrides {
|
||||
result.Fields[k] = v
|
||||
}
|
||||
|
||||
// Check for required field errors (after overrides)
|
||||
if len(result.Errors) > 0 {
|
||||
writeError(w, http.StatusBadRequest, "missing_required_fields",
|
||||
fmt.Sprintf("Required fields missing: %s", strings.Join(result.Errors, ", ")))
|
||||
return
|
||||
}
|
||||
|
||||
// Serialize migrated fields
|
||||
fieldsJSON, err := json.Marshal(result.Fields)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to serialize fields")
|
||||
return
|
||||
}
|
||||
|
||||
// Move the item
|
||||
moved, err := s.store.MoveItem(item.ID, targetColl.ID, string(fieldsJSON))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Log activity with metadata about the move
|
||||
_ = s.store.CreateActivity(models.Activity{
|
||||
WorkspaceID: workspaceID,
|
||||
DocumentID: moved.ID,
|
||||
Action: "moved",
|
||||
Actor: input.Actor,
|
||||
Source: input.Source,
|
||||
Metadata: fmt.Sprintf(`{"from_collection":"%s","to_collection":"%s"}`, sourceColl.Slug, targetColl.Slug),
|
||||
})
|
||||
|
||||
// Publish events for both old and new collections
|
||||
s.publishItemEvent(events.ItemUpdated, workspaceID, moved.ID, moved.Title, targetColl.Slug, input.Actor, input.Source)
|
||||
|
||||
writeJSON(w, http.StatusOK, moved)
|
||||
}
|
||||
|
||||
// publishItemEvent publishes a real-time event for item changes.
|
||||
func (s *Server) publishItemEvent(eventType, workspaceID, itemID, title, collection, actor, source string) {
|
||||
if s.events == nil {
|
||||
|
||||
@@ -126,6 +126,7 @@ func (s *Server) setupRouter() {
|
||||
r.Patch("/", s.handleUpdateItem)
|
||||
r.Delete("/", s.handleDeleteItem)
|
||||
r.Post("/restore", s.handleRestoreItem)
|
||||
r.Post("/move", s.handleMoveItem)
|
||||
r.Get("/versions", s.handleListItemVersions)
|
||||
r.Post("/versions/{versionID}/restore", s.handleRestoreItemVersion)
|
||||
r.Get("/links", s.handleGetItemLinks)
|
||||
|
||||
@@ -800,6 +800,40 @@ func (s *Store) GetTasksForPhase(phaseItemID string) ([]models.Item, error) {
|
||||
return scanItems(rows)
|
||||
}
|
||||
|
||||
// MoveItem moves an item to a different collection within the same workspace.
|
||||
// It updates the collection_id, assigns a new item_number in the target collection,
|
||||
// and updates the fields JSON.
|
||||
func (s *Store) MoveItem(itemID, targetCollectionID, newFieldsJSON string) (*models.Item, error) {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Get next item_number in the target collection
|
||||
var nextNumber int
|
||||
err = tx.QueryRow(`SELECT COALESCE(MAX(item_number), 0) + 1 FROM items WHERE collection_id = ?`, targetCollectionID).Scan(&nextNumber)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get next item number: %w", err)
|
||||
}
|
||||
|
||||
// Update the item
|
||||
_, err = tx.Exec(`
|
||||
UPDATE items
|
||||
SET collection_id = ?, fields = ?, item_number = ?, updated_at = ?
|
||||
WHERE id = ? AND deleted_at IS NULL`,
|
||||
targetCollectionID, newFieldsJSON, nextNumber, time.Now().UTC().Format(time.RFC3339), itemID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("move item: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.GetItem(itemID)
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
// validSortField matches safe field names (alphanumeric + underscore, starting with a letter).
|
||||
|
||||
@@ -158,6 +158,16 @@ export const api = {
|
||||
method: 'POST'
|
||||
}),
|
||||
|
||||
move: (ws: string, slug: string, targetCollection: string, fieldOverrides?: Record<string, any>) =>
|
||||
request<Item>(`/workspaces/${ws}/items/${slug}/move`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
target_collection: targetCollection,
|
||||
field_overrides: fieldOverrides,
|
||||
source: 'web'
|
||||
})
|
||||
}),
|
||||
|
||||
/** Get tasks linked to a phase item */
|
||||
tasks: (ws: string, slug: string) =>
|
||||
request<Item[]>(`/workspaces/${ws}/items/${slug}/tasks`),
|
||||
|
||||
@@ -58,6 +58,8 @@
|
||||
let confirmDelete = $state(false);
|
||||
let deleting = $state(false);
|
||||
let rawMode = $state(false);
|
||||
let showMoveMenu = $state(false);
|
||||
let moving = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (wsSlug && collSlug && itemSlug) {
|
||||
@@ -233,6 +235,24 @@
|
||||
confirmDelete = false;
|
||||
}
|
||||
}
|
||||
|
||||
let allCollections = $derived(collectionStore.collections ?? []);
|
||||
let moveTargets = $derived(allCollections.filter(c => c.slug !== collSlug));
|
||||
|
||||
async function handleMove(targetSlug: string) {
|
||||
if (!item || moving) return;
|
||||
moving = true;
|
||||
showMoveMenu = false;
|
||||
try {
|
||||
const moved = await api.items.move(wsSlug, item.slug, targetSlug);
|
||||
toastStore.show(`Moved to ${targetSlug}`, 'success');
|
||||
goto(`/${wsSlug}/${targetSlug}/${moved.slug}`);
|
||||
} catch (e: any) {
|
||||
toastStore.show(e.message ?? 'Failed to move item', 'error');
|
||||
} finally {
|
||||
moving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
@@ -291,6 +311,21 @@
|
||||
>
|
||||
History
|
||||
</button>
|
||||
<div class="move-wrapper">
|
||||
<button class="history-btn" onclick={() => { showMoveMenu = !showMoveMenu; }} disabled={moving}>
|
||||
{moving ? 'Moving...' : 'Move to...'}
|
||||
</button>
|
||||
{#if showMoveMenu}
|
||||
<div class="move-dropdown">
|
||||
{#each moveTargets as target (target.slug)}
|
||||
<button class="move-option" onclick={() => handleMove(target.slug)}>
|
||||
{#if target.icon}<span class="move-icon">{target.icon}</span>{/if}
|
||||
{target.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if confirmDelete}
|
||||
<span class="delete-confirm">
|
||||
Delete this item?
|
||||
@@ -693,6 +728,42 @@
|
||||
border-color: var(--accent-blue);
|
||||
color: #fff;
|
||||
}
|
||||
.move-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
.move-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: var(--space-1);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
z-index: 100;
|
||||
min-width: 180px;
|
||||
padding: var(--space-1);
|
||||
}
|
||||
.move-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.85em;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
text-align: left;
|
||||
}
|
||||
.move-option:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.move-icon {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
.delete-btn:hover {
|
||||
color: var(--accent-orange);
|
||||
border-color: var(--accent-orange);
|
||||
|
||||
Reference in New Issue
Block a user