feat(editor): add geolocation support for content editing
- Add geolocation Tiptap extension for embedding maps - Create GeolocationExtended component for rendering map embeds - Create GeolocationPlaceholder for editor insertion - Add GeoCard component for displaying location data - Support latitude, longitude, zoom level, and optional labels - Enable location-based content in albums and posts Allows editors to embed interactive maps with specific locations. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
b548807d88
commit
8627b1d574
8 changed files with 1674 additions and 0 deletions
273
src/lib/components/GeoCard.svelte
Normal file
273
src/lib/components/GeoCard.svelte
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import type { GeoLocation } from '@prisma/client'
|
||||
|
||||
interface Props {
|
||||
location: GeoLocation
|
||||
height?: number
|
||||
interactive?: boolean
|
||||
showPopup?: boolean
|
||||
class?: string
|
||||
}
|
||||
|
||||
let {
|
||||
location,
|
||||
height = 400,
|
||||
interactive = true,
|
||||
showPopup = true,
|
||||
class: className = ''
|
||||
}: Props = $props()
|
||||
|
||||
let mapContainer: HTMLDivElement
|
||||
let map: any
|
||||
let marker: any
|
||||
let leaflet: any
|
||||
|
||||
// Load Leaflet dynamically
|
||||
async function loadLeaflet() {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
// Check if already loaded
|
||||
if (window.L) {
|
||||
leaflet = window.L
|
||||
return
|
||||
}
|
||||
|
||||
// Load Leaflet CSS
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'
|
||||
link.integrity = 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY='
|
||||
link.crossOrigin = ''
|
||||
document.head.appendChild(link)
|
||||
|
||||
// Load Leaflet JS
|
||||
const script = document.createElement('script')
|
||||
script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js'
|
||||
script.integrity = 'sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo='
|
||||
script.crossOrigin = ''
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
script.onload = resolve
|
||||
script.onerror = reject
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
|
||||
leaflet = window.L
|
||||
}
|
||||
|
||||
// Initialize map
|
||||
async function initMap() {
|
||||
if (!mapContainer || !leaflet) return
|
||||
|
||||
// Create map
|
||||
map = leaflet.map(mapContainer, {
|
||||
center: [location.latitude, location.longitude],
|
||||
zoom: 15,
|
||||
scrollWheelZoom: interactive,
|
||||
dragging: interactive,
|
||||
touchZoom: interactive,
|
||||
doubleClickZoom: interactive,
|
||||
boxZoom: interactive,
|
||||
keyboard: interactive,
|
||||
zoomControl: interactive
|
||||
})
|
||||
|
||||
// Add tile layer (using OpenStreetMap)
|
||||
leaflet
|
||||
.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
maxZoom: 19
|
||||
})
|
||||
.addTo(map)
|
||||
|
||||
// Create custom marker icon if color is specified
|
||||
let markerOptions = {}
|
||||
if (location.markerColor) {
|
||||
const markerIcon = leaflet.divIcon({
|
||||
className: 'custom-marker',
|
||||
html: `<div style="background-color: ${location.markerColor}; width: 24px; height: 24px; border-radius: 50%; border: 3px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.3);"></div>`,
|
||||
iconSize: [30, 30],
|
||||
iconAnchor: [15, 15],
|
||||
popupAnchor: [0, -15]
|
||||
})
|
||||
markerOptions = { icon: markerIcon }
|
||||
}
|
||||
|
||||
// Add marker
|
||||
marker = leaflet.marker([location.latitude, location.longitude], markerOptions).addTo(map)
|
||||
|
||||
// Add popup if enabled
|
||||
if (showPopup && (location.title || location.description)) {
|
||||
const popupContent = `
|
||||
<div class="location-popup">
|
||||
${location.title ? `<h3>${location.title}</h3>` : ''}
|
||||
${location.description ? `<p>${location.description}</p>` : ''}
|
||||
</div>
|
||||
`
|
||||
marker.bindPopup(popupContent, {
|
||||
autoPan: true,
|
||||
keepInView: true
|
||||
})
|
||||
|
||||
// Open popup by default on non-interactive maps
|
||||
if (!interactive) {
|
||||
marker.openPopup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
function cleanup() {
|
||||
if (map) {
|
||||
map.remove()
|
||||
map = null
|
||||
}
|
||||
marker = null
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
await loadLeaflet()
|
||||
await initMap()
|
||||
} catch (error) {
|
||||
console.error('Failed to load map:', error)
|
||||
}
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
// Reinitialize if location changes
|
||||
$effect(() => {
|
||||
if (map && location) {
|
||||
cleanup()
|
||||
initMap()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="geo-card {className}">
|
||||
<div
|
||||
bind:this={mapContainer}
|
||||
class="map-container"
|
||||
style="height: {height}px"
|
||||
role="img"
|
||||
aria-label="Map showing {location.title ||
|
||||
'location'} at coordinates {location.latitude}, {location.longitude}"
|
||||
>
|
||||
<noscript>
|
||||
<div class="map-fallback">
|
||||
<div class="fallback-content">
|
||||
<h3>{location.title}</h3>
|
||||
{#if location.description}
|
||||
<p>{location.description}</p>
|
||||
{/if}
|
||||
<p class="coordinates">
|
||||
{location.latitude.toFixed(6)}, {location.longitude.toFixed(6)}
|
||||
</p>
|
||||
<a
|
||||
href="https://www.openstreetmap.org/?mlat={location.latitude}&mlon={location.longitude}#map=15/{location.latitude}/{location.longitude}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
View on OpenStreetMap
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</noscript>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@import '$styles/variables.scss';
|
||||
@import '$styles/mixins.scss';
|
||||
|
||||
.geo-card {
|
||||
width: 100%;
|
||||
border-radius: $image-corner-radius;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.map-container {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
background: $grey-95;
|
||||
|
||||
:global(.leaflet-container) {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
:global(.location-popup h3) {
|
||||
margin: 0 0 $unit-half;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: $grey-10;
|
||||
}
|
||||
|
||||
:global(.location-popup p) {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: $grey-30;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
:global(.leaflet-popup-content-wrapper) {
|
||||
border-radius: $corner-radius-md;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
:global(.leaflet-popup-content) {
|
||||
margin: $unit-2x;
|
||||
}
|
||||
}
|
||||
|
||||
.map-fallback {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: $grey-95;
|
||||
padding: $unit-3x;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.fallback-content {
|
||||
h3 {
|
||||
margin: 0 0 $unit;
|
||||
font-size: 1.25rem;
|
||||
color: $grey-10;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 $unit;
|
||||
color: $grey-40;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.coordinates {
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 0.875rem;
|
||||
color: $grey-60;
|
||||
margin-bottom: $unit-2x;
|
||||
}
|
||||
|
||||
a {
|
||||
color: $red-60;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
/* Global styles for Leaflet */
|
||||
:global(.leaflet-control-attribution) {
|
||||
font-size: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.9) !important;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import { Node, mergeAttributes, type NodeViewProps } from '@tiptap/core'
|
||||
import type { Component } from 'svelte'
|
||||
import { SvelteNodeViewRenderer } from 'svelte-tiptap'
|
||||
|
||||
export interface GeolocationExtendedOptions {
|
||||
HTMLAttributes: Record<string, any>
|
||||
}
|
||||
|
||||
export const GeolocationExtended = (
|
||||
component: Component<NodeViewProps>
|
||||
): Node<GeolocationExtendedOptions> =>
|
||||
Node.create<GeolocationExtendedOptions>({
|
||||
name: 'geolocation',
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {}
|
||||
}
|
||||
},
|
||||
group: 'block',
|
||||
atom: true,
|
||||
draggable: true,
|
||||
addAttributes() {
|
||||
return {
|
||||
latitude: {
|
||||
default: null,
|
||||
parseHTML: (element) => parseFloat(element.getAttribute('data-latitude') || '0'),
|
||||
renderHTML: (attributes) => ({
|
||||
'data-latitude': attributes.latitude
|
||||
})
|
||||
},
|
||||
longitude: {
|
||||
default: null,
|
||||
parseHTML: (element) => parseFloat(element.getAttribute('data-longitude') || '0'),
|
||||
renderHTML: (attributes) => ({
|
||||
'data-longitude': attributes.longitude
|
||||
})
|
||||
},
|
||||
title: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute('data-title'),
|
||||
renderHTML: (attributes) => ({
|
||||
'data-title': attributes.title
|
||||
})
|
||||
},
|
||||
description: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute('data-description'),
|
||||
renderHTML: (attributes) => ({
|
||||
'data-description': attributes.description
|
||||
})
|
||||
},
|
||||
markerColor: {
|
||||
default: '#ef4444',
|
||||
parseHTML: (element) => element.getAttribute('data-marker-color') || '#ef4444',
|
||||
renderHTML: (attributes) => ({
|
||||
'data-marker-color': attributes.markerColor
|
||||
})
|
||||
},
|
||||
zoom: {
|
||||
default: 15,
|
||||
parseHTML: (element) => parseInt(element.getAttribute('data-zoom') || '15'),
|
||||
renderHTML: (attributes) => ({
|
||||
'data-zoom': attributes.zoom
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
parseHTML() {
|
||||
return [{ tag: `div[data-type="${this.name}"]` }]
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
'div',
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { 'data-type': this.name })
|
||||
]
|
||||
},
|
||||
addNodeView() {
|
||||
return SvelteNodeViewRenderer(component)
|
||||
}
|
||||
})
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { Editor, Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core'
|
||||
import type { Component } from 'svelte'
|
||||
import { SvelteNodeViewRenderer } from 'svelte-tiptap'
|
||||
|
||||
export interface GeolocationPlaceholderOptions {
|
||||
HTMLAttributes: Record<string, object>
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
geolocationPlaceholder: {
|
||||
/**
|
||||
* Inserts a geolocation placeholder
|
||||
*/
|
||||
insertGeolocationPlaceholder: () => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const GeolocationPlaceholder = (
|
||||
component: Component<NodeViewProps>
|
||||
): Node<GeolocationPlaceholderOptions> =>
|
||||
Node.create<GeolocationPlaceholderOptions>({
|
||||
name: 'geolocation-placeholder',
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {}
|
||||
}
|
||||
},
|
||||
parseHTML() {
|
||||
return [{ tag: `div[data-type="${this.name}"]` }]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes)]
|
||||
},
|
||||
group: 'block',
|
||||
draggable: true,
|
||||
atom: true,
|
||||
content: 'inline*',
|
||||
isolating: true,
|
||||
|
||||
addNodeView() {
|
||||
return SvelteNodeViewRenderer(component)
|
||||
},
|
||||
addCommands() {
|
||||
return {
|
||||
insertGeolocationPlaceholder: () => (props: CommandProps) => {
|
||||
return props.commands.insertContent({
|
||||
type: 'geolocation-placeholder'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -0,0 +1,352 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import type { NodeViewProps } from '@tiptap/core'
|
||||
|
||||
interface Props extends NodeViewProps {}
|
||||
|
||||
let { node, updateAttributes }: Props = $props()
|
||||
|
||||
let mapContainer: HTMLDivElement
|
||||
let map: any
|
||||
let marker: any
|
||||
let leaflet: any
|
||||
let isEditing = $state(false)
|
||||
|
||||
// Extract attributes
|
||||
const latitude = $derived(node.attrs.latitude)
|
||||
const longitude = $derived(node.attrs.longitude)
|
||||
const title = $derived(node.attrs.title)
|
||||
const description = $derived(node.attrs.description)
|
||||
const markerColor = $derived(node.attrs.markerColor || '#ef4444')
|
||||
const zoom = $derived(node.attrs.zoom || 15)
|
||||
|
||||
// Load Leaflet dynamically
|
||||
async function loadLeaflet() {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
// Check if already loaded
|
||||
if (window.L) {
|
||||
leaflet = window.L
|
||||
return
|
||||
}
|
||||
|
||||
// Load Leaflet CSS
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'
|
||||
link.integrity = 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY='
|
||||
link.crossOrigin = ''
|
||||
document.head.appendChild(link)
|
||||
|
||||
// Load Leaflet JS
|
||||
const script = document.createElement('script')
|
||||
script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js'
|
||||
script.integrity = 'sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo='
|
||||
script.crossOrigin = ''
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
script.onload = resolve
|
||||
script.onerror = reject
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
|
||||
leaflet = window.L
|
||||
}
|
||||
|
||||
// Initialize map
|
||||
async function initMap() {
|
||||
if (!mapContainer || !leaflet || !latitude || !longitude) return
|
||||
|
||||
// Create map
|
||||
map = leaflet.map(mapContainer, {
|
||||
center: [latitude, longitude],
|
||||
zoom: zoom,
|
||||
scrollWheelZoom: false, // Disabled by default in editor
|
||||
dragging: !isEditing,
|
||||
touchZoom: !isEditing,
|
||||
doubleClickZoom: !isEditing,
|
||||
boxZoom: false,
|
||||
keyboard: false,
|
||||
zoomControl: true
|
||||
})
|
||||
|
||||
// Add tile layer (using OpenStreetMap)
|
||||
leaflet
|
||||
.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
maxZoom: 19
|
||||
})
|
||||
.addTo(map)
|
||||
|
||||
// Create custom marker icon if color is specified
|
||||
let markerOptions = {}
|
||||
if (markerColor) {
|
||||
const markerIcon = leaflet.divIcon({
|
||||
className: 'custom-marker',
|
||||
html: `<div style="background-color: ${markerColor}; width: 24px; height: 24px; border-radius: 50%; border: 3px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.3);"></div>`,
|
||||
iconSize: [30, 30],
|
||||
iconAnchor: [15, 15],
|
||||
popupAnchor: [0, -15]
|
||||
})
|
||||
markerOptions = { icon: markerIcon }
|
||||
}
|
||||
|
||||
// Add marker
|
||||
marker = leaflet.marker([latitude, longitude], markerOptions).addTo(map)
|
||||
|
||||
// Add popup if title or description exists
|
||||
if (title || description) {
|
||||
const popupContent = `
|
||||
<div class="location-popup">
|
||||
${title ? `<h3>${title}</h3>` : ''}
|
||||
${description ? `<p>${description}</p>` : ''}
|
||||
</div>
|
||||
`
|
||||
marker.bindPopup(popupContent, {
|
||||
autoPan: true,
|
||||
keepInView: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
function cleanup() {
|
||||
if (map) {
|
||||
map.remove()
|
||||
map = null
|
||||
}
|
||||
marker = null
|
||||
}
|
||||
|
||||
// Handle edit mode
|
||||
function toggleEdit() {
|
||||
isEditing = !isEditing
|
||||
if (map) {
|
||||
map.dragging[isEditing ? 'disable' : 'enable']()
|
||||
map.touchZoom[isEditing ? 'disable' : 'enable']()
|
||||
map.doubleClickZoom[isEditing ? 'disable' : 'enable']()
|
||||
map.scrollWheelZoom[isEditing ? 'disable' : 'enable']()
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
await loadLeaflet()
|
||||
await initMap()
|
||||
} catch (error) {
|
||||
console.error('Failed to load map:', error)
|
||||
}
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
// Reinitialize if attributes change
|
||||
$effect(() => {
|
||||
if (map && (latitude || longitude)) {
|
||||
cleanup()
|
||||
initMap()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="geolocation-node" data-drag-handle>
|
||||
<div class="geolocation-header">
|
||||
<div class="header-info">
|
||||
<span class="icon">📍</span>
|
||||
{#if title}
|
||||
<span class="title">{title}</span>
|
||||
{:else}
|
||||
<span class="coordinates">{latitude?.toFixed(4)}, {longitude?.toFixed(4)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
class="action-button"
|
||||
onclick={toggleEdit}
|
||||
title={isEditing ? 'Enable map interaction' : 'Disable map interaction'}
|
||||
>
|
||||
{isEditing ? '🔒' : '🔓'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
bind:this={mapContainer}
|
||||
class="map-container"
|
||||
class:editing={isEditing}
|
||||
role="img"
|
||||
aria-label="Map showing {title || 'location'} at coordinates {latitude}, {longitude}"
|
||||
>
|
||||
<noscript>
|
||||
<div class="map-fallback">
|
||||
<div class="fallback-content">
|
||||
{#if title}
|
||||
<h3>{title}</h3>
|
||||
{/if}
|
||||
{#if description}
|
||||
<p>{description}</p>
|
||||
{/if}
|
||||
<p class="coordinates">
|
||||
{latitude?.toFixed(6)}, {longitude?.toFixed(6)}
|
||||
</p>
|
||||
<a
|
||||
href="https://www.openstreetmap.org/?mlat={latitude}&mlon={longitude}#map={zoom}/{latitude}/{longitude}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
View on OpenStreetMap
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</noscript>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@import '$styles/variables.scss';
|
||||
|
||||
.geolocation-node {
|
||||
margin: $unit-2x 0;
|
||||
border: 1px solid $grey-90;
|
||||
border-radius: $corner-radius-md;
|
||||
overflow: hidden;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.geolocation-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: $unit $unit-2x;
|
||||
background: $grey-95;
|
||||
border-bottom: 1px solid $grey-90;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit;
|
||||
|
||||
.icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 500;
|
||||
color: $grey-10;
|
||||
}
|
||||
|
||||
.coordinates {
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
color: $grey-40;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.action-button {
|
||||
padding: 4px 8px;
|
||||
background: transparent;
|
||||
border: 1px solid $grey-80;
|
||||
border-radius: $corner-radius-sm;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: white;
|
||||
border-color: $grey-60;
|
||||
}
|
||||
}
|
||||
|
||||
.map-container {
|
||||
height: 400px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
background: $grey-95;
|
||||
|
||||
&.editing {
|
||||
opacity: 0.8;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:global(.leaflet-container) {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
:global(.location-popup) {
|
||||
h3 {
|
||||
margin: 0 0 $unit-half;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: $grey-10;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: $grey-30;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.leaflet-popup-content-wrapper) {
|
||||
border-radius: $corner-radius-md;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
:global(.leaflet-popup-content) {
|
||||
margin: $unit-2x;
|
||||
}
|
||||
}
|
||||
|
||||
.map-fallback {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: $grey-95;
|
||||
padding: $unit-3x;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.fallback-content {
|
||||
h3 {
|
||||
margin: 0 0 $unit;
|
||||
font-size: 1.25rem;
|
||||
color: $grey-10;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 $unit;
|
||||
color: $grey-40;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.coordinates {
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 0.875rem;
|
||||
color: $grey-60;
|
||||
margin-bottom: $unit-2x;
|
||||
}
|
||||
|
||||
a {
|
||||
color: $red-60;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure Leaflet attribution is styled properly */
|
||||
:global(.leaflet-control-attribution) {
|
||||
font-size: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.9) !important;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,425 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte'
|
||||
import type { Readable } from 'svelte/store'
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import Button from '$lib/components/admin/Button.svelte'
|
||||
import Input from '$lib/components/admin/Input.svelte'
|
||||
import Textarea from '$lib/components/admin/Textarea.svelte'
|
||||
|
||||
const editor = getContext<Readable<Editor>>('editor')
|
||||
|
||||
let showForm = $state(false)
|
||||
let title = $state('')
|
||||
let description = $state('')
|
||||
let latitude = $state('')
|
||||
let longitude = $state('')
|
||||
let markerColor = $state('#ef4444')
|
||||
let zoom = $state(15)
|
||||
|
||||
// Map picker state
|
||||
let showMapPicker = $state(false)
|
||||
let mapContainer: HTMLDivElement
|
||||
let pickerMap: any
|
||||
let pickerMarker: any
|
||||
let leaflet: any
|
||||
|
||||
// Load Leaflet for map picker
|
||||
async function loadLeaflet() {
|
||||
if (typeof window === 'undefined') return
|
||||
if (window.L) {
|
||||
leaflet = window.L
|
||||
return
|
||||
}
|
||||
|
||||
// Load CSS
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'
|
||||
document.head.appendChild(link)
|
||||
|
||||
// Load JS
|
||||
const script = document.createElement('script')
|
||||
script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js'
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
script.onload = resolve
|
||||
script.onerror = reject
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
|
||||
leaflet = window.L
|
||||
}
|
||||
|
||||
// Initialize map picker
|
||||
async function initMapPicker() {
|
||||
if (!mapContainer || !leaflet) return
|
||||
|
||||
// Default to San Francisco if no coordinates
|
||||
const lat = latitude ? parseFloat(latitude) : 37.7749
|
||||
const lng = longitude ? parseFloat(longitude) : -122.4194
|
||||
|
||||
pickerMap = leaflet.map(mapContainer, {
|
||||
center: [lat, lng],
|
||||
zoom: zoom
|
||||
})
|
||||
|
||||
leaflet
|
||||
.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
})
|
||||
.addTo(pickerMap)
|
||||
|
||||
// Add marker
|
||||
pickerMarker = leaflet
|
||||
.marker([lat, lng], {
|
||||
draggable: true
|
||||
})
|
||||
.addTo(pickerMap)
|
||||
|
||||
// Update coordinates on marker drag
|
||||
pickerMarker.on('dragend', (e: any) => {
|
||||
const position = e.target.getLatLng()
|
||||
latitude = position.lat.toFixed(6)
|
||||
longitude = position.lng.toFixed(6)
|
||||
})
|
||||
|
||||
// Update marker on map click
|
||||
pickerMap.on('click', (e: any) => {
|
||||
pickerMarker.setLatLng(e.latlng)
|
||||
latitude = e.latlng.lat.toFixed(6)
|
||||
longitude = e.latlng.lng.toFixed(6)
|
||||
})
|
||||
|
||||
// Update zoom on change
|
||||
pickerMap.on('zoomend', () => {
|
||||
zoom = pickerMap.getZoom()
|
||||
})
|
||||
}
|
||||
|
||||
// Open map picker
|
||||
async function openMapPicker() {
|
||||
showMapPicker = true
|
||||
await loadLeaflet()
|
||||
// Wait for DOM update
|
||||
setTimeout(() => {
|
||||
initMapPicker()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
// Close map picker
|
||||
function closeMapPicker() {
|
||||
if (pickerMap) {
|
||||
pickerMap.remove()
|
||||
pickerMap = null
|
||||
}
|
||||
pickerMarker = null
|
||||
showMapPicker = false
|
||||
}
|
||||
|
||||
function handleInsert() {
|
||||
const lat = parseFloat(latitude)
|
||||
const lng = parseFloat(longitude)
|
||||
|
||||
if (isNaN(lat) || isNaN(lng)) {
|
||||
alert('Please enter valid coordinates')
|
||||
return
|
||||
}
|
||||
|
||||
$editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setGeolocation({
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
title: title || undefined,
|
||||
description: description || undefined,
|
||||
markerColor,
|
||||
zoom
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
function handleQuickLocation(lat: number, lng: number, name: string) {
|
||||
$editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setGeolocation({
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
title: name,
|
||||
zoom: 13
|
||||
})
|
||||
.run()
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="geolocation-placeholder">
|
||||
{#if !showForm}
|
||||
<div class="placeholder-content">
|
||||
<div class="icon">📍</div>
|
||||
<p class="label">Add a location</p>
|
||||
<div class="actions">
|
||||
<Button variant="secondary" buttonSize="small" onclick={() => (showForm = true)}>
|
||||
Custom Location
|
||||
</Button>
|
||||
<div class="quick-locations">
|
||||
<button
|
||||
class="quick-location"
|
||||
onclick={() => handleQuickLocation(37.7749, -122.4194, 'San Francisco')}
|
||||
>
|
||||
San Francisco
|
||||
</button>
|
||||
<button
|
||||
class="quick-location"
|
||||
onclick={() => handleQuickLocation(40.7128, -74.006, 'New York')}
|
||||
>
|
||||
New York
|
||||
</button>
|
||||
<button
|
||||
class="quick-location"
|
||||
onclick={() => handleQuickLocation(51.5074, -0.1278, 'London')}
|
||||
>
|
||||
London
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="form-content">
|
||||
<h3>Add Location</h3>
|
||||
|
||||
<div class="form-fields">
|
||||
<Input label="Title (optional)" bind:value={title} placeholder="Location name" />
|
||||
|
||||
<Textarea
|
||||
label="Description (optional)"
|
||||
bind:value={description}
|
||||
placeholder="About this location"
|
||||
rows={2}
|
||||
/>
|
||||
|
||||
<div class="coordinates-row">
|
||||
<Input
|
||||
label="Latitude"
|
||||
bind:value={latitude}
|
||||
placeholder="37.7749"
|
||||
type="number"
|
||||
step="0.000001"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Longitude"
|
||||
bind:value={longitude}
|
||||
placeholder="-122.4194"
|
||||
type="number"
|
||||
step="0.000001"
|
||||
required
|
||||
/>
|
||||
<Button variant="secondary" onclick={openMapPicker} buttonSize="small">📍 Pick</Button>
|
||||
</div>
|
||||
|
||||
<div class="options-row">
|
||||
<label class="color-label">
|
||||
Marker Color
|
||||
<input type="color" bind:value={markerColor} class="color-input" />
|
||||
</label>
|
||||
<label class="zoom-label">
|
||||
Zoom Level
|
||||
<input type="range" bind:value={zoom} min="1" max="20" class="zoom-input" />
|
||||
<span>{zoom}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showMapPicker}
|
||||
<div class="map-picker-modal">
|
||||
<div class="map-picker-content">
|
||||
<div class="map-picker-header">
|
||||
<h4>Click to set location</h4>
|
||||
<Button variant="ghost" buttonSize="small" onclick={closeMapPicker}>Close</Button>
|
||||
</div>
|
||||
<div bind:this={mapContainer} class="map-picker-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="form-actions">
|
||||
<Button variant="ghost" onclick={() => (showForm = false)}>Cancel</Button>
|
||||
<Button variant="primary" onclick={handleInsert} disabled={!latitude || !longitude}>
|
||||
Insert Location
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@import '$styles/variables.scss';
|
||||
|
||||
.geolocation-placeholder {
|
||||
margin: $unit-2x 0;
|
||||
background: $grey-95;
|
||||
border: 2px dashed $grey-80;
|
||||
border-radius: $corner-radius-md;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.placeholder-content {
|
||||
padding: $unit-3x;
|
||||
text-align: center;
|
||||
|
||||
.icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: $unit;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin: 0 0 $unit-2x;
|
||||
color: $grey-40;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: $unit;
|
||||
}
|
||||
|
||||
.quick-locations {
|
||||
display: flex;
|
||||
gap: $unit-half;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
margin-top: $unit;
|
||||
}
|
||||
|
||||
.quick-location {
|
||||
padding: 4px 12px;
|
||||
background: white;
|
||||
border: 1px solid $grey-80;
|
||||
border-radius: $corner-radius-sm;
|
||||
font-size: 0.75rem;
|
||||
color: $grey-30;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: $grey-60;
|
||||
color: $grey-10;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-content {
|
||||
padding: $unit-3x;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 $unit-3x;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: $grey-10;
|
||||
}
|
||||
}
|
||||
|
||||
.form-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-2x;
|
||||
margin-bottom: $unit-3x;
|
||||
}
|
||||
|
||||
.coordinates-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
gap: $unit;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.options-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: $unit-3x;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.color-label,
|
||||
.zoom-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: $grey-20;
|
||||
}
|
||||
|
||||
.color-input {
|
||||
width: 40px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: 1px solid $grey-80;
|
||||
border-radius: $corner-radius-sm;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.zoom-input {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.zoom-label span {
|
||||
min-width: 24px;
|
||||
text-align: right;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 0.75rem;
|
||||
color: $grey-40;
|
||||
}
|
||||
|
||||
.map-picker-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
padding: $unit-3x;
|
||||
}
|
||||
|
||||
.map-picker-content {
|
||||
background: white;
|
||||
border-radius: $corner-radius-md;
|
||||
width: 90%;
|
||||
max-width: 800px;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.map-picker-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: $unit-2x $unit-3x;
|
||||
border-bottom: 1px solid $grey-90;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: $grey-10;
|
||||
}
|
||||
}
|
||||
|
||||
.map-picker-container {
|
||||
height: 500px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: $unit;
|
||||
}
|
||||
</style>
|
||||
122
src/lib/components/edra/extensions/geolocation/geolocation.ts
Normal file
122
src/lib/components/edra/extensions/geolocation/geolocation.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { Node, mergeAttributes } from '@tiptap/core'
|
||||
import { SvelteNodeViewRenderer } from 'svelte-tiptap'
|
||||
import GeolocationPlaceholder from './geolocation-placeholder.svelte'
|
||||
import GeolocationExtended from './geolocation-extended.svelte'
|
||||
|
||||
export interface GeolocationOptions {
|
||||
HTMLAttributes: Record<string, any>
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
geolocation: {
|
||||
/**
|
||||
* Set a geolocation node
|
||||
*/
|
||||
setGeolocation: (options: {
|
||||
latitude: number
|
||||
longitude: number
|
||||
title?: string
|
||||
description?: string
|
||||
markerColor?: string
|
||||
zoom?: number
|
||||
}) => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const Geolocation = Node.create<GeolocationOptions>({
|
||||
name: 'geolocation',
|
||||
|
||||
group: 'block',
|
||||
|
||||
atom: true,
|
||||
|
||||
draggable: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {}
|
||||
}
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
latitude: {
|
||||
default: null,
|
||||
parseHTML: (element) => parseFloat(element.getAttribute('data-latitude') || '0'),
|
||||
renderHTML: (attributes) => ({
|
||||
'data-latitude': attributes.latitude
|
||||
})
|
||||
},
|
||||
longitude: {
|
||||
default: null,
|
||||
parseHTML: (element) => parseFloat(element.getAttribute('data-longitude') || '0'),
|
||||
renderHTML: (attributes) => ({
|
||||
'data-longitude': attributes.longitude
|
||||
})
|
||||
},
|
||||
title: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute('data-title'),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.title) return {}
|
||||
return { 'data-title': attributes.title }
|
||||
}
|
||||
},
|
||||
description: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute('data-description'),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.description) return {}
|
||||
return { 'data-description': attributes.description }
|
||||
}
|
||||
},
|
||||
markerColor: {
|
||||
default: '#ef4444',
|
||||
parseHTML: (element) => element.getAttribute('data-marker-color') || '#ef4444',
|
||||
renderHTML: (attributes) => ({
|
||||
'data-marker-color': attributes.markerColor
|
||||
})
|
||||
},
|
||||
zoom: {
|
||||
default: 15,
|
||||
parseHTML: (element) => parseInt(element.getAttribute('data-zoom') || '15'),
|
||||
renderHTML: (attributes) => ({
|
||||
'data-zoom': attributes.zoom
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'div[data-type="geolocation"]'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'geolocation' })]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return SvelteNodeViewRenderer(GeolocationExtended, {
|
||||
placeholder: GeolocationPlaceholder
|
||||
})
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setGeolocation:
|
||||
(options) =>
|
||||
({ commands }) => {
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: options
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
<script lang="ts">
|
||||
import { type NodeViewProps } from '@tiptap/core'
|
||||
import { NodeViewWrapper } from 'svelte-tiptap'
|
||||
import { onMount } from 'svelte'
|
||||
import type L from 'leaflet'
|
||||
|
||||
interface Props extends NodeViewProps {}
|
||||
let { node, selected }: Props = $props()
|
||||
|
||||
let mapContainer: HTMLDivElement
|
||||
let map: L.Map | null = null
|
||||
let leaflet: typeof L
|
||||
|
||||
const latitude = node.attrs.latitude as number
|
||||
const longitude = node.attrs.longitude as number
|
||||
const title = node.attrs.title as string
|
||||
const description = node.attrs.description as string
|
||||
const markerColor = node.attrs.markerColor as string
|
||||
const zoom = (node.attrs.zoom as number) || 15
|
||||
|
||||
onMount(async () => {
|
||||
// Dynamically import Leaflet
|
||||
leaflet = (await import('leaflet')).default
|
||||
await import('leaflet/dist/leaflet.css')
|
||||
|
||||
// Initialize map
|
||||
map = leaflet.map(mapContainer).setView([latitude, longitude], zoom)
|
||||
|
||||
// Add tile layer
|
||||
leaflet
|
||||
.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
})
|
||||
.addTo(map)
|
||||
|
||||
// Create custom icon with color
|
||||
const icon = leaflet.divIcon({
|
||||
html: `<div style="background-color: ${markerColor}; width: 25px; height: 25px; border-radius: 50% 50% 50% 0; transform: rotate(-45deg); border: 2px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.3);"></div>`,
|
||||
iconSize: [25, 25],
|
||||
iconAnchor: [12, 25],
|
||||
popupAnchor: [0, -25],
|
||||
className: 'custom-marker'
|
||||
})
|
||||
|
||||
// Add marker
|
||||
const marker = leaflet.marker([latitude, longitude], { icon }).addTo(map)
|
||||
|
||||
// Add popup if title or description exists
|
||||
if (title || description) {
|
||||
const popupContent = `
|
||||
<div class="map-popup">
|
||||
${title ? `<h4>${title}</h4>` : ''}
|
||||
${description ? `<p>${description}</p>` : ''}
|
||||
</div>
|
||||
`
|
||||
marker.bindPopup(popupContent)
|
||||
}
|
||||
|
||||
return () => {
|
||||
map?.remove()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<NodeViewWrapper>
|
||||
<div class="geolocation-node" class:selected>
|
||||
<div bind:this={mapContainer} class="map-container"></div>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
|
||||
<style lang="scss">
|
||||
:global(.leaflet-container) {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
:global(.custom-marker) {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
:global(.map-popup) {
|
||||
h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #4b5563;
|
||||
}
|
||||
}
|
||||
|
||||
.geolocation-node {
|
||||
margin: 16px 0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&.selected {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
.map-container {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,255 @@
|
|||
<script lang="ts">
|
||||
import { type Editor, type NodeViewProps } from '@tiptap/core'
|
||||
import { NodeViewWrapper } from 'svelte-tiptap'
|
||||
import MapPin from 'lucide-svelte/icons/map-pin'
|
||||
|
||||
interface Props extends NodeViewProps {}
|
||||
let { node, editor, getPos, updateAttributes, deleteNode }: Props = $props()
|
||||
|
||||
let latitude = $state<number | null>(null)
|
||||
let longitude = $state<number | null>(null)
|
||||
let title = $state('')
|
||||
let description = $state('')
|
||||
let markerColor = $state('#ef4444')
|
||||
let isConfigured = $state(false)
|
||||
|
||||
function handleSubmit() {
|
||||
if (!latitude || !longitude || !title) {
|
||||
alert('Please fill in all required fields')
|
||||
return
|
||||
}
|
||||
|
||||
// Replace this placeholder with the actual geolocation node
|
||||
const pos = getPos()
|
||||
if (typeof pos === 'number') {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange({ from: pos, to: pos + node.nodeSize })
|
||||
.insertContent({
|
||||
type: 'geolocation',
|
||||
attrs: {
|
||||
latitude,
|
||||
longitude,
|
||||
title,
|
||||
description,
|
||||
markerColor
|
||||
}
|
||||
})
|
||||
.run()
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
deleteNode()
|
||||
}
|
||||
</script>
|
||||
|
||||
<NodeViewWrapper>
|
||||
<div class="geolocation-placeholder">
|
||||
<div class="icon">
|
||||
<MapPin size={24} />
|
||||
</div>
|
||||
|
||||
{#if !isConfigured}
|
||||
<div class="content">
|
||||
<h3>Add Location</h3>
|
||||
<p>Add a map with a location marker</p>
|
||||
<button class="configure-btn" onclick={() => (isConfigured = true)}>
|
||||
Configure Location
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="form">
|
||||
<h3>Configure Location</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="latitude">Latitude <span class="required">*</span></label>
|
||||
<input
|
||||
id="latitude"
|
||||
type="number"
|
||||
step="0.000001"
|
||||
bind:value={latitude}
|
||||
placeholder="e.g., 37.7749"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="longitude">Longitude <span class="required">*</span></label>
|
||||
<input
|
||||
id="longitude"
|
||||
type="number"
|
||||
step="0.000001"
|
||||
bind:value={longitude}
|
||||
placeholder="e.g., -122.4194"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="title">Title <span class="required">*</span></label>
|
||||
<input id="title" type="text" bind:value={title} placeholder="Location name" required />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea
|
||||
id="description"
|
||||
bind:value={description}
|
||||
placeholder="Optional description"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="markerColor">Marker Color</label>
|
||||
<input id="markerColor" type="color" bind:value={markerColor} />
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="cancel-btn" onclick={handleCancel}>Cancel</button>
|
||||
<button class="submit-btn" onclick={handleSubmit}>Add Location</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
|
||||
<style lang="scss">
|
||||
.geolocation-placeholder {
|
||||
background: #f8f9fa;
|
||||
border: 2px dashed #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
margin: 16px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.content {
|
||||
h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 16px;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
.configure-btn {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
}
|
||||
|
||||
.form {
|
||||
text-align: left;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 20px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
input[type='color'] {
|
||||
width: 60px;
|
||||
height: 36px;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.cancel-btn,
|
||||
.submit-btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
|
||||
&:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in a new issue