feat: move leaflet map to a web component
This commit is contained in:
parent
430c50b3ae
commit
d0df5fbbc6
|
@ -1,160 +0,0 @@
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
|
|
||||||
import { LockClosedIcon, LockOpen1Icon } from "@radix-ui/react-icons";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
import "../styles/locked-content.css";
|
|
||||||
import type { Generated } from "kysely";
|
|
||||||
import { onLocationError } from "@/lib/error";
|
|
||||||
|
|
||||||
const incrementCounter = async (id: string | Generated<string>) =>
|
|
||||||
await fetch(`http://localhost:3000/api/location/increment/${id}`, {
|
|
||||||
method: "PATCH",
|
|
||||||
});
|
|
||||||
|
|
||||||
const LocationButton = ({
|
|
||||||
contentId = "",
|
|
||||||
imageUrl = "#",
|
|
||||||
location = "",
|
|
||||||
}: {
|
|
||||||
contentId?: string | Generated<string>;
|
|
||||||
imageUrl?: string;
|
|
||||||
location?: string;
|
|
||||||
}) => {
|
|
||||||
const [atTarget, setAtTarget] = useState(false);
|
|
||||||
const [contentVisible, setContentVisible] = useState(false);
|
|
||||||
const [hasPermission, setHasPermission] = useState(false);
|
|
||||||
const [watchId, setWatchId] = useState<number>();
|
|
||||||
const [distanceRemain, setDistanceRemain] = useState<string>("");
|
|
||||||
|
|
||||||
const targetCoordinates = JSON.parse(location);
|
|
||||||
|
|
||||||
const targetPos = {
|
|
||||||
lat: targetCoordinates[0],
|
|
||||||
lng: targetCoordinates[1],
|
|
||||||
};
|
|
||||||
|
|
||||||
const startWatchingLocation = () => {
|
|
||||||
setHasPermission(true);
|
|
||||||
if (!watchId) {
|
|
||||||
const id = navigator.geolocation.watchPosition(
|
|
||||||
(position: GeolocationPosition) => {
|
|
||||||
const pos = {
|
|
||||||
lat: position.coords.latitude,
|
|
||||||
lng: position.coords.longitude,
|
|
||||||
};
|
|
||||||
|
|
||||||
// @ts-expect-error 3rd party script
|
|
||||||
const targetLatLng = L.latLng(targetPos);
|
|
||||||
|
|
||||||
// @ts-expect-error 3rd party script
|
|
||||||
const currentLatLng = L.latLng(pos);
|
|
||||||
|
|
||||||
const betweenMeters = currentLatLng.distanceTo(targetLatLng);
|
|
||||||
|
|
||||||
if (betweenMeters > 1000) {
|
|
||||||
setDistanceRemain(`${(betweenMeters / 1000).toFixed()} KM`);
|
|
||||||
} else if (betweenMeters > 100) {
|
|
||||||
setDistanceRemain(`${betweenMeters.toFixed(0)} M`);
|
|
||||||
} else {
|
|
||||||
navigator.geolocation.clearWatch(id);
|
|
||||||
setAtTarget(true);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
(err) => onLocationError(err),
|
|
||||||
{ enableHighAccuracy: true, timeout: 27000, maximumAge: 10000 }
|
|
||||||
);
|
|
||||||
|
|
||||||
setWatchId(id);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUnlock = async () => {
|
|
||||||
setContentVisible(true);
|
|
||||||
await incrementCounter(contentId);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
navigator.permissions
|
|
||||||
.query({ name: "geolocation" })
|
|
||||||
.then((permissionStatus) => {
|
|
||||||
if (permissionStatus.state === "granted") {
|
|
||||||
setHasPermission(true);
|
|
||||||
startWatchingLocation();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (contentVisible) {
|
|
||||||
return (
|
|
||||||
<div className="w-full h-[475px] p-4 flex justify-center">
|
|
||||||
<img src={imageUrl} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="w-full h-[475px] overflow-hidden border border-zinc-200 shadow-sm p-4 rounded">
|
|
||||||
{atTarget ? (
|
|
||||||
<div className="flex flex-col justify-center items-center image-wrapper">
|
|
||||||
<img src={imageUrl} className="blur-2xl h-[450px]" />
|
|
||||||
|
|
||||||
<div className="flex flex-col justify-center gap-4 overlay">
|
|
||||||
<Button
|
|
||||||
size="lg"
|
|
||||||
className="text-lg p-6 animate-pulse bg-indigo-600 hover:bg-indigo-700 hover:animate-none border-2 border-indigo-800"
|
|
||||||
onClick={handleUnlock}
|
|
||||||
>
|
|
||||||
<LockOpen1Icon className="mr-2 h-4 w-4" />
|
|
||||||
İçeriğin Kilidi Açıldı
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Card className="p-2">
|
|
||||||
<CardContent className="pb-0 text-center">
|
|
||||||
İçeriği görmek için butona bas!
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col justify-center items-center image-wrapper">
|
|
||||||
<img src={imageUrl} className="blur-2xl h-[450px]" />
|
|
||||||
<div className="flex flex-col justify-center gap-4 overlay">
|
|
||||||
<Button size="lg" className="text-md">
|
|
||||||
<LockClosedIcon className="mr-2 h-4 w-4" /> İçerik Kilitli
|
|
||||||
</Button>
|
|
||||||
<Card className="p-2">
|
|
||||||
{hasPermission ? (
|
|
||||||
<CardContent className="pb-0 text-center">
|
|
||||||
<p>İçeriği görmek için konuma gitmelisin!</p>
|
|
||||||
<p>
|
|
||||||
{distanceRemain && `Kalan mesafe: ${distanceRemain}`}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<CardContent className="pb-0 text-center">
|
|
||||||
Ne kadar yaklaştığını görmek için aşağıdaki butona bas.
|
|
||||||
</CardContent>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className="bg-green-700 hover:bg-green-600 text-md"
|
|
||||||
onClick={() => startWatchingLocation()}
|
|
||||||
>
|
|
||||||
Konum İzni Ver
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LocationButton;
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { html } from "lit";
|
import { html } from "lit";
|
||||||
import { lockSVG, unlockSVG } from "./svg";
|
import { lockSVG, unlockSVG } from "./icons";
|
||||||
|
|
||||||
// This template is shown when user hasn't give geolocation permission yet
|
// This template is shown when user hasn't give geolocation permission yet
|
||||||
// When user click the button user is asked for geolocation permission
|
// When user click the button user is asked for geolocation permission
|
||||||
|
|
|
@ -1,122 +0,0 @@
|
||||||
---
|
|
||||||
import { LockClosedIcon, LockOpen1Icon } from "@radix-ui/react-icons";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
contentId?: string;
|
|
||||||
imageUrl: string;
|
|
||||||
location?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { contentId = "", imageUrl = "#", location = "" } = Astro.props;
|
|
||||||
---
|
|
||||||
|
|
||||||
<template id="locked-content-template">
|
|
||||||
<div
|
|
||||||
class="w-full h-[475px] overflow-hidden border border-zinc-200 shadow-sm p-4 rounded"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col justify-center items-center image-wrapper">
|
|
||||||
<img id="content" src="#" class="blur-2xl h-[450px]" />
|
|
||||||
|
|
||||||
<template id="permission-button-template">
|
|
||||||
<div class="flex flex-col justify-center gap-4 overlay">
|
|
||||||
<button
|
|
||||||
id="unlock-content-button"
|
|
||||||
class="inline-flex items-center justify-center whitespace-nowrap font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-11 rounded-md text-lg p-6 text-md"
|
|
||||||
>
|
|
||||||
<LockClosedIcon className="mr-2 h-4 w-4" /> İçerik Kilitli
|
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
class="rounded-lg border bg-card text-card-foreground shadow-sm p-2"
|
|
||||||
>
|
|
||||||
<div class="pb-0 text-center flex flex-col gap-4">
|
|
||||||
<p id="locked-content-description">
|
|
||||||
Ne kadar yaklaştığını görmek için aşağıdaki butona bas.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
class="inline-flex items-center justify-center whitespace-nowrap font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 text-primary-foreground h-9 rounded-md px-3 bg-green-700 hover:bg-green-600 text-md"
|
|
||||||
>Konum İzni Ver</button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template id="locked-button-template">
|
|
||||||
<div class="flex flex-col justify-center gap-4 overlay">
|
|
||||||
<button
|
|
||||||
id="unlock-content-button"
|
|
||||||
class="inline-flex items-center justify-center whitespace-nowrap font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-11 rounded-md text-lg p-6 text-md"
|
|
||||||
>
|
|
||||||
<LockClosedIcon className="mr-2 h-4 w-4" /> İçerik Kilitli
|
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
class="rounded-lg border bg-card text-card-foreground shadow-sm p-2"
|
|
||||||
>
|
|
||||||
<div class="pb-0 px-4 text-center">
|
|
||||||
<p id="locked-content-description">
|
|
||||||
İçeriği görmek için konuma gitmelisin!
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template id="unlocked-button-template">
|
|
||||||
<div class="flex flex-col justify-center gap-4 overlay">
|
|
||||||
<button
|
|
||||||
id="unlock-content-button"
|
|
||||||
class="inline-flex items-center justify-center whitespace-nowrap font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 text-primary-foreground h-11 rounded-md text-lg p-6 animate-pulse bg-indigo-600 hover:bg-indigo-700 hover:animate-none border-2 border-indigo-800"
|
|
||||||
>
|
|
||||||
<LockOpen1Icon className="mr-2 h-4 w-4" />
|
|
||||||
İçeriğin Kilidi Açıldı
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="rounded-lg border bg-card text-card-foreground shadow-sm p-2"
|
|
||||||
>
|
|
||||||
<div class="pb-0 px-4 text-center">
|
|
||||||
<p id="locked-content-description">
|
|
||||||
İçeriği görmek için butona bas!
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<unlock-content-button locked id="unlock-content-button-element"
|
|
||||||
></unlock-content-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<style>
|
|
||||||
@import url("css/tailwind.css");
|
|
||||||
.image-wrapper {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overlay {
|
|
||||||
position: absolute;
|
|
||||||
|
|
||||||
/* center overlay text */
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
inset: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
<locked-content
|
|
||||||
contentId={contentId}
|
|
||||||
imageURL={imageUrl}
|
|
||||||
targetPos={location}
|
|
||||||
id="locked-content"></locked-content>
|
|
||||||
-->
|
|
||||||
<locked-content-lit
|
|
||||||
imageId={contentId}
|
|
||||||
imageURL={imageUrl}
|
|
||||||
targetPosition={location}></locked-content-lit>
|
|
||||||
|
|
||||||
<script src="../components/locked-content.ts"></script>
|
|
||||||
<script src="../components/locked-content-lit.ts"></script>
|
|
52
src/components/leaflet-map.ts
Normal file
52
src/components/leaflet-map.ts
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
// Lit
|
||||||
|
import { html, LitElement } from "lit";
|
||||||
|
import { customElement, property, query, state } from "lit/decorators.js";
|
||||||
|
|
||||||
|
// Leaflet
|
||||||
|
import L from "leaflet";
|
||||||
|
import type { LatLngTuple } from "leaflet";
|
||||||
|
|
||||||
|
@customElement("leaflet-map")
|
||||||
|
export class LeafletMap extends LitElement {
|
||||||
|
@property({ type: Object }) targetLocation?: LatLngTuple;
|
||||||
|
|
||||||
|
@query("#leaflet-map-container")
|
||||||
|
_mapElement!: HTMLDivElement;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
protected _map?: L.Map;
|
||||||
|
@state()
|
||||||
|
protected _geolocationPermissionStatus: PermissionState = "prompt";
|
||||||
|
@state()
|
||||||
|
protected _currentLocationMarker?: L.Marker;
|
||||||
|
@state()
|
||||||
|
protected _watchingLocation = false;
|
||||||
|
|
||||||
|
connectedCallback(): void {
|
||||||
|
super.connectedCallback();
|
||||||
|
|
||||||
|
// Check geolocation permission, if user has given permission before
|
||||||
|
// start watching user location
|
||||||
|
navigator.permissions
|
||||||
|
.query({ name: "geolocation" })
|
||||||
|
.then((permissionStatus) => {
|
||||||
|
switch (permissionStatus.state) {
|
||||||
|
case "granted":
|
||||||
|
this._geolocationPermissionStatus = "granted";
|
||||||
|
break;
|
||||||
|
case "denied":
|
||||||
|
this._geolocationPermissionStatus = "denied";
|
||||||
|
case "prompt":
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return html`<div
|
||||||
|
id="leaflet-map-container"
|
||||||
|
class="w-full h-[450px] rounded"
|
||||||
|
></div>`;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,192 +0,0 @@
|
||||||
// Lit imports
|
|
||||||
import { LitElement, html, nothing, unsafeCSS, type CSSResultGroup } from "lit";
|
|
||||||
import { customElement, property, state } from "lit/decorators.js";
|
|
||||||
|
|
||||||
// Leaflet
|
|
||||||
import { type LatLngTuple } from "leaflet";
|
|
||||||
|
|
||||||
// Styles
|
|
||||||
import globalStyles from "@/styles/globals.css?inline";
|
|
||||||
import lockedContentStyles from "../styles/locked-content.css?inline";
|
|
||||||
|
|
||||||
// Templates
|
|
||||||
import {
|
|
||||||
lockedButtonTemplate,
|
|
||||||
permissionButtonTemplate,
|
|
||||||
permissionDeniedButtonTemplate,
|
|
||||||
unlockedButtonTemplate,
|
|
||||||
} from "./LockedContent/templates";
|
|
||||||
|
|
||||||
// Geolocation utils
|
|
||||||
import { calculateDistance, errorCallback } from "./LockedContent/geolocation";
|
|
||||||
import { incrementUnlockCounter } from "./LockedContent/middleware";
|
|
||||||
|
|
||||||
// LockedContent is a custom element watching user location and blurring
|
|
||||||
// given image until user has arrived a certain position
|
|
||||||
@customElement("locked-content-lit")
|
|
||||||
export class LockedContent extends LitElement {
|
|
||||||
// Constants
|
|
||||||
geolocationOptions = {
|
|
||||||
enableHighAccuracy: true,
|
|
||||||
timeout: 15000,
|
|
||||||
maximumAge: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Tailwind and custom styles
|
|
||||||
static styles: CSSResultGroup | undefined = [
|
|
||||||
unsafeCSS(globalStyles),
|
|
||||||
unsafeCSS(lockedContentStyles),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Components properties/attributes, no accessor attribute disables detecting
|
|
||||||
// changes as these are readonly attriubtes there is no need to attach setters
|
|
||||||
@property({ noAccessor: true }) readonly imageId?: string;
|
|
||||||
@property({ noAccessor: true }) readonly imageURL?: string;
|
|
||||||
@property({ type: Object, noAccessor: true })
|
|
||||||
readonly targetPosition?: LatLngTuple;
|
|
||||||
|
|
||||||
// Reactive states, template is rendered according to this states
|
|
||||||
@state()
|
|
||||||
protected _geolocationPermissionStatus: PermissionState = "prompt";
|
|
||||||
@state()
|
|
||||||
protected _unlocked = false;
|
|
||||||
@state()
|
|
||||||
protected _arrived = false;
|
|
||||||
@state()
|
|
||||||
protected _distanceText?: string;
|
|
||||||
@state()
|
|
||||||
protected _watchId?: number;
|
|
||||||
|
|
||||||
// This callback will be fired when geolocation info is available
|
|
||||||
successCallback(position: GeolocationPosition) {
|
|
||||||
// Set hasGeolocationPermission state true to change the template
|
|
||||||
this._geolocationPermissionStatus = "granted";
|
|
||||||
|
|
||||||
// Target position must be set
|
|
||||||
if (!this.targetPosition) return;
|
|
||||||
|
|
||||||
// Calculate the distance between target and current position in meters
|
|
||||||
const distance = calculateDistance(position, this.targetPosition);
|
|
||||||
|
|
||||||
// Update the text based on the distance
|
|
||||||
this._updateDistanceText(distance);
|
|
||||||
|
|
||||||
this._checkArrived(distance);
|
|
||||||
}
|
|
||||||
|
|
||||||
private _updateDistanceText(distance: number) {
|
|
||||||
// Update the proximity text according to the distance remaining
|
|
||||||
if (distance > 1000) {
|
|
||||||
this._distanceText = `${(distance / 1000).toFixed()} KM`;
|
|
||||||
} else if (distance > 100) {
|
|
||||||
this._distanceText = `${distance.toFixed(0)} M`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private _checkArrived(distance: number) {
|
|
||||||
// If target is close less then 100 meters user has arrived to target location
|
|
||||||
if (distance < 100) {
|
|
||||||
if (this._watchId) {
|
|
||||||
// Stop watching location
|
|
||||||
navigator.geolocation.clearWatch(this._watchId);
|
|
||||||
}
|
|
||||||
// Update state to reveal the image
|
|
||||||
this._arrived = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This template is shown when user hasn't give geolocation permission yet
|
|
||||||
// When user click the button user is asked for geolocation permission
|
|
||||||
private _permissionButtonTemplate = () =>
|
|
||||||
permissionButtonTemplate(this._startWatchingLocation);
|
|
||||||
|
|
||||||
// This template is shown when user has given permission but has not arrived yet
|
|
||||||
private _lockedButtonTemplate = () =>
|
|
||||||
lockedButtonTemplate(this._distanceText);
|
|
||||||
|
|
||||||
// This template is shown when user has arrived to the target location
|
|
||||||
// When user click the button counter at the bottom of the page is incremented
|
|
||||||
// and image is revealed
|
|
||||||
private _unlockedButtonTemplate = () =>
|
|
||||||
unlockedButtonTemplate(() => {
|
|
||||||
incrementUnlockCounter(this.imageId);
|
|
||||||
this._unlocked = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start watching user location, if user has not given permission yet
|
|
||||||
// this will ask the user for permission and update the watch id
|
|
||||||
private _startWatchingLocation() {
|
|
||||||
// User is already being watched no need to
|
|
||||||
// watch position
|
|
||||||
if (this._watchId) return;
|
|
||||||
|
|
||||||
const id = navigator.geolocation.watchPosition(
|
|
||||||
this.successCallback.bind(this),
|
|
||||||
(err) => {
|
|
||||||
if (err.code == GeolocationPositionError.PERMISSION_DENIED) {
|
|
||||||
this._geolocationPermissionStatus = "denied";
|
|
||||||
}
|
|
||||||
errorCallback(err);
|
|
||||||
},
|
|
||||||
this.geolocationOptions
|
|
||||||
);
|
|
||||||
|
|
||||||
this._watchId = id;
|
|
||||||
}
|
|
||||||
|
|
||||||
connectedCallback(): void {
|
|
||||||
super.connectedCallback();
|
|
||||||
|
|
||||||
// Check geolocation permission, if user has given permission before
|
|
||||||
// start watching user location
|
|
||||||
navigator.permissions
|
|
||||||
.query({ name: "geolocation" })
|
|
||||||
.then((permissionStatus) => {
|
|
||||||
switch (permissionStatus.state) {
|
|
||||||
case "granted":
|
|
||||||
this._startWatchingLocation();
|
|
||||||
break;
|
|
||||||
case "denied":
|
|
||||||
this._geolocationPermissionStatus = "denied";
|
|
||||||
case "prompt":
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
let buttonTemplate;
|
|
||||||
|
|
||||||
// Determine which template to show, there are 3 states:
|
|
||||||
// 1 - No geolocation permission given
|
|
||||||
// 2 - Permission given but has no arrived to target position yet
|
|
||||||
// 3 - Arrived to target position
|
|
||||||
// 4 - User did not give geolocation permission
|
|
||||||
if (this._arrived) {
|
|
||||||
buttonTemplate = this._unlockedButtonTemplate;
|
|
||||||
} else if (this._geolocationPermissionStatus == "granted") {
|
|
||||||
buttonTemplate = this._lockedButtonTemplate;
|
|
||||||
} else if (this._geolocationPermissionStatus == "prompt") {
|
|
||||||
buttonTemplate = this._permissionButtonTemplate;
|
|
||||||
} else {
|
|
||||||
buttonTemplate = permissionDeniedButtonTemplate;
|
|
||||||
}
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<div
|
|
||||||
class="w-full h-[475px] overflow-hidden border border-zinc-200 shadow-sm p-4 rounded"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col justify-center items-center image-wrapper">
|
|
||||||
<img
|
|
||||||
id="content"
|
|
||||||
src="${this.imageURL}"
|
|
||||||
class="h-[450px] ${this._unlocked ? "" : "blur-2xl"}"
|
|
||||||
/>
|
|
||||||
|
|
||||||
${this._unlocked ? nothing : buttonTemplate()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,214 +1,192 @@
|
||||||
import L, { type LatLngTuple } from "leaflet";
|
// Lit imports
|
||||||
import Toastify from "toastify-js";
|
import { LitElement, html, nothing, unsafeCSS, type CSSResultGroup } from "lit";
|
||||||
|
import { customElement, property, state } from "lit/decorators.js";
|
||||||
|
|
||||||
class LockedContent extends HTMLElement {
|
// Leaflet
|
||||||
watchId: number;
|
import { type LatLngTuple } from "leaflet";
|
||||||
targetPos: LatLngTuple;
|
|
||||||
|
// Styles
|
||||||
|
import globalStyles from "@/styles/globals.css?inline";
|
||||||
|
import lockedContentStyles from "../styles/locked-content.css?inline";
|
||||||
|
|
||||||
|
// Templates
|
||||||
|
import {
|
||||||
|
lockedButtonTemplate,
|
||||||
|
permissionButtonTemplate,
|
||||||
|
permissionDeniedButtonTemplate,
|
||||||
|
unlockedButtonTemplate,
|
||||||
|
} from "./LockedContent/templates";
|
||||||
|
|
||||||
|
// Geolocation utils
|
||||||
|
import { calculateDistance, errorCallback } from "./LockedContent/geolocation";
|
||||||
|
import { incrementUnlockCounter } from "./LockedContent/serverUtils";
|
||||||
|
|
||||||
|
// LockedContent is a custom element watching user location and blurring
|
||||||
|
// given image until user has arrived a certain position
|
||||||
|
@customElement("locked-content")
|
||||||
|
export class LockedContent extends LitElement {
|
||||||
|
// Constants
|
||||||
geolocationOptions = {
|
geolocationOptions = {
|
||||||
enableHighAccuracy: true,
|
enableHighAccuracy: true,
|
||||||
timeout: 5000,
|
timeout: 15000,
|
||||||
maximumAge: 0,
|
maximumAge: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor() {
|
// Tailwind and custom styles
|
||||||
super();
|
static styles: CSSResultGroup | undefined = [
|
||||||
|
unsafeCSS(globalStyles),
|
||||||
|
unsafeCSS(lockedContentStyles),
|
||||||
|
];
|
||||||
|
|
||||||
// Clone the template
|
// Components properties/attributes, no accessor attribute disables detecting
|
||||||
let template = document.getElementById(
|
// changes as these are readonly attriubtes there is no need to attach setters
|
||||||
"locked-content-template"
|
@property({ noAccessor: true }) readonly imageId?: string;
|
||||||
) as HTMLTemplateElement;
|
@property({ noAccessor: true }) readonly imageURL?: string;
|
||||||
let templateContent = template.content;
|
@property({ type: Object, noAccessor: true })
|
||||||
|
readonly targetPosition?: LatLngTuple;
|
||||||
|
|
||||||
// Get attributes
|
// Reactive states, template is rendered according to this states
|
||||||
const imageURL = this.getAttribute("imageURL") ?? "#";
|
@state()
|
||||||
const targetPosAttribute = this.getAttribute("targetPos") ?? "[]";
|
protected _geolocationPermissionStatus: PermissionState = "prompt";
|
||||||
this.targetPos = JSON.parse(targetPosAttribute);
|
@state()
|
||||||
|
protected _unlocked = false;
|
||||||
// Attach cloned template to DOM
|
@state()
|
||||||
const shadowRoot = this.attachShadow({ mode: "open" });
|
protected _arrived = false;
|
||||||
shadowRoot.appendChild(templateContent.cloneNode(true));
|
@state()
|
||||||
|
protected _distanceText?: string;
|
||||||
// Set image source URL
|
@state()
|
||||||
const content = shadowRoot.getElementById("content") as HTMLImageElement;
|
protected _watchId?: number;
|
||||||
content.src = imageURL;
|
|
||||||
|
|
||||||
// start geolocation services
|
|
||||||
const id = navigator.geolocation.watchPosition(
|
|
||||||
this.successCallback.bind(this),
|
|
||||||
this.errorCallback,
|
|
||||||
this.geolocationOptions
|
|
||||||
);
|
|
||||||
|
|
||||||
this.watchId = id;
|
|
||||||
}
|
|
||||||
|
|
||||||
changeDescription(description: string) {
|
|
||||||
const descriptionElement = this?.shadowRoot?.getElementById(
|
|
||||||
"locked-content-description"
|
|
||||||
);
|
|
||||||
|
|
||||||
if (descriptionElement) {
|
|
||||||
descriptionElement.innerText = description;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
unlockContent() {
|
|
||||||
const unlockButton = this?.shadowRoot?.getElementById(
|
|
||||||
"unlock-content-button-element"
|
|
||||||
);
|
|
||||||
|
|
||||||
if (unlockButton) {
|
|
||||||
unlockButton.removeAttribute("locked");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This callback will be fired when geolocation info is available
|
// This callback will be fired when geolocation info is available
|
||||||
successCallback(position: GeolocationPosition) {
|
successCallback(position: GeolocationPosition) {
|
||||||
const pos = {
|
// Set hasGeolocationPermission state true to change the template
|
||||||
lat: position.coords.latitude,
|
this._geolocationPermissionStatus = "granted";
|
||||||
lng: position.coords.longitude,
|
|
||||||
};
|
|
||||||
|
|
||||||
const targetLatLng = L.latLng(this.targetPos);
|
// Target position must be set
|
||||||
|
if (!this.targetPosition) return;
|
||||||
|
|
||||||
const currentLatLng = L.latLng(pos);
|
// Calculate the distance between target and current position in meters
|
||||||
|
const distance = calculateDistance(position, this.targetPosition);
|
||||||
|
|
||||||
const betweenMeters = currentLatLng.distanceTo(targetLatLng);
|
// Update the text based on the distance
|
||||||
|
this._updateDistanceText(distance);
|
||||||
|
|
||||||
if (betweenMeters > 1000) {
|
this._checkArrived(distance);
|
||||||
this.changeDescription(`${(betweenMeters / 1000).toFixed()} KM`);
|
}
|
||||||
} else if (betweenMeters > 100) {
|
|
||||||
this.changeDescription(`${betweenMeters.toFixed(0)} M`);
|
private _updateDistanceText(distance: number) {
|
||||||
} else {
|
// Update the proximity text according to the distance remaining
|
||||||
navigator.geolocation.clearWatch(this.watchId);
|
if (distance > 1000) {
|
||||||
this.unlockContent();
|
this._distanceText = `${(distance / 1000).toFixed()} KM`;
|
||||||
|
} else if (distance > 100) {
|
||||||
|
this._distanceText = `${distance.toFixed(0)} M`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// This callback will be fired on geolocation error
|
private _checkArrived(distance: number) {
|
||||||
errorCallback(err: GeolocationPositionError) {
|
// If target is close less then 100 meters user has arrived to target location
|
||||||
let errorMessage;
|
if (distance < 100) {
|
||||||
switch (err.code) {
|
if (this._watchId) {
|
||||||
case GeolocationPositionError.PERMISSION_DENIED:
|
// Stop watching location
|
||||||
errorMessage =
|
navigator.geolocation.clearWatch(this._watchId);
|
||||||
"Konum izni alınamadı, lütfen tarayıcınızın ve cihazınızın gizlilik ayarlarını kontrol edin.";
|
}
|
||||||
break;
|
// Update state to reveal the image
|
||||||
case GeolocationPositionError.POSITION_UNAVAILABLE:
|
this._arrived = true;
|
||||||
errorMessage =
|
|
||||||
"Konumunuz tespit edilemedi, lütfen biraz sonra tekrar deneyiniz.";
|
|
||||||
break;
|
|
||||||
case GeolocationPositionError.TIMEOUT:
|
|
||||||
errorMessage =
|
|
||||||
"Konum isteği zaman aşımına uğradı, lütfen sayfayı yenileyip tekrar deneyiniz.";
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
errorMessage =
|
|
||||||
"Konum izni alınamadı, lütfen tarayıcınızın ve cihazınızın gizlilik ayarlarını kontrol edin.";
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Toastify({
|
|
||||||
text: errorMessage,
|
|
||||||
duration: 3000,
|
|
||||||
gravity: "top", // `top` or `bottom`
|
|
||||||
position: "center", // `left`, `center` or `right`
|
|
||||||
stopOnFocus: true, // Prevents dismissing of toast on hover
|
|
||||||
style: {
|
|
||||||
background: "black",
|
|
||||||
borderRadius: "6px",
|
|
||||||
margin: "16px",
|
|
||||||
},
|
|
||||||
onClick: function () {}, // Callback after click
|
|
||||||
}).showToast();
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
class UnlockContentButton extends HTMLElement {
|
// This template is shown when user hasn't give geolocation permission yet
|
||||||
host: HTMLElement | null;
|
// When user click the button user is asked for geolocation permission
|
||||||
// Templates are required to create nodes
|
private _permissionButtonTemplate = () =>
|
||||||
permissionTemplateContent: DocumentFragment | null;
|
permissionButtonTemplate(this._startWatchingLocation);
|
||||||
lockedTemplateContent: DocumentFragment | null;
|
|
||||||
unlockedTemplateContent: DocumentFragment | null;
|
|
||||||
|
|
||||||
// Image element to blur and show when user is on target
|
// This template is shown when user has given permission but has not arrived yet
|
||||||
imageElement: HTMLElement | null;
|
private _lockedButtonTemplate = () =>
|
||||||
|
lockedButtonTemplate(this._distanceText);
|
||||||
|
|
||||||
// Content id is required to count how many times a content
|
// This template is shown when user has arrived to the target location
|
||||||
// is unlocked
|
// When user click the button counter at the bottom of the page is incremented
|
||||||
contentId: string | null;
|
// and image is revealed
|
||||||
|
private _unlockedButtonTemplate = () =>
|
||||||
static observedAttributes = ["locked"];
|
unlockedButtonTemplate(() => {
|
||||||
|
incrementUnlockCounter(this.imageId);
|
||||||
incrementCounter = async (id: string) =>
|
this._unlocked = true;
|
||||||
fetch(`http://localhost:3000/api/location/increment/${id}`, {
|
|
||||||
method: "PATCH",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
constructor() {
|
// Start watching user location, if user has not given permission yet
|
||||||
super();
|
// this will ask the user for permission and update the watch id
|
||||||
|
private _startWatchingLocation() {
|
||||||
|
// User is already being watched no need to
|
||||||
|
// watch position
|
||||||
|
if (this._watchId) return;
|
||||||
|
|
||||||
const host = document.getElementById("locked-content");
|
const id = navigator.geolocation.watchPosition(
|
||||||
this.host = host;
|
this.successCallback.bind(this),
|
||||||
|
(err) => {
|
||||||
this.contentId = host?.getAttribute("contentId") ?? null;
|
if (err.code == GeolocationPositionError.PERMISSION_DENIED) {
|
||||||
|
this._geolocationPermissionStatus = "denied";
|
||||||
let permissionTemplate = host?.shadowRoot?.getElementById(
|
}
|
||||||
"locked-button-template"
|
errorCallback(err);
|
||||||
) as HTMLTemplateElement | null;
|
},
|
||||||
this.permissionTemplateContent = permissionTemplate?.content ?? null;
|
this.geolocationOptions
|
||||||
|
);
|
||||||
let lockedTemplate = host?.shadowRoot?.getElementById(
|
|
||||||
"locked-button-template"
|
this._watchId = id;
|
||||||
) as HTMLTemplateElement | null;
|
}
|
||||||
this.lockedTemplateContent = lockedTemplate?.content ?? null;
|
|
||||||
|
connectedCallback(): void {
|
||||||
let unlockedTemplate = host?.shadowRoot?.getElementById(
|
super.connectedCallback();
|
||||||
"unlocked-button-template"
|
|
||||||
) as HTMLTemplateElement | null;
|
// Check geolocation permission, if user has given permission before
|
||||||
this.unlockedTemplateContent = unlockedTemplate?.content ?? null;
|
// start watching user location
|
||||||
|
navigator.permissions
|
||||||
this.imageElement = host?.shadowRoot?.getElementById("content") ?? null;
|
.query({ name: "geolocation" })
|
||||||
}
|
.then((permissionStatus) => {
|
||||||
|
switch (permissionStatus.state) {
|
||||||
connectedCallback() {
|
case "granted":
|
||||||
if (this.hasAttribute("locked")) {
|
this._startWatchingLocation();
|
||||||
if (this.lockedTemplateContent) {
|
break;
|
||||||
this.appendChild(this.lockedTemplateContent.cloneNode(true));
|
case "denied":
|
||||||
}
|
this._geolocationPermissionStatus = "denied";
|
||||||
} else {
|
case "prompt":
|
||||||
if (this.unlockedTemplateContent) {
|
default:
|
||||||
this.appendChild(this.unlockedTemplateContent.cloneNode(true));
|
break;
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
attributeChangedCallback(name: string, _: string, newValue: string) {
|
|
||||||
if (name != "locked") return;
|
|
||||||
|
|
||||||
if (newValue == "true") {
|
|
||||||
const child = this.firstElementChild;
|
|
||||||
if (this.lockedTemplateContent)
|
|
||||||
child?.replaceWith(this.lockedTemplateContent.cloneNode(true));
|
|
||||||
this.replaceWith;
|
|
||||||
} else {
|
|
||||||
const child = this.firstElementChild;
|
|
||||||
if (this.unlockedTemplateContent)
|
|
||||||
child?.replaceWith(this.unlockedTemplateContent.cloneNode(true));
|
|
||||||
|
|
||||||
const unlockButton = this.host?.shadowRoot?.getElementById(
|
|
||||||
"unlock-content-button"
|
|
||||||
);
|
|
||||||
|
|
||||||
unlockButton?.addEventListener("click", (el) => {
|
|
||||||
if (this.contentId) {
|
|
||||||
this.incrementCounter(this.contentId);
|
|
||||||
}
|
}
|
||||||
this.imageElement?.classList.remove("blur-2xl");
|
|
||||||
this.remove();
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
let buttonTemplate;
|
||||||
|
|
||||||
|
// Determine which template to show, there are 3 states:
|
||||||
|
// 1 - No geolocation permission given
|
||||||
|
// 2 - Permission given but has no arrived to target position yet
|
||||||
|
// 3 - Arrived to target position
|
||||||
|
// 4 - User did not give geolocation permission
|
||||||
|
if (this._arrived) {
|
||||||
|
buttonTemplate = this._unlockedButtonTemplate;
|
||||||
|
} else if (this._geolocationPermissionStatus == "granted") {
|
||||||
|
buttonTemplate = this._lockedButtonTemplate;
|
||||||
|
} else if (this._geolocationPermissionStatus == "prompt") {
|
||||||
|
buttonTemplate = this._permissionButtonTemplate;
|
||||||
|
} else {
|
||||||
|
buttonTemplate = permissionDeniedButtonTemplate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div
|
||||||
|
class="w-full h-[475px] overflow-hidden border border-zinc-200 shadow-sm p-4 rounded"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col justify-center items-center image-wrapper">
|
||||||
|
<img
|
||||||
|
id="content"
|
||||||
|
src="${this.imageURL}"
|
||||||
|
class="h-[450px] ${this._unlocked ? "" : "blur-2xl"}"
|
||||||
|
/>
|
||||||
|
|
||||||
|
${this._unlocked ? nothing : buttonTemplate()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define("locked-content", LockedContent);
|
|
||||||
customElements.define("unlock-content-button", UnlockContentButton);
|
|
||||||
|
|
|
@ -11,7 +11,6 @@ import {
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import WebComponentWrapper from "@/components/WebComponentWrapper.astro";
|
|
||||||
import { CalendarIcon } from "@radix-ui/react-icons";
|
import { CalendarIcon } from "@radix-ui/react-icons";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import type { ContentTable } from "@/lib/db";
|
import type { ContentTable } from "@/lib/db";
|
||||||
|
@ -65,11 +64,12 @@ const dateFromNow = dayjs.utc(data?.created_at).from(dayjs.utc());
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<WebComponentWrapper
|
<locked-content
|
||||||
contentId={data?.url}
|
imageId={data?.url}
|
||||||
imageUrl={`http://localhost:3000/images/${data?.blob_url}`}
|
imageURL={`http://localhost:3000/images/${data?.blob_url}`}
|
||||||
location={data?.loc}
|
targetPosition={data?.loc}></locked-content>
|
||||||
/>
|
|
||||||
|
<leaflet-map targetLocation={data?.loc}></leaflet-map>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
id="map"
|
id="map"
|
||||||
|
@ -86,4 +86,6 @@ const dateFromNow = dayjs.utc(data?.created_at).from(dayjs.utc());
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<script src="../scripts/initMap.ts"></script>
|
<script src="../scripts/initMap.ts"></script>
|
||||||
|
<script src="../components/locked-content.ts"></script>
|
||||||
|
<script src="../components/leaflet-map.ts"></script>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import L from "leaflet";
|
import L from "leaflet";
|
||||||
|
|
||||||
|
type TargetLocation = [lat: number, lng: number] | null;
|
||||||
|
|
||||||
const mapEl = document.getElementById("map");
|
const mapEl = document.getElementById("map");
|
||||||
|
|
||||||
var targetLocationIcon = L.icon({
|
var targetLocationIcon = L.icon({
|
||||||
|
@ -14,19 +16,13 @@ var currentLocationIcon = L.icon({
|
||||||
|
|
||||||
const targetLocation = mapEl?.dataset.targetLocation;
|
const targetLocation = mapEl?.dataset.targetLocation;
|
||||||
|
|
||||||
const data = JSON.parse(targetLocation ?? "{}");
|
const data = targetLocation ? JSON.parse(targetLocation) : null;
|
||||||
const TARGET_LOCATION = data;
|
|
||||||
|
|
||||||
var map = L.map("map").setView(TARGET_LOCATION, 13);
|
const TARGET_LOCATION = data as TargetLocation;
|
||||||
|
|
||||||
L.marker(TARGET_LOCATION, { icon: targetLocationIcon }).addTo(map);
|
var map = L.map("map");
|
||||||
|
|
||||||
L.circle(TARGET_LOCATION, {
|
if (TARGET_LOCATION) map.setView(TARGET_LOCATION, 13);
|
||||||
color: "blue",
|
|
||||||
fillColor: "#30f",
|
|
||||||
fillOpacity: 0.2,
|
|
||||||
radius: 50,
|
|
||||||
}).addTo(map);
|
|
||||||
|
|
||||||
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||||
maxZoom: 19,
|
maxZoom: 19,
|
||||||
|
@ -161,7 +157,7 @@ L.Control.GoToTargetLocation = L.Control.extend({
|
||||||
locationButton.classList.add("custom-map-control-button");
|
locationButton.classList.add("custom-map-control-button");
|
||||||
|
|
||||||
locationButton.addEventListener("click", () => {
|
locationButton.addEventListener("click", () => {
|
||||||
map.setView(TARGET_LOCATION, 18);
|
if (TARGET_LOCATION) map.setView(TARGET_LOCATION, 18);
|
||||||
});
|
});
|
||||||
|
|
||||||
return locationButton;
|
return locationButton;
|
||||||
|
@ -201,10 +197,22 @@ const targetLocationControl = L.control.targetLocation({
|
||||||
position: "bottomleft",
|
position: "bottomleft",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function addTargetLocationMarker(target: TargetLocation) {
|
||||||
|
if (target) {
|
||||||
|
L.marker(target, { icon: targetLocationIcon }).addTo(map);
|
||||||
|
|
||||||
|
L.circle(target, {
|
||||||
|
color: "blue",
|
||||||
|
fillColor: "#30f",
|
||||||
|
fillOpacity: 0.2,
|
||||||
|
radius: 50,
|
||||||
|
}).addTo(map);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function startWatchingLocation() {
|
function startWatchingLocation() {
|
||||||
goToCurrentLocationControl.addTo(map);
|
goToCurrentLocationControl.addTo(map);
|
||||||
askPermissionControl.remove();
|
askPermissionControl.remove();
|
||||||
map.locate({ watch: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function initLocationControls() {
|
function initLocationControls() {
|
||||||
|
@ -227,4 +235,5 @@ function initLocationControls() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addTargetLocationMarker(TARGET_LOCATION);
|
||||||
initLocationControls();
|
initLocationControls();
|
||||||
|
|
Loading…
Reference in New Issue
Block a user